From 5311d16aa0a3243e5478cef68bae326a6d79fdc4 Mon Sep 17 00:00:00 2001 From: "samuel.gjabel" Date: Wed, 15 Oct 2025 15:01:38 +0700 Subject: [PATCH 01/24] feat(joint-react): sync with react plus and general fixes + remove paperProvider --- examples/joint-react/package.json | 2 +- packages/joint-core/types/joint.d.ts | 14 +- packages/joint-react-eslint/eslint.config.mjs | 7 +- packages/joint-react-eslint/package.json | 41 +- .../decorators/with-simple-data.tsx | 40 +- packages/joint-react/.storybook/main.ts | 16 +- packages/joint-react/README.md | 400 +- packages/joint-react/build.ts | 39 +- packages/joint-react/package.json | 52 +- .../graph-provider.test.tsx.snap | 25 - .../__tests__/graph-provider.test.tsx | 145 - .../graph-provider/graph-provider.stories.tsx | 207 - .../graph-provider/graph-provider.tsx | 171 - .../graph/graph-provider.stories.tsx | 92 + .../src/components/graph/graph-provider.tsx | 255 + .../__snapshots__/custom.test.tsx.snap | 8 +- .../__snapshots__/mask.test.tsx.snap | 8 +- .../__snapshots__/opacity.test.tsx.snap | 4 +- .../__snapshots__/store.test.tsx.snap | 4 +- .../highlighters/__tests__/custom.test.tsx | 26 - .../highlighters/__tests__/mask.test.tsx | 18 - .../highlighters/__tests__/opacity.test.tsx | 18 - .../highlighters/__tests__/store.test.tsx | 18 - .../highlighters/custom.stories.tsx | 6 +- .../src/components/highlighters/custom.tsx | 69 +- .../components/highlighters/mask.stories.tsx | 9 +- .../src/components/highlighters/mask.tsx | 7 +- .../highlighters/opacity.stories.tsx | 6 +- .../src/components/highlighters/opacity.tsx | 6 +- .../highlighters/stroke.stories.tsx | 6 +- .../src/components/highlighters/stroke.tsx | 6 +- packages/joint-react/src/components/index.ts | 4 +- .../__snapshots__/measured-node.test.tsx.snap | 8 +- .../measured-node/measured-node.stories.tsx | 8 +- .../__tests__/paper-provider.test.tsx | 117 - .../paper-provider/paper-provider.tsx | 136 - .../graph-provider.test.tsx.snap | 15 + .../__snapshots__/paper.test.tsx.snap | 17 - .../paper/__tests__/graph-provider.test.tsx | 373 ++ .../components/paper/__tests__/paper.test.tsx | 299 +- .../joint-react/src/components/paper/index.ts | 4 + .../src/components/paper/paper-check.tsx | 92 - .../src/components/paper/paper.stories.tsx | 109 +- .../src/components/paper/paper.tsx | 579 ++- .../src/components/paper/paper.types.ts | 129 + .../paper-element-item.tsx | 14 +- .../paper-html-container.tsx | 14 +- .../__snapshots__/port-group.test.tsx.snap | 4 +- .../__snapshots__/port-item.test.tsx.snap | 4 +- .../components/port/__tests__/port.test.tsx | 56 +- .../joint-react/src/components/port/index.ts | 2 +- .../components/port/port-group.stories.tsx | 11 +- .../src/components/port/port-item.stories.tsx | 19 +- .../src/components/port/port-item.tsx | 2 +- .../__snapshots__/text-node.test.tsx.snap | 10 +- .../text-node/text-node.stories.tsx | 7 +- .../src/context/cell-id.context.tsx | 9 - .../src/context/graph-store-context.tsx | 6 - packages/joint-react/src/context/index.ts | 44 +- .../joint-react/src/context/paper-context.tsx | 14 - .../src/context/tools-view.context.tsx | 9 - .../data/__tests__/create-store-data.test.ts | 22 +- .../src/data/__tests__/create-store.test.ts | 29 +- .../src/data/create-graph-store.ts | 370 ++ .../joint-react/src/data/create-store-data.ts | 218 +- packages/joint-react/src/data/create-store.ts | 243 - packages/joint-react/src/data/index.ts | 2 + .../hooks/__tests__/use-cell-actions.test.tsx | 237 + .../hooks/__tests__/use-combined-ref.test.tsx | 14 +- .../__tests__/use-create-element.test.ts | 43 - .../hooks/__tests__/use-create-link.test.ts | 58 - .../src/hooks/__tests__/use-element.test.ts | 122 - .../src/hooks/__tests__/use-elements.test.ts | 4 +- .../__tests__/use-imperative-api.test.tsx | 122 + .../src/hooks/__tests__/use-links.test.ts | 67 + .../__tests__/use-measure-node-size.test.tsx | 123 + .../hooks/__tests__/use-remove-cell.test.ts | 146 - .../__tests__/use-update-element.test.ts | 278 -- packages/joint-react/src/hooks/index.ts | 11 +- .../src/hooks/use-cell-actions.stories.tsx | 369 ++ .../joint-react/src/hooks/use-cell-actions.ts | 93 + .../src/hooks/use-cell-id.stories.tsx | 4 +- packages/joint-react/src/hooks/use-cell-id.ts | 16 +- .../src/hooks/use-create-element.stories.tsx | 92 - .../src/hooks/use-create-element.ts | 30 - .../joint-react/src/hooks/use-create-link.ts | 25 - .../joint-react/src/hooks/use-create-paper.ts | 111 - .../src/hooks/use-element-views.ts | 35 + .../src/hooks/use-element.stories.tsx | 6 +- packages/joint-react/src/hooks/use-element.ts | 10 +- .../src/hooks/use-elements.stories.tsx | 15 +- .../joint-react/src/hooks/use-elements.ts | 9 +- .../joint-react/src/hooks/use-graph-store.ts | 11 +- packages/joint-react/src/hooks/use-graph.ts | 5 +- .../joint-react/src/hooks/use-highlighter.ts | 64 - .../src/hooks/use-imperative-api.ts | 130 + .../src/hooks/use-links.stories.tsx | 8 +- packages/joint-react/src/hooks/use-links.ts | 9 +- .../src/hooks/use-measure-node-size.tsx | 7 - .../src/hooks/use-paper-context.ts | 16 + .../src/hooks/use-paper-element-renderer.ts | 29 - .../joint-react/src/hooks/use-paper-events.ts | 35 + packages/joint-react/src/hooks/use-paper.ts | 15 +- .../joint-react/src/hooks/use-remove-cell.ts | 77 - .../src/hooks/use-update-element.stories.tsx | 300 -- .../src/hooks/use-update-element.ts | 139 - packages/joint-react/src/index.ts | 4 +- .../joint-react/src/models/react-element.tsx | 1 - .../src/stories/demos/flowchart/code.tsx | 2 +- .../src/stories/demos/flowchart/story.tsx | 4 +- .../stories/demos/introduction-demo/code.tsx | 75 +- .../stories/demos/introduction-demo/story.tsx | 6 +- .../src/stories/demos/pulsing-port/code.tsx | 8 +- .../src/stories/demos/pulsing-port/story.tsx | 4 +- .../src/stories/demos/user-flow/code.tsx | 2 +- .../src/stories/demos/user-flow/story.tsx | 4 +- .../code-with-build-in-shapes.tsx | 2 +- .../examples/with-auto-layout/code.tsx | 14 +- .../examples/with-auto-layout/story.tsx | 6 +- .../code-bordered-image.tsx | 2 +- .../with-build-in-shapes/code-circle.tsx | 2 +- .../with-build-in-shapes/code-cylinder.tsx | 2 +- .../with-build-in-shapes/code-double-link.tsx | 2 +- .../with-build-in-shapes/code-ellipse.tsx | 2 +- .../code-embedded-image.tsx | 2 +- .../code-headered-rectangle.tsx | 2 +- .../with-build-in-shapes/code-image.tsx | 2 +- .../code-inscribed-image.tsx | 2 +- .../with-build-in-shapes/code-link.tsx | 2 +- .../with-build-in-shapes/code-path.tsx | 2 +- .../with-build-in-shapes/code-polygon.tsx | 2 +- .../with-build-in-shapes/code-polyline.tsx | 2 +- .../with-build-in-shapes/code-rectangle.tsx | 2 +- .../with-build-in-shapes/code-shadow-link.tsx | 2 +- .../with-build-in-shapes/code-text-block.tsx | 2 +- .../examples/with-build-in-shapes/story.tsx | 51 +- .../src/stories/examples/with-card/code.tsx | 2 +- .../src/stories/examples/with-card/story.tsx | 4 +- .../code-with-create-links-classname.tsx | 3 +- .../code-with-create-links.tsx | 3 +- .../with-custom-link/code-with-dia-links.tsx | 14 +- .../examples/with-custom-link/story.tsx | 9 +- .../examples/with-highlighter/code.tsx | 2 +- .../examples/with-highlighter/story.tsx | 4 +- .../examples/with-intersection/code.tsx | 4 +- .../examples/with-intersection/story.tsx | 4 +- .../src/stories/examples/with-json/story.tsx | 4 +- .../stories/examples/with-link-tools/code.tsx | 4 +- .../stories/examples/with-link-tools/docs.mdx | 2 +- .../examples/with-link-tools/story.tsx | 4 +- .../stories/examples/with-list-node/code.tsx | 12 +- .../stories/examples/with-list-node/story.tsx | 4 +- .../stories/examples/with-minimap/code.tsx | 11 +- .../stories/examples/with-minimap/docs.mdx | 4 +- .../stories/examples/with-minimap/story.tsx | 3 +- .../with-node-update/code-add-remove-node.tsx | 8 +- .../with-node-update/code-with-color.tsx | 16 +- .../with-node-update/code-with-svg.tsx | 8 +- .../examples/with-node-update/code.tsx | 8 +- .../examples/with-node-update/story.tsx | 10 +- .../examples/with-proximity-link/code.tsx | 2 +- .../examples/with-proximity-link/story.tsx | 4 +- .../examples/with-resizable-node/code.tsx | 2 +- .../examples/with-resizable-node/story.tsx | 4 +- .../examples/with-rotable-node/code.tsx | 11 +- .../examples/with-rotable-node/docs.mdx | 2 +- .../examples/with-rotable-node/story.tsx | 4 +- .../stories/examples/with-svg-node/code.tsx | 3 +- .../stories/examples/with-svg-node/story.tsx | 4 +- .../joint-react/src/stories/introduction.mdx | 44 +- .../src/stories/tutorials/redux/code.tsx | 285 ++ .../src/stories/tutorials/redux/story.tsx | 12 + .../step-by-step/code-html-renderer.tsx | 13 +- .../tutorials/step-by-step/code-html.tsx | 9 +- .../tutorials/step-by-step/code-svg.tsx | 2 +- .../stories/tutorials/step-by-step/docs.mdx | 349 +- .../stories/tutorials/step-by-step/story.tsx | 2 +- .../src/stories/utils/cell-explorer.tsx | 128 - .../stories/utils/get-api-docs-base-url.tsx | 1 - .../src/stories/utils/hook-tester.tsx | 14 +- .../src/stories/utils/make-story.tsx | 13 +- .../joint-react/src/types/element-types.ts | 2 +- packages/joint-react/src/types/event.types.ts | 198 +- .../create-element-size-observer.test.ts | 13 +- .../src/utils/__tests__/create.test.ts | 2 +- .../src/utils/__tests__/get-cell.test.ts | 22 +- .../__tests__/handle-paper-events.test.ts | 639 +-- .../src/utils/__tests__/is.test.ts | 10 - .../utils/__tests__/link-utilities.test.ts | 6 +- .../src/utils/cell/__tests__/cell-map.test.ts | 27 - .../cell/__tests__/cell-utilities.test.ts | 31 + .../joint-react/src/utils/cell/cell-map.ts | 25 - .../cell/{set-cells.ts => cell-utilities.ts} | 55 +- .../joint-react/src/utils/cell/get-cell.ts | 4 - .../src/utils/cell/listen-to-cell-change.ts | 4 +- .../src/utils/create-element-size-observer.ts | 7 +- packages/joint-react/src/utils/create.ts | 59 +- packages/joint-react/src/utils/diff-update.ts | 38 - .../src/utils/graph/update-graph.ts | 97 + .../src/utils/handle-paper-events.ts | 1316 +++-- packages/joint-react/src/utils/is.ts | 9 - .../__tests__/jsx-to-markup.test.tsx | 15 - .../utils/joint-jsx/jsx-to-markup.stories.tsx | 12 +- .../joint-react/src/utils/link-utilities.ts | 2 +- .../joint-react/src/utils/object-utilities.ts | 67 + .../src/utils/subscriber-handler.ts | 79 +- .../joint-react/src/utils/test-wrappers.tsx | 39 +- packages/joint-react/tsconfig.json | 34 +- packages/joint-react/vite.config.ts | 3 +- .../joint-vitest-plugin-mock-svg/package.json | 8 +- yarn.lock | 4277 ++++++++++------- 211 files changed, 8817 insertions(+), 7162 deletions(-) delete mode 100644 packages/joint-react/src/components/graph-provider/__tests__/__snapshots__/graph-provider.test.tsx.snap delete mode 100644 packages/joint-react/src/components/graph-provider/__tests__/graph-provider.test.tsx delete mode 100644 packages/joint-react/src/components/graph-provider/graph-provider.stories.tsx delete mode 100644 packages/joint-react/src/components/graph-provider/graph-provider.tsx create mode 100644 packages/joint-react/src/components/graph/graph-provider.stories.tsx create mode 100644 packages/joint-react/src/components/graph/graph-provider.tsx delete mode 100644 packages/joint-react/src/components/paper-provider/__tests__/paper-provider.test.tsx delete mode 100644 packages/joint-react/src/components/paper-provider/paper-provider.tsx create mode 100644 packages/joint-react/src/components/paper/__tests__/__snapshots__/graph-provider.test.tsx.snap delete mode 100644 packages/joint-react/src/components/paper/__tests__/__snapshots__/paper.test.tsx.snap create mode 100644 packages/joint-react/src/components/paper/__tests__/graph-provider.test.tsx create mode 100644 packages/joint-react/src/components/paper/index.ts delete mode 100644 packages/joint-react/src/components/paper/paper-check.tsx create mode 100644 packages/joint-react/src/components/paper/paper.types.ts rename packages/joint-react/src/components/paper/{ => render-element}/paper-element-item.tsx (90%) rename packages/joint-react/src/components/paper/{ => render-element}/paper-html-container.tsx (87%) delete mode 100644 packages/joint-react/src/context/cell-id.context.tsx delete mode 100644 packages/joint-react/src/context/graph-store-context.tsx delete mode 100644 packages/joint-react/src/context/paper-context.tsx delete mode 100644 packages/joint-react/src/context/tools-view.context.tsx create mode 100644 packages/joint-react/src/data/create-graph-store.ts delete mode 100644 packages/joint-react/src/data/create-store.ts create mode 100644 packages/joint-react/src/data/index.ts create mode 100644 packages/joint-react/src/hooks/__tests__/use-cell-actions.test.tsx delete mode 100644 packages/joint-react/src/hooks/__tests__/use-create-element.test.ts delete mode 100644 packages/joint-react/src/hooks/__tests__/use-create-link.test.ts delete mode 100644 packages/joint-react/src/hooks/__tests__/use-element.test.ts create mode 100644 packages/joint-react/src/hooks/__tests__/use-imperative-api.test.tsx create mode 100644 packages/joint-react/src/hooks/__tests__/use-links.test.ts create mode 100644 packages/joint-react/src/hooks/__tests__/use-measure-node-size.test.tsx delete mode 100644 packages/joint-react/src/hooks/__tests__/use-remove-cell.test.ts delete mode 100644 packages/joint-react/src/hooks/__tests__/use-update-element.test.ts create mode 100644 packages/joint-react/src/hooks/use-cell-actions.stories.tsx create mode 100644 packages/joint-react/src/hooks/use-cell-actions.ts delete mode 100644 packages/joint-react/src/hooks/use-create-element.stories.tsx delete mode 100644 packages/joint-react/src/hooks/use-create-element.ts delete mode 100644 packages/joint-react/src/hooks/use-create-link.ts delete mode 100644 packages/joint-react/src/hooks/use-create-paper.ts create mode 100644 packages/joint-react/src/hooks/use-element-views.ts delete mode 100644 packages/joint-react/src/hooks/use-highlighter.ts create mode 100644 packages/joint-react/src/hooks/use-imperative-api.ts create mode 100644 packages/joint-react/src/hooks/use-paper-context.ts delete mode 100644 packages/joint-react/src/hooks/use-paper-element-renderer.ts create mode 100644 packages/joint-react/src/hooks/use-paper-events.ts delete mode 100644 packages/joint-react/src/hooks/use-remove-cell.ts delete mode 100644 packages/joint-react/src/hooks/use-update-element.stories.tsx delete mode 100644 packages/joint-react/src/hooks/use-update-element.ts create mode 100644 packages/joint-react/src/stories/tutorials/redux/code.tsx create mode 100644 packages/joint-react/src/stories/tutorials/redux/story.tsx delete mode 100644 packages/joint-react/src/stories/utils/cell-explorer.tsx delete mode 100644 packages/joint-react/src/utils/cell/__tests__/cell-map.test.ts create mode 100644 packages/joint-react/src/utils/cell/__tests__/cell-utilities.test.ts delete mode 100644 packages/joint-react/src/utils/cell/cell-map.ts rename packages/joint-react/src/utils/cell/{set-cells.ts => cell-utilities.ts} (79%) delete mode 100644 packages/joint-react/src/utils/diff-update.ts create mode 100644 packages/joint-react/src/utils/graph/update-graph.ts create mode 100644 packages/joint-react/src/utils/object-utilities.ts diff --git a/examples/joint-react/package.json b/examples/joint-react/package.json index 06f4811bb2..d729c91b2d 100644 --- a/examples/joint-react/package.json +++ b/examples/joint-react/package.json @@ -16,7 +16,7 @@ }, "devDependencies": { "@eslint/js": "^9.25.0", - "@types/react": "^19.1.2", + "@types/react": "^19.1.10", "@types/react-dom": "^19.1.2", "@vitejs/plugin-react": "^4.4.1", "eslint": "^9.25.0", diff --git a/packages/joint-core/types/joint.d.ts b/packages/joint-core/types/joint.d.ts index 2e5f0799a2..10e7aa43fd 100644 --- a/packages/joint-core/types/joint.d.ts +++ b/packages/joint-core/types/joint.d.ts @@ -2323,17 +2323,17 @@ export namespace dia { export namespace highlighters { - import HighlighterView = dia.HighlighterView; + type HighlighterView = dia.HighlighterView; - interface AddClassHighlighterArguments extends HighlighterView.Options { + interface AddClassHighlighterArguments extends dia.HighlighterView.Options { className?: string; } - interface OpacityHighlighterArguments extends HighlighterView.Options { + interface OpacityHighlighterArguments extends dia.HighlighterView.Options { alphaValue?: number; } - interface StrokeHighlighterArguments extends HighlighterView.Options { + interface StrokeHighlighterArguments extends dia.HighlighterView.Options { padding?: number; rx?: number; ry?: number; @@ -2342,7 +2342,7 @@ export namespace highlighters { attrs?: attributes.NativeSVGAttributes; } - interface MaskHighlighterArguments extends HighlighterView.Options { + interface MaskHighlighterArguments extends dia.HighlighterView.Options { padding?: number; maskClip?: number; deep?: boolean; @@ -4281,8 +4281,8 @@ export namespace attributes { filter?: string | dia.SVGFilterJSON; fill?: string | dia.SVGPatternJSON | dia.SVGGradientJSON; stroke?: string | dia.SVGPatternJSON | dia.SVGGradientJSON; - sourceMarker?: dia.SVGMarkerJSON; - targetMarker?: dia.SVGMarkerJSON; + sourceMarker?: dia.SVGMarkerJSON | null; + targetMarker?: dia.SVGMarkerJSON | null; vertexMarker?: dia.SVGMarkerJSON; props?: SVGAttributeProps; text?: string; diff --git a/packages/joint-react-eslint/eslint.config.mjs b/packages/joint-react-eslint/eslint.config.mjs index c08f6a2932..440c91b3d8 100644 --- a/packages/joint-react-eslint/eslint.config.mjs +++ b/packages/joint-react-eslint/eslint.config.mjs @@ -21,7 +21,7 @@ const tsConfigPath = path.resolve("./", "tsconfig.json"); const config = [ // Global ignores { - ignores: ["node_modules", "dist", "tsconfig.json"], + ignores: ["node_modules", "dist", "bundle-dist", "tsconfig.json"], }, // Base recommended configs @@ -124,7 +124,7 @@ const config = [ "react-hooks/exhaustive-deps": [ "error", { - additionalHooks: "useInitAndSync", + additionalHooks: "", }, ], @@ -139,6 +139,9 @@ const config = [ "sonarjs/cognitive-complexity": "error", "sonarjs/prefer-immediate-return": "off", "sonarjs/todo-tag": "warn", + // We do not switch to 19 yet! Remove in major React upgrade (with not support for lower version than react 19!) + "@eslint-react/no-use-context": "off", + "@eslint-react/no-forward-ref": "off", // JSDoc "jsdoc/require-description": "error", diff --git a/packages/joint-react-eslint/package.json b/packages/joint-react-eslint/package.json index 7d678d7d25..e63d825f5e 100644 --- a/packages/joint-react-eslint/package.json +++ b/packages/joint-react-eslint/package.json @@ -6,28 +6,31 @@ "module": "./eslint.config.mjs", "sideEffects": false, "devDependencies": { - "@eslint-react/eslint-plugin": "^1.28.0", - "@eslint/compat": "^1.1.1", - "@eslint/js": "9.24.0", - "@stylistic/eslint-plugin": "4.2.0", - "@stylistic/eslint-plugin-jsx": "4.2.0", - "@stylistic/eslint-plugin-ts": "4.2.0", - "@typescript-eslint/eslint-plugin": "8.29.0", - "@typescript-eslint/parser": "8.29.0", - "eslint": "9.24.0", - "eslint-plugin-depend": "0.12.0", - "eslint-plugin-jest": "^28.8.3", - "eslint-plugin-jsdoc": "^50.6.9", - "eslint-plugin-react": "^7.37.4", - "eslint-plugin-react-hooks": "5.1.0", + "@eslint-react/eslint-plugin": "1.52.4", + "@eslint/compat": "^1.3.2", + "@eslint/js": "9.33.0", + "@stylistic/eslint-plugin": "5.2.3", + "@stylistic/eslint-plugin-jsx": "4.4.1", + "@stylistic/eslint-plugin-ts": "4.4.1", + "@typescript-eslint/eslint-plugin": "8.39.1", + "@typescript-eslint/parser": "8.39.1", + "eslint": "9.33.0", + "eslint-plugin-depend": "1.2.0", + "eslint-plugin-jest": "29.0.1", + "eslint-plugin-jsdoc": "54.0.0", + "eslint-plugin-react": "7.37.5", + "eslint-plugin-react-hooks": "5.2.0", "eslint-plugin-react-perf": "^3.3.3", "eslint-plugin-security": "3.0.1", - "eslint-plugin-sonarjs": "3.0.2", - "eslint-plugin-unicorn": "58.0.0", - "typescript-eslint": "8.29.0" + "eslint-plugin-sonarjs": "3.0.4", + "eslint-plugin-unicorn": "60.0.0", + "typescript-eslint": "8.39.1" }, "peerDependencies": { - "eslint": "9.24.0", - "typescript": "^5.0.0" + "eslint": "9.33.0", + "typescript": "^5.9.2" + }, + "scripts": { + "lint": "eslint . --ext .js,.jsx,.ts,.tsx" } } diff --git a/packages/joint-react/.storybook/decorators/with-simple-data.tsx b/packages/joint-react/.storybook/decorators/with-simple-data.tsx index 02e178e2ec..2f98eb0d49 100644 --- a/packages/joint-react/.storybook/decorators/with-simple-data.tsx +++ b/packages/joint-react/.storybook/decorators/with-simple-data.tsx @@ -1,21 +1,25 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable react-perf/jsx-no-new-object-as-prop */ -//@ts-expect-error its js package without types -import JsonViewer from '@andypf/json-viewer/dist/esm/react/JsonViewer'; -import type { HTMLProps, JSX, PropsWithChildren } from 'react'; -import type { InferElement } from '@joint/react'; +// @ts-expect-error do not provide typings. +import JsonViewer from '@andypf/json-viewer/dist/esm/react/JsonViewer'; +import { useCallback, type HTMLProps, type JSX, type PropsWithChildren } from 'react'; import { createElements, createLinks, GraphProvider, MeasuredNode, - Paper, useElement, + type InferElement, } from '@joint/react'; import { PAPER_CLASSNAME, PRIMARY } from '../theme'; +import type { PartialStoryFn, StoryContext } from 'storybook/internal/types'; +import { Paper } from '../../src/components/paper/paper'; + +export type StoryFunction = PartialStoryFn; +export type StoryCtx = StoryContext; -const initialElements = createElements([ +export const testElements = createElements([ { id: '1', label: 'Node 1', @@ -36,8 +40,8 @@ const initialElements = createElements([ }, ]); -export type SimpleElement = InferElement; -const initialLinks = createLinks([ +export type SimpleElement = InferElement; +export const testLinks = createLinks([ { id: 'l-1', source: '1', @@ -52,16 +56,16 @@ const initialLinks = createLinks([ export function SimpleGraphProviderDecorator({ children }: Readonly) { return ( - + {children} ); } -export function SimpleGraphDecorator(Story: any) { +export function SimpleGraphDecorator(Story: StoryFunction, { args }: StoryCtx) { return ( - + ); } @@ -91,7 +95,7 @@ function RenderSimpleRectElement(properties: SimpleElement) { return ; } -export function RenderPaperWithChildren(properties: Readonly<{ children: JSX.Element }>) { +export function RenderGraphViewWithChildren(properties: Readonly<{ children: JSX.Element }>) { return (
@@ -108,12 +112,12 @@ export function RenderPaperWithChildren(properties: Readonly<{ children: JSX.Ele ); } -export function SimpleRenderItemDecorator(Story: any) { - return ; -} - -export function SimpleRenderPaperDecorator(Story: any) { - return {Story}; +export function SimpleRenderItemDecorator(Story: StoryFunction, { args }: StoryCtx) { + const component = useCallback( + (element: SimpleElement) => , + [Story, args] + ); + return ; } export function HTMLNode(props: PropsWithChildren>) { diff --git a/packages/joint-react/.storybook/main.ts b/packages/joint-react/.storybook/main.ts index 106c3217a9..b2a233efa7 100644 --- a/packages/joint-react/.storybook/main.ts +++ b/packages/joint-react/.storybook/main.ts @@ -1,4 +1,7 @@ +/* eslint-disable @typescript-eslint/no-shadow */ +/* eslint-disable no-shadow */ import type { StorybookConfig } from '@storybook/react-vite'; +import path from 'node:path'; import { configureSort } from 'storybook-multilevel-sort'; configureSort({ @@ -23,8 +26,6 @@ const config: StorybookConfig = { '@storybook/addon-interactions', '@storybook/addon-docs', '@storybook/addon-a11y', - // TODO: this library is not compatible with Vite storybook, so we will wait to fix it and then we can again enable. - // '@storybook/addon-storysource', '@storybook/addon-links', 'storybook-addon-performance', '@codesandbox/storybook-addon', @@ -37,5 +38,16 @@ const config: StorybookConfig = { docs: { autodocs: true, }, + + // 👇 extend Vite config here to resolve libraries properly (in storybook) + viteFinal: async (config) => { + config.resolve = config.resolve || {}; + config.resolve.alias = { + ...config.resolve.alias, + '@joint/react': path.resolve(__dirname, '../src/index.ts'), + '@joint/react/src/*': path.resolve(__dirname, '../src/*'), + }; + return config; + }, }; export default config; diff --git a/packages/joint-react/README.md b/packages/joint-react/README.md index b79a17349a..a32c486907 100644 --- a/packages/joint-react/README.md +++ b/packages/joint-react/README.md @@ -1,306 +1,262 @@ -
- JointJS Logo +
+ JointJS Logo
# @joint/react -A React library for building interactive diagrams, flowcharts, and graph-based visualizations. This library provides React components and hooks that wrap JointJS, making it easy to create powerful diagramming applications. +**React-first diagramming.** Build flowcharts, workflows, network maps, mind maps — any graph‑based UI — with an idiomatic React API on top of JointJS. Type‑safe, customizable, and production‑ready. -## What can you build with @joint/react? - -- 📊 Flowcharts and process diagrams -- 🌐 Network topology visualizations -- 🧠 Mind maps and organization charts -- ⚙️ State machines and workflow editors -- 📈 Any interactive connected graphs - -## Why @joint/react? - -- 🎯 **React-First**: React components and hooks for modern React applications -- 🔌 **Easy Integration**: Simple drop-in components with minimal setup -- 🎨 **Customizable**: Full control over node and link appearances -- ⚡ **Interactive**: Built-in support for dragging, connecting, and editing -- 🎭 **Type-Safe**: Written in TypeScript with full type definitions +--- -## Prerequisites +## ✨ Highlights -Before installing @joint/react, ensure you have: -- React 16.8+ (for Hooks support) -- Node.js 14+ -- A modern browser (Chrome, Firefox, Safari, Edge) +- **API** — Components and hooks, no imperative glue required. +- **TypeScript by default** — Written in TypeScript with full type definitions +- **Custom rendering** — SVG or real HTML with an overlay. +- **Interactive** — Dragging, connecting, selection, events. +- **Composable** — Multiple views per diagram / graph (e.g., canvas + minimap). -## Installation +--- -Install the library using your preferred package manager: +## 📦 Installation -```sh -# Using npm +### npm +```bash npm install @joint/react +``` -# Using yarn +### yarn +```bash yarn add @joint/react +``` -# Using bun +### bun +```bash bun add @joint/react ``` -## Documentation +> Requires React 16.8+ and a modern browser (Chrome, Firefox, Safari, Edge). -The documentation is available online: -- [API reference](https://react.jointjs.com/api/index.html) -- [Storybook with examples](https://react.jointjs.com/learn/?path=/docs/introduction--docs) +--- -## Core Concepts +## 🧭 Core Ideas -Before diving into the code, let's understand the basic building blocks: +- **Elements (nodes)** and **links (edges)** are plain objects. +- Define **`id` explicitly** and mark it as a literal (`'foo' as const`) so TypeScript keeps it precise. +- The **`GraphProvider`** component provides the graph context; **`Paper`** renders it. +- Use hooks like `useElements`, `useLinks`, `useGraph`, `usePaper` for reading/updating state. -- **Elements**: Nodes in your diagram (boxes, circles, or custom shapes) -- **Links**: Connections between elements (lines, arrows, or custom connectors) -- **Paper**: The canvas (UI) where your diagram is rendered -- **Graph**: The data model that holds your diagram's structure +--- -## Quick Start +## 🚀 Quick Start (TypeScript) -Here's a complete example of a simple diagram with two connected nodes: +`id` must be present for every element and link. ```tsx -import React, { useCallback } from 'react'; -import { GraphProvider, Paper, createElements, createLinks } from '@joint/react'; - -// Define your diagram elements (nodes) -const initialElements = createElements([ - { - id: '1', - label: 'Start', - x: 100, // Position from left - y: 50, // Position from top - width: 120, - height: 60 - }, - { - id: '2', - label: 'End', - x: 100, - y: 200, - width: 120, - height: 60 - }, -]); - -// Define connections between elements -const initialLinks = createLinks([ - { - id: 'link1', - source: '1', // ID of source element - target: '2' // ID of target element - } -]); - -// Main component that renders the diagram -function DiagramExample() { - // Define how each element should look - const renderElement = useCallback((element) => ( -
- {element.label} -
- ), []); +import React from 'react' +import { GraphProvider } from '@joint/react' + +const elements = [ + { id: 'node1', label: 'Start', x: 100, y: 50, width: 120, height: 60 }, + { id: 'node2', label: 'End', x: 100, y: 200, width: 120, height: 60 }, +] as const +const links = [ + { id: 'link1', source: 'node1', target: 'node2' }, +] as const + +// Narrow element type straight from the array: +type Element = typeof elements[number] + +export default function App() { return ( -
+ ( +
+ {el.label} +
+ )} /> -
- ); -} - -// Wrap your app with GraphProvider -export default function App() { - return ( - - - ); + ) } ``` -## Event Handling +--- + +## 🧩 Idiomatic Patterns & Examples -@joint/react provides various events to handle user interactions: +### 1) Multiple views (canvas + minimap) +Share one diagram across views. Give each view a stable `id`. ```tsx -function DiagramExample() { - const handleElementClick = useCallback((element) => { - console.log('Element clicked:', element); - }, []); +import React from 'react' +import { GraphProvider } from '@joint/react' + +const elements = [ + { id: 'a' as const, label: 'A', x: 40, y: 60, width: 80, height: 40 }, + { id: 'b' as const, label: 'B', x: 260, y: 180, width: 80, height: 40 }, +] as const + +const links = [{ id: 'a-b' as const, source: 'a', target: 'b' }] as const +export function MultiView() { return ( - - ); + + +
+ +
+
+ ) } ``` -## TypeScript Support - -@joint/react is written in TypeScript and includes comprehensive type definitions. Here's an example of using types: +### 2) SVG vs real HTML nodes +SVG with `` keeps everything in one tree; `useHTMLOverlay` renders real HTML above the SVG for full CSS support. ```tsx -import { InferElement } from '@joint/react'; - -const elements = createElements([ - { id: '1', label: 'Node', x: 0, y: 0, width: 100, height: 40 } -]); - -type CustomElement = InferElement; - -const renderElement = (element: CustomElement) => ( -
{element.label}
-); +// ForeignObject (SVG) + ( + +
+ {label} +
+
+ )} +/> + +// HTML overlay (React portal outside SVG) + ( +
+ {label} +
+ )} +/> ``` -## Performance Considerations - -To ensure optimal performance: +### 3) Events & interactions +Subscribe to pointer events on elements/links. -1. **Memoization** ```tsx -// Memoize render functions -const renderElement = useCallback((element) => { - return ; -}, []); - -// Memoize event handlers -const handleElementClick = useCallback((element) => { - // Handle click -}, []); + console.log('Clicked:', el.id)} + onBlankPointerDown={() => console.log('Canvas mousedown')} +/> ``` -## 📌 Core Components - -### 1. **GraphProvider** -The `GraphProvider` component manages a shared [JointJS Graph instance](https://docs.jointjs.com/api/dia/Graph/) to handle the state of your diagram. Wrap it around any components that interact with the graph. +### 4) Controlled updates (React state drives the graph) +Pass `elements/links` + `onElementsChange/onLinksChange` to keep React in charge. ```tsx -import { GraphProvider } from '@joint/react'; - - - {/* Components like Paper for rendering nodes and edges */} - -``` +import React, { useState } from 'react' +import { GraphProvider } from '@joint/react' -### 2. **Paper** -The `Paper` component wraps [JointJS Paper](https://docs.jointjs.com/learn/quickstart/paper/) to render nodes and links. Use the `renderElement` prop to define how nodes are displayed. +const initialElements = [ + { id: 'n1' as const, label: 'Item', x: 60, y: 60, width: 100, height: 40 }, +] as const -```tsx -import { Paper } from '@joint/react'; +const initialLinks = [] as const -const renderElement = (element) => ( - -); +export function Controlled() { + const [els, setEls] = useState([...initialElements]) + const [lns, setLns] = useState([...initialLinks]) - + return ( + + + + ) +} ``` -### 3. **Rendering HTML Elements** -Although JointJS is SVG-based, you can render HTML content inside nodes using SVG's ``: +### 5) Imperative access (ref) for one‑off actions +Useful for `fitToContent`, scaling, exporting. ```tsx -const renderElement = ({ width, height }) => ( - -
- HTML Content here -
-
-); -``` - -## 🛠️ Core Hooks and Utilities +import React, { useEffect, useRef } from 'react' +import type { PaperContext } from '@joint/react' -### 🔹 Accessing Elements -- `useElements()`: Retrieve all diagram elements (requires `GraphProvider` context). -- `useElement()`: Retrieve individual element data, typically used within `renderElement`. +export function FitOnMount() { + const ref = useRef(null) + useEffect(() => { + ref.current?.paper.fitToContent({ padding: 20 }) + }, []) -### 🔹 Modifying Elements -- `useUpdateElement()`: Update existing elements in the diagram. -- `useCreateElement()`: Create new elements in the diagram. -- `useRemoveElement()`: Remove elements from the diagram. + return ( + + + + ) +} +``` -- `useCreateLink()`: Create new elements in the diagram. -- `useRemoveLink()`: Remove elements from the diagram. +--- -### 🔹 Graph and Paper Instances -- `useGraph()`: Access the [dia.Graph](https://docs.jointjs.com/api/dia/Graph/) instance directly. -- `usePaper()`: Access the [dia.Paper](https://docs.jointjs.com/learn/quickstart/paper) instance directly. +## 🧠 Best Practices -### 🔹 Creating Nodes and Links -- `createElements()`: Utility for creating nodes. +- **Define ids as literals**: `id: 'node1' as const` — enables exact typing and prevents mismatches. +- **Type elements from data**: `type Element = typeof elements[number]` — reuse data as your source of truth. +- **Memoize renderers & handlers**: `useCallback` to minimize re-renders. +- **Keep overlay HTML lightweight**: Prefer simple layout; avoid heavy transforms/animations in `` (Safari can be picky). +- **Give each view a stable `id`** when rendering multiple `Paper` instances. +- **Prefer declarative first**: Reach for hooks/props; use imperative APIs (refs/graph methods) for targeted operations only. +- **Test in Safari early** when using ``; fall back to `useHTMLOverlay` if needed. +- **Accessing component instances via refs**: Any component that accepts a `ref` (such as `Paper` or `GraphProvider`) exposes its instance/context via the ref. For `Paper`, the instance (including the underlying JointJS Paper) can be accessed via the `paperCtx` property on the ref object. +--- -```ts -import { createElements } from '@joint/react'; +## ⚙️ API Surface (at a glance) -const initialElements = createElements([ - { id: '1', type: 'rect', x: 10, y: 10, width: 100, height: 100 }, -]); -``` +- **Components** + - `GraphProvider` — provides the shared graph + - `Paper` — renders the graph (Paper) -- `createLinks()`: Utility for creating links between nodes. +- **Hooks** + - `useElements()` / `useLinks()` — subscribe to data + - `useGraph()` — low-level graph access + - `usePaper()` — access the underlying Paper (from within a view) -```ts -import { createLinks } from '@joint/react'; +- **Controlled mode props** + - `elements`, `links`, `onElementsChange`, `onLinksChange` -const initialLinks = createLinks([ - { source: '1', target: '2', id: '1-2' }, -]); -``` +> Tip: You can pass an existing JointJS `dia.Graph` into `GraphProvider` if you need to integrate with external data lifecycles. --- -## How It Works - -Under the hood, **@joint/react** listens to changes in the `dia.Graph`, which acts as the single source of truth. When you update the graph—such as adding or modifying cells—the React components automatically observe and react to these changes, keeping the UI in sync. +## 🐞 Notes & caveats -Hooks like `useUpdateElement` provide a convenient way to update the graph, but you can also directly access the graph using `useGraph()` and call methods like `graph.setCells()`. +- **`` CSS** — Avoid `position` (non‑static), `transform`, `transition`, and certain `-webkit-*` properties inside SVG foreign objects; some browsers (esp. Safari) may flicker or misrender. Consider `useHTMLOverlay` for complex HTML. +- **Performance** — Favor memoized renderers, avoid heavy component trees inside `renderElement`. +- **Flicker** — Rapid port/size changes can cause transient flickers while elements measure; we’re improving defaults. --- -## Known Issues and Recommendations +## 🔗 Further reading -### Avoid Certain CSS Properties in `` -Some CSS properties can cause rendering issues in Safari when used inside an SVG ``. To ensure compatibility, avoid the following properties: +- API Reference & Guides: https://react.jointjs.com/api/index.html +- Storybook & Examples: https://react.jointjs.com/learn/?path=/docs/introduction--docs -- `position` (other than `static`) -- `-webkit-transform-style` -- `-webkit-backface-visibility` -- `transition` -- `transform` - -### Recommended Workaround -If you need to use HTML inside an SVG with cross-browser support: - -- Use minimal CSS inside ``. -- Stick to static positioning and avoid CSS transforms. -- Consider overlaying HTML outside the SVG using absolute positioning. +--- -### Flickering -React's asynchronous rendering can cause flickering when dynamically adding ports or resizing elements. We are aware of this issue and are working on a fix. +## 📝 License -### Controlled Mode -Currently, **@joint/react** uses `useSyncExternalStore` to listen to graph changes. The graph is the source of truth, so `initialElements` and `initialLinks` are only used during initialization. To modify the state, update the graph directly using hooks like `useGraph`, `useUpdateElement`, or `useCreateElement`. A fully controlled mode is under development. +MIT diff --git a/packages/joint-react/build.ts b/packages/joint-react/build.ts index c2cf1d0973..20f1c26295 100644 --- a/packages/joint-react/build.ts +++ b/packages/joint-react/build.ts @@ -9,7 +9,6 @@ import { glob } from 'glob'; const execAsync = promisify(exec); const entryDir = 'src'; -const entry = path.join(entryDir, 'index.ts'); const outDir = 'dist'; const external = [ 'react', @@ -61,38 +60,40 @@ async function cleanDist() { } /** - * Build the library + * Build the library (development-optimized build) */ async function build() { try { await cleanDist(); + const commonOptions: esbuild.BuildOptions = { + preserveSymlinks: true, + external: [...external, 'process'], // 👈 keep process unresolved + sourcemap: true, + minify: false, // 👈 no minification (dev friendly) + treeShaking: true, + target: ['esnext'], // modern for dev + define: { + 'process.env.NODE_ENV': 'process.env.NODE_ENV', + }, + }; + // CommonJS build await esbuild.build({ - entryPoints: [entry], + ...commonOptions, + entryPoints: await getAllFiles(entryDir), bundle: true, format: 'cjs', - outfile: path.join(outDir, 'cjs/index.js'), - minify: true, - treeShaking: true, - pure: ['console.log'], - preserveSymlinks: true, - external, - sourcemap: true, - target: ['node14'], + outdir: path.join(outDir, 'cjs'), }); - // ESM build + // ESM build (per-file, no bundle) await esbuild.build({ + ...commonOptions, entryPoints: await getAllFiles(entryDir), - bundle: false, + bundle: true, format: 'esm', outdir: path.join(outDir, 'esm'), - minify: true, - treeShaking: true, - preserveSymlinks: true, - sourcemap: true, - target: ['node14'], }); // Generate TypeScript declarations @@ -100,7 +101,7 @@ async function build() { 'npx tsc --project tsconfig.types.json --declaration --emitDeclarationOnly --outDir dist/types' ); - console.log('Build completed successfully!'); + console.log('Development build completed successfully!'); } catch (error) { console.error('Build failed:', error); // eslint-disable-next-line unicorn/no-process-exit diff --git a/packages/joint-react/package.json b/packages/joint-react/package.json index d8d4c8698e..0f17da6885 100644 --- a/packages/joint-react/package.json +++ b/packages/joint-react/package.json @@ -45,13 +45,15 @@ "build": "node --experimental-json-modules --loader ts-node/esm build.ts", "docs:typedoc": "typedoc --out docs/api src", "prepublishOnly": "echo \"Publishing via NPM is not allowed!\" && exit 1", - "prepack": "yarn test && yarn build" + "prepack": "yarn test && yarn build", + "knip": "knip" }, "devDependencies": { "@andypf/json-viewer": "^2.1.10", "@chromatic-com/storybook": "^3.2.5", "@joint/layout-directed-graph": "workspace:*", "@joint/react-eslint": "*", + "@reduxjs/toolkit": "^2.8.2", "@storybook/addon-a11y": "^8.6.12", "@storybook/addon-docs": "8.6.12", "@storybook/addon-essentials": "8.6.12", @@ -66,27 +68,31 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.0.1", "@testing-library/react-hooks": "^8.0.1", - "@types/jest": "29.5.14", - "@types/react": "19.0.8", - "@types/react-dom": "^19.0.3", - "@types/react-test-renderer": "19.0.0", - "@types/use-sync-external-store": "^0.0.6", - "@vitejs/plugin-react": "^4.3.4", - "@welldone-software/why-did-you-render": "8", + "@types/jest": "30.0.0", + "@types/node": "^24.3.0", + "@types/react": "19.1.12", + "@types/react-dom": "19.1.9", + "@types/react-test-renderer": "19.1.0", + "@types/use-sync-external-store": "1.5.0", + "@vitejs/plugin-react": "^5.0.2", + "@welldone-software/why-did-you-render": "10.0.1", "canvas": "^3.1.0", - "eslint": "9.19.0", + "eslint": "9.33.0", "glob": "^11.0.1", - "jest": "^29.7.0", - "jest-environment-jsdom": "^29.7.0", + "jest": "30.1.2", + "jest-environment-jsdom": "30.1.2", + "knip": "5.63.0", "prettier": "3.3.3", "prettier-plugin-tailwindcss": "^0.6.5", - "react": "18.x", + "react": "^19.1.1", "react-docgen-typescript-plugin": "^1.0.8", - "react-dom": "18.x", - "react-test-renderer": "19.0.0", - "storybook": "^8.6.12", - "storybook-addon-performance": "^0.17.3", - "storybook-multilevel-sort": "^2.0.1", + "react-dom": "^19.1.1", + "react-redux": "^9.2.0", + "react-test-renderer": "^19.1.1", + "redux": "^5.0.1", + "storybook": "8.6.14", + "storybook-addon-performance": "0.17.3", + "storybook-multilevel-sort": "2.0.1", "ts-jest": "^29.2.5", "ts-node": "^10.9.2", "typedoc": "^0.28.5", @@ -94,8 +100,9 @@ "typedoc-plugin-external-module-name": "^4.0.6", "typedoc-plugin-markdown": "^4.6.4", "typedoc-plugin-mdn-links": "5.0.1", - "typescript": "5.7.3", - "vite-plugin-md": "^0.21.5", + "typescript": "^5.9.2", + "vite-plugin-md": "0.22.5", + "vite-plugin-node-polyfills": "^0.24.0", "vite-tsconfig-paths": "^5.1.4", "vitest": "^3.0.4" }, @@ -113,8 +120,11 @@ "yarn": "4.7.0" }, "resolutions": { - "react": "18.x", - "react-dom": "18.x" + "react": "19.1.1", + "react-dom": "19.1.1", + "@types/react": "19.1.12", + "@types/react-dom": "19.1.9", + "@types/react-test-renderer": "19.1.0" }, "publishConfig": { "access": "public" diff --git a/packages/joint-react/src/components/graph-provider/__tests__/__snapshots__/graph-provider.test.tsx.snap b/packages/joint-react/src/components/graph-provider/__tests__/__snapshots__/graph-provider.test.tsx.snap deleted file mode 100644 index f644879caa..0000000000 --- a/packages/joint-react/src/components/graph-provider/__tests__/__snapshots__/graph-provider.test.tsx.snap +++ /dev/null @@ -1,25 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`GraphProvider "Default": GraphProvider-Default 1`] = `"
"`; - -exports[`GraphProvider "WithExternalGraph": GraphProvider-WithExternalGraph 1`] = `"
"`; - -exports[`GraphProvider "WithExternalGraphAndLayout": GraphProvider-WithExternalGraphAndLayout 1`] = `"
"`; - -exports[`GraphProvider "WithLink": GraphProvider-WithLink 1`] = `"
"`; - -exports[`GraphProvider "WithoutSizeDefinedInElements": GraphProvider-WithoutSizeDefinedInElements 1`] = `"
"`; - -exports[`graph-provider should render children and match snapshot 1`] = ` -
- Child Content -
-`; - -exports[`graph-provider should render children and match snapshot 2`] = ` - -
- Child Content -
-
-`; diff --git a/packages/joint-react/src/components/graph-provider/__tests__/graph-provider.test.tsx b/packages/joint-react/src/components/graph-provider/__tests__/graph-provider.test.tsx deleted file mode 100644 index 161ad986c1..0000000000 --- a/packages/joint-react/src/components/graph-provider/__tests__/graph-provider.test.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import React from 'react'; -import { act, render, waitFor } from '@testing-library/react'; -import { GraphStoreContext } from '../../../context/graph-store-context'; -import { GraphProvider } from '../graph-provider'; -import { createStore, type Store } from '../../../data/create-store'; -import { dia } from '@joint/core'; -import { useElements, useLinks } from '../../../hooks'; -import { createElements } from '../../../utils/create'; -import * as stories from '../graph-provider.stories'; -import { runStorybookSnapshot } from '../../../utils/run-storybook-snapshot'; - -runStorybookSnapshot({ - Component: GraphProvider, - stories, - name: 'GraphProvider', -}); -describe('graph-provider', () => { - it('should render children and match snapshot', () => { - const { asFragment, getByText } = render( - -
Child Content
-
- ); - expect(getByText('Child Content')).toMatchSnapshot(); - expect(asFragment()).toMatchSnapshot(); - }); - - it('should provide a graph instance in context', () => { - let contextGraph: Store | undefined; - function TestComponent() { - contextGraph = React.useContext(GraphStoreContext); - return null; - } - render( - - - - ); - expect(contextGraph).toBeInstanceOf(Object); - }); - - it('should render graph provider with links and elements', async () => { - const elements = createElements([ - { - width: 100, - height: 100, - id: 'element1', - }, - ]); - const link = new dia.Link({ id: 'link1', type: 'standard.Link', source: { id: 'element1' } }); - let linkCount = 0; - let elementCount = 0; - function TestComponent() { - linkCount = useElements((items) => items.size); - elementCount = useLinks((items) => { - return items.size; - }); - return null; - } - render( - // eslint-disable-next-line react-perf/jsx-no-new-array-as-prop - - - - ); - - await waitFor(() => { - expect(linkCount).toBe(1); - expect(elementCount).toBe(1); - }); - }); - it('should add elements and links after initial load and useElements and useLinks should catch them', async () => { - const graph = new dia.Graph(); - let linkCount = 0; - let elementCount = 0; - // eslint-disable-next-line sonarjs/no-identical-functions - function TestComponent() { - linkCount = useElements((items) => items.size); - elementCount = useLinks((items) => { - return items.size; - }); - return null; - } - render( - - - - ); - - await waitFor(() => { - expect(linkCount).toBe(0); - expect(elementCount).toBe(0); - }); - - act(() => { - graph.addCells([ - new dia.Element({ id: 'element1', type: 'standard.Rectangle' }), - new dia.Link({ id: 'link1', type: 'standard.Link', source: { id: 'element1' } }), - ]); - }); - - await waitFor(() => { - expect(linkCount).toBe(1); - expect(elementCount).toBe(1); - }); - }); - - it('should initialize with default elements', async () => { - const elements = createElements([ - { width: 100, height: 100, id: 'element1' }, - { width: 200, height: 200, id: 'element2' }, - ]); - let elementCount = 0; - function TestComponent() { - elementCount = useElements((items) => items.size); - return null; - } - render( - - - - ); - - await waitFor(() => { - expect(elementCount).toBe(2); - }); - }); - - it('should use provided store and clean up on unmount', () => { - const mockDestroy = jest.fn(); - const mockStore = createStore({}); - // @ts-expect-error its just unit test, readonly is not needed - mockStore.destroy = mockDestroy; - - const { unmount } = render( - -
Test
-
- ); - - expect(mockDestroy).not.toHaveBeenCalled(); - unmount(); - expect(mockDestroy).toHaveBeenCalled(); - }); -}); diff --git a/packages/joint-react/src/components/graph-provider/graph-provider.stories.tsx b/packages/joint-react/src/components/graph-provider/graph-provider.stories.tsx deleted file mode 100644 index 350dac537b..0000000000 --- a/packages/joint-react/src/components/graph-provider/graph-provider.stories.tsx +++ /dev/null @@ -1,207 +0,0 @@ -/* eslint-disable sonarjs/no-unused-vars */ -/* eslint-disable sonarjs/pseudo-random */ -/* eslint-disable @eslint-react/dom/no-missing-button-type */ -/* eslint-disable react-perf/jsx-no-new-function-as-prop */ - -import type { Meta, StoryObj } from '@storybook/react/*'; -import { GraphProvider } from './graph-provider'; -import { createElements, createLinks, type InferElement, ReactElement } from '@joint/react'; -import { Paper, type RenderElement } from '../paper/paper'; -import { dia } from '@joint/core'; -import { BUTTON_CLASSNAME, PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; -import { makeRootDocumentation, makeStory } from '@joint/react/src/stories/utils/make-story'; -import { getAPILink } from '@joint/react/src/stories/utils/get-api-documentation-link'; -import { HTMLNode } from 'storybook-config/decorators/with-simple-data'; - -const API_URL = getAPILink('GraphProvider'); - -export type Story = StoryObj; -const meta: Meta = { - title: 'Components/GraphProvider', - component: GraphProvider, - parameters: makeRootDocumentation({ - description: ` -GraphProvider is a component that provides a graph context to its children. It is used to manage and render graph elements. - `, - apiURL: API_URL, - code: `import { GraphProvider } from '@joint/react' - - } /> - - `, - }), -}; - -export default meta; - -const STYLE = { padding: 10, backgroundColor: PRIMARY, borderRadius: 10, width: 80 }; - -const initialElementsWithSize = createElements([ - { id: 1, width: 100, height: 50, x: 20, y: 200, color: PRIMARY }, - { id: 2, width: 100, height: 50, x: 200, y: 200, color: PRIMARY }, -]); -const initialElementsWithoutSize = createElements([ - { id: 1, x: 20, y: 200, color: PRIMARY }, - { id: 2, x: 200, y: 200, color: PRIMARY }, -]); -const initialLinks = createLinks([ - { - id: '1-1', - source: 2, - target: 1, - attrs: { - line: { - stroke: PRIMARY, - }, - }, - }, -]); - -type ElementType = InferElement; - -function RenderElement({ color, width, height }: ElementType) { - return ; -} -function PaperChildren(props: Readonly<{ renderElement?: RenderElement }>) { - const { renderElement = RenderElement } = props; - return ; -} - -export const Default = makeStory({ - args: { - initialElements: initialElementsWithSize, - children: , - }, - - apiURL: API_URL, - description: 'Default graph provider with rectangle children.', -}); - -export const WithExternalGraph = makeStory({ - args: { - initialElements: initialElementsWithSize, - children: , - graph: new dia.Graph({}, { cellNamespace: { ReactElement } }), - }, - - apiURL: API_URL, - description: 'Graph provider with external graph.', - code: `import { GraphProvider } from '@joint/react' -import { dia } from '@joint/core'; -import { Paper } from '../paper/paper'; -import { ReactElement } from '@joint/react/src/core/react-element'; -const graph = new dia.Graph({}, { cellNamespace: { ReactElement } }); - - } /> - - `, -}); - -export const WithLink = makeStory({ - args: { - initialLinks, - initialElements: initialElementsWithSize, - children: , - }, - - apiURL: API_URL, - description: 'Graph provider with links.', -}); - -export const WithoutSizeDefinedInElements = makeStory({ - args: { - initialLinks, - initialElements: initialElementsWithoutSize, - children: ( - Hello world!} /> - ), - }, - - apiURL: API_URL, - description: 'Graph provider without size defined in elements.', -}); - -const graph = new dia.Graph({}, { cellNamespace: { ReactElement } }); - -function generateRandomElements(length: number) { - return createElements( - Array.from({ length }, (_, index) => ({ - id: `node-${index}`, - width: 100, - height: 50, - x: Math.random() * 500, - y: Math.random() * 500, - color: 'magenta', - })) - ); -} - -export const WithExternalGraphAndLayout = makeStory({ - args: { - graph, - initialElements: generateRandomElements(20), - children: ( - <> - - Hello world!} /> - - ), - }, - - apiURL: API_URL, - description: 'Graph provider with external graph and layout.', - code: `import { GraphProvider } from '@joint/react' -import { dia } from '@joint/core'; -import { Paper } from '../paper/paper'; -import { ReactElement } from '@joint/react/src/core/react-element'; -import { DirectedGraph } from '@joint/layout-directed-graph'; -const graph = new dia.Graph({}, { cellNamespace: { ReactElement } }); -const elements = generateRandomElements(20); - - - } /> - - `, -}); diff --git a/packages/joint-react/src/components/graph-provider/graph-provider.tsx b/packages/joint-react/src/components/graph-provider/graph-provider.tsx deleted file mode 100644 index 2968c87bdd..0000000000 --- a/packages/joint-react/src/components/graph-provider/graph-provider.tsx +++ /dev/null @@ -1,171 +0,0 @@ -import type { dia } from '@joint/core'; -import type { GraphLink } from '../../types/link-types'; -import { - GraphAreElementsMeasuredContext, - GraphStoreContext, -} from '../../context/graph-store-context'; -import { useEffect, useState, type PropsWithChildren } from 'react'; -import { createStore, type Store } from '../../data/create-store'; -import { useElements } from '../../hooks/use-elements'; -import { useGraph } from '../../hooks'; -import { setLinks } from '../../utils/cell/set-cells'; -import type { GraphElement } from '../../types/element-types'; - -interface GraphProviderHandlerProps { - /** - * Initial links to be added to graph - * It's loaded just once, so it cannot be used as React state. - */ - readonly initialLinks?: Array; -} - -/** - * GraphProviderHandler component is used to handle the graph instance and provide it to the children. - * It also handles the default elements and links. - * @param props - {GraphProviderHandler} props - * @param props.children - Children to render. - * @param props.initialLinks - Initial links to be added to graph - * @returns GraphProviderHandler component - * @private - */ -function GraphProviderHandler({ - children, - initialLinks, -}: PropsWithChildren) { - const areElementsMeasured = useElements((items) => { - let areMeasured = true; - for (const [, { width = 0, height = 0 }] of items) { - if (width <= 1 || height <= 1) { - areMeasured = false; - break; - } - } - return areMeasured; - }); - const graph = useGraph(); - - useEffect(() => { - if (areElementsMeasured) { - setLinks({ graph, initialLinks }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [areElementsMeasured, graph]); - - return ( - - {children} - - ); -} - -export interface GraphProps { - /** - * 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; - /** - * Children to render. - */ - readonly children?: React.ReactNode; - /** - * Namespace for cell models. - * It's loaded just once, so it cannot be used as React state. - * When added new shape, it will not remove existing ones, it will just add new ones. - * So `{ ...shapes, ReactElement }` elements are still available. - * @default `{ ...shapes, ReactElement }` - * @see https://docs.jointjs.com/api/shapes - */ - readonly cellNamespace?: unknown; - /** - * Custom cell model to use. - * It's loaded just once, so it cannot be used as React state. - * @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; - /** - * Initial links to be added to graph - * It's loaded just once, so it cannot be used as React state. - */ - readonly initialLinks?: Array; - - /** - * Store is build around graph, it handles react updates and states, it can be created separately and passed to the provider via `createStore` function. - * @see `createStore` - */ - readonly store?: Store; -} - -/** - * - * GraphProvider component creates a graph instance and provide `dia.graph` to it's children. - * It relies on @see useCreateGraphStore hook to create the graph instance. - * - * Without this provider, the library will not work. - * @param props - {GraphProvider} props - * @returns GraphProvider component - * @example - * Using provider: - * ```tsx - * import { GraphProvider } from '@joint/react' - * - * function App() { - * return ( - * - * - * - * ) - * ``` - * @example - * Using provider with default elements and links: - * ```tsx - * import { GraphProvider } from '@joint/react' - * - * function App() { - * return ( - * - * - * - * ) - * ``` - * @group Components - */ -export function GraphProvider(props: Readonly) { - const { children, initialLinks, store, ...rest } = props; - - /** - * Graph store instance. - * @returns - The graph store instance. - */ - - const [graphStore, setGraphStore] = useState(null); - - useEffect(() => { - const newStore = store ?? createStore({ ...rest }); - // We must use state initialization for the store, because it can be used in the same component. - // eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect - setGraphStore(newStore); - return () => { - if (newStore) { - newStore.destroy(); - } - }; - // On load initialization - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - if (graphStore === null) { - return null; - } - - return ( - - {children} - - ); -} diff --git a/packages/joint-react/src/components/graph/graph-provider.stories.tsx b/packages/joint-react/src/components/graph/graph-provider.stories.tsx new file mode 100644 index 0000000000..bc95f862b0 --- /dev/null +++ b/packages/joint-react/src/components/graph/graph-provider.stories.tsx @@ -0,0 +1,92 @@ +/* eslint-disable react-perf/jsx-no-new-object-as-prop */ + +import type { Meta, StoryObj } from '@storybook/react'; +import { + testElements, + testLinks, + type SimpleElement, +} from '../../../.storybook/decorators/with-simple-data'; +import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; +import { MeasuredNode } from '../measured-node/measured-node'; +import { useEffect, useState } from 'react'; +import { getAPILink } from '../../stories/utils/get-api-documentation-link'; +import { makeRootDocumentation } from '../../stories/utils/make-story'; +import { GraphProvider } from './graph-provider'; +import { Paper } from '../paper/paper'; + +export type Story = StoryObj; + +const API_URL = getAPILink('GraphProvider', 'variables'); +const meta: Meta = { + title: 'Components/GraphProvider', + component: GraphProvider, + parameters: makeRootDocumentation({ + description: ` +GraphProvider provides a shared Graph context for its descendants. Use it to scope any components that read or write the graph state. You can render one or multiple Paper instances inside. + `, + apiURL: API_URL, + code: `import { GraphProvider } from '@joint/react' +function Render({ width, height }) { + return +} + + + + `, + }), +}; + +export default meta; + +function RenderHTMLElement({ width, height }: SimpleElement) { + return ( + + +
+ Hello +
+
+
+ ); +} + +export const Default: Story = { + args: { + elements: testElements, + links: testLinks, + children: , + }, +}; +function Component() { + const [isReady, setIsReady] = useState(false); + useEffect(() => { + // eslint-disable-next-line @eslint-react/web-api/no-leaked-timeout + setTimeout(() => { + setIsReady(true); + }, 1000); + }, []); + return ( + isReady && ( + + ) + ); +} + +export const ConditionalRender: Story = { + args: { + elements: testElements, + links: testLinks, + children: , + }, +}; diff --git a/packages/joint-react/src/components/graph/graph-provider.tsx b/packages/joint-react/src/components/graph/graph-provider.tsx new file mode 100644 index 0000000000..b4d935be1c --- /dev/null +++ b/packages/joint-react/src/components/graph/graph-provider.tsx @@ -0,0 +1,255 @@ +import type { dia } from '@joint/core'; +import type { GraphLink } from '../../types/link-types'; +import { + forwardRef, + useLayoutEffect, + type Dispatch, + type PropsWithChildren, + type SetStateAction, +} from 'react'; +import { createStore, type GraphStore } from '../../data/create-graph-store'; +import { useElements } from '../../hooks/use-elements'; +import { useGraph } from '../../hooks'; +import { setElements, setLinks } from '../../utils/cell/cell-utilities'; +import type { GraphElement } from '../../types/element-types'; +import { CONTROLLED_MODE_BATCH_NAME } from '../../utils/graph/update-graph'; +import { useImperativeApi } from '../../hooks/use-imperative-api'; +import { GraphAreElementsMeasuredContext, GraphStoreContext } from '../../context'; +import { getTargetOrSource } from '../../utils/cell/get-link-targe-and-source-ids'; + +interface GraphProviderBaseProps< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Element extends dia.Element | GraphElement = any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Link extends dia.Link | GraphLink = any, +> { + /** + * Elements (nodes) to be added to graph. + * When `onElementsChange`, it enabled controlled mode. + * If there is no `onElementsChange` provided, it will be used just on onload (initial) + */ + readonly elements?: Element[]; + + /** + * Links (edges) to be added to graph. + * When `onLinksChange`, it enabled controlled mode. + * If there is no `onLinksChange` provided, it will be used just on onload (initial) + */ + readonly links?: Link[]; + + /** + * Callback triggered when eleme§nts (nodes) change. + * Providing this prop enables controlled mode for elements. + * If specified, this function will override the default behavior, allowing you to manage all element changes manually instead of relying on `graph.change`. + */ + readonly onElementsChange?: Dispatch>; + + /** + * Callback triggered when links (edges) change. + * Providing this prop enables controlled mode for links. + * If specified, this function will override the default behavior, allowing you to manage all link changes manually instead of relying on `graph.change`. + */ + readonly onLinksChange?: Dispatch>; +} + +/** + * Internal handler coordinating initial population and controlled-mode mirroring + * for elements and links. Also delays link creation until elements are measured + * in uncontrolled mode to avoid flicker. + * @param props - The properties for the GraphProviderHandler, including elements, links, and callbacks. + * @returns A context provider for the measured state of elements. + * @private + */ +export function GraphProviderHandler(props: PropsWithChildren) { + const { elements, links, onElementsChange, onLinksChange, children } = props; + const areElementsMeasured = useElements((items) => { + let areMeasured = true; + for (const { width = 0, height = 0 } of items) { + if (width <= 1 || height <= 1) { + areMeasured = false; + break; + } + } + return areMeasured; + }); + + const graph = useGraph(); + + const areElementsInControlledMode = !!onElementsChange; + const areLinksInControlledMode = !!onLinksChange; + const isControlledMode = areElementsInControlledMode || areLinksInControlledMode; + // Controlled mode for elements + useLayoutEffect(() => { + if (!areElementsMeasured) return; + if (!graph) return; + if (!isControlledMode) return; + + graph.startBatch(CONTROLLED_MODE_BATCH_NAME); + if (areElementsInControlledMode) { + setElements({ graph, elements }); + } + if (areLinksInControlledMode) { + setLinks({ graph, links }); + } + graph.stopBatch(CONTROLLED_MODE_BATCH_NAME); + }, [ + areElementsInControlledMode, + areElementsMeasured, + areLinksInControlledMode, + graph, + elements, + links, + isControlledMode, + ]); + + useLayoutEffect(() => { + // with this all links are connected only when react elements are measured + // It fixes issue with a flickering of un-measured react elements. + if (isControlledMode) return; + if (!areElementsMeasured) return; + + const hasSomePort = links?.some((link) => { + const { source, target } = link; + const sourceObject = getTargetOrSource(source); + const targetObject = getTargetOrSource(target); + return sourceObject.port || targetObject.port; + }); + if (!hasSomePort) return; + setLinks({ graph, links }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [areElementsMeasured, isControlledMode]); + + return ( + + {children} + + ); +} + +export interface GraphProps< + Graph extends dia.Graph = dia.Graph, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Element extends dia.Element | GraphElement = any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Link extends dia.Link | GraphLink = any, +> extends GraphProviderBaseProps { + /** + * 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?: Graph; + /** + * Children to render. + */ + readonly children?: React.ReactNode; + /** + * Namespace for cell models. + * It's loaded just once, so it cannot be used as React state. + * When added new shape, it will not remove existing ones, it will just add new ones. + * So `{ ...shapes, ReactElement }` elements are still available. + * @default `{ ...shapes, ReactElement }` + * @see https://docs.jointjs.com/api/shapes + */ + readonly cellNamespace?: unknown; + /** + * Custom cell model to use. + * It's loaded just once, so it cannot be used as React state. + * @see https://docs.jointjs.com/api/dia/Cell + */ + readonly cellModel?: typeof dia.Cell; + + /** + * Store is build around graph, it handles react updates and states, it can be created separately and passed to the provider via `createStore` function. + * @see `createStore` + */ + readonly store?: GraphStore; +} + +/** + * Graph component creates a graph instance and provides `dia.graph` to its children. + * This component is essential for the library to function correctly. It manages the graph instance and supports controlled and uncontrolled modes for elements and links. + * @param props - The properties for the Graph component. + * @param forwardedRef - A reference to the GraphStore instance. + * @returns The Graph component. + * @example + * Using the Graph component: + * ```tsx + * import { Graph } from '@joint/react'; + * function App() { + * return ( + * + * + * + * ); + * } + * ``` + * @example + * Using the Graph component with default elements and links: + * ```tsx + * import { Graph } from '@joint/react'; + * function App() { + * return ( + * + * + * + * ); + * } + * ``` + */ +function GraphBase( + props: Readonly>, + forwardedRef: React.Ref +) { + const { children, store, ...rest } = props; + /** + * Graph store instance. + * @returns - The graph store instance. + */ + + const { isReady, ref } = useImperativeApi( + { + forwardedRef, + onLoad() { + const newStore = store ?? createStore({ ...rest }); + // We must use state initialization for the store, because it can be used in the same component. + + return { + cleanup() { + if (newStore) { + newStore.destroy(!!rest.graph || !!store?.graph); + } + }, + instance: newStore, + }; + }, + }, + [] + ); + + if (!isReady) { + return null; + } + + return ( + + {children} + + ); +} + +/** + * GraphProviderHandler component is used to handle the graph instance and provide it to the children. + * It also handles the default elements and links. + * @returns GraphProviderHandler component + * @param props - {GraphProviderHandler} props + * @private + */ +export const GraphProvider = forwardRef(GraphBase) as < + Element extends dia.Element | GraphElement = dia.Element, + Link extends dia.Link | GraphLink = dia.Link, +>( + props: Readonly> & { + ref?: React.Ref; + } +) => ReturnType; diff --git a/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/custom.test.tsx.snap b/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/custom.test.tsx.snap index 740ccd9ef8..43a36a4a35 100644 --- a/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/custom.test.tsx.snap +++ b/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/custom.test.tsx.snap @@ -1,7 +1,7 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`Highlighters/Custom "CustomWithMask": Highlighters/Custom-CustomWithMask 1`] = `"
"`; +exports[`Highlighters/Custom "CustomWithMask": Highlighters/Custom-CustomWithMask 1`] = `"
"`; -exports[`Highlighters/Custom "CustomWithOpacity": Highlighters/Custom-CustomWithOpacity 1`] = `"
"`; +exports[`Highlighters/Custom "CustomWithOpacity": Highlighters/Custom-CustomWithOpacity 1`] = `"
"`; -exports[`Highlighters/Custom "CustomWithStroke": Highlighters/Custom-CustomWithStroke 1`] = `"
"`; +exports[`Highlighters/Custom "CustomWithStroke": Highlighters/Custom-CustomWithStroke 1`] = `"
"`; diff --git a/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/mask.test.tsx.snap b/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/mask.test.tsx.snap index fea01dd039..7f99da095b 100644 --- a/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/mask.test.tsx.snap +++ b/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/mask.test.tsx.snap @@ -1,7 +1,7 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`Highlighters/Mask "Default": Highlighters/Mask-Default 1`] = `"
"`; +exports[`Highlighters/Mask "Default": Highlighters/Mask-Default 1`] = `"
"`; -exports[`Highlighters/Mask "WithPadding": Highlighters/Mask-WithPadding 1`] = `"
"`; +exports[`Highlighters/Mask "WithPadding": Highlighters/Mask-WithPadding 1`] = `"
"`; -exports[`Highlighters/Mask "WithSVGProps": Highlighters/Mask-WithSVGProps 1`] = `"
"`; +exports[`Highlighters/Mask "WithSVGProps": Highlighters/Mask-WithSVGProps 1`] = `"
"`; diff --git a/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/opacity.test.tsx.snap b/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/opacity.test.tsx.snap index 71bb9591b9..fbc3cd6e1b 100644 --- a/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/opacity.test.tsx.snap +++ b/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/opacity.test.tsx.snap @@ -1,3 +1,3 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`Highlighters/Opacity "Default": Highlighters/Opacity-Default 1`] = `"
"`; +exports[`Highlighters/Opacity "Default": Highlighters/Opacity-Default 1`] = `"
"`; diff --git a/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/store.test.tsx.snap b/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/store.test.tsx.snap index 85d786c53d..5a312dd833 100644 --- a/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/store.test.tsx.snap +++ b/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/store.test.tsx.snap @@ -1,3 +1,3 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`Highlighters/Stroke "Default": Highlighters/Stroke-Default 1`] = `"
"`; +exports[`Highlighters/Stroke "Default": Highlighters/Stroke-Default 1`] = `"
"`; diff --git a/packages/joint-react/src/components/highlighters/__tests__/custom.test.tsx b/packages/joint-react/src/components/highlighters/__tests__/custom.test.tsx index 3f3020a5a1..207d75530a 100644 --- a/packages/joint-react/src/components/highlighters/__tests__/custom.test.tsx +++ b/packages/joint-react/src/components/highlighters/__tests__/custom.test.tsx @@ -1,35 +1,9 @@ -/* eslint-disable react-perf/jsx-no-new-object-as-prop */ -/* eslint-disable react-perf/jsx-no-new-function-as-prop */ -import { render, waitFor } from '@testing-library/react'; import { runStorybookSnapshot } from '../../../utils/run-storybook-snapshot'; import { Custom } from '../custom'; import * as stories from '../custom.stories'; -import { highlighters } from '@joint/core'; -import { simpleRenderElementWrapper } from '../../../utils/test-wrappers'; runStorybookSnapshot({ Component: Custom, stories, name: 'Highlighters/Custom', withRenderElementWrapper: true, }); - -describe('custom highlighter', () => { - it('should render custom highlighter', async () => { - const { container } = render( - { - return highlighters.opacity.add(cellView, element, highlighterId, options); - }} - options={{ alphaValue: 0.5 }} - > - - , - { wrapper: simpleRenderElementWrapper } - ); - - await waitFor(() => { - expect(container.querySelector('rect#myRect')).toBeInTheDocument(); - expect(container.querySelector('rect#myRect')?.getAttribute('fill')).toBe('blue'); - }); - }); -}); diff --git a/packages/joint-react/src/components/highlighters/__tests__/mask.test.tsx b/packages/joint-react/src/components/highlighters/__tests__/mask.test.tsx index d2b8d51684..acfcefb996 100644 --- a/packages/joint-react/src/components/highlighters/__tests__/mask.test.tsx +++ b/packages/joint-react/src/components/highlighters/__tests__/mask.test.tsx @@ -1,27 +1,9 @@ -import { render, waitFor } from '@testing-library/react'; import { runStorybookSnapshot } from '../../../utils/run-storybook-snapshot'; import { Mask } from '../mask'; import * as stories from '../mask.stories'; -import { simpleRenderElementWrapper } from '../../../utils/test-wrappers'; runStorybookSnapshot({ Component: Mask, stories, name: 'Highlighters/Mask', withRenderElementWrapper: true, }); - -describe('mask highlighter', () => { - it('should render custom highlighter', async () => { - const { container } = render( - - - , - { wrapper: simpleRenderElementWrapper } - ); - - await waitFor(() => { - expect(container.querySelector('rect#myRect')).toBeInTheDocument(); - expect(container.querySelector('rect#myRect')?.getAttribute('fill')).toBe('blue'); - }); - }); -}); diff --git a/packages/joint-react/src/components/highlighters/__tests__/opacity.test.tsx b/packages/joint-react/src/components/highlighters/__tests__/opacity.test.tsx index d9b1388bbb..4c15422ab9 100644 --- a/packages/joint-react/src/components/highlighters/__tests__/opacity.test.tsx +++ b/packages/joint-react/src/components/highlighters/__tests__/opacity.test.tsx @@ -1,27 +1,9 @@ -import { render, waitFor } from '@testing-library/react'; import { runStorybookSnapshot } from '../../../utils/run-storybook-snapshot'; import { Opacity } from '../opacity'; import * as stories from '../opacity.stories'; -import { simpleRenderElementWrapper } from '../../../utils/test-wrappers'; runStorybookSnapshot({ Component: Opacity, stories, name: 'Highlighters/Opacity', withRenderElementWrapper: true, }); - -describe('opacity highlighter', () => { - it('should render custom highlighter', async () => { - const { container } = render( - - - , - { wrapper: simpleRenderElementWrapper } - ); - - await waitFor(() => { - expect(container.querySelector('rect#myRect')).toBeInTheDocument(); - expect(container.querySelector('rect#myRect')?.getAttribute('fill')).toBe('blue'); - }); - }); -}); diff --git a/packages/joint-react/src/components/highlighters/__tests__/store.test.tsx b/packages/joint-react/src/components/highlighters/__tests__/store.test.tsx index 8e3aa038e3..8edc997d7f 100644 --- a/packages/joint-react/src/components/highlighters/__tests__/store.test.tsx +++ b/packages/joint-react/src/components/highlighters/__tests__/store.test.tsx @@ -1,27 +1,9 @@ -import { render, waitFor } from '@testing-library/react'; import { runStorybookSnapshot } from '../../../utils/run-storybook-snapshot'; import { Stroke } from '../stroke'; import * as stories from '../stroke.stories'; -import { simpleRenderElementWrapper } from '../../../utils/test-wrappers'; runStorybookSnapshot({ Component: Stroke, stories, name: 'Highlighters/Stroke', withRenderElementWrapper: true, }); - -describe('stroke highlighter', () => { - it('should render custom highlighter', async () => { - const { container } = render( - - - , - { wrapper: simpleRenderElementWrapper } - ); - - await waitFor(() => { - expect(container.querySelector('rect#myRect')).toBeInTheDocument(); - expect(container.querySelector('rect#myRect')?.getAttribute('fill')).toBe('blue'); - }); - }); -}); diff --git a/packages/joint-react/src/components/highlighters/custom.stories.tsx b/packages/joint-react/src/components/highlighters/custom.stories.tsx index 81c95349d5..20ab4029ca 100644 --- a/packages/joint-react/src/components/highlighters/custom.stories.tsx +++ b/packages/joint-react/src/components/highlighters/custom.stories.tsx @@ -1,12 +1,12 @@ -import type { Meta, StoryObj } from '@storybook/react/*'; +import type { Meta, StoryObj } from '@storybook/react'; import { SimpleRenderItemDecorator } from '../../../.storybook/decorators/with-simple-data'; import { Custom } from './custom'; import { highlighters } from '@joint/core'; import { PRIMARY } from 'storybook-config/theme'; -import { makeRootDocumentation, makeStory } from '@joint/react/src/stories/utils/make-story'; -import { getAPILink } from '@joint/react/src/stories/utils/get-api-documentation-link'; import { forwardRef, type PropsWithChildren } from 'react'; import { useElement } from '../../hooks'; +import { getAPILink } from '../../stories/utils/get-api-documentation-link'; +import { makeRootDocumentation, makeStory } from '../../stories/utils/make-story'; const API_URL = getAPILink('Highlighter.Custom', 'variables'); diff --git a/packages/joint-react/src/components/highlighters/custom.tsx b/packages/joint-react/src/components/highlighters/custom.tsx index 40ed703be3..1e869fe3e7 100644 --- a/packages/joint-react/src/components/highlighters/custom.tsx +++ b/packages/joint-react/src/components/highlighters/custom.tsx @@ -1,11 +1,12 @@ import type React from 'react'; -import { forwardRef, useCallback, useEffect, useId } from 'react'; +import { forwardRef, useId } from 'react'; import { useCellId } from '../../hooks/use-cell-id'; import { usePaper } from '../../hooks/use-paper'; import type { dia } from '@joint/core'; import { useChildrenRef } from '../../hooks/use-children-ref'; -import { useHighlighter } from '../../hooks/use-highlighter'; import typedMemo from '../../utils/typed-memo'; +import { useImperativeApi } from '../../hooks/use-imperative-api'; +import { assignOptions, dependencyExtract } from '../../utils/object-utilities'; export type OnCreateHighlighter< Highlighter extends dia.HighlighterView.Options = dia.HighlighterView.Options, @@ -48,45 +49,47 @@ export interface CustomHighlighterProps< // eslint-disable-next-line jsdoc/require-jsdoc function RawComponent< Highlighter extends dia.HighlighterView.Options = dia.HighlighterView.Options, ->(props: CustomHighlighterProps, forwardedRef: React.Ref) { +>(props: CustomHighlighterProps, forwardedRef?: React.Ref) { const { children, options, onCreateHighlighter, isHidden } = props; const id = useCellId(); const paper = usePaper(); const highlighterId = useId(); const { elementRef, elementChildren } = useChildrenRef(children, forwardedRef); - - // ERROR HANDLING - useEffect(() => { - if (!elementRef.current) { - throw new Error('Highlighter children component must have accessible ref'); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - const create = useCallback( - (hOptions: Highlighter) => { - const cellView = paper.findViewByModel(id); - if (!cellView) { - return; - } - return onCreateHighlighter(cellView, elementRef.current ?? {}, highlighterId, hOptions); + const hasPaper = !!paper; + useImperativeApi( + { + onLoad() { + if (!paper) { + throw new Error('Paper not found in Highlighter.Custom'); + } + const cellView = paper.findViewByModel(id); + if (!cellView) { + throw new Error('CellView not found for highlighter'); + } + const instance = onCreateHighlighter( + cellView, + elementRef.current ?? {}, + highlighterId, + options + ); + return { + instance, + cleanup() { + instance?.remove(); + }, + }; + }, + onUpdate(instance) { + const oldOptions = instance?.options ?? {}; + assignOptions(oldOptions, options as Partial); + // @ts-expect-error Internal API + instance.update(); + }, + isDisabled: isHidden || !hasPaper, }, - [onCreateHighlighter, elementRef, highlighterId, id, paper] + dependencyExtract(options) ); - const update = useCallback((instance: ReturnType, hOptions: Highlighter) => { - const oldOptions = instance?.options ?? {}; - if (!instance?.options) { - return; - } - instance.options = { - ...oldOptions, - ...hOptions, - }; - - // @ts-expect-error Internal API - instance.update(); - }, []); - useHighlighter(create, update, options, isHidden); return elementChildren; } diff --git a/packages/joint-react/src/components/highlighters/mask.stories.tsx b/packages/joint-react/src/components/highlighters/mask.stories.tsx index 6791a4da16..9ace55a2c1 100644 --- a/packages/joint-react/src/components/highlighters/mask.stories.tsx +++ b/packages/joint-react/src/components/highlighters/mask.stories.tsx @@ -1,11 +1,11 @@ -import type { Meta, StoryObj } from '@storybook/react/*'; +import type { Meta, StoryObj } from '@storybook/react'; import { SimpleRenderItemDecorator } from '../../../.storybook/decorators/with-simple-data'; import { Mask } from './mask'; import { PRIMARY, SECONDARY } from 'storybook-config/theme'; -import { makeRootDocumentation, makeStory } from '@joint/react/src/stories/utils/make-story'; -import { getAPILink } from '@joint/react/src/stories/utils/get-api-documentation-link'; import { forwardRef, type PropsWithChildren } from 'react'; import { useElement } from '../../hooks'; +import { makeRootDocumentation, makeStory } from '../../stories/utils/make-story'; +import { getAPILink } from '../../stories/utils/get-api-documentation-link'; const API_URL = getAPILink('Highlighter.Mask', 'variables'); @@ -40,6 +40,7 @@ export const Default = makeStory({ args: { stroke: SECONDARY, children: , + isHidden: false, }, apiURL: API_URL, @@ -54,6 +55,7 @@ export const WithPadding = makeStory({ padding: 10, stroke: SECONDARY, children: , + isHidden: false, }, apiURL: API_URL, @@ -70,6 +72,7 @@ export const WithSVGProps = makeStory({ strokeWidth: 5, strokeLinejoin: 'bevel', children: , + isHidden: false, }, apiURL: API_URL, diff --git a/packages/joint-react/src/components/highlighters/mask.tsx b/packages/joint-react/src/components/highlighters/mask.tsx index 84119ab172..6ae2116d14 100644 --- a/packages/joint-react/src/components/highlighters/mask.tsx +++ b/packages/joint-react/src/components/highlighters/mask.tsx @@ -1,4 +1,4 @@ -import type { FC, PropsWithChildren } from 'react'; +import type { PropsWithChildren } from 'react'; import { forwardRef, useCallback, useMemo } from 'react'; import type { OnCreateHighlighter } from './custom'; import { Custom } from './custom'; @@ -36,7 +36,7 @@ const DEFAULT_MASK_HIGHLIGHTER_PROPS: MaskHighlighterProps = { }; // eslint-disable-next-line jsdoc/require-jsdoc -function Component(props: MaskHighlighterProps, forwardedRef: React.Ref) { +function Component(props: MaskHighlighterProps, forwardedRef?: React.Ref) { const { layer, children, padding, isHidden, ...svgAttributes } = props; const options = useMemo((): dia.HighlighterView.Options => { const data: dia.HighlighterView.Options = { @@ -74,4 +74,5 @@ function Component(props: MaskHighlighterProps, forwardedRef: React.Ref * ``` */ -export const Mask: FC = forwardRef(Component); + +export const Mask = forwardRef(Component); diff --git a/packages/joint-react/src/components/highlighters/opacity.stories.tsx b/packages/joint-react/src/components/highlighters/opacity.stories.tsx index ca2f012961..76105334c9 100644 --- a/packages/joint-react/src/components/highlighters/opacity.stories.tsx +++ b/packages/joint-react/src/components/highlighters/opacity.stories.tsx @@ -1,11 +1,11 @@ -import type { Meta, StoryObj } from '@storybook/react/*'; +import type { Meta, StoryObj } from '@storybook/react'; import { SimpleRenderItemDecorator } from '../../../.storybook/decorators/with-simple-data'; import { Opacity } from './opacity'; import { PRIMARY } from 'storybook-config/theme'; -import { makeRootDocumentation, makeStory } from '@joint/react/src/stories/utils/make-story'; -import { getAPILink } from '@joint/react/src/stories/utils/get-api-documentation-link'; import { forwardRef, type PropsWithChildren } from 'react'; import { useElement } from '../../hooks'; +import { getAPILink } from '../../stories/utils/get-api-documentation-link'; +import { makeRootDocumentation, makeStory } from '../../stories/utils/make-story'; const API_URL = getAPILink('Highlighter.Opacity', 'variables'); diff --git a/packages/joint-react/src/components/highlighters/opacity.tsx b/packages/joint-react/src/components/highlighters/opacity.tsx index 35687f70db..bfcbc8beea 100644 --- a/packages/joint-react/src/components/highlighters/opacity.tsx +++ b/packages/joint-react/src/components/highlighters/opacity.tsx @@ -1,4 +1,4 @@ -import type { FC, PropsWithChildren } from 'react'; +import type { PropsWithChildren } from 'react'; import { forwardRef, useCallback, useMemo } from 'react'; import type { OnCreateHighlighter } from './custom'; import { Custom } from './custom'; @@ -18,7 +18,7 @@ export interface OpacityHighlighterProps extends PropsWithChildren { } // eslint-disable-next-line jsdoc/require-jsdoc -function Component(props: OpacityHighlighterProps, forwardedRef: React.Ref) { +function Component(props: OpacityHighlighterProps, forwardedRef?: React.Ref) { const { children, alphaValue = 1, isHidden } = props; const options = useMemo((): dia.HighlighterView.Options => { const data: dia.HighlighterView.Options = { @@ -49,4 +49,4 @@ function Component(props: OpacityHighlighterProps, forwardedRef: React.Ref * ``` */ -export const Opacity: FC = forwardRef(Component); +export const Opacity = forwardRef(Component); diff --git a/packages/joint-react/src/components/highlighters/stroke.stories.tsx b/packages/joint-react/src/components/highlighters/stroke.stories.tsx index ae59170b6b..c827319364 100644 --- a/packages/joint-react/src/components/highlighters/stroke.stories.tsx +++ b/packages/joint-react/src/components/highlighters/stroke.stories.tsx @@ -1,11 +1,11 @@ -import type { Meta, StoryObj } from '@storybook/react/*'; +import type { Meta, StoryObj } from '@storybook/react'; import { SimpleRenderItemDecorator } from '../../../.storybook/decorators/with-simple-data'; import { Stroke } from './stroke'; import { PRIMARY, SECONDARY } from 'storybook-config/theme'; -import { makeRootDocumentation } from '@joint/react/src/stories/utils/make-story'; -import { getAPILink } from '@joint/react/src/stories/utils/get-api-documentation-link'; import { useElement } from '../../hooks'; import { forwardRef, type PropsWithChildren } from 'react'; +import { makeRootDocumentation } from '../../stories/utils/make-story'; +import { getAPILink } from '../../stories/utils/get-api-documentation-link'; const API_URL = getAPILink('Highlighter.Stroke', 'variables'); diff --git a/packages/joint-react/src/components/highlighters/stroke.tsx b/packages/joint-react/src/components/highlighters/stroke.tsx index 7a6d424a86..eb9223afdc 100644 --- a/packages/joint-react/src/components/highlighters/stroke.tsx +++ b/packages/joint-react/src/components/highlighters/stroke.tsx @@ -1,4 +1,4 @@ -import type { FC, PropsWithChildren } from 'react'; +import type { PropsWithChildren } from 'react'; import { forwardRef, useCallback, useMemo } from 'react'; import type { OnCreateHighlighter } from './custom'; import { Custom } from './custom'; @@ -37,7 +37,7 @@ export interface StrokeHighlighterProps extends PropsWithChildren, React.SVGProp } // eslint-disable-next-line jsdoc/require-jsdoc -function Component(props: StrokeHighlighterProps, forwardedRef: React.Ref) { +function Component(props: StrokeHighlighterProps, forwardedRef?: React.Ref) { const { children, layer, @@ -84,4 +84,4 @@ function Component(props: StrokeHighlighterProps, forwardedRef: React.Ref * ``` */ -export const Stroke: FC = forwardRef(Component); +export const Stroke = forwardRef(Component); diff --git a/packages/joint-react/src/components/index.ts b/packages/joint-react/src/components/index.ts index cf521c8d78..77f38cae84 100644 --- a/packages/joint-react/src/components/index.ts +++ b/packages/joint-react/src/components/index.ts @@ -1,5 +1,5 @@ -export * from './graph-provider/graph-provider'; -export * from './paper/paper'; +export * from './graph/graph-provider'; +export * from './paper'; export * from './highlighters'; export * from './measured-node/measured-node'; export * from './port'; diff --git a/packages/joint-react/src/components/measured-node/__tests__/__snapshots__/measured-node.test.tsx.snap b/packages/joint-react/src/components/measured-node/__tests__/__snapshots__/measured-node.test.tsx.snap index 317254e7e3..fc1f23528c 100644 --- a/packages/joint-react/src/components/measured-node/__tests__/__snapshots__/measured-node.test.tsx.snap +++ b/packages/joint-react/src/components/measured-node/__tests__/__snapshots__/measured-node.test.tsx.snap @@ -1,7 +1,7 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`MeasuredNode "DivWithExactSize": MeasuredNode-DivWithExactSize 1`] = `"
"`; +exports[`MeasuredNode "DivWithExactSize": MeasuredNode-DivWithExactSize 1`] = `"
"`; -exports[`MeasuredNode "DivWithPaddingAndText": MeasuredNode-DivWithPaddingAndText 1`] = `"
"`; +exports[`MeasuredNode "DivWithPaddingAndText": MeasuredNode-DivWithPaddingAndText 1`] = `"
"`; -exports[`MeasuredNode "TailwindSizing": MeasuredNode-TailwindSizing 1`] = `"
"`; +exports[`MeasuredNode "TailwindSizing": MeasuredNode-TailwindSizing 1`] = `"
"`; diff --git a/packages/joint-react/src/components/measured-node/measured-node.stories.tsx b/packages/joint-react/src/components/measured-node/measured-node.stories.tsx index 891f893464..2c995d1217 100644 --- a/packages/joint-react/src/components/measured-node/measured-node.stories.tsx +++ b/packages/joint-react/src/components/measured-node/measured-node.stories.tsx @@ -1,12 +1,12 @@ /* eslint-disable react-perf/jsx-no-new-object-as-prop */ -import type { Meta, StoryObj } from '@storybook/react/*'; +import type { Meta, StoryObj } from '@storybook/react'; import { SimpleRenderItemDecorator } from '../../../.storybook/decorators/with-simple-data'; import { MeasuredNode } from './measured-node'; -import { useElement } from '@joint/react/src/hooks/use-element'; import { PRIMARY } from 'storybook-config/theme'; -import { makeRootDocumentation, makeStory } from '@joint/react/src/stories/utils/make-story'; -import { getAPILink } from '@joint/react/src/stories/utils/get-api-documentation-link'; +import { getAPILink } from '../../stories/utils/get-api-documentation-link'; +import { useElement } from '../../hooks'; +import { makeRootDocumentation, makeStory } from '../../stories/utils/make-story'; const API_URL = getAPILink('MeasuredNode', 'variables'); diff --git a/packages/joint-react/src/components/paper-provider/__tests__/paper-provider.test.tsx b/packages/joint-react/src/components/paper-provider/__tests__/paper-provider.test.tsx deleted file mode 100644 index e6c2b71205..0000000000 --- a/packages/joint-react/src/components/paper-provider/__tests__/paper-provider.test.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { render } from '@testing-library/react'; -import { useEffect } from 'react'; -import { PaperProvider } from '../paper-provider'; -import { usePaper } from '../../../hooks'; -import type { dia } from '@joint/core'; -import { GraphProvider } from '../../graph-provider/graph-provider'; -import { Paper } from '../../paper/paper'; - -function MockChild() { - const paper = usePaper(); - useEffect(() => { - paper.trigger('TestEvent', { id: 'mock' }); - }, [paper]); - return
Mock Child
; -} - -describe('PaperProvider', () => { - it('should create a paper context and pass paper instance to children', async () => { - const onCustomEvent = jest.fn(); - // here width will be firstly 100, and then it will change to 99. - render( - - {/* Mock children fire event, so we test that it works */} - - - - ); - expect(onCustomEvent).toHaveBeenCalled(); - }); - - it('should set clickThreshold and other paper options', () => { - let customPaper: dia.Paper | undefined; - function ChildCheck() { - customPaper = usePaper(); - return null; - } - render( - - - - ); - expect(customPaper?.options.clickThreshold).toBe(10); - }); - - it('should allow outside component to access paper and its options', () => { - let outsidePaper: dia.Paper | undefined; - function OutsideComponent() { - outsidePaper = usePaper(); - return
Outside
; - } - render( - - - - - ); - expect(outsidePaper).toBeDefined(); - expect(outsidePaper?.options.width).toBe(123); - expect(outsidePaper?.options.height).toBe(456); - expect(outsidePaper?.options.clickThreshold).toBe(42); - }); - - it('should update paper options when PaperProvider props change', () => { - let paperInstance: dia.Paper | undefined; - function CheckPaper() { - paperInstance = usePaper(); - return null; - } - let reRenders = 0; - const { rerender } = render( - - {reRenders++} - - - ); - expect(paperInstance?.options.width).toBe(10); - expect(paperInstance?.options.height).toBe(20); - expect(reRenders).toBe(1); - - rerender( - - {reRenders++} - - - ); - expect(reRenders).toBe(2); - expect(paperInstance?.options.width).toBe(99); - expect(paperInstance?.options.height).toBe(77); - }); - - it('should share the same paper instance between outside and inside Paper components', () => { - let outsidePaper: dia.Paper | undefined; - let insidePaper: dia.Paper | undefined; - function OutsideComponent() { - outsidePaper = usePaper(); - return null; - } - function InsideComponent() { - insidePaper = usePaper(); - return null; - } - render( - - - - - - - - - ); - expect(outsidePaper).toBeDefined(); - expect(insidePaper).toBeDefined(); - expect(outsidePaper).toBe(insidePaper); - expect(outsidePaper?.options.width).toBe(55); - }); -}); diff --git a/packages/joint-react/src/components/paper-provider/paper-provider.tsx b/packages/joint-react/src/components/paper-provider/paper-provider.tsx deleted file mode 100644 index 1999401f11..0000000000 --- a/packages/joint-react/src/components/paper-provider/paper-provider.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { useContext, useEffect, useMemo, useState } from 'react'; -import { GraphStoreContext, PaperContext } from '../../context'; -import { dia } from '@joint/core'; -import { useGraph } from '../../hooks'; -import { createPortsStore } from '../../data/create-ports-store'; -import type { PortElementsCacheEntry } from '../../data/create-ports-data'; -import type { OmitWithoutIndexSignature } from '../../types'; -import { GraphProvider, type GraphProps } from '../graph-provider/graph-provider'; -import { usePaperElementRenderer } from '../../hooks/use-paper-element-renderer'; -const DEFAULT_CLICK_THRESHOLD = 10; - -export type OnPaperRenderElement = (element: dia.Element, portalElement: SVGElement) => void; - -export type ReactPaperOptions = OmitWithoutIndexSignature; - -// Interface for Paper options, extending JointJS Paper options -export interface PaperOptions extends ReactPaperOptions { - readonly scale?: number; - /** - * A function that is called when the paper is ready. - * @param element - The element that is being rendered - * @param portalElement - The portal element that is being rendered - * @returns - */ - readonly onRenderElement?: OnPaperRenderElement; -} - -export interface PaperProviderProps extends ReactPaperOptions, GraphProps { - readonly children: React.ReactNode; -} -const EMPTY_OBJECT = {} as const; - -// eslint-disable-next-line jsdoc/require-jsdoc -function Component(props: Readonly) { - const { children, ...paperOptions } = props; - const graph = useGraph(); - - const { onRenderElement, recordOfSVGElements } = usePaperElementRenderer(); - - const [paperCtx] = useState(function (): PaperContext { - const portsStore = createPortsStore(); - const elementView = dia.ElementView.extend({ - // Render element using react, `elementView.el` is used as portal gate for react (createPortal) - onRender() { - // eslint-disable-next-line unicorn/no-this-assignment, @typescript-eslint/no-this-alias, no-shadow, @typescript-eslint/no-shadow - const elementView: dia.ElementView = this; - onRenderElement(elementView.model, elementView.el as SVGGElement); - }, - // Render port using react, `portData.portElement.node` is used as portal gate for react (createPortal) - _renderPorts() { - // This is firing when the ports are rendered (updated, inserted, removed) - // @ts-expect-error we use private jointjs api method, it throw error here. - dia.ElementView.prototype._renderPorts.call(this); - // eslint-disable-next-line unicorn/no-this-assignment, @typescript-eslint/no-this-alias, no-shadow, @typescript-eslint/no-shadow - const elementView: dia.ElementView = this; - - const portElementsCache: Record = this._portElementsCache; - portsStore.onRenderPorts(elementView.model.id, portElementsCache); - }, - }); - - // Create a new JointJS Paper with the provided options - const paper = new dia.Paper({ - async: true, - sorting: dia.Paper.sorting.APPROX, - preventDefaultBlankAction: false, - frozen: true, - model: graph, - elementView, - ...paperOptions, - clickThreshold: paperOptions.clickThreshold ?? DEFAULT_CLICK_THRESHOLD, - }); - - return { - paper, - portsStore, - recordOfSVGElements: EMPTY_OBJECT, - }; - }); - - useEffect(() => { - paperCtx.paper.options = { - ...paperCtx.paper.options, - ...paperOptions, - }; - }, [paperCtx.paper, paperOptions]); - - const paperContextFull = useMemo((): PaperContext => { - return { - ...paperCtx, - recordOfSVGElements, - }; - }, [paperCtx, recordOfSVGElements]); - - // Remove the check for existing context, always provide PaperContext - return {children}; -} - -/** - * PaperProvider is a React component that provides a context for managing the state of the paper. - * It uses the PaperContext to provide a value to its children. - * The context value is an array containing the current paper context and a function to update it. - * @param props - The props object containing the children components. - * @param props.children - The children components that will have access to the PaperContext. - * @returns - A JSX element that wraps the children with the PaperContext provider. - * @group Components - */ -export function PaperProvider(props: Readonly) { - const { - children, - initialElements, - initialLinks, - graph, - cellNamespace, - cellModel, - store, - ...paperOptions - } = props; - const hasStore = !!useContext(GraphStoreContext); - const content = {children}; - if (hasStore) { - return content; - } - return ( - - {content} - - ); -} diff --git a/packages/joint-react/src/components/paper/__tests__/__snapshots__/graph-provider.test.tsx.snap b/packages/joint-react/src/components/paper/__tests__/__snapshots__/graph-provider.test.tsx.snap new file mode 100644 index 0000000000..0f42aafeeb --- /dev/null +++ b/packages/joint-react/src/components/paper/__tests__/__snapshots__/graph-provider.test.tsx.snap @@ -0,0 +1,15 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`graph should render children and match snapshot 1`] = ` +
+ Child Content +
+`; + +exports[`graph should render children and match snapshot 2`] = ` + +
+ Child Content +
+
+`; diff --git a/packages/joint-react/src/components/paper/__tests__/__snapshots__/paper.test.tsx.snap b/packages/joint-react/src/components/paper/__tests__/__snapshots__/paper.test.tsx.snap deleted file mode 100644 index 51c63795e1..0000000000 --- a/packages/joint-react/src/components/paper/__tests__/__snapshots__/paper.test.tsx.snap +++ /dev/null @@ -1,17 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Paper "WithAutoFitContent": Paper-WithAutoFitContent 1`] = `"
"`; - -exports[`Paper "WithCustomEvent": Paper-WithCustomEvent 1`] = `"
"`; - -exports[`Paper "WithEvent": Paper-WithEvent 1`] = `"
"`; - -exports[`Paper "WithGrid": Paper-WithGrid 1`] = `"
"`; - -exports[`Paper "WithHTMLElement": Paper-WithHTMLElement 1`] = `"
"`; - -exports[`Paper "WithLinkTools": Paper-WithLinkTools 1`] = `"
"`; - -exports[`Paper "WithRectElement": Paper-WithRectElement 1`] = `"
"`; - -exports[`Paper "WithScaleDown": Paper-WithScaleDown 1`] = `"
"`; diff --git a/packages/joint-react/src/components/paper/__tests__/graph-provider.test.tsx b/packages/joint-react/src/components/paper/__tests__/graph-provider.test.tsx new file mode 100644 index 0000000000..eac4d99730 --- /dev/null +++ b/packages/joint-react/src/components/paper/__tests__/graph-provider.test.tsx @@ -0,0 +1,373 @@ +import React, { createRef, useState } from 'react'; +import { act, render, waitFor } from '@testing-library/react'; +import { GraphStoreContext } from '../../../context'; +import { createStore, type GraphStore } from '../../../data/create-graph-store'; +import { dia } from '@joint/core'; +import { useElements, useLinks } from '../../../hooks'; +import { createElements } from '../../../utils/create'; +import type { GraphElement } from '../../../types/element-types'; +import { GraphProvider } from '../../graph/graph-provider'; + +describe('graph', () => { + it('should render children and match snapshot', () => { + const { asFragment, getByText } = render( + +
Child Content
+
+ ); + expect(getByText('Child Content')).toMatchSnapshot(); + expect(asFragment()).toMatchSnapshot(); + }); + + it('should provide a graph instance in context', () => { + let contextGraph: GraphStore | null = null; + function TestComponent() { + contextGraph = React.useContext(GraphStoreContext); + return null; + } + render( + + + + ); + + if (!contextGraph) { + throw new Error('contextGraph is not defined'); + } + expect(contextGraph).toBeDefined(); + }); + + it('should render graph provider with links and elements', async () => { + const elements = createElements([ + { + width: 100, + height: 100, + id: 'element1', + }, + ]); + const link = new dia.Link({ id: 'link1', type: 'standard.Link', source: { id: 'element1' } }); + let linkCount = 0; + let elementCount = 0; + function TestComponent() { + linkCount = useElements((items) => items.length); + elementCount = useLinks((items) => { + return items.length; + }); + return null; + } + render( + // eslint-disable-next-line react-perf/jsx-no-new-array-as-prop + + + + ); + + await waitFor(() => { + expect(linkCount).toBe(1); + expect(elementCount).toBe(1); + }); + }); + it('should add elements and links after initial load and useElements and useLinks should catch them', async () => { + const graph = new dia.Graph(); + let linkCount = 0; + let elementCount = 0; + // eslint-disable-next-line sonarjs/no-identical-functions + function TestComponent() { + linkCount = useElements((items) => items.length); + elementCount = useLinks((items) => { + return items.length; + }); + return null; + } + render( + + + + ); + + await waitFor(() => { + expect(linkCount).toBe(0); + expect(elementCount).toBe(0); + }); + + act(() => { + graph.addCells([ + new dia.Element({ id: 'element1', type: 'standard.Rectangle' }), + new dia.Link({ id: 'link1', type: 'standard.Link', source: { id: 'element1' } }), + ]); + }); + + await waitFor(() => { + expect(linkCount).toBe(1); + expect(elementCount).toBe(1); + }); + }); + + it('should initialize with default elements', async () => { + const elements = createElements([ + { width: 100, height: 100, id: 'element1' }, + { width: 200, height: 200, id: 'element2' }, + ]); + let elementCount = 0; + function TestComponent() { + elementCount = useElements((items) => items.length); + return null; + } + render( + + + + ); + + await waitFor(() => { + expect(elementCount).toBe(2); + }); + }); + + it('should use provided store and clean up on unmount', () => { + const mockDestroy = jest.fn(); + const mockStore = createStore({}); + // @ts-expect-error its just unit test, readonly is not needed + mockStore.destroy = mockDestroy; + + const { unmount } = render( + +
Test
+
+ ); + + expect(mockDestroy).not.toHaveBeenCalled(); + unmount(); + expect(mockDestroy).toHaveBeenCalled(); + }); + + it('should use graph provided by PaperOptions', async () => { + const graph = new dia.Graph(); + const cell = new dia.Element({ id: 'element1', type: 'standard.Rectangle' }); + graph.addCell(cell); + let currentElements: GraphElement[] = []; + function Elements() { + const elements = useElements(); + currentElements = elements; + return null; + } + + const { unmount } = render( + + +
Test
+
+ ); + + expect(graph.getCell('element1')).toBe(cell); + + await waitFor(() => { + expect(graph.getCells()).toHaveLength(1); + expect(currentElements).toHaveLength(1); + }); + + act(() => { + graph.addCell(new dia.Element({ id: 'element2', type: 'standard.Rectangle' })); + }); + + await waitFor(() => { + expect(graph.getCell('element2')).toBeDefined(); + expect(graph.getCells()).toHaveLength(2); + expect(currentElements).toHaveLength(2); + }); + + // its external graph, so we do not destroy it + unmount(); + await waitFor(() => { + expect(graph.getCells()).toHaveLength(2); + expect(currentElements).toHaveLength(2); + }); + }); + + it('should use store provided by PaperOptions', async () => { + const graph = new dia.Graph(); + const store = createStore({ graph }); + const cell = new dia.Element({ id: 'element1', type: 'standard.Rectangle' }); + graph.addCell(cell); + let currentElements: GraphElement[] = []; + // eslint-disable-next-line sonarjs/no-identical-functions + function Elements() { + const elements = useElements(); + currentElements = elements; + return null; + } + + const { unmount } = render( + + +
Test
+
+ ); + + expect(graph.getCell('element1')).toBe(cell); + + await waitFor(() => { + expect(graph.getCells()).toHaveLength(1); + expect(currentElements).toHaveLength(1); + }); + + act(() => { + graph.addCell(new dia.Element({ id: 'element2', type: 'standard.Rectangle' })); + }); + + await waitFor(() => { + expect(graph.getCell('element2')).toBeDefined(); + expect(graph.getCells()).toHaveLength(2); + expect(currentElements).toHaveLength(2); + }); + + // its external graph, so we do not destroy it + unmount(); + await waitFor(() => { + expect(graph.getCells()).toHaveLength(2); + expect(currentElements).toHaveLength(2); + }); + }); + + it('should render graph provider with links and elements - with explicit react type', async () => { + const elements = createElements([ + { + width: 100, + height: 100, + id: 'element1', + type: 'ReactElement', + }, + ]); + const link = new dia.Link({ id: 'link1', type: 'standard.Link', source: { id: 'element1' } }); + let linkCount = 0; + let elementCount = 0; + // eslint-disable-next-line sonarjs/no-identical-functions + function TestComponent() { + linkCount = useElements((items) => items.length); + elementCount = useLinks((items) => { + return items.length; + }); + return null; + } + render( + // eslint-disable-next-line react-perf/jsx-no-new-array-as-prop + + + + ); + + await waitFor(() => { + expect(linkCount).toBe(1); + expect(elementCount).toBe(1); + }); + }); + + it('should update graph in controlled mode', async () => { + const initialElements = createElements([ + { + width: 100, + height: 100, + id: 'element1', + type: 'ReactElement', + }, + ]); + const initialLink = new dia.Link({ + id: 'link1', + type: 'standard.Link', + source: { id: 'element1' }, + }); + let linkCount = 0; + let elementCount = 0; + function TestComponent() { + linkCount = useLinks((items) => { + return items.length; + }); + elementCount = useElements((items) => { + return items.length; + }); + return null; + } + + // eslint-disable-next-line unicorn/consistent-function-scoping + let setElementsOutside = (_: GraphElement[]) => {}; + let setLinksOutside = (_: dia.Link[]) => {}; + + function Graph() { + const [elements, setElements] = useState(initialElements); + const [links, setLinks] = useState([initialLink]); + setElementsOutside = setElements as unknown as (elements: GraphElement[]) => void; + setLinksOutside = setLinks as unknown as (links: dia.Link[]) => void; + return ( + + + + ); + } + render(); + + await waitFor(() => { + expect(linkCount).toBe(1); + expect(elementCount).toBe(1); + }); + + act(() => { + setElementsOutside( + createElements([ + { + width: 100, + height: 100, + id: 'element1', + type: 'ReactElement', + }, + { + width: 10, + height: 10, + id: 'element2', + type: 'ReactElement', + }, + ]) + ); + }); + + await waitFor(() => { + expect(linkCount).toBe(1); + expect(elementCount).toBe(2); + }); + + // add link + act(() => { + setLinksOutside([ + new dia.Link({ + id: 'link2', + type: 'standard.Link', + source: { id: 'element1' }, + target: { id: 'element2' }, + }), + new dia.Link({ + id: 'link3', + type: 'standard.Link', + source: { id: 'element1' }, + target: { id: 'element2' }, + }), + ]); + }); + + await waitFor(() => { + expect(linkCount).toBe(2); + expect(elementCount).toBe(2); + }); + }); + + it('should pass ref instance to the GraphProvider component', () => { + // eslint-disable-next-line @eslint-react/no-create-ref + const graphRef = createRef(); + render(); + expect(graphRef.current).not.toBeNull(); + expect(graphRef.current?.destroy).toBeDefined(); + }); +}); diff --git a/packages/joint-react/src/components/paper/__tests__/paper.test.tsx b/packages/joint-react/src/components/paper/__tests__/paper.test.tsx index e5de311b1d..5baa08e2a7 100644 --- a/packages/joint-react/src/components/paper/__tests__/paper.test.tsx +++ b/packages/joint-react/src/components/paper/__tests__/paper.test.tsx @@ -1,21 +1,24 @@ +/* eslint-disable @eslint-react/web-api/no-leaked-timeout */ +/* eslint-disable react-perf/jsx-no-new-object-as-prop */ +/* eslint-disable sonarjs/no-nested-functions */ /* eslint-disable react-perf/jsx-no-new-function-as-prop */ import { render, screen, waitFor } from '@testing-library/react'; -import { GraphProvider } from '../../graph-provider/graph-provider'; +import '@testing-library/jest-dom'; import { createElements, type InferElement } from '../../../utils/create'; -import { Paper } from '../paper'; import { MeasuredNode } from '../../measured-node/measured-node'; -import { runStorybookSnapshot } from '../../../utils/run-storybook-snapshot'; -import * as stories from '../paper.stories'; -import { usePaper } from '../../../hooks'; -import { useEffect } from 'react'; - -const initialElements = createElements([ - { id: '1', label: 'Node 1' }, - { id: '2', label: 'Node 2' }, +import { act, useEffect, useRef, useState, type RefObject } from 'react'; +import type { PaperContext } from '../../../context'; +import { useGraph, usePaperContext } from '../../../hooks'; +import { GraphProvider } from '../../graph/graph-provider'; +import { Paper } from '../paper'; + +const elements = createElements([ + { id: '1', label: 'Node 1', width: 10, height: 10 }, + { id: '2', label: 'Node 2', width: 10, height: 10 }, ]); -type Element = InferElement; -const PAPER_WIDTH = 200; +type Element = InferElement; +const WIDTH = 200; // we need to mock `new ResizeObserver`, to return the size width 50 and height 50 for test purposes // Mock ResizeObserver to return a size with width 50 and height 50 @@ -33,32 +36,29 @@ jest.mock('../../../hooks/use-are-elements-measured', () => ({ useAreElementMeasured: jest.fn(() => true), })); -runStorybookSnapshot({ - Component: Paper, - name: 'Paper', - stories, -}); - describe('Paper Component', () => { it('renders elements correctly with correct measured node and onMeasured event', async () => { const onMeasuredMock = jest.fn(); let size = { width: 0, height: 0 }; + + const renderElement = ({ label, width, height }: Element) => { + size = { width, height }; + return ( + + +
{label}
+
+
+ ); + }; + render( - - - width={PAPER_WIDTH} + + { - size = { width, height }; - return ( - - -
{label}
-
-
- ); - }} + renderElement={renderElement} />
); @@ -72,10 +72,12 @@ describe('Paper Component', () => { it('renders elements correctly with useHTMLOverlay enabled', async () => { render( - + useHTMLOverlay - renderElement={({ label }) =>
{label}
} + renderElement={({ label }) => { + return
{label}
; + }} />
); @@ -94,7 +96,7 @@ describe('Paper Component', () => { ]); const { rerender } = render( - + onElementsSizeChange={onElementsSizeChangeMock} renderElement={({ label }) =>
{label}
} @@ -104,7 +106,7 @@ describe('Paper Component', () => { // Simulate element size change by rerendering with updated elements rerender( - + onElementsSizeChange={onElementsSizeChangeMock} renderElement={({ label }) =>
{label}
} @@ -117,30 +119,22 @@ describe('Paper Component', () => { }); }); - it('overwrites default paper element with overwriteDefaultPaperElement', () => { - const customElement = document.createElement('div'); - customElement.className = 'custom-paper-element'; - render( - - overwriteDefaultPaperElement={() => customElement} /> - - ); - expect(document.querySelector('.custom-paper-element')).toBeInTheDocument(); - }); - it('should fire custom event on the paper', async () => { + it('should fire custom event on the Paper', async () => { const handleCustomEvent = jest.fn(); // eslint-disable-next-line unicorn/consistent-function-scoping function FireEvent() { - const paper = usePaper(); + const { paper } = usePaperContext() ?? {}; useEffect(() => { - paper.trigger('MyCustomEventOnClick', { message: 'Hello from custom event!' }); + paper?.trigger('MyCustomEventOnClick', { message: 'Hello from custom event!' }); }, [paper]); return null; } + + const customEvents = { MyCustomEventOnClick: handleCustomEvent }; render( - - onCustomEvent={handleCustomEvent}> + + customEvents={customEvents}>
@@ -153,30 +147,33 @@ describe('Paper Component', () => { it('applies default clickThreshold and custom clickThreshold', () => { render( - + /> ); - const paperElement = document.querySelector('.joint-paper'); - expect(paperElement).toBeInTheDocument(); + const PaperElement = document.querySelector('.joint-paper'); + expect(PaperElement).toBeInTheDocument(); render( - + clickThreshold={20} /> ); // Ensure no errors occur when custom clickThreshold is applied - expect(paperElement).toBeInTheDocument(); + expect(PaperElement).toBeInTheDocument(); }); - it('applies scale to the paper', () => { + it('applies scale to the Paper', async () => { render( - + scale={2} /> ); - const layersGroup = document.querySelector('.joint-layers'); - expect(layersGroup).toHaveAttribute('transform', 'matrix(2,0,0,2,0,0)'); + + await waitFor(() => { + const layersGroup = document.querySelector('.joint-layers'); + expect(layersGroup).toHaveAttribute('transform', 'matrix(2,0,0,2,0,0)'); + }); }); it('uses default elementSelector and custom elementSelector', async () => { @@ -186,14 +183,11 @@ describe('Paper Component', () => { return ; } render( - + elementSelector={customSelector} renderElement={RenderElement} /> ); - // Ensure the customSelector is called for each element - expect(customSelector).toHaveBeenCalledTimes(initialElements.length); - await waitFor(() => { // Validate that the elements are rendered correctly const element = document.querySelector('#isCustom'); @@ -205,7 +199,7 @@ describe('Paper Component', () => { it('calls onElementsSizeReady when elements are measured', async () => { const onElementsSizeReadyMock = jest.fn(); render( - + onElementsSizeReady={onElementsSizeReadyMock} /> ); @@ -213,4 +207,181 @@ describe('Paper Component', () => { expect(onElementsSizeReadyMock).toHaveBeenCalledTimes(1); }); }); + + it('calls onElementsSizeReady when elements are measured - conditional render', async () => { + const RenderElement = jest.fn(({ label }) =>
{label}
); + function Content() { + const [isReady, setIsReady] = useState(false); + useEffect(() => { + setTimeout(() => { + setIsReady(true); + }, 100); + }, []); + return ( + + {isReady && ( + + renderElement={RenderElement} + onElementsSizeReady={onElementsSizeReadyMock} + /> + )} + + ); + } + const onElementsSizeReadyMock = jest.fn(); + render(); + await waitFor(() => { + expect(RenderElement).toHaveBeenCalledTimes(2); // Called for each element + expect(onElementsSizeReadyMock).toHaveBeenCalledTimes(1); + }); + }); + + it('handles ref from Paper correctly', () => { + const ref = { current: null }; + + render( + + ref={ref} /> + + ); + expect(ref.current).not.toBeNull(); + }); + it('should access paper via context and change scale', async () => { + // eslint-disable-next-line unicorn/consistent-function-scoping + function ChangeScale({ paperRef }: { paperRef: RefObject }) { + useEffect(() => { + const { paper } = paperRef.current ?? {}; + paper?.scale(2, 2); + }, [paperRef]); + return null; + } + + function Component() { + const ref = useRef(null); + return ( + + ref={ref} /> + + + ); + } + + render(); + + await waitFor(() => { + const layersGroup = document.querySelector('.joint-layers'); + expect(layersGroup).toHaveAttribute('transform', 'matrix(2,0,0,2,0,0)'); + }); + }); + it('should access paper via ref and change scale', async () => { + const ref: RefObject = { current: null }; + function ChangeScale() { + const { paper } = ref.current ?? {}; + useEffect(() => { + paper?.scale(2, 2); + }, [paper]); + return null; + } + + render( + + ref={ref} /> + + + ); + }); + + it('should set elements and positions via react state, when change it via paper api', async () => { + // eslint-disable-next-line unicorn/consistent-function-scoping + function UpdatePosition() { + const graph = useGraph(); + useEffect(() => { + setTimeout(() => { + const element = graph.getCell('1'); + element.set('position', { x: 100, y: 100 }); + }, 20); + }, [graph]); + return null; + } + let currentOutsideElements: Element[] = []; + function Content() { + const [currentElements, setCurrentElements] = useState(elements); + currentOutsideElements = currentElements; + return ( + + /> + + + ); + } + render(); + await waitFor(() => { + const element1 = currentOutsideElements.find((element) => element.id === '1'); + expect(element1).toBeDefined(); + // @ts-expect-error we know it's element + expect(element1.x).toBe(100); + // @ts-expect-error we know it's element + expect(element1.y).toBe(100); + }); + }); + it('should update elements via react state, and then reflect the changes in the paper', async () => { + function Content() { + const [currentElements, setCurrentElements] = useState(elements); + + return ( + + + renderElement={({ width, height, id }) => { + return ( +
+ {id} +
+ ); + }} + /> + +
+ ); + } + render(); + const button = screen.getByRole('button', { name: 'Update Element 1' }); + expect(button).toBeInTheDocument(); + act(() => { + button.click(); + }); + await waitFor(() => { + const element = document.querySelector('#node-1'); + expect(element).toBeDefined(); + expect(element).toHaveStyle({ width: '200px', height: '200px' }); + }); + }); + it('should test two separate Paper with same paper, and get their data via ref', async () => { + const view1Ref: RefObject = { current: null }; + const view2Ref: RefObject = { current: null }; + + render( + + ref={view1Ref} /> + ref={view2Ref} /> + + ); + + await waitFor(() => { + expect(view1Ref.current).not.toBeNull(); + expect(view2Ref.current).not.toBeNull(); + expect(view1Ref.current).not.toBe(view2Ref.current); + expect(view1Ref.current?.paper).not.toBe(view2Ref.current?.paper); + }); + }); }); diff --git a/packages/joint-react/src/components/paper/index.ts b/packages/joint-react/src/components/paper/index.ts new file mode 100644 index 0000000000..5b0a24b2f3 --- /dev/null +++ b/packages/joint-react/src/components/paper/index.ts @@ -0,0 +1,4 @@ +export * from './paper'; +export * from './paper.types'; +export * from './render-element/paper-element-item'; +export * from './render-element/paper-html-container'; diff --git a/packages/joint-react/src/components/paper/paper-check.tsx b/packages/joint-react/src/components/paper/paper-check.tsx deleted file mode 100644 index 4d4bf82613..0000000000 --- a/packages/joint-react/src/components/paper/paper-check.tsx +++ /dev/null @@ -1,92 +0,0 @@ -/* eslint-disable sonarjs/cognitive-complexity */ -import { useEffect } from 'react'; -import type { GraphElement } from '../../types/element-types'; -import type { PaperProps } from './paper'; -import type { ReactPaperOptions } from '../paper-provider/paper-provider'; - -const PAPER_PROPS_NAMES: Array = [ - 'afterRender', - 'allowLink', - 'anchorNamespace', - 'async', - 'autoFreeze', - 'background', - 'beforeRender', - 'cellViewNamespace', - 'clickThreshold', - 'connectionPointNamespace', - 'connectionStrategy', - 'connectorNamespace', - 'defaultAnchor', - 'defaultConnectionPoint', - 'defaultConnector', - 'defaultLink', - 'defaultLinkAnchor', - 'defaultRouter', - 'drawGrid', - 'drawGridSize', - 'elementView', - 'embeddingMode', - 'findParentBy', - 'frontParentOnly', - 'gridSize', - 'guard', - 'height', - 'highlighterNamespace', - 'highlighting', - 'interactive', - 'labelsLayer', - 'linkAnchorNamespace', - 'linkPinning', - 'linkView', - 'magnetThreshold', - 'markAvailable', - 'measureNode', - 'moveThreshold', - 'multiLinks', - 'onViewPostponed', - 'onViewUpdate', - 'overflow', - 'preventContextMenu', - 'preventDefaultBlankAction', - 'preventDefaultViewAction', - 'restrictTranslate', - 'routerNamespace', - 'snapLabels', - 'snapLinks', - 'snapLinksSelf', - 'sorting', - 'validateConnection', - 'validateEmbedding', - 'validateMagnet', - 'validateUnembedding', - 'viewport', - 'width', -]; -/** - * `VerifyProps` is a component that checks the properties of the Paper component in development mode. - * This component is ignored in production mode. - * @param props - The properties of the Paper component. - * @returns - Returns null in production mode, or a verification component in development mode. - */ -export function PaperCheck( - props: PaperProps -) { - useEffect(() => { - if (process.env.NODE_ENV === 'production') { - return; - } - const warnings = PAPER_PROPS_NAMES.filter((propertyName) => props[propertyName] !== undefined); - if (warnings.length === 0) { - return; - } - // eslint-disable-next-line no-console - console.warn( - `[Paper] The following props were set directly on \n"": "${warnings.join(', ')}". -If you're using , these options should be defined there instead. -When" is present, any props set on will be ignored. -If you're NOT using "", then it's perfectly fine to set options directly on .` - ); - }, [props]); - return null; -} diff --git a/packages/joint-react/src/components/paper/paper.stories.tsx b/packages/joint-react/src/components/paper/paper.stories.tsx index 90a911969b..dd9286ab16 100644 --- a/packages/joint-react/src/components/paper/paper.stories.tsx +++ b/packages/joint-react/src/components/paper/paper.stories.tsx @@ -1,18 +1,24 @@ +/* eslint-disable react-hooks/rules-of-hooks */ +/* eslint-disable react-perf/jsx-no-new-array-as-prop */ +/* eslint-disable react-perf/jsx-no-new-function-as-prop */ /* eslint-disable react-perf/jsx-no-new-object-as-prop */ -import type { Meta, StoryObj } from '@storybook/react/*'; -import { Paper } from './paper'; +import type { Meta, StoryObj } from '@storybook/react'; import { SimpleGraphDecorator, type SimpleElement, } from '../../../.storybook/decorators/with-simple-data'; import { action } from '@storybook/addon-actions'; import { dia, linkTools } from '@joint/core'; -import { jsx } from '@joint/react/src/utils/joint-jsx/jsx-to-markup'; import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; -import { makeRootDocumentation } from '@joint/react/src/stories/utils/make-story'; -import { getAPILink } from '@joint/react/src/stories/utils/get-api-documentation-link'; import { MeasuredNode } from '../measured-node/measured-node'; +import { getAPILink } from '../../stories/utils/get-api-documentation-link'; +import { makeRootDocumentation } from '../../stories/utils/make-story'; +import { jsx } from '../../utils/joint-jsx/jsx-to-markup'; +import { useCellActions } from '../../hooks/use-cell-actions'; +import { Paper } from './paper'; +import type { RenderElement } from './paper.types'; +import { GraphProvider } from '../graph/graph-provider'; export type Story = StoryObj; @@ -23,11 +29,15 @@ const meta: Meta = { decorators: [SimpleGraphDecorator], parameters: makeRootDocumentation({ description: ` -Paper is a component that renders graph elements. It is used to display and interact with graph elements. +Paper renders nodes and links using the JointJS Paper under the hood. Compose it inside a GraphProvider. Define node UI via the renderElement prop, and use useHTMLOverlay or for HTML content. `, apiURL: API_URL, - code: `import { Paper } from '@joint/react' - } /> + code: `import { GraphProvider } from '@joint/react' + + ( + + )} /> + `, }), }; @@ -77,16 +87,6 @@ export const WithHTMLElement: Story = { }, }; -export const WithGrid: Story = { - args: { - drawGrid: true, - gridSize: 10, - renderElement: RenderHTMLElement as never, - width: '100%', - className: PAPER_CLASSNAME, - }, -}; - export const WithScaleDown: Story = { args: { scale: 0.7, @@ -133,7 +133,6 @@ export const WithEvent: Story = { onCellPointerDown: action('onCellPointerDown'), onCellPointerMove: action('onCellPointerMove'), onCellPointerUp: action('onCellPointerUp'), - onCustomEvent: action('onCustomEvent'), onElementContextMenu: action('onElementContextMenu'), onElementMagnetContextMenu: action('onElementMagnetContextMenu'), onElementMagnetPointerClick: action('onElementMagnetPointerClick'), @@ -220,12 +219,74 @@ export const WithCustomEvent: Story = { onElementPointerClick: ({ paper }) => { paper.trigger('MyCustomEventOnClick', { message: 'Hello from custom event!' }); }, - onCustomEvent: ({ args, eventName }) => { - action('onCustomEvent')( - `Custom event triggered: ${eventName} with args: ${JSON.stringify(args)}` - ); - }, width: '100%', className: PAPER_CLASSNAME, }, }; + +export const WithDrawGrid: Story = { + args: { + renderElement: RenderHTMLElement as never, + onElementPointerClick: ({ paper }) => { + paper.trigger('MyCustomEventOnClick', { message: 'Hello from custom event!' }); + }, + className: PAPER_CLASSNAME, + drawGrid: { name: 'dot', thickness: 2, color: 'white' }, + drawGridSize: 10, + }, +}; + +export const WithOnClickColorChange: Story = { + args: {}, + render: () => { + const renderElement: RenderElement = ({ width, height, hoverColor, id }) => { + const { set } = useCellActions(); + return ( +
{ + set(id, (previous) => ({ ...previous, hoverColor: 'blue' })); + }} + style={{ width, height, backgroundColor: hoverColor }} + >
+ ); + }; + return ( + + + + ); + }, +}; diff --git a/packages/joint-react/src/components/paper/paper.tsx b/packages/joint-react/src/components/paper/paper.tsx index c05392ef33..db7cc514dc 100644 --- a/packages/joint-react/src/components/paper/paper.tsx +++ b/packages/joint-react/src/components/paper/paper.tsx @@ -1,178 +1,271 @@ -import { type dia } from '@joint/core'; +import { dia, mvc, shapes } from '@joint/core'; +import { useElementViews } from '../../hooks/use-element-views'; +import { useGraphStore } from '../../hooks/use-graph-store'; import { + forwardRef, + useCallback, useContext, useEffect, + useId, + useLayoutEffect, useMemo, useRef, useState, type CSSProperties, - type ReactNode, } from 'react'; +import { useAreElementMeasured, useElements, useImperativeApi } from '../../hooks'; +import { createPortsStore } from '../../data/create-ports-store'; +import type { PortElementsCacheEntry } from '../../data/create-ports-data'; import type { GraphElement } from '../../types/element-types'; +import type { PaperProps } from './paper.types'; +import { assignOptions, dependencyExtract } from '../../utils/object-utilities'; import { noopSelector } from '../../utils/noop-selector'; -import { useCreatePaper } from '../../hooks/use-create-paper'; -import { useElements } from '../../hooks/use-elements'; -import { CellIdContext } from '../../context/cell-id.context'; -import { HTMLElementItem, SVGElementItem } from './paper-element-item'; -import { type GraphProps } from '../graph-provider/graph-provider'; -import typedMemo from '../../utils/typed-memo'; -import type { PaperEvents } from '../../types/event.types'; +import { PaperHTMLContainer } from './render-element/paper-html-container'; +import { + CellIdContext, + PaperConfigContext, + PaperContext, + type OverWriteResult, +} from '../../context'; +import { HTMLElementItem, SVGElementItem } from './render-element/paper-element-item'; import { REACT_TYPE } from '../../models/react-element'; -import { useAreElementMeasured } from '../../hooks/use-are-elements-measured'; -import { PaperHTMLContainer } from './paper-html-container'; -import { useGraph } from '../../hooks'; -import { PaperProvider, type ReactPaperOptions } from '../paper-provider/paper-provider'; -import { PaperContext } from '../../context'; -import { PaperCheck } from './paper-check'; -export interface OnLoadOptions { - readonly paper: dia.Paper; - readonly graph: dia.Graph; -} -export type RenderElement = ( - element: ElementItem -) => ReactNode; +import { handlePaperEvents, PAPER_EVENT_KEYS } from '../../utils/handle-paper-events'; + +const DEFAULT_CLICK_THRESHOLD = 10; + +const EMPTY_OBJECT = {} as Record; /** - * The props for the Paper component. Extend the `dia.Paper.Options` interface. - * For more information, see the JointJS documentation. - * @see https://docs.jointjs.com/api/dia/Paper + * Paper component renders the visual representation of the graph using JointJS Paper. + * This component is responsible for managing the rendering of elements and links, handling events, and providing customization options for the graph view. + * @param props - The properties for the Paper component. + * @param forwardedRef - A reference to the PaperContext instance. + * @returns The Paper component. + * @example + * Using the Paper component: + * ```tsx + * import { Paper } from '@joint/react'; + * function App() { + * return ( + * } + * defaultLink={(cellView, magnet) => new dia.Link()} + * > + * + * + * ); + * } + * ``` */ -export interface PaperProps - extends ReactPaperOptions, - GraphProps, - PaperEvents { - /** - * A function that renders the element. - * - * Note: Jointjs works by default with SVG's so by default renderElement is append inside the SVGElement node. - * To use HTML elements, you need to use the `HTMLNode` component or `foreignObject` element. - * - * This is called when the data from `elementSelector` changes. - * @example - * Example with `global component`: - * ```tsx - * type BaseElementWithData = InferElement - * function RenderElement({ label }: BaseElementWithData) { - * return {label} - * } - * ``` - * @example - * Example with `local component`: - * ```tsx - * - type BaseElementWithData = InferElement - const renderElement: RenderElement = useCallback( - (element) => {element.label}, - [] - ) - * ``` - */ - readonly renderElement?: RenderElement; - /** - * Event called when all elements are properly measured (has all elements width and height greater than 1 - default). - * In react, we cannot detect jointjs paper render:done event properly, so we use this special event to check if all elements are measured. - * It is useful for like onLoad event to do some layout or other operations with `graph` or `paper`. - */ - readonly onElementsSizeReady?: (options: OnLoadOptions) => void; - - /** - * Event called when the paper is resized. - * It is useful for like onLoad event to do some layout or other operations with `graph` or `paper`. - */ - readonly onElementsSizeChange?: (options: OnLoadOptions) => void; - - /** - * The style of the paper element. - */ - readonly style?: CSSProperties; - /** - * Class name of the paper element. - */ - readonly className?: string; - - /** - * A function that selects the elements to be rendered. - * It defaults to the `GraphElement` elements because `dia.Element` is not a valid React element (it do not change reference after update). - * @default (item: dia.Cell) => `BaseElement` - * @see GraphElement - */ - readonly elementSelector?: (item: GraphElement) => ElementItem; - /** - * The scale of the paper. It's useful to create for example a zoom feature or minimap Paper. - */ - - readonly scale?: number; - /** - * Children to render. Paper automatically wrap the children with the PaperContext, if there is no PaperContext in the parent tree. - */ - readonly children?: ReactNode; - - /** - * On load custom element. - * If provided, it must return valid HTML or SVG element and it will be replaced with the default paper element. - * So it overwrite default paper rendering. - * It is used internally for example to render `PaperScroller` from [joint plus](https://www.jointjs.com/jointjs-plus) package. - * @param paperCtx - The paper context - * @returns - */ - readonly overwriteDefaultPaperElement?: (paperCtx: PaperContext) => HTMLElement | SVGElement; - - /** - * The threshold for click events in pixels. - * If the mouse moves more than this distance, it will be considered a drag event. - * @default 10 - */ - readonly clickThreshold?: number; - - /** - * Enabled if renderElements is render to pure HTML elements. - * By default, `joint/react` renderElements to SVG elements, so for using HTML elements without this prop, you need to use `foreignObject` element. - * @default false - */ - readonly useHTMLOverlay?: boolean; -} - -// eslint-disable-next-line jsdoc/require-jsdoc -function Component( - props: Readonly> +function PaperBase( + props: PaperProps, + forwardedRef: React.ForwardedRef ) { const { renderElement, + defaultLink, style, className, elementSelector = noopSelector as (item: GraphElement) => ElementItem, - scale, - children, onElementsSizeReady, onElementsSizeChange, useHTMLOverlay, + children, + scale, ...paperOptions } = props; - const { paperContainerElement, paperCtx } = useCreatePaper({ - ...paperOptions, - scale, - }); + const { graph } = useGraphStore(); + const areElementsMeasured = useAreElementMeasured(); + const { onRenderElement, elementViews } = useElementViews(); + const elements = useElements((items) => items.map(elementSelector)); + const reactId = useId(); + const { overWrite } = useContext(PaperConfigContext) ?? {}; - const paperContext = useContext(PaperContext); - if (!paperContext) { - throw new Error('Paper must be used within a `PaperProvider` or `Paper` component'); - } - const { recordOfSVGElements } = paperContext; + const paperHTMLElement = useRef(null); + const measured = useRef(false); + const previousSizesRef = useRef([]); - const graph = useGraph(); const [HTMLRendererContainer, setHTMLRendererContainer] = useState(null); - const elements = useElements((items) => items.map(elementSelector)); - const areElementsMeasured = useAreElementMeasured(); - // Keep previous sizes in a ref - const previousSizesRef = useRef([]); + const id = props.id ?? `paper-${reactId}`; + const hasRenderElement = !!renderElement; + + const defaultLinkJointJS = useCallback( + (cellView: dia.CellView, magnet: SVGElement) => { + const link = typeof defaultLink === 'function' ? defaultLink(cellView, magnet) : defaultLink; + if (!link) { + return new shapes.standard.Link(); + } + if (link instanceof dia.Link) { + return link; + } + return new shapes.standard.Link(link as dia.Link.EndJSON); + }, + [defaultLink] + ); + const isReactId = !props.id; + + const { ref, isReady } = useImperativeApi( + { + forwardedRef, + onLoad() { + const portsStore = createPortsStore(); + const elementView = dia.ElementView.extend({ + // Render element using react, `elementView.el` is used as portal gate for react (createPortal) + onRender() { + // eslint-disable-next-line unicorn/no-this-assignment, @typescript-eslint/no-this-alias, no-shadow, @typescript-eslint/no-shadow + const elementView: dia.ElementView = this; + onRenderElement(elementView); + }, + // Render port using react, `portData.portElement.node` is used as portal gate for react (createPortal) + _renderPorts() { + // This is firing when the ports are rendered (updated, inserted, removed) + // @ts-expect-error we use private jointjs api method, it throw error here. + dia.ElementView.prototype._renderPorts.call(this); + // eslint-disable-next-line unicorn/no-this-assignment, @typescript-eslint/no-this-alias, no-shadow, @typescript-eslint/no-shadow + const elementView: dia.ElementView = this; + + const portElementsCache: Record = + this._portElementsCache; + portsStore.onRenderPorts(elementView.model.id, portElementsCache); + }, + }); + // Create a new JointJS Paper with the provided options + const paper = new dia.Paper({ + async: true, + sorting: dia.Paper.sorting.APPROX, + preventDefaultBlankAction: false, + frozen: true, + defaultLink: defaultLinkJointJS, + + model: graph, + elementView, + ...paperOptions, + // 👇 override to always allow connection + validateConnection: () => true, + + // 👇 also, allow links to start or end on empty space + validateMagnet: () => true, + clickThreshold: paperOptions.clickThreshold ?? DEFAULT_CLICK_THRESHOLD, + }); + + /** + * Render paper utility - is called when html element is bind to the react paper component + * @param element - The HTML element to render the paper into + * @returns - Context update if any + */ + function renderPaper(element: HTMLElement | SVGElement): OverWriteResult | undefined { + if (!paper) { + throw new Error('Paper is not created'); + } + + let elementToRender: HTMLElement | SVGElement = paper.el; + let overWriteResult: OverWriteResult | undefined = undefined; + if (overWrite) { + overWriteResult = overWrite(instance); + elementToRender = overWriteResult.element; + } + + if (!elementToRender) { + throw new Error('overwriteDefaultPaperElement must return a valid HTML or SVG element'); + } + + element.replaceChildren(elementToRender); + paper.unfreeze(); + return overWriteResult; + } + if (!paperHTMLElement.current) { + throw new Error('Paper HTML element is not available'); + } + + if (scale !== undefined) { + paper.scale(scale); + } + + const instance: PaperContext = { + paper, + portsStore, + elementViews: EMPTY_OBJECT, + id, + isReactId, + renderElement, + }; + + const contextUpdate = renderPaper(paperHTMLElement.current); + if (contextUpdate) { + Object.assign(instance, contextUpdate.contextUpdate); + } + return { + instance, + cleanup() { + paper.remove(); + portsStore.destroy(); + contextUpdate?.cleanup?.(); + }, + }; + }, + onUpdate(instance, reset) { + if (instance.id !== id) { + reset(); + } + const { paper } = instance; + assignOptions(paper.options, { + defaultLink: defaultLinkJointJS, + ...paperOptions, + }); + const { drawGrid, height, width, theme, gridSize } = paperOptions; + if (width !== undefined && height !== undefined) { + paper.setDimensions(width, height); + } + if (drawGrid) { + paper.setGrid(drawGrid); + } + if (gridSize !== undefined) { + paper.setGridSize(gridSize); + } + if (theme) { + paper.setTheme(theme); + } + if (scale !== undefined) { + paper.scale(scale); + } + }, + }, + [defaultLinkJointJS, id, scale, isReactId, ...dependencyExtract(paperOptions)] + ); + + useEffect(() => { + if (!isReady) return; + if (measured.current) return; + const { paper } = ref.current ?? {}; + if (!paper) return; + if (areElementsMeasured) { + measured.current = true; + return onElementsSizeReady?.({ paper, graph: paper.model }); + } + + // Handling dev warning check + if (process.env.NODE_ENV !== 'production') { + const timeout = setTimeout(() => { + if (!areElementsMeasured) { + // eslint-disable-next-line no-console + console.error( + 'The elements are not measured yet, please check if elements has defined width and height inside the nodes or using `MeasuredNode` component.' + ); + } + }, 1000); + return () => { + clearTimeout(timeout); + }; + } + }, [areElementsMeasured, isReady, onElementsSizeReady, ref]); // Whenever elements change (or we’ve just become measured) compare old ↔ new useEffect(() => { - if (!paperCtx) return; + if (!isReady) return; if (!onElementsSizeChange) return; if (!areElementsMeasured) return; - const { paper } = paperCtx; + const { paper } = ref.current ?? {}; if (!paper) return; // Build current list of [width, height] @@ -202,57 +295,33 @@ function Component( // store for next time previousSizesRef.current = currentSizes; onElementsSizeChange({ paper, graph: paper.model }); - }, [elements, areElementsMeasured, onElementsSizeChange, paperCtx]); - - const hasRenderElement = !!renderElement; - - const paperContainerStyle = useMemo( - (): CSSProperties => ({ - opacity: areElementsMeasured ? 1 : 0, - pointerEvents: areElementsMeasured ? 'all' : 'none', - position: 'relative', - overflow: 'hidden', - width: '100%', - height: '100%', - ...style, - }), - [areElementsMeasured, style] - ); + }, [areElementsMeasured, elements, isReady, onElementsSizeChange, ref]); - const measured = useRef(false); - - useEffect(() => { - if (!paperCtx) { - return; - } - if (measured.current) { - // If we already measured, we can skip this effect - return; - } - const { paper } = paperCtx; + useLayoutEffect(() => { + const { paper } = ref.current ?? {}; if (!paper) { return; } - if (areElementsMeasured) { - measured.current = true; - return onElementsSizeReady?.({ paper, graph: paper.model }); - } - - // Handling dev warning check - if (process.env.NODE_ENV !== 'production') { - const timeout = setTimeout(() => { - if (!areElementsMeasured) { - // eslint-disable-next-line no-console - console.error( - 'The elements are not measured yet, please check if elements has defined width and height inside the nodes or using `MeasuredNode` component.' - ); - } - }, 1000); - return () => { - clearTimeout(timeout); - }; + /** + * Resize the paper container element to match the paper size. + * @param jointPaper - The paper instance. + */ + function resizePaperContainer(jointPaper: dia.Paper) { + if (paperHTMLElement.current && jointPaper.el) { + paperHTMLElement.current.style.width = jointPaper.el.style.width; + paperHTMLElement.current.style.height = jointPaper.el.style.height; + } } - }, [areElementsMeasured, graph, onElementsSizeReady, paperCtx]); + // An object to keep track of the listeners. It's not exposed, so the users + const controller = new mvc.Listener(); + controller.listenTo(paper, 'resize', resizePaperContainer); + const stopListening = handlePaperEvents(graph, paper, paperOptions); + return () => { + controller.stopListening(); + stopListening(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [graph, isReady, ref, ...dependencyExtract(paperOptions, PAPER_EVENT_KEYS)]); const content = ( <> @@ -264,8 +333,17 @@ function Component( if (!cell.id) { return null; } - const portalHTMLElement = recordOfSVGElements[cell.id]; - if (!portalHTMLElement) { + const elementView = elementViews[cell.id]; + if (!elementView) { + return null; + } + + const SVG = elementView.el; + if (!SVG) { + return null; + } + + if (!elementView) { return null; } if (cell.type !== REACT_TYPE) { @@ -281,11 +359,7 @@ function Component( renderElement={renderElement} /> ) : ( - + )} ); @@ -293,88 +367,59 @@ function Component( ); - if (paperCtx) { - // we need this for shared paper context - joint plus - paperCtx.renderElement = renderElement as RenderElement; - } - const hasPaper = !!paperCtx?.paper; + const defaultStyle = useMemo((): CSSProperties => { + if (style) { + return style; + } + return { + width: paperOptions.width ?? '100%', + height: paperOptions.height ?? '100%', + }; + }, [paperOptions.height, paperOptions.width, style]); + + const paperContainerStyle = useMemo( + (): CSSProperties => ({ + opacity: areElementsMeasured ? 1 : 0, + position: 'relative', + ...defaultStyle, + }), + [areElementsMeasured, defaultStyle] + ); return ( - <> -
- {hasPaper && content} + +
+ {isReady && content}
- {hasPaper && children} - + {isReady && children} +
); } -// eslint-disable-next-line jsdoc/require-jsdoc -function PaperWithProviders( - props: Readonly> -) { - const hasPaperCtx = !!useContext(PaperContext); - const { children, ...rest } = props; - const content = {children}; - if (hasPaperCtx) { - const verifyProps = process.env.NODE_ENV !== 'production' && ; - // If PaperContext is already provided, we don't need to wrap it again - return ( - <> - {verifyProps} - {content} - - ); - } - return {content}; -} /** - * Paper component that renders the JointJS paper elements inside HTML. - * It uses `renderElement` to render the elements. - * It must be used within a `GraphProvider` context. - * @see GraphProvider - * @see PaperProps - * - * Props also extends `dia.Paper.Options` interface. - * @see dia.Paper.Options - * @group Components + * Paper component renders the visual representation of the graph using JointJS Paper. + * This component is responsible for managing the rendering of elements and links, handling events, and providing customization options for the graph view. + * @param props - The properties for the Paper component. + * @param forwardedRef - A reference to the PaperContext instance. + * @returns The Paper component. * @example - * Example with `global renderElement component`: + * Using the Paper component: * ```tsx - * import { createElements, InferElement, GraphProvider, Paper } from '@joint/react' - * - * const initialElements = createElements([ { id: '1', label: 'Node 1' , x: 100, y: 0, width: 100, height: 50 } ]) - * type BaseElementWithData = InferElement - * - * function RenderElement({ label }: BaseElementWithData) { - * return {label} + * import { Paper } from '@joint/react'; + * function App() { + * return ( + * } + * defaultLink={(cellView, magnet) => new dia.Link()} + * > + * + * + * ); * } - * function MyApp() { - * return - * - * - * } - * ``` - * @example - * Example with `local renderElement component`: - * ```tsx - const initialElements = createElements([ - { id: '1', label: 'Node 1', x: 100, y: 0, width: 100, height: 50 }, - ]) - type BaseElementWithData = InferElement - - function MyApp() { - const renderElement: RenderElement = useCallback( - (element) => {element.label}, - [] - ) - - return ( - - - - ) - } * ``` */ -export const Paper = typedMemo(PaperWithProviders); +export const Paper = forwardRef(PaperBase) as ( + props: Readonly> & { + ref?: React.Ref; + } +) => ReturnType; diff --git a/packages/joint-react/src/components/paper/paper.types.ts b/packages/joint-react/src/components/paper/paper.types.ts new file mode 100644 index 0000000000..ec937bafe2 --- /dev/null +++ b/packages/joint-react/src/components/paper/paper.types.ts @@ -0,0 +1,129 @@ +import type { dia } from '@joint/core'; +import type { GraphElement } from '../../types/element-types'; +import type { OmitWithoutIndexSignature } from '../../types'; +import type { GraphLink } from '../../types/link-types'; +import type { OnPaperRenderElement } from '../../hooks/use-element-views'; +import type { CSSProperties, PropsWithChildren, ReactNode } from 'react'; +import type { PaperEvents } from '../../types/event.types'; + +export interface OnLoadOptions { + readonly paper: dia.Paper; + readonly graph: dia.Graph; +} + +type ReactPaperOptionsBase = OmitWithoutIndexSignature; +export interface ReactPaperOptions extends ReactPaperOptionsBase { + /** + * Default link for the paper - for example if there is new element added, this will be used as default. + */ + readonly defaultLink?: + | ((cellView: dia.CellView, magnet: SVGElement) => dia.Link | GraphLink) + | dia.Link + | GraphLink; +} + +export type RenderElement = ( + element: ElementItem +) => ReactNode; + +/** + * The props for the Paper component. Extend the `dia.Paper.Options` interface. + * For more information, see the JointJS documentation. + * @see https://docs.jointjs.com/api/dia/Paper + */ +export interface PaperProps + extends ReactPaperOptions, + PropsWithChildren, + PaperEvents { + /** + * A function that renders the element. + * + * Note: JointJS works with SVG by default, so `renderElement` is appended inside an SVG node. + * To render HTML elements, use the experimental `useHTMLOverlay` prop or an SVG `foreignObject`. + * + * This is called when the data from `elementSelector` changes. + * @example + * Example with `global component`: + * ```tsx + * type BaseElementWithData = InferElement + * function RenderElement({ label }: BaseElementWithData) { + * return {label} + * } + * ``` + * @example + * Example with `local component`: + * ```tsx + * + type BaseElementWithData = InferElement + const renderElement: RenderElement = useCallback( + (element) => {element.label}, + [] + ) + * ``` + */ + + readonly renderElement?: RenderElement; + /** + * Event called when all elements are properly measured (has all elements width and height greater than 1 - default). + * In react, we cannot detect jointjs paper render:done event properly, so we use this special event to check if all elements are measured. + * It is useful for like onLoad event to do some layout or other operations with `graph` or `paper`. + */ + readonly onElementsSizeReady?: (options: OnLoadOptions) => void; + + /** + * Event called when the paper is resized. + * It is useful for like onLoad event to do some layout or other operations with `graph` or `paper`. + */ + readonly onElementsSizeChange?: (options: OnLoadOptions) => void; + + /** + * The style of the paper element. + */ + readonly style?: CSSProperties; + /** + * Class name of the paper element. + */ + readonly className?: string; + + /** + * A function that selects the elements to be rendered. + * It defaults to the `GraphElement` elements because `dia.Element` is not a valid React element (it do not change reference after update). + * @default (item: dia.Cell) => `BaseElement` + * @see GraphElement + */ + readonly elementSelector?: (item: GraphElement) => ElementItem; + /** + * The scale of the paper. It's useful to create for example a zoom feature or minimap Paper. + */ + + readonly scale?: number; + + /** + * The threshold for click events in pixels. + * If the mouse moves more than this distance, it will be considered a drag event. + * @default 10 + */ + readonly clickThreshold?: number; + + /** + * Enabled if renderElements is render to pure HTML elements. + * By default, `joint/react` renderElements to SVG elements, so for using HTML elements without this prop, you need to use `foreignObject` element. + * @experimental - this feature is still experimental and there are known issues with HTML elements rendering. Use at your own risk. + * @default false + */ + readonly useHTMLOverlay?: boolean; + + /** + * A function that is called when the paper is ready. + * @param element - The element that is being rendered + * @param portalElement - The portal element that is being rendered + * @returns + */ + readonly onRenderElement?: OnPaperRenderElement; + + /** + * Optional ID for the view, if not provided, a unique ID will be generated. + * !important - when using multiple views (on DEV), you need to provide an unique ID to each view to avoid conflicts. + */ + readonly id?: string; +} diff --git a/packages/joint-react/src/components/paper/paper-element-item.tsx b/packages/joint-react/src/components/paper/render-element/paper-element-item.tsx similarity index 90% rename from packages/joint-react/src/components/paper/paper-element-item.tsx rename to packages/joint-react/src/components/paper/render-element/paper-element-item.tsx index 8533a8e7a4..830b7a97a9 100644 --- a/packages/joint-react/src/components/paper/paper-element-item.tsx +++ b/packages/joint-react/src/components/paper/render-element/paper-element-item.tsx @@ -1,11 +1,11 @@ import { useMemo, type CSSProperties, type ReactNode } from 'react'; import { createPortal } from 'react-dom'; -import type { CellWithId } from '../../types/cell.types'; -import type { GraphElement } from '../../types/element-types'; -import typedMemo from '../../utils/typed-memo'; -import { useElement } from '../../hooks'; +import type { CellWithId } from '../../../types/cell.types'; +import typedMemo from '../../../utils/typed-memo'; +import { useElement } from '../../../hooks'; +import type { GraphElement } from '../../../types/element-types'; -export interface PaperPortalProps { +export interface ElementItemProps { /** * A function that renders the element. It is called every time the element is rendered. */ @@ -18,7 +18,7 @@ export interface PaperPortalProps { // eslint-disable-next-line jsdoc/require-jsdoc function SVGElementItemComponent( - props: PaperPortalProps + props: ElementItemProps ) { const { renderElement, portalElement, ...rest } = props; if (!portalElement) { @@ -57,7 +57,7 @@ export const SVGElementItem = typedMemo(SVGElementItemComponent); * @internal */ function HTMLElementItemComponent( - props: PaperPortalProps + props: ElementItemProps ) { const { renderElement, portalElement, ...rest } = props; const cell = rest as Data; diff --git a/packages/joint-react/src/components/paper/paper-html-container.tsx b/packages/joint-react/src/components/paper/render-element/paper-html-container.tsx similarity index 87% rename from packages/joint-react/src/components/paper/paper-html-container.tsx rename to packages/joint-react/src/components/paper/render-element/paper-html-container.tsx index 8966d9c0a5..a099cbd19e 100644 --- a/packages/joint-react/src/components/paper/paper-html-container.tsx +++ b/packages/joint-react/src/components/paper/render-element/paper-html-container.tsx @@ -1,5 +1,5 @@ import { memo, useEffect, useMemo, useRef, type CSSProperties } from 'react'; -import { usePaper } from '../../hooks'; +import { usePaper } from '../../../hooks'; import { mvc, V } from '@joint/core'; import { createPortal } from 'react-dom'; @@ -20,6 +20,7 @@ function Component({ onSetElement }: Readonly) { if (!divRef.current) { return; } + if (!paper) return; divRef.current.style.width = paper.el.style.width; divRef.current.style.height = paper.el.style.height; divRef.current.style.transformOrigin = '0 0'; @@ -39,17 +40,20 @@ function Component({ onSetElement }: Readonly) { const style = useMemo( (): CSSProperties => ({ - width: paper.el.style.width, - height: paper.el.style.height, + width: paper?.el.style.width, + height: paper?.el.style.height, position: 'absolute', left: '0px', top: '0px', pointerEvents: 'none', - overflow: 'hidden', }), - [paper.el.style.height, paper.el.style.width] + [paper?.el.style.height, paper?.el.style.width] ); + if (!paper) { + return; + } + const element =
; return <>{createPortal(element, paper.el)}; } diff --git a/packages/joint-react/src/components/port/__tests__/__snapshots__/port-group.test.tsx.snap b/packages/joint-react/src/components/port/__tests__/__snapshots__/port-group.test.tsx.snap index 7ca62c3ab4..6485a271bc 100644 --- a/packages/joint-react/src/components/port/__tests__/__snapshots__/port-group.test.tsx.snap +++ b/packages/joint-react/src/components/port/__tests__/__snapshots__/port-group.test.tsx.snap @@ -1,3 +1,3 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`Port/Item "Default": Port/Item-Default 1`] = `"
"`; +exports[`Port/Item "Default": Port/Item-Default 1`] = `"
"`; diff --git a/packages/joint-react/src/components/port/__tests__/__snapshots__/port-item.test.tsx.snap b/packages/joint-react/src/components/port/__tests__/__snapshots__/port-item.test.tsx.snap index 7ca62c3ab4..6485a271bc 100644 --- a/packages/joint-react/src/components/port/__tests__/__snapshots__/port-item.test.tsx.snap +++ b/packages/joint-react/src/components/port/__tests__/__snapshots__/port-item.test.tsx.snap @@ -1,3 +1,3 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`Port/Item "Default": Port/Item-Default 1`] = `"
"`; +exports[`Port/Item "Default": Port/Item-Default 1`] = `"
"`; diff --git a/packages/joint-react/src/components/port/__tests__/port.test.tsx b/packages/joint-react/src/components/port/__tests__/port.test.tsx index 343e1d4576..d8c648e14b 100644 --- a/packages/joint-react/src/components/port/__tests__/port.test.tsx +++ b/packages/joint-react/src/components/port/__tests__/port.test.tsx @@ -1,35 +1,16 @@ import { render, waitFor } from '@testing-library/react'; import { Port } from '..'; -import { - paperRenderElementWrapper, - simpleRenderElementWrapper, -} from '../../../utils/test-wrappers'; +import { paperRenderElementWrapper } from '../../../utils/test-wrappers'; import { dia } from '@joint/core'; import { ReactElement } from '../../../models/react-element'; describe('port', () => { - it('should render port - check if react element is properly rendered to the dom via portals', async () => { - render( - - - , - { wrapper: simpleRenderElementWrapper } - ); - - await waitFor(() => { - const port = document.querySelector('rect#myRect'); - expect(port).toBeInTheDocument(); - expect(port?.getAttribute('width')).toBe('10'); - expect(port?.getAttribute('height')).toBe('10'); - expect(port?.getAttribute('fill')).toBe('red'); - }); - }); it('should check if the port is created on the graph instance properly', async () => { const graph = new dia.Graph({}, { cellNamespace: { ReactElement } }); const wrapper = paperRenderElementWrapper({ - graphProps: { + graphProviderProps: { graph, - initialElements: [ + elements: [ { id: 'element-1', }, @@ -53,6 +34,7 @@ describe('port', () => { const port = element.getPort('port-one'); expect(port).toBeDefined(); expect(port.id).toBe('port-one'); + // eslint-disable-next-line sonarjs/deprecation expect(port.args).toEqual({ x: 10, y: 11, @@ -61,38 +43,13 @@ describe('port', () => { }); }); }); - it('should render group with port - check if react element is properly rendered to the dom via portals', async () => { - render( - - - - - - - - , - { wrapper: simpleRenderElementWrapper } - ); - await waitFor(() => { - const port1 = document.querySelector('rect#myRect1'); - const port2 = document.querySelector('rect#myRect2'); - expect(port1).toBeInTheDocument(); - expect(port2).toBeInTheDocument(); - expect(port1?.getAttribute('width')).toBe('10'); - expect(port1?.getAttribute('height')).toBe('10'); - expect(port1?.getAttribute('fill')).toBe('red'); - expect(port2?.getAttribute('width')).toBe('10'); - expect(port2?.getAttribute('height')).toBe('10'); - expect(port2?.getAttribute('fill')).toBe('red'); - }); - }); it('should check if the group with port is created on the graph instance properly', async () => { const graph = new dia.Graph({}, { cellNamespace: { ReactElement } }); const wrapper = paperRenderElementWrapper({ - graphProps: { + graphProviderProps: { graph, - initialElements: [ + elements: [ { id: 'element-1', }, @@ -119,6 +76,7 @@ describe('port', () => { expect(ports).toHaveLength(1); const [port] = ports; expect(port.id).toBe('port-one'); + // eslint-disable-next-line sonarjs/deprecation expect(port.args).toEqual({ x: 0, y: 1, diff --git a/packages/joint-react/src/components/port/index.ts b/packages/joint-react/src/components/port/index.ts index b8759c9ab1..31d72320b3 100644 --- a/packages/joint-react/src/components/port/index.ts +++ b/packages/joint-react/src/components/port/index.ts @@ -1,10 +1,10 @@ /* eslint-disable @typescript-eslint/no-namespace */ +export * from './port.types'; import { PortGroup } from './port-group'; import { PortItem } from './port-item'; export type { PortItemProps as PortProps } from './port-item'; export type { PortGroupProps } from './port-group'; - const Component = { PortGroup, PortItem, diff --git a/packages/joint-react/src/components/port/port-group.stories.tsx b/packages/joint-react/src/components/port/port-group.stories.tsx index b581aa9afb..06f4ef3080 100644 --- a/packages/joint-react/src/components/port/port-group.stories.tsx +++ b/packages/joint-react/src/components/port/port-group.stories.tsx @@ -1,21 +1,20 @@ /* eslint-disable react-perf/jsx-no-new-function-as-prop */ -/* eslint-disable no-shadow */ /* eslint-disable sonarjs/prefer-read-only-props */ -import type { Meta, StoryObj } from '@storybook/react/*'; -import { makeRootDocumentation, makeStory } from '@joint/react/src/stories/utils/make-story'; -import { getAPILink } from '@joint/react/src/stories/utils/get-api-documentation-link'; +import type { Meta, StoryObj } from '@storybook/react'; import '../../stories/examples/index.css'; import { createElements, createLinks, GraphProvider, MeasuredNode, - Paper, Port, useElement, } from '@joint/react'; import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; import { PortGroup } from './port-group'; +import { getAPILink } from '../../stories/utils/get-api-documentation-link'; +import { makeRootDocumentation, makeStory } from '../../stories/utils/make-story'; +import { Paper } from '../paper/paper'; const initialElements = createElements([ { @@ -73,7 +72,7 @@ function RenderItem(Story: React.FC) { function PaperDecorator(Story: React.FC) { const renderItem = () => RenderItem(Story); return ( - + RenderItem(Story); return ( - - + + ); } diff --git a/packages/joint-react/src/components/port/port-item.tsx b/packages/joint-react/src/components/port/port-item.tsx index dc64dbbc4b..26149db0d6 100644 --- a/packages/joint-react/src/components/port/port-item.tsx +++ b/packages/joint-react/src/components/port/port-item.tsx @@ -61,7 +61,7 @@ function Component(props: PortItemProps) { const cellId = useCellId(); const paperCtx = useContext(PaperContext); if (!paperCtx) { - throw new Error('PortItem must be used within a `PaperProvider` or `Paper` component'); + throw new Error('PortItem must be used within a Paper context'); } const { portsStore, paper } = paperCtx; const { graph } = useGraphStore(); diff --git a/packages/joint-react/src/components/text-node/__tests__/__snapshots__/text-node.test.tsx.snap b/packages/joint-react/src/components/text-node/__tests__/__snapshots__/text-node.test.tsx.snap index f559ddbd34..2031c752ed 100644 --- a/packages/joint-react/src/components/text-node/__tests__/__snapshots__/text-node.test.tsx.snap +++ b/packages/joint-react/src/components/text-node/__tests__/__snapshots__/text-node.test.tsx.snap @@ -1,9 +1,9 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`TextNode "Default": TextNode-Default 1`] = `"
"`; +exports[`TextNode "Default": TextNode-Default 1`] = `"
"`; -exports[`TextNode "WithBreakText": TextNode-WithBreakText 1`] = `"
"`; +exports[`TextNode "WithBreakText": TextNode-WithBreakText 1`] = `"
"`; -exports[`TextNode "WithBreakTextWithoutDefinedWith": TextNode-WithBreakTextWithoutDefinedWith 1`] = `"
"`; +exports[`TextNode "WithBreakTextWithoutDefinedWith": TextNode-WithBreakTextWithoutDefinedWith 1`] = `"
"`; -exports[`TextNode "WithEllipsis": TextNode-WithEllipsis 1`] = `"
"`; +exports[`TextNode "WithEllipsis": TextNode-WithEllipsis 1`] = `"
"`; diff --git a/packages/joint-react/src/components/text-node/text-node.stories.tsx b/packages/joint-react/src/components/text-node/text-node.stories.tsx index 088890741d..5129d2c3b7 100644 --- a/packages/joint-react/src/components/text-node/text-node.stories.tsx +++ b/packages/joint-react/src/components/text-node/text-node.stories.tsx @@ -1,14 +1,13 @@ /* eslint-disable react-perf/jsx-no-new-function-as-prop */ -/* eslint-disable no-shadow */ -import type { Meta, StoryObj } from '@storybook/react/*'; +import type { Meta, StoryObj } from '@storybook/react'; import { SimpleRenderItemDecorator } from '../../../.storybook/decorators/with-simple-data'; -import { makeRootDocumentation, makeStory } from '@joint/react/src/stories/utils/make-story'; -import { getAPILink } from '@joint/react/src/stories/utils/get-api-documentation-link'; import { TextNode } from './text-node'; import { PRIMARY } from 'storybook-config/theme'; import { useElement } from '../../hooks'; import { MeasuredNode } from '../measured-node/measured-node'; +import { getAPILink } from '../../stories/utils/get-api-documentation-link'; +import { makeRootDocumentation, makeStory } from '../../stories/utils/make-story'; const API_URL = getAPILink('TextNode', 'variables'); export type Story = StoryObj; diff --git a/packages/joint-react/src/context/cell-id.context.tsx b/packages/joint-react/src/context/cell-id.context.tsx deleted file mode 100644 index f0730375e3..0000000000 --- a/packages/joint-react/src/context/cell-id.context.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import type { dia } from '@joint/core'; -import { createContext } from 'react'; - -/** - * Context get stored cell id inside `renderElement` function. - * @internal - * @group context - */ -export const CellIdContext = createContext(undefined); diff --git a/packages/joint-react/src/context/graph-store-context.tsx b/packages/joint-react/src/context/graph-store-context.tsx deleted file mode 100644 index 33509095d8..0000000000 --- a/packages/joint-react/src/context/graph-store-context.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { createContext } from 'react'; -import type { Store } from '../data/create-store'; -export type StoreContext = Store; - -export const GraphStoreContext = createContext(undefined); -export const GraphAreElementsMeasuredContext = createContext(false); diff --git a/packages/joint-react/src/context/index.ts b/packages/joint-react/src/context/index.ts index 4a4bf7207f..47cb82f148 100644 --- a/packages/joint-react/src/context/index.ts +++ b/packages/joint-react/src/context/index.ts @@ -1,4 +1,40 @@ -export * from './cell-id.context'; -export * from './graph-store-context'; -export * from './paper-context'; -export * from './tools-view.context'; +export * from './port-group-context'; +import { createContext } from 'react'; +import type { GraphStore } from '../data/create-graph-store'; +import type { dia } from '@joint/core'; +import type { GraphElement } from '../types/element-types'; +import type { PortsStore } from '../data/create-ports-store'; +import type { RenderElement } from '../components/paper/paper.types'; +export interface PaperContext { + readonly id: string; + readonly paper: dia.Paper; + readonly portsStore: PortsStore; + readonly elementViews: Record; + renderElement?: RenderElement; + readonly isReactId: boolean; +} + +export type StoreContext = GraphStore; +export const GraphStoreContext = createContext(null); +export const GraphAreElementsMeasuredContext = createContext(false); +export const PaperContext = createContext(null); +export const CellIdContext = createContext(undefined); + +export interface OverWriteResult { + readonly element: HTMLElement | SVGElement; + readonly contextUpdate: Record; + readonly cleanup: () => void; +} +export interface PaperConfigContext { + /** + * On load custom element. + * If provided, it must return valid HTML or SVG element and it will be replaced with the default paper element. + * So it overwrite default paper rendering. + * It is used internally for example to render `PaperScroller` from [joint plus](https://www.jointjs.com/jointjs-plus) package. + * @param ctx - The paper context + * @returns + */ + overWrite?: (ctx: PaperContext) => OverWriteResult; +} + +export const PaperConfigContext = createContext(null); diff --git a/packages/joint-react/src/context/paper-context.tsx b/packages/joint-react/src/context/paper-context.tsx deleted file mode 100644 index b2896507b2..0000000000 --- a/packages/joint-react/src/context/paper-context.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import type { dia } from '@joint/core'; -import { createContext } from 'react'; -import type { RenderElement } from '../components'; -import type { GraphElement } from '../types/element-types'; -import { type PortsStore } from '../data/create-ports-store'; - -export interface PaperContext { - paper: dia.Paper; - renderElement?: RenderElement; - portsStore: PortsStore; - recordOfSVGElements: Record; -} - -export const PaperContext = createContext(null); diff --git a/packages/joint-react/src/context/tools-view.context.tsx b/packages/joint-react/src/context/tools-view.context.tsx deleted file mode 100644 index f1ba158eeb..0000000000 --- a/packages/joint-react/src/context/tools-view.context.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import type { dia } from '@joint/core'; -import { createContext } from 'react'; - -/** - * ToolsView context provides a tools view instance to its children. - * @see https://docs.jointjs.com/api/dia/ToolView - * @group context - */ -export const ToolsViewContext = createContext(undefined); diff --git a/packages/joint-react/src/data/__tests__/create-store-data.test.ts b/packages/joint-react/src/data/__tests__/create-store-data.test.ts index 1e2931311d..823f95a065 100644 --- a/packages/joint-react/src/data/__tests__/create-store-data.test.ts +++ b/packages/joint-react/src/data/__tests__/create-store-data.test.ts @@ -11,9 +11,9 @@ describe('create-store-data', () => { x: 10, }); graph.addCell(element); - expect(storeData.elements.size).toBe(0); + expect(storeData.elements.length).toBe(0); storeData.updateStore(graph); - expect(storeData.elements.size).toBe(1); + expect(storeData.elements.length).toBe(1); }); it('should handle proper data update', () => { const graph = new dia.Graph(); @@ -24,10 +24,10 @@ describe('create-store-data', () => { position: { x: 10, y: 20 }, }); graph.addCell(element); - expect(storeData.elements.size).toBe(0); + expect(storeData.elements.length).toBe(0); storeData.updateStore(graph); - expect(storeData.elements.size).toBe(1); - expect(storeData.elements.get('element1')?.x).toBe(10); + expect(storeData.elements.length).toBe(1); + expect(storeData.elements.find((element_) => element_.id === 'element1')?.x).toBe(10); const updatedElement = new dia.Element({ type: 'standard.Rectangle', @@ -36,9 +36,9 @@ describe('create-store-data', () => { }); graph.resetCells([updatedElement]); - expect(storeData.elements.size).toBe(1); + expect(storeData.elements.length).toBe(1); storeData.updateStore(graph); - expect(storeData.elements.get('element1')?.x).toBe(30); + expect(storeData.elements.find((element_) => element_.id === 'element1')?.x).toBe(30); }); it('should handle proper data deletion', () => { const graph = new dia.Graph(); @@ -49,12 +49,12 @@ describe('create-store-data', () => { x: 10, }); graph.addCell(element); - expect(storeData.elements.size).toBe(0); + expect(storeData.elements.length).toBe(0); storeData.updateStore(graph); - expect(storeData.elements.size).toBe(1); + expect(storeData.elements.length).toBe(1); graph.removeCells([element]); - expect(storeData.elements.size).toBe(1); + expect(storeData.elements.length).toBe(1); storeData.updateStore(graph); - expect(storeData.elements.size).toBe(0); + expect(storeData.elements.length).toBe(0); }); }); diff --git a/packages/joint-react/src/data/__tests__/create-store.test.ts b/packages/joint-react/src/data/__tests__/create-store.test.ts index a560878837..3fe6e064b9 100644 --- a/packages/joint-react/src/data/__tests__/create-store.test.ts +++ b/packages/joint-react/src/data/__tests__/create-store.test.ts @@ -1,14 +1,13 @@ -/* eslint-disable sonarjs/deprecation */ import { dia } from '@joint/core'; -import { createStore } from '../create-store'; +import { createStore } from '../create-graph-store'; import { waitFor } from '@testing-library/react'; describe('createStore', () => { it('should initialize with default options', () => { const store = createStore(); expect(store.graph).toBeDefined(); - expect(store.getElements().size).toBe(0); - expect(store.getLinks().size).toBe(0); + expect(store.getElements().length).toBe(0); + expect(store.getLinks().length).toBe(0); }); it('should initialize with custom graph instance', () => { @@ -17,29 +16,17 @@ describe('createStore', () => { expect(store.graph).toBe(customGraph); }); - it('should add default elements', () => { + it('should add default elements', async () => { const element = new dia.Element({ id: 'element1', type: 'standard.Rectangle' }); const link = new dia.Link({ id: 'link1', type: 'standard.Link', source: { id: 'element1' } }); const store = createStore({ - initialElements: [element], - initialLinks: [link], + elements: [element], + links: [link], }); - expect(store.getElements().size).toBe(1); + expect(store.getElements().length).toBe(1); expect(store.getElement('element1')).toBeDefined(); }); - it('should throw an error when getting a non-existent element', () => { - const store = createStore(); - expect(() => store.getElement('nonexistent')).toThrowError( - 'Element with id nonexistent not found' - ); - }); - - it('should throw an error when getting a non-existent link', () => { - const store = createStore(); - expect(() => store.getLink('nonexistent')).toThrowError('Link with id nonexistent not found'); - }); - it('should notify subscribers on changes', async () => { const store = createStore(); const callback = jest.fn(); @@ -58,7 +45,7 @@ describe('createStore', () => { const store = createStore(); const unsubscribeSpy = jest.spyOn(store, 'destroy'); - store.destroy(); + store.destroy(false); expect(unsubscribeSpy).toHaveBeenCalled(); expect(store.graph.getCells().length).toBe(0); }); diff --git a/packages/joint-react/src/data/create-graph-store.ts b/packages/joint-react/src/data/create-graph-store.ts new file mode 100644 index 0000000000..1f638de0f8 --- /dev/null +++ b/packages/joint-react/src/data/create-graph-store.ts @@ -0,0 +1,370 @@ +/* eslint-disable unicorn/prefer-query-selector */ +import { dia, shapes } from '@joint/core'; +import { listenToCellChange } from '../utils/cell/listen-to-cell-change'; +import { ReactElement } from '../models/react-element'; +import { setElements, setLinks } from '../utils/cell/cell-utilities'; +import type { GraphElement } from '../types/element-types'; +import type { GraphLink } from '../types/link-types'; +import { subscribeHandler } from '../utils/subscriber-handler'; +import { createStoreData, type UpdateResult } from './create-store-data'; +import type { Dispatch, SetStateAction } from 'react'; +import { CONTROLLED_MODE_BATCH_NAME } from '../utils/graph/update-graph'; + +export const DEFAULT_CELL_NAMESPACE: Record = { ...shapes, ReactElement }; + +export interface StoreOptions< + Graph extends dia.Graph, + Element extends dia.Element | GraphElement, + Link extends dia.Link | GraphLink, +> { + /** + * 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?: 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 elements?: Element[]; + + /** + * Initial links to be added to graph + * It's loaded just once, so it cannot be used as React state. + */ + readonly links?: Link[]; + /** + * Callback triggered when elements (nodes) change. + * Providing this prop enables controlled mode for elements. + * If specified, this function will override the default behavior, allowing you to manage all element changes manually instead of relying on `graph.change`. + */ + readonly onElementsChange?: Dispatch>; + + /** + * Callback triggered when links (edges) change. + * Providing this prop enables controlled mode for links. + * If specified, this function will override the default behavior, allowing you to manage all link changes manually instead of relying on `graph.change`. + */ + readonly onLinksChange?: Dispatch>; +} + +export interface GraphStore { + /** + * The JointJS graph instance. + */ + readonly graph: Graph; + /** + * Subscribes to the store changes. + */ + readonly subscribe: (onStoreChange: (changedIds?: UpdateResult) => void) => () => void; + + /** + * Get elements + */ + readonly getElements: () => GraphElement[]; + /** + * Get element by id + */ + readonly getElement: (id: dia.Cell.ID) => Element; + /** + * Get links + */ + readonly getLinks: () => GraphLink[]; + /** + * Get link by id + */ + readonly getLink: (id: dia.Cell.ID) => GraphLink; + /** + * Remove all listeners and cleanup the graph. + */ + readonly destroy: (isGraphExternal: boolean) => 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; + + /** + * Force update the graph store. + * This will trigger a re-render of all components that are subscribed to the store. + */ + readonly forceUpdateStore: () => UpdateResult; +} + +/** + * 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< + Graph extends dia.Graph = dia.Graph, + Element extends dia.Element | GraphElement = dia.Element | GraphElement, + Link extends dia.Link | GraphLink = dia.Link | GraphLink, +>(options: StoreOptions = {}): 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 as Graph; +} + +// eslint-disable-next-line jsdoc/require-jsdoc +function isBatchNameObject(value: unknown): value is { batchName: string } { + return typeof value === 'object' && value !== null && 'batchName' in value; +} + +/** + * 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 createStoreWithGraph< + Graph extends dia.Graph, + Element extends dia.Element | GraphElement, + Link extends dia.Link | GraphLink, +>(options?: StoreOptions): GraphStore { + const { elements, links, graph, onElementsChange, onLinksChange } = options || {}; + + if (!graph) { + // Create a new graph instance or use the provided one + throw new Error('Graph instance is required'); + } + // set elements to the graph + setElements({ + graph, + elements, + }); + + setLinks({ + graph, + links, + }); + + // create store data - caching the elements and links for the react + const graphData = createStoreData(); + // listen to dia.graph cell changes and trigger `onCellChange` where there is change occurs in graph + const unsubscribe = listenToCellChange(graph, onCellChange); + // elements events notify all react components using `useSyncExternalStore` + const elementsEvents = subscribeHandler(forceUpdateStore); + + // Notify subscribers of initial elements + graphData.updateStore(graph); + + // add method to handle batch stop, so then we can also notify all react components + graph.on('batch:stop', onBatchStop); + + const measuredNodes = new Set(); + + /** + * 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 + * @param batchName - The name of the batch. + */ + function forceUpdateStore(batchName?: string): UpdateResult { + if (!graph) { + // Create a new graph instance or use the provided one + throw new Error('Graph instance is required'); + } + + const updateResult = graphData.updateStore(graph); + // Skip processing changes in controlled mode since they are already handled. + // This prevents circular calls to `onElementsChange`. + // For example, if a user manages elements via React state and updates the graph using setElements, + // this function will be triggered. However, we avoid re-triggering `onElementsChange` to prevent redundant updates. + // We call `onElementsChange` and `onLinksChange` explicitly only when direct change on `dia.Graph` occurs. + if (batchName !== CONTROLLED_MODE_BATCH_NAME) { + if (onElementsChange && updateResult.areElementsChanged) { + const mappedElements = graphData.elements.map((element) => element); + onElementsChange(mappedElements as SetStateAction); + } + if (onLinksChange && updateResult.areLinksChanged) { + const changedLinks = graphData.links.map((link) => link); + onLinksChange(changedLinks as SetStateAction); + } + } + return updateResult; + } + /** + * 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. + */ + function onCellChange() { + if (!graph) { + // Create a new graph instance or use the provided one + throw new Error('Graph instance is required'); + } + + if (graph.hasActiveBatch()) { + return; + } + + elementsEvents.notifySubscribers(); + } + + // eslint-disable-next-line jsdoc/require-jsdoc, no-shadow, @typescript-eslint/no-shadow + function onBatchStop(options?: unknown) { + if (!isBatchNameObject(options)) { + elementsEvents.notifySubscribers(); + return; + } + const { batchName } = options; + + elementsEvents.notifySubscribers(batchName); + } + + /** + * Cleanup the store. + * @param isGraphExternal - If true, the graph is external and should not be cleared. + */ + function destroy(isGraphExternal: boolean) { + if (!graph) { + // Create a new graph instance or use the provided one + throw new Error('Graph instance is required'); + } + unsubscribe(); + graph.off('batch:stop', onBatchStop); + graphData.destroy(); + measuredNodes.clear(); + if (isGraphExternal) { + return; + } + graph.clear(); + } + // Force update the graph to ensure it's in sync with the store. + forceUpdateStore(); + + const store: GraphStore = { + forceUpdateStore, + destroy, + graph, + subscribe: elementsEvents.subscribe, + getElements() { + return graphData.elements; + }, + getLinks() { + return graphData.links; + }, + getElement(id: dia.Cell.ID) { + const item = graphData.getElementById(id); + + if (!item) { + throw new Error(`Element with id ${id} not found`); + } + return item as E; + }, + getLink(id) { + const item = graphData.getLinkById(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; +} + +/** + * 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< + Graph extends dia.Graph, + Element extends dia.Element | GraphElement, + Link extends dia.Link | GraphLink, +>(options?: StoreOptions): GraphStore { + const graph = createGraph(options); + return createStoreWithGraph({ + ...options, + graph, + }); +} diff --git a/packages/joint-react/src/data/create-store-data.ts b/packages/joint-react/src/data/create-store-data.ts index 8ba62c92e0..0fa97ffb1f 100644 --- a/packages/joint-react/src/data/create-store-data.ts +++ b/packages/joint-react/src/data/create-store-data.ts @@ -1,76 +1,204 @@ +/* eslint-disable sonarjs/cognitive-complexity */ +/* eslint-disable unicorn/prevent-abbreviations */ import type { dia } from '@joint/core'; import { util } from '@joint/core'; import { getElement, getLink } from '../utils/cell/get-cell'; -import { CellMap } from '../utils/cell/cell-map'; import type { GraphLink } from '../types/link-types'; import type { GraphElement } from '../types/element-types'; -import { diffUpdate } from '../utils/diff-update'; -interface StoreData { - readonly updateStore: (graph: dia.Graph) => Set; + +export interface UpdateResult { + readonly diffIds: Set; + readonly areElementsChanged: boolean; + readonly areLinksChanged: boolean; +} + +interface StoreData< + Graph extends dia.Graph = dia.Graph, + Element extends GraphElement = GraphElement, +> { + /** Rebuilds arrays (and internal indices) from the graph, returns a diff summary */ + readonly updateStore: (graph: Graph) => UpdateResult; + /** Clear everything */ readonly destroy: () => void; - elements: CellMap; - links: CellMap; + + /** Public, array-first shape */ + elements: Element[]; + links: GraphLink[]; + + /** O(1) helpers built on top of private indices */ + readonly getElementById: (id: dia.Cell.ID) => Element | undefined; + readonly getLinkById: (id: dia.Cell.ID) => GraphLink | undefined; +} +interface Options { + readonly elements?: Element[]; + readonly links?: GraphLink[]; } /** - * Main data structure for the graph store data. - * We avoid using dia.elements and dia.link due to their mutable state. + * Array-first store with internal id->index maps. + * Keeps public API as arrays while preserving O(1) lookups. + * Arrays are rebuilt in graph order each update for stable determinism. * @group Data - * @returns - The store data. - * @description - * This function is used to create a store data for the graph. - * @internal + * @param options - Initial elements and links. + * @template Graph - The type of the graph, extending dia.Graph. + * @template Element - The type of elements in the store, extending GraphElement. + * @returns - The store data containing elements, links, and utility methods. * @example - * ```ts - * const graph = new joint.dia.Graph(); - * const storeData = new GraphStoreData(graph); - * storeData.update(graph); - * ``` */ -export function createStoreData(): StoreData { +export function createStoreData< + Graph extends dia.Graph = dia.Graph, + Element extends GraphElement = GraphElement, +>(options: Options = {}): StoreData { + // Public arrays + + const ref: { + elements: Element[]; + links: GraphLink[]; + } = { + elements: options.elements ?? [], + links: options.links ?? [], + }; + + // Private indices (id -> array index) + let eIndex = new Map(); + let lIndex = new Map(); + /** - * Update the store data with the graph data. - * @param graph - The graph to update the store data with.. - * @description + * Retrieves an element by its ID. + * @param id - The ID of the element to retrieve. + * @returns The element if found, otherwise undefined. */ - function updateStore(graph: dia.Graph): Set { - const cells = graph.get('cells'); + function getElementById(id: dia.Cell.ID): Element | undefined { + const i = eIndex.get(id); + return i == null ? undefined : ref.elements[i]; + } + /** + * Retrieves a link by its ID. + * @param id - The ID of the link to retrieve. + * @returns The link if found, otherwise undefined. + */ + function getLinkById(id: dia.Cell.ID): GraphLink | undefined { + const i = lIndex.get(id); + return i == null ? undefined : ref.links[i]; + } + /** + * Rebuilds arrays (and internal indices) from the graph, returns a diff summary + * @param graph - The graph to update the store from. + * @returns - The update result containing diff information. + */ + function updateStore(graph: Graph): UpdateResult { + const cells = graph.get('cells'); if (!cells) throw new Error('Graph cells are not initialized'); - // New updates, if cell is inserted or updated, we track it inside this diff. - const elementsDiff = new CellMap(); - const linkDiff = new CellMap(); + const nextElements: Element[] = []; + const nextLinks: GraphLink[] = []; + const nextEIndex = new Map(); + const nextLIndex = new Map(); const diffIds = new Set(); + + let areElementsChanged = false; + let areLinksChanged = false; + + // Build new arrays in the same pass, while diffing per id for (const cell of cells) { if (cell.isElement()) { - const newElement = getElement(cell); - if (!util.isEqual(newElement, data.elements.get(cell.id))) { - elementsDiff.set(cell.id, newElement); - diffIds.add(cell.id); + const id = cell.id as dia.Cell.ID; + const next = getElement(cell); + const prev = getElementById(id); + if (!prev || !util.isEqual(prev, next)) { + diffIds.add(id); + areElementsChanged = true; } + nextEIndex.set(id, nextElements.length); + nextElements.push(next); } else if (cell.isLink()) { - const newLink = getLink(cell); - if (!util.isEqual(newLink, data.links.get(cell.id))) { - linkDiff.set(cell.id, newLink); - diffIds.add(cell.id); + const id = cell.id as dia.Cell.ID; + const next = getLink(cell); + const prev = getLinkById(id); + if (!prev || !util.isEqual(prev, next)) { + diffIds.add(id); + areLinksChanged = true; } + nextLIndex.set(id, nextLinks.length); + nextLinks.push(next); } } - data.elements = diffUpdate(data.elements, elementsDiff, (cellId) => cells.has(cellId)); - data.links = diffUpdate(data.links, linkDiff, (cellId) => cells.has(cellId)); - return diffIds; + // Deletions: if the new arrays are shorter than old or some ids disappeared, + // we’ve already “changed”. To catch pure deletions where values equal but gone: + if (!areElementsChanged) { + areElementsChanged = ref.elements.length !== nextElements.length; + if (!areElementsChanged) { + // Cheap structural check: same length but different ids/order? + for (const [i, nextElement] of nextElements.entries()) { + const idNow = nextElement?.id as dia.Cell.ID | undefined; + const prevIdx = idNow ? eIndex.get(idNow) : undefined; + if (prevIdx !== i) { + areElementsChanged = true; + break; + } + } + } + } + if (!areLinksChanged) { + areLinksChanged = ref.links.length !== nextLinks.length; + if (!areLinksChanged) { + for (const [i, nextLink] of nextLinks.entries()) { + const idNow = nextLink?.id as dia.Cell.ID | undefined; + const prevIdx = idNow ? lIndex.get(idNow) : undefined; + if (prevIdx !== i) { + areLinksChanged = true; + break; + } + } + } + } + + // Swap (immutably) only when changed to preserve referential equality + if (areElementsChanged) { + ref.elements = nextElements; + eIndex = nextEIndex; + } + + if (areLinksChanged) { + ref.links = nextLinks; + lIndex = nextLIndex; + } + + return { + diffIds, + areElementsChanged, + areLinksChanged, + }; + } + + /** + * Clears all elements and links from the store and resets internal indices. + */ + function destroy() { + ref.elements = []; + ref.links = []; + eIndex.clear(); + lIndex.clear(); } - const data: StoreData = { + return { updateStore, - elements: new CellMap(), - links: new CellMap(), - destroy: () => { - data.elements.clear(); - data.links.clear(); + destroy, + getElementById, + getLinkById, + get elements() { + return ref.elements; + }, + set elements(_value: Element[]) { + throw new Error('elements is read-only; call updateStore(graph) instead.'); }, - }; - return data; + get links() { + return ref.links; + }, + set links(_value: GraphLink[]) { + throw new Error('links is read-only; call updateStore(graph) instead.'); + }, + } as StoreData; } diff --git a/packages/joint-react/src/data/create-store.ts b/packages/joint-react/src/data/create-store.ts deleted file mode 100644 index f7f79baeaa..0000000000 --- a/packages/joint-react/src/data/create-store.ts +++ /dev/null @@ -1,243 +0,0 @@ -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; - - /** - * Initial links to be added to graph - * It's loaded just once, so it cannot be used as React state. - */ - readonly initialLinks?: Array; -} - -export interface Store { - /** - * The JointJS graph instance. - */ - readonly graph: dia.Graph; - /** - * Subscribes to the store changes. - */ - readonly subscribe: (onStoreChange: (changedIds?: Set) => void) => () => void; - - /** - * Get elements - */ - readonly getElements: () => CellMap; - /** - * Get element by id - */ - readonly getElement: (id: dia.Cell.ID) => Element; - /** - * Get links - */ - readonly getLinks: () => CellMap; - /** - * 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(); - - /** - * 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 { - 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(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; -} diff --git a/packages/joint-react/src/data/index.ts b/packages/joint-react/src/data/index.ts new file mode 100644 index 0000000000..9ca700eec8 --- /dev/null +++ b/packages/joint-react/src/data/index.ts @@ -0,0 +1,2 @@ +export * from './create-graph-store'; +export * from './create-ports-store'; diff --git a/packages/joint-react/src/hooks/__tests__/use-cell-actions.test.tsx b/packages/joint-react/src/hooks/__tests__/use-cell-actions.test.tsx new file mode 100644 index 0000000000..3e1f6d34f3 --- /dev/null +++ b/packages/joint-react/src/hooks/__tests__/use-cell-actions.test.tsx @@ -0,0 +1,237 @@ +/* eslint-disable @typescript-eslint/no-shadow */ +/* eslint-disable no-shadow */ +import { renderHook, waitFor } from '@testing-library/react'; +import { graphProviderWrapper } from '../../utils/test-wrappers'; +import { useGraph } from '../use-graph'; +import { useCellActions } from '../use-cell-actions'; +import { useElements } from '../use-elements'; +import { act } from 'react'; +import type { ReducerType } from '@reduxjs/toolkit'; +import { useLinks } from '../use-links'; + +describe('useCellActions', () => { + // @ts-expect-error - We setup in beforeEach + let wrapper: ReducerType; + beforeEach(() => { + wrapper = graphProviderWrapper({ + elements: [ + { + id: '1', + width: 97, + height: 99, + }, + { + id: '2', + width: 97, + height: 99, + }, + ], + links: [ + { + id: '3', + source: '1', + target: '2', + }, + ], + }); + }); + + it('should test set and remove actions for elements', async () => { + const renders = jest.fn(); + const { result } = renderHook( + () => { + renders(); + return { + actions: useCellActions(), + graph: useGraph(), + reactElementsSizeCheck: useElements(), + }; + }, + { + wrapper, + } + ); + await waitFor(() => { + expect(renders).toHaveBeenCalledTimes(1); + const { graph } = result.current; + expect(graph.getElements().length).toBe(2); + expect(result.current.reactElementsSizeCheck.length).toBe(2); + expect(result.current.reactElementsSizeCheck[0].width).toBe(97); + }); + + act(() => { + result.current.actions.set({ id: '1', width: 1000 }); + }); + + // check if the element is set in the graph + await waitFor(() => { + const { graph } = result.current; + expect(graph.getElements().length).toBe(2); + expect(result.current.reactElementsSizeCheck.length).toBe(2); + expect(result.current.reactElementsSizeCheck[0].width).toBe(1000); + }); + + act(() => { + result.current.actions.set({ id: '10', width: 999 }); + }); + + // check if the element is set in the graph + await waitFor(() => { + const { graph } = result.current; + expect(graph.getElements().length).toBe(3); + expect(result.current.reactElementsSizeCheck.length).toBe(3); + expect(result.current.reactElementsSizeCheck[2].width).toBe(999); + }); + + act(() => { + result.current.actions.set('2', (previous) => ({ ...previous, width: 500 })); + }); + + // check if the element is set in the graph + await waitFor(() => { + const { graph } = result.current; + expect(graph.getElements().length).toBe(3); + expect(result.current.reactElementsSizeCheck.length).toBe(3); + expect(result.current.reactElementsSizeCheck[1].width).toBe(500); + }); + + // remove element + act(() => { + result.current.actions.remove('1'); + }); + + await waitFor(() => { + const { graph } = result.current; + expect(graph.getElements().length).toBe(2); + expect(result.current.reactElementsSizeCheck.length).toBe(2); + expect( + result.current.reactElementsSizeCheck.find((element) => element.id === '1') + ).toBeUndefined(); + }); + + // check if other element is still there + await waitFor(() => { + const { graph } = result.current; + expect(graph.getElements().length).toBe(2); + expect(result.current.reactElementsSizeCheck.length).toBe(2); + const element = result.current.reactElementsSizeCheck.find((element) => element.id === '2'); + expect(element).toBeDefined(); + expect(element?.width).toBe(500); + }); + }); + + it('should test set and remove actions for links', async () => { + const renders = jest.fn(); + const { result } = renderHook( + () => { + renders(); + return { + actions: useCellActions(), + graph: useGraph(), + links: useLinks(), + }; + }, + { + wrapper, + } + ); + await waitFor(() => { + expect(renders).toHaveBeenCalledTimes(1); + const { graph } = result.current; + expect(graph.getLinks().length).toBe(1); + }); + + act(() => { + result.current.actions.set({ + id: '3', + source: { id: '1' }, + target: { id: '2' }, + attrs: { + line: { stroke: '#001DFF' }, + }, + }); + }); + + // check if the link is set in the graph + await waitFor(() => { + const { graph } = result.current; + expect(graph.getLinks().length).toBe(1); + const link = graph.getCell('3'); + expect(result.current.links.length).toBe(1); + expect(result.current.links[0].id).toBe('3'); + expect(link).toBeDefined(); + expect(link?.get('attrs')?.line?.stroke ?? '').toBe('#001DFF'); + }); + + // update link with updater function + act(() => { + result.current.actions.set('3', (previous) => ({ + ...previous, + attrs: { + line: { stroke: '#FF0000' }, + }, + })); + }); + + // check if the link is updated in the graph + await waitFor(() => { + const { graph } = result.current; + expect(graph.getLinks().length).toBe(1); + const link = graph.getCell('3'); + expect(result.current.links.length).toBe(1); + expect(result.current.links[0].id).toBe('3'); + expect(link).toBeDefined(); + expect(link?.get('attrs')?.line?.stroke ?? '').toBe('#FF0000'); + }); + + // add new link + act(() => { + result.current.actions.set({ + id: '30', + source: { id: '2' }, + target: { id: '1' }, + attrs: { + line: { stroke: '#00FF00' }, + }, + }); + }); + + // check if the link is set in the graph + await waitFor(() => { + const { graph } = result.current; + expect(graph.getLinks().length).toBe(2); + const link = graph.getCell('30'); + expect(result.current.links.length).toBe(2); + expect(result.current.links.find((l) => l.id === '30')?.id).toBe('30'); + expect(link).toBeDefined(); + expect(link?.get('attrs')?.line?.stroke ?? '').toBe('#00FF00'); + }); + + // remove link + act(() => { + result.current.actions.remove('3'); + }); + + await waitFor(() => { + const { graph } = result.current; + expect(graph.getLinks().length).toBe(1); + expect(result.current.links.length).toBe(1); + expect(result.current.links[0].id).toBe('30'); + const link = graph.getCell('3'); + expect(link).toBeUndefined(); + }); + + // remove last + act(() => { + result.current.actions.remove('30'); + }); + + await waitFor(() => { + const { graph } = result.current; + expect(graph.getLinks().length).toBe(0); + expect(result.current.links.length).toBe(0); + const link = graph.getCell('30'); + expect(link).toBeUndefined(); + }); + }); +}); diff --git a/packages/joint-react/src/hooks/__tests__/use-combined-ref.test.tsx b/packages/joint-react/src/hooks/__tests__/use-combined-ref.test.tsx index 986e942bc1..297e18c52d 100644 --- a/packages/joint-react/src/hooks/__tests__/use-combined-ref.test.tsx +++ b/packages/joint-react/src/hooks/__tests__/use-combined-ref.test.tsx @@ -1,7 +1,7 @@ /* eslint-disable @eslint-react/no-create-ref */ import { render, screen } from '@testing-library/react'; import { renderHook } from '@testing-library/react'; -import { createRef, forwardRef, useImperativeHandle } from 'react'; +import { createRef } from 'react'; import { useCombinedRef } from '../use-combined-ref'; describe('useCombinedRef', () => { @@ -35,16 +35,4 @@ describe('useCombinedRef', () => { const element = screen.getByTestId('el'); expect(calledValue).toBe(element); }); - - it('should work with React.forwardRef', () => { - const Comp = forwardRef((props, ref) => { - const combinedRef = useCombinedRef(ref); - // @ts-expect-error its just test - useImperativeHandle(ref, () => ({ test: true }), []); - return
; - }); - const ref = createRef(); - renderHook(() => ); - // No error means it works - }); }); diff --git a/packages/joint-react/src/hooks/__tests__/use-create-element.test.ts b/packages/joint-react/src/hooks/__tests__/use-create-element.test.ts deleted file mode 100644 index 7ef5785bd9..0000000000 --- a/packages/joint-react/src/hooks/__tests__/use-create-element.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { act, renderHook, waitFor } from '@testing-library/react'; -import { useCreateElement } from '../use-create-element'; -import { simpleRenderElementWrapper } from '../../utils/test-wrappers'; -import { useGraph } from '../use-graph'; -import { useElements } from '../use-elements'; - -describe('use-create-element', () => { - it('should add element and should it be added also to the graph', async () => { - const renders = jest.fn(); - const { result } = renderHook( - () => { - renders(); - return { - add: useCreateElement(), - graph: useGraph(), - reactElementsCheck: useElements((items) => items.size), - }; - }, - { - wrapper: simpleRenderElementWrapper, - } - ); - - await waitFor(() => { - expect(renders).toHaveBeenCalledTimes(1); - const { graph } = result.current; - expect(graph.getElements().length).toBe(1); - expect(result.current.reactElementsCheck).toBe(1); - }); - act(() => { - result.current.add({ - id: '100', - }); - }); - - // check if the element is added to the graph - await waitFor(() => { - const { graph } = result.current; - expect(graph.getElements().length).toBe(2); - expect(result.current.reactElementsCheck).toBe(2); - }); - }); -}); diff --git a/packages/joint-react/src/hooks/__tests__/use-create-link.test.ts b/packages/joint-react/src/hooks/__tests__/use-create-link.test.ts deleted file mode 100644 index 5eccc23fb2..0000000000 --- a/packages/joint-react/src/hooks/__tests__/use-create-link.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { act, renderHook, waitFor } from '@testing-library/react'; -import { useCreateLink } from '../use-create-link'; -import { useGraph } from '../use-graph'; -import { useLinks } from '../use-links'; -import { graphProviderWrapper } from '../../utils/test-wrappers'; - -describe('use-create-link', () => { - const wrapper = graphProviderWrapper({ - initialElements: [ - { - id: '1', - width: 97, - height: 99, - }, - { - id: '2', - width: 97, - height: 99, - }, - ], - }); - - it('should test adding link properly', async () => { - const renders = jest.fn(); - const { result } = renderHook( - () => { - renders(); - return { - add: useCreateLink(), - graph: useGraph(), - reactLinksSizeCheck: useLinks((items) => items.size), - }; - }, - { - wrapper, - } - ); - await waitFor(() => { - expect(renders).toHaveBeenCalledTimes(1); - const { graph } = result.current; - expect(graph.getLinks().length).toBe(0); - expect(result.current.reactLinksSizeCheck).toBe(0); - }); - act(() => { - result.current.add({ - id: '100', - source: { id: '1' }, - target: { id: '2' }, - }); - }); - // check if the link is added to the graph - await waitFor(() => { - const { graph } = result.current; - expect(graph.getLinks().length).toBe(1); - expect(result.current.reactLinksSizeCheck).toBe(1); - }); - }); -}); diff --git a/packages/joint-react/src/hooks/__tests__/use-element.test.ts b/packages/joint-react/src/hooks/__tests__/use-element.test.ts deleted file mode 100644 index 115a4017c8..0000000000 --- a/packages/joint-react/src/hooks/__tests__/use-element.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { act, renderHook, waitFor } from '@testing-library/react'; -import { useElement } from '../use-element'; -import { paperRenderElementWrapper, simpleRenderElementWrapper } from '../../utils/test-wrappers'; -import { useUpdateElement } from '../use-update-element'; -import { useCreateElement } from '../use-create-element'; -import type { GraphElement } from '../../types/element-types'; - -describe('use-element', () => { - it('should return data from usuElement hook without selector', async () => { - const renders = jest.fn(); - const { result } = renderHook( - () => { - renders(); - return useElement(); - }, - { - wrapper: simpleRenderElementWrapper, - } - ); - - await waitFor(() => { - expect(renders).toHaveBeenCalledTimes(1); - expect(result.current.id).toBe('1'); - expect(result.current.width).toBe(97); - expect(result.current.height).toBe(99); - }); - }); - it('should return data from useElement hook with selector', async () => { - const renders = jest.fn(); - const { result } = renderHook( - () => { - renders(); - return useElement((element) => element.width); - }, - { - wrapper: simpleRenderElementWrapper, - } - ); - - await waitFor(() => { - expect(renders).toHaveBeenCalledTimes(1); - expect(result.current).toBe(97); - }); - }); - it('should return data from useElement hook with selector and isEqual', async () => { - const renders = jest.fn(); - const { result } = renderHook( - () => { - renders(); - return useElement( - (element) => element.width, - (previous, next) => previous === next - ); - }, - { - wrapper: simpleRenderElementWrapper, - } - ); - - await waitFor(() => { - expect(renders).toHaveBeenCalledTimes(1); - expect(result.current).toBe(97); - }); - }); - - it('should measure use-elements selector - how many count it was called', async () => { - const wrapper = paperRenderElementWrapper({ - graphProps: { - initialElements: [ - { - id: '1', - width: 97, - height: 99, - }, - { - id: '2', - width: 97, - height: 99, - }, - ], - }, - }); - const renders = jest.fn(); - let selectorCalls = 0; - function selector(element: GraphElement) { - selectorCalls++; - return element; - } - const { result } = renderHook( - () => { - renders(); - return { - element: useElement(selector), - update: useUpdateElement(), - create: useCreateElement(), - }; - }, - { - wrapper, - } - ); - - await waitFor(() => { - expect(renders).toHaveBeenCalledTimes(2); - expect(selectorCalls).toBe(2); - }); - - act(() => { - result.current.update('1', 'size', { - width: 100, - height: 100, - }); - }); - - await waitFor(() => { - expect(renders).toHaveBeenCalledTimes(3); - // TODO WE SHOULD HANDLE THIS TO NOT BE CALLED - // now useElement is part of all data, so when one item is changed, it will be called - expect(selectorCalls).toBe(4); - }); - }); -}); diff --git a/packages/joint-react/src/hooks/__tests__/use-elements.test.ts b/packages/joint-react/src/hooks/__tests__/use-elements.test.ts index 73bc2922d7..cefe9f61cd 100644 --- a/packages/joint-react/src/hooks/__tests__/use-elements.test.ts +++ b/packages/joint-react/src/hooks/__tests__/use-elements.test.ts @@ -4,7 +4,7 @@ import { useElements } from '../use-elements'; describe('use-elements', () => { const wrapper = graphProviderWrapper({ - initialElements: [ + elements: [ { id: '1', width: 97, @@ -16,7 +16,7 @@ describe('use-elements', () => { height: 99, }, ], - initialLinks: [ + links: [ { id: '3', source: '1', diff --git a/packages/joint-react/src/hooks/__tests__/use-imperative-api.test.tsx b/packages/joint-react/src/hooks/__tests__/use-imperative-api.test.tsx new file mode 100644 index 0000000000..0911521944 --- /dev/null +++ b/packages/joint-react/src/hooks/__tests__/use-imperative-api.test.tsx @@ -0,0 +1,122 @@ +import { renderHook, act } from '@testing-library/react'; +import { useImperativeApi } from '../use-imperative-api'; + +describe('useImperativeApi', () => { + it('should initialize and cleanup properly', () => { + const onLoad = jest.fn(() => ({ + instance: { value: 'test-instance' }, + cleanup: jest.fn(), + })); + const onUpdate = jest.fn(); + const { result, unmount } = renderHook(() => useImperativeApi({ onLoad, onUpdate }, [])); + + // Verify onLoad is called and instance is set + expect(onLoad).toHaveBeenCalledTimes(1); + expect(result.current.isReady).toBe(true); + expect(result.current.ref.current).toEqual({ value: 'test-instance' }); + + // Unmount and verify cleanup + unmount(); + expect(onLoad.mock.results[0].value.cleanup).toHaveBeenCalledTimes(1); + }); + + it('should handle isDisabled properly', () => { + const onLoad = jest.fn(() => ({ + instance: { value: 'test-instance' }, + cleanup: jest.fn(), + })); + const { result, rerender } = renderHook( + ({ isDisabled }) => useImperativeApi({ onLoad, isDisabled }, []), + { initialProps: { isDisabled: false } } + ); + + // Verify instance is loaded initially + expect(result.current.isReady).toBe(true); + expect(result.current.ref.current).toEqual({ value: 'test-instance' }); + + // Set isDisabled to true and verify cleanup + act(() => { + rerender({ isDisabled: true }); + }); + expect(result.current.isReady).toBe(false); + expect(result.current.ref.current).toBeNull(); + expect(onLoad.mock.results[0].value.cleanup).toHaveBeenCalledTimes(2); + }); + + it('should call onUpdate when dependencies change', () => { + const onLoad = jest.fn(() => ({ + instance: { value: 'test-instance' }, + cleanup: jest.fn(), + })); + const onUpdate = jest.fn(); + const { rerender } = renderHook( + ({ dependencies }) => useImperativeApi({ onLoad, onUpdate }, dependencies), + { initialProps: { dependencies: [1] } } + ); + + // Verify onUpdate is not called initially + expect(onUpdate).not.toHaveBeenCalled(); + + // Change dependencies and verify onUpdate is called with reset function + rerender({ dependencies: [2] }); + expect(onUpdate).toHaveBeenCalledTimes(1); + expect(onUpdate).toHaveBeenCalledWith( + { value: 'test-instance' }, + expect.any(Function) // Ensure reset function is passed + ); + }); + + it('should handle cleanup from onUpdate', () => { + const onLoad = jest.fn(() => ({ + instance: { value: 'test-instance' }, + cleanup: jest.fn(), + })); + const onUpdateCleanup = jest.fn(); + const onUpdate = jest.fn(() => onUpdateCleanup); + const { rerender, unmount } = renderHook( + ({ dependencies }) => useImperativeApi({ onLoad, onUpdate }, dependencies), + { initialProps: { dependencies: [1] } } + ); + + // Change dependencies and verify onUpdate cleanup is called + rerender({ dependencies: [2] }); + + // Unmount and verify final cleanup + unmount(); + expect(onLoad.mock.results[0].value.cleanup).toHaveBeenCalledTimes(1); + expect(onUpdateCleanup).toHaveBeenCalledTimes(1); + }); + + it('should handle reset functionality properly', () => { + let instanceValue = 'test-load'; + const onLoad = jest.fn(() => ({ + instance: { value: instanceValue }, + cleanup: jest.fn(), + })); + const onUpdate = jest.fn((instance, reset) => { + reset(); + }); + const { result, rerender } = renderHook( + ({ counter }) => useImperativeApi({ onLoad, onUpdate }, [counter]), + { + initialProps: { counter: 0 }, + } + ); + + // Verify initial load + expect(onLoad).toHaveBeenCalledTimes(1); + expect(onUpdate).not.toHaveBeenCalled(); + expect(result.current.isReady).toBe(true); + expect(result.current.ref.current).toEqual({ value: 'test-load' }); + + act(() => { + instanceValue = 'test-reset'; + rerender({ counter: 1 }); + }); + + // Verify onUpdate and reset behavior + expect(onLoad).toHaveBeenCalledTimes(2); + expect(onUpdate).toHaveBeenCalledTimes(1); + expect(result.current.ref.current).toEqual({ value: 'test-reset' }); + }); +}); diff --git a/packages/joint-react/src/hooks/__tests__/use-links.test.ts b/packages/joint-react/src/hooks/__tests__/use-links.test.ts new file mode 100644 index 0000000000..3f01af8246 --- /dev/null +++ b/packages/joint-react/src/hooks/__tests__/use-links.test.ts @@ -0,0 +1,67 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { graphProviderWrapper } from '../../utils/test-wrappers'; +import { useLinks } from '../use-links'; + +describe('use-links', () => { + const wrapper = graphProviderWrapper({ + elements: [ + { + id: '1', + width: 97, + height: 99, + }, + { + id: '2', + width: 97, + height: 99, + }, + ], + links: [ + { + id: '3', + source: '1', + target: '2', + }, + ], + }); + + it('should get links properly without selector', async () => { + const renders = jest.fn(); + const { result } = renderHook( + () => { + renders(); + return useLinks(); + }, + { + wrapper, + } + ); + + await waitFor(() => { + expect(renders).toHaveBeenCalledTimes(1); + expect(result.current.length).toBe(1); + expect(result.current[0].id).toBe('3'); + }); + }); + + it('should get links properly with selector', async () => { + const renders = jest.fn(); + const { result } = renderHook( + () => { + renders(); + // @ts-expect-error - We are testing the selector functionality + // eslint-disable-next-line sonarjs/no-nested-functions + return useLinks((element) => element.map((items) => items.source.id)); + }, + { + wrapper, + } + ); + + await waitFor(() => { + expect(renders).toHaveBeenCalledTimes(1); + expect(result.current.length).toBe(1); + expect(result.current[0]).toBe('1'); + }); + }); +}); diff --git a/packages/joint-react/src/hooks/__tests__/use-measure-node-size.test.tsx b/packages/joint-react/src/hooks/__tests__/use-measure-node-size.test.tsx new file mode 100644 index 0000000000..f8a7677499 --- /dev/null +++ b/packages/joint-react/src/hooks/__tests__/use-measure-node-size.test.tsx @@ -0,0 +1,123 @@ +/* eslint-disable @eslint-react/hooks-extra/no-unnecessary-use-prefix */ +import React, { useRef } from 'react'; +import { render, act } from '@testing-library/react'; +import { useMeasureNodeSize } from '../use-measure-node-size'; + +// Mocks for @joint/core and useGraphStore + +// This is a mock for a hook, but the linter wants no 'use' prefix if not a real hook +jest.mock('../use-graph-store', () => { + const graph = { + getCell: jest.fn((_id: string) => ({ + isElement: () => true, + set: jest.fn(), + })), + }; + return { + useGraphStore: () => ({ + graph, + setMeasuredNode: jest.fn(() => jest.fn()), + hasMeasuredNode: jest.fn(), + }), + }; +}); + +// This is a mock for a hook, but the linter wants no 'use' prefix if not a real hook +jest.mock('../use-cell-id', () => ({ + useCellId: () => 'cell-1', +})); + +jest.mock('../../utils/create-element-size-observer', () => ({ + createElementSizeObserver: ( + element: HTMLElement, + cb: (size: { width: number; height: number }) => void + ) => { + // Simulate initial measurement + setTimeout(() => { + const rect = element.getBoundingClientRect(); + cb({ width: rect.width, height: rect.height }); + }, 0); + return jest.fn(); + }, +})); + +describe('useMeasureNodeSize', () => { + interface TestComponentProps { + readonly style: React.CSSProperties; + readonly children?: React.ReactNode; + readonly setSize: (options: { + element: unknown; + size: { width: number; height: number }; + }) => void; + } + function TestComponent({ style, children, setSize }: TestComponentProps) { + const ref = useRef(null); + useMeasureNodeSize(ref, { setSize }); + return ( +
+ {children} +
+ ); + } + + const explicitStyle = { width: '123px', height: '45px' }; + const contentStyle = { padding: '10px', fontSize: '20px' }; + + it('measures element with explicit width and height', async () => { + const setSize = jest.fn(); + // Mock getBoundingClientRect for this test + const getBoundingClientRect = jest.fn(() => ({ width: 123, height: 45 })); + // Render and patch the ref after mount + const { getByTestId } = render( + + Explicit + + ); + const element = getByTestId('measured'); + // @ts-expect-error assigning mock getBoundingClientRect to element for test + element.getBoundingClientRect = getBoundingClientRect; + + // Wait for measurement + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + + expect(setSize).toHaveBeenCalledWith( + expect.objectContaining({ + size: { width: 123, height: 45 }, + }) + ); + }); + + it('measures element with size from content/margin/padding', async () => { + const setSize = jest.fn(); + // Mock getBoundingClientRect for this test + const getBoundingClientRect = jest.fn(() => ({ width: 50, height: 30 })); + const { getByTestId } = render( + + Hello world + + ); + const element = getByTestId('measured'); + // @ts-expect-error assigning mock getBoundingClientRect to element for test + element.getBoundingClientRect = getBoundingClientRect; + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + + // Should be called with some nonzero size + expect(setSize).toHaveBeenCalledWith( + expect.objectContaining({ + size: expect.objectContaining({ + width: expect.any(Number), + height: expect.any(Number), + }), + }) + ); + // Should not be zero + const [[call]] = setSize.mock.calls; + expect(call.size.width).toBeGreaterThan(0); + expect(call.size.height).toBeGreaterThan(0); + }); +}); diff --git a/packages/joint-react/src/hooks/__tests__/use-remove-cell.test.ts b/packages/joint-react/src/hooks/__tests__/use-remove-cell.test.ts deleted file mode 100644 index 1d08e98493..0000000000 --- a/packages/joint-react/src/hooks/__tests__/use-remove-cell.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { act, renderHook, waitFor } from '@testing-library/react'; -import { graphProviderWrapper } from '../../utils/test-wrappers'; -import { useRemoveCell } from '../use-remove-cell'; -import { useGraph } from '../use-graph'; -import { useLinks } from '../use-links'; -import { useElements } from '../use-elements'; - -describe('use-remove-cell', () => { - const wrapper = graphProviderWrapper({ - initialElements: [ - { - id: '1', - width: 97, - height: 99, - }, - { - id: '2', - width: 97, - height: 99, - }, - ], - initialLinks: [ - { - id: '3', - source: '1', - target: '2', - }, - ], - }); - - it('should remove element from the graph', async () => { - const renders = jest.fn(); - const { result } = renderHook( - () => { - renders(); - return { - remove: useRemoveCell(), - graph: useGraph(), - reactLinksSizeCheck: useLinks((items) => items.size), - reactElementsSizeCheck: useElements((items) => items.size), - }; - }, - { - wrapper, - } - ); - - await waitFor(() => { - expect(renders).toHaveBeenCalledTimes(2); - const { graph } = result.current; - expect(graph.getElements().length).toBe(2); - expect(graph.getLinks().length).toBe(1); - expect(result.current.reactElementsSizeCheck).toBe(2); - expect(result.current.reactLinksSizeCheck).toBe(1); - }); - act(() => { - // remove link - result.current.remove('3'); - }); - // check if the element is removed from the graph - await waitFor(() => { - const { graph } = result.current; - expect(graph.getElements().length).toBe(2); - expect(graph.getLinks().length).toBe(0); - expect(result.current.reactElementsSizeCheck).toBe(2); - expect(result.current.reactLinksSizeCheck).toBe(0); - }); - - act(() => { - // remove element - result.current.remove('1'); - }); - // check if the element is removed from the graph - await waitFor(() => { - const { graph } = result.current; - expect(graph.getElements().length).toBe(1); - expect(graph.getLinks().length).toBe(0); - expect(result.current.reactElementsSizeCheck).toBe(1); - expect(result.current.reactLinksSizeCheck).toBe(0); - }); - - act(() => { - // remove element - result.current.remove('2'); - }); - // check if the element is removed from the graph - await waitFor(() => { - const { graph } = result.current; - - expect(graph.getElements().length).toBe(0); - expect(graph.getLinks().length).toBe(0); - expect(result.current.reactElementsSizeCheck).toBe(0); - expect(result.current.reactLinksSizeCheck).toBe(0); - }); - }); - - it('should do nothing if id does not exist', async () => { - const { result } = renderHook( - () => ({ - remove: useRemoveCell(), - graph: useGraph(), - reactLinksSizeCheck: useLinks((items) => items.size), - reactElementsSizeCheck: useElements((items) => items.size), - }), - { wrapper } - ); - - // Remove a non-existent cell - act(() => { - result.current.remove('non-existent-id'); - }); - - // Graph should remain unchanged - await waitFor(() => { - expect(result.current.graph.getElements().length).toBe(2); - expect(result.current.graph.getLinks().length).toBe(1); - expect(result.current.reactElementsSizeCheck).toBe(2); - expect(result.current.reactLinksSizeCheck).toBe(1); - }); - }); - - it('should not throw if called with undefined/null', async () => { - const { result } = renderHook( - () => ({ - remove: useRemoveCell(), - graph: useGraph(), - reactLinksSizeCheck: useLinks((items) => items.size), - reactElementsSizeCheck: useElements((items) => items.size), - }), - { wrapper } - ); - - act(() => { - expect(() => result.current.remove(undefined as never)).not.toThrow(); - expect(() => result.current.remove(null as never)).not.toThrow(); - }); - - // Graph should remain unchanged - await waitFor(() => { - expect(result.current.graph.getElements().length).toBe(2); - expect(result.current.graph.getLinks().length).toBe(1); - expect(result.current.reactElementsSizeCheck).toBe(2); - expect(result.current.reactLinksSizeCheck).toBe(1); - }); - }); -}); diff --git a/packages/joint-react/src/hooks/__tests__/use-update-element.test.ts b/packages/joint-react/src/hooks/__tests__/use-update-element.test.ts deleted file mode 100644 index 1f77a7962e..0000000000 --- a/packages/joint-react/src/hooks/__tests__/use-update-element.test.ts +++ /dev/null @@ -1,278 +0,0 @@ -import { act, renderHook, waitFor } from '@testing-library/react'; -import { graphProviderWrapper } from '../../utils/test-wrappers'; -import { useUpdateElement } from '../use-update-element'; -import { useGraph } from '../use-graph'; -import { useElements } from '../use-elements'; - -describe('use-update-element', () => { - const wrapper = graphProviderWrapper({ - initialElements: [ - { - id: '1', - width: 97, - height: 99, - }, - { - id: '2', - width: 97, - height: 99, - }, - ], - initialLinks: [ - { - id: '3', - source: '1', - target: '2', - }, - ], - }); - - it('should set element with all properties', async () => { - const renders = jest.fn(); - const { result } = renderHook( - () => { - renders(); - return { - set: useUpdateElement(), - graph: useGraph(), - reactElementsSizeCheck: useElements(), - }; - }, - { - wrapper, - } - ); - - await waitFor(() => { - expect(renders).toHaveBeenCalledTimes(1); - const { graph } = result.current; - expect(graph.getElements().length).toBe(2); - expect(result.current.reactElementsSizeCheck.length).toBe(2); - expect(result.current.reactElementsSizeCheck[0].width).toBe(97); - }); - - act(() => { - result.current.set('1', 'size', { width: 1000, height: 1000 }); - }); - - // check if the element is set in the graph - await waitFor(() => { - const { graph } = result.current; - expect(graph.getElements().length).toBe(2); - expect(result.current.reactElementsSizeCheck.length).toBe(2); - expect(result.current.reactElementsSizeCheck[0].width).toBe(1000); - }); - }); - - it('should set element with selected id', async () => { - const renders = jest.fn(); - const { result } = renderHook( - () => { - renders(); - return { - set: useUpdateElement('1'), - graph: useGraph(), - reactElementsSizeCheck: useElements(), - }; - }, - { - wrapper, - } - ); - - await waitFor(() => { - expect(renders).toHaveBeenCalledTimes(1); - const { graph } = result.current; - expect(graph.getElements().length).toBe(2); - expect(result.current.reactElementsSizeCheck.length).toBe(2); - expect(result.current.reactElementsSizeCheck[0].width).toBe(97); - }); - - act(() => { - result.current.set('size', { width: 1000, height: 1000 }); - }); - - // check if the element is set in the graph - await waitFor(() => { - const { graph } = result.current; - expect(graph.getElements().length).toBe(2); - expect(result.current.reactElementsSizeCheck.length).toBe(2); - expect(result.current.reactElementsSizeCheck[0].width).toBe(1000); - }); - }); - - it('should set element with selected id and attribute', async () => { - const renders = jest.fn(); - const { result } = renderHook( - () => { - renders(); - return { - set: useUpdateElement('1', 'size'), - graph: useGraph(), - reactElementsSizeCheck: useElements(), - }; - }, - { - wrapper, - } - ); - - await waitFor(() => { - expect(renders).toHaveBeenCalledTimes(1); - const { graph } = result.current; - expect(graph.getElements().length).toBe(2); - expect(result.current.reactElementsSizeCheck.length).toBe(2); - expect(result.current.reactElementsSizeCheck[0].width).toBe(97); - }); - - act(() => { - result.current.set({ width: 1000, height: 1000 }); - }); - - // check if the element is set in the graph - await waitFor(() => { - const { graph } = result.current; - expect(graph.getElements().length).toBe(2); - expect(result.current.reactElementsSizeCheck.length).toBe(2); - expect(result.current.reactElementsSizeCheck[0].width).toBe(1000); - }); - }); - - it('should not set element if ID is invalid', async () => { - const renders = jest.fn(); - const { result } = renderHook( - () => { - renders(); - return { - set: useUpdateElement(), - graph: useGraph(), - }; - }, - { - wrapper, - } - ); - - act(() => { - result.current.set('invalid-id', 'size', { width: 500, height: 500 }); - }); - - await waitFor(() => { - const { graph } = result.current; - expect(graph.getElements().length).toBe(2); - expect(graph.getCell('invalid-id')).toBeUndefined(); - }); - }); - - it('should set element if attribute is invalid', async () => { - const renders = jest.fn(); - const { result } = renderHook( - () => { - renders(); - return { - set: useUpdateElement('1'), - graph: useGraph(), - }; - }, - { - wrapper, - } - ); - - act(() => { - result.current.set('invalid-attribute', { width: 500, height: 500 }); - }); - - await waitFor(() => { - const { graph } = result.current; - const element = graph.getCell('1'); - expect(element.get('invalid-attribute')).toEqual({ - width: 500, - height: 500, - }); - }); - }); - - it('should set element if value is undefined', async () => { - const renders = jest.fn(); - const { result } = renderHook( - () => { - renders(); - return { - set: useUpdateElement('1', 'size'), - graph: useGraph(), - }; - }, - { - wrapper, - } - ); - - act(() => { - result.current.set(undefined); - }); - - await waitFor(() => { - const { graph } = result.current; - const element = graph.getCell('1'); - expect(element.get('size')).toEqual(undefined); - }); - }); - - it('should skip setting if value is the same as the current value', async () => { - const renders = jest.fn(); - const { result } = renderHook( - () => { - renders(); - return { - set: useUpdateElement('1', 'size'), - graph: useGraph(), - }; - }, - { - wrapper, - } - ); - - act(() => { - result.current.set({ width: 97, height: 99 }); - }); - - await waitFor(() => { - const { graph } = result.current; - const element = graph.getCell('1'); - expect(element.get('size')).toEqual({ width: 97, height: 99 }); - }); - }); - - it('should handle setter function for value', async () => { - const renders = jest.fn(); - const { result } = renderHook( - () => { - renders(); - return { - set: useUpdateElement('1', 'size'), - graph: useGraph(), - }; - }, - { - wrapper, - } - ); - - act(() => { - result.current.set((previous) => ({ - // @ts-expect-error just mocks - width: previous?.width + 10, - // @ts-expect-error just mocks - height: previous?.height + 10, - })); - }); - - await waitFor(() => { - const { graph } = result.current; - const element = graph.getCell('1'); - expect(element.get('size')).toEqual({ width: 107, height: 109 }); - }); - }); -}); diff --git a/packages/joint-react/src/hooks/index.ts b/packages/joint-react/src/hooks/index.ts index e8f2085e1f..83fac6c61d 100644 --- a/packages/joint-react/src/hooks/index.ts +++ b/packages/joint-react/src/hooks/index.ts @@ -4,8 +4,11 @@ export * from './use-links'; export * from './use-elements'; export * from './use-element'; export * from './use-measure-node-size'; -export * from './use-update-element'; export * from './use-cell-id'; -export * from './use-remove-cell'; -export * from './use-create-element'; -export * from './use-create-link'; +export * from './use-are-elements-measured'; +export * from './use-paper-events'; +export * from './use-imperative-api'; +export * from './use-cell-actions'; +export * from './use-graph-store'; +export * from './use-paper-context'; +export * from './use-element-views'; diff --git a/packages/joint-react/src/hooks/use-cell-actions.stories.tsx b/packages/joint-react/src/hooks/use-cell-actions.stories.tsx new file mode 100644 index 0000000000..1ddb85a396 --- /dev/null +++ b/packages/joint-react/src/hooks/use-cell-actions.stories.tsx @@ -0,0 +1,369 @@ +/* eslint-disable @eslint-react/dom/no-missing-button-type */ +/* eslint-disable react-perf/jsx-no-new-function-as-prop */ +import type { Meta, StoryObj } from '@storybook/react'; +import type { SimpleElement } from '../../.storybook/decorators/with-simple-data'; +import { HTMLNode, RenderItemDecorator } from '../../.storybook/decorators/with-simple-data'; +import '../stories/examples/index.css'; +import { BUTTON_CLASSNAME, PRIMARY } from 'storybook-config/theme'; +import { makeRootDocumentation, makeStory } from '../stories/utils/make-story'; +import { getAPILink } from '../stories/utils/get-api-documentation-link'; +import { useCellActions } from './use-cell-actions'; + +const API_URL = getAPILink('useCellActions'); + +export type Story = StoryObj; + +const meta: Meta = { + title: 'Hooks/useCellActions', + component: Hook, + render: () => , + parameters: makeRootDocumentation({ + apiURL: API_URL, + description: `\`useCellActions\` is a hook to set / insert / remove elements and links in the graph. It returns functions to update cells. Use it under \`GraphProvider\` (graph context). + `, + code: `import { useCellActions } from '@joint/react' + +function Component() { + const { set } = useCellActions(); + return ; +}`, + }), +}; + +export default meta; + +function Hook({ label, id }: SimpleElement) { + const { set } = useCellActions(); + + return ( + + + label: {label} + + ); +} +export const SetLabel: Story = makeStory({ + args: { + label: 'default', + color: 'red', + id: 'default-id', + }, + apiURL: API_URL, + code: `import { useCellActions } from '@joint/react' + + + function Hook({ label , id }: SimpleElement) { + const { set } = useCellActions(); + + return ( + + + label: {label} + + ); + }`, + description: 'Set new data for the element.', +}); + +function HookSetPosition({ label, id }: SimpleElement) { + const { set } = useCellActions(); + + return ( + + + label: {label} + + ); +} + +export const SetPosition: Story = makeStory({ + component: () => , + apiURL: API_URL, + code: ` +import { useCellActions } from '@joint/react' + +function HookSetPosition({ label , id }: SimpleElement) { + const { set } = useCellActions(); + + return ( + + + label: {label} + + ); +} + `, + description: 'Set the position of the element.', +}); + +function HookSetSize({ label, id }: SimpleElement) { + const { set } = useCellActions(); + + return ( + + + label: {label} + + ); +} + +export const SetSize: Story = makeStory({ + component: () => , + apiURL: API_URL, + code: `import { useCellActions } from '@joint/react' + +function HookSetSize({ label , id }: SimpleElement) { + const { set } = useCellActions(); + + return ( + + + label: {label} + + ); +}`, + description: 'Set the size of the element.', +}); + +function HookSetAngle({ label, id }: SimpleElement) { + const { set } = useCellActions(); + + return ( + + + label: {label} + + ); +} + +export const SetAngle: Story = makeStory({ + component: () => , + apiURL: API_URL, + code: `import { useCellActions } from '@joint/react' + +function HookSetAngle({ label , id }: SimpleElement) { + const { set } = useCellActions(); + + return ( + + + label: {label} + + ); +}`, + description: 'Set the angle of the element.', +}); + +function HookSetAny({ label, id }: SimpleElement) { + const { set } = useCellActions(); + + return ( + + + + label: {label} + + ); +} + +export const SetAnyProperty: Story = makeStory({ + apiURL: API_URL, + component: () => , + code: `import { useCellActions } from '@joint/react' + +function HookSetAny({ label , id }: SimpleElement) { + const { set } = useCellActions(); + + return ( + + + + label: {label} + + ); +} +`, + description: 'Set the markup of the element.', +}); + +// remove elements +function HookRemoveElement({ label, id }: SimpleElement) { + const { remove } = useCellActions(); + + return ( + + + label: {label} + + ); +} +export const RemoveElement: Story = makeStory({ + component: () => , + apiURL: API_URL, + code: `import { useCellActions } from '@joint/react' + +function HookRemoveElement({ label , id }: SimpleElement) { + const { remove } = useCellActions(); + + return ( + + + label: {label} + + ); +} +`, + description: 'Remove the element from the graph.', +}); + +// set link example +function HookSetAndRemoveLink({ label, id }: SimpleElement) { + const { remove, set } = useCellActions(); + + return ( + + + + label: {label} + + ); +} + +export const SetAndRemoveLink: Story = makeStory({ + component: () => , + apiURL: API_URL, + code: `import { useCellActions } from '@joint/react' + +function SetAndRemoveLink({ label , id }: SimpleElement) { + const { remove, set } = useCellActions(); + + return ( + + + + label: {label} + + ); +} +`, + description: 'Set the link source and target.', +}); diff --git a/packages/joint-react/src/hooks/use-cell-actions.ts b/packages/joint-react/src/hooks/use-cell-actions.ts new file mode 100644 index 0000000000..5a627e142f --- /dev/null +++ b/packages/joint-react/src/hooks/use-cell-actions.ts @@ -0,0 +1,93 @@ +import { useMemo } from 'react'; +import { dia } from '@joint/core'; +import { processElement, processLink } from '../utils/cell/cell-utilities'; +import { updateCell } from '../utils/graph/update-graph'; +import type { GraphElement } from '../types/element-types'; +import type { CellWithId } from '../types/cell.types'; +import type { GraphLink } from '../types/link-types'; +import { useGraphStore } from './use-graph-store'; + +interface CellActions { + set: { + (attributes: Attributes): void; + (id: dia.Cell.ID, updater: (previous: Attributes) => Attributes): void; + }; + remove: (id: dia.Cell.ID) => void; +} + +/** + * Type guard to check if a cell represents a link. + * @param cell - The cell to check. + * @returns True if the cell is a link, false otherwise. + */ +function isLink(cell: CellWithId): cell is GraphLink<'standard.Link'> { + return cell instanceof dia.Link || ('source' in cell && 'target' in cell); +} + +/** + * Hook to provide actions for manipulating cells in the graph. + * @group Hooks + * @template Attributes - The type of cell attributes, which can be an element or a link. + * @returns - An object containing methods to set and remove cells. + * @example + * ```tsx + * const { set, remove } = useCellActions>(); + * + * // Update element + * set({ id: '1', position: { x: 100, y: 150 } }); + * // Update with updater fn + * set('1', (cell) => ({ ...cell.toJSON(), position: { x: 200, y: 250 } })); + * // Remove element + * remove('1'); + * ``` + */ +export function useCellActions< + Attributes extends GraphElement | GraphLink<'standard.Link'>, +>(): CellActions { + const { graph, getElement, getLink } = useGraphStore(); + + return useMemo( + (): CellActions => ({ + set( + attributesOrId: Attributes | dia.Cell.ID, + maybeUpdater?: (previousAttributes: Attributes) => Attributes + ) { + let attributes: Attributes; + + if ( + typeof attributesOrId !== 'object' && + maybeUpdater && + typeof maybeUpdater === 'function' + ) { + // let cell: Attributes extends GraphElement | GraphLink<"standard.Link"> + + let cell: GraphElement | GraphLink | undefined; + try { + cell = getElement(attributesOrId); + } catch { + cell = getLink(attributesOrId); + } + if (!cell) throw new Error(`Cell with id "${attributesOrId}" not found.`); + attributes = maybeUpdater(cell as Attributes); + } else if (typeof attributesOrId === 'object') { + attributes = attributesOrId; + } else { + throw new TypeError('Invalid arguments for set().'); + } + const areAttributesLink = isLink(attributes); + const cell = areAttributesLink + ? processLink(attributes as dia.Link | GraphLink<'standard.Link'>) + : processElement(attributes); + updateCell({ + graph, + newCell: cell, + }); + }, + + remove(id) { + graph.getCell(id)?.remove(); + }, + }), + [getElement, getLink, graph] + ); +} diff --git a/packages/joint-react/src/hooks/use-cell-id.stories.tsx b/packages/joint-react/src/hooks/use-cell-id.stories.tsx index fe65dd3199..07a9065217 100644 --- a/packages/joint-react/src/hooks/use-cell-id.stories.tsx +++ b/packages/joint-react/src/hooks/use-cell-id.stories.tsx @@ -2,9 +2,9 @@ import type { Meta, StoryObj } from '@storybook/react'; import { useCellId } from './use-cell-id'; // Adjust path accordingly import type { SimpleElement } from '../../.storybook/decorators/with-simple-data'; import { HTMLNode, SimpleRenderItemDecorator } from '../../.storybook/decorators/with-simple-data'; -import { makeRootDocumentation } from '@joint/react/src/stories/utils/make-story'; -import { getAPILink } from '@joint/react/src/stories/utils/get-api-documentation-link'; import '../stories/examples/index.css'; +import { getAPILink } from '../stories/utils/get-api-documentation-link'; +import { makeRootDocumentation } from '../stories/utils/make-story'; function Hook(_: SimpleElement) { const cellId = useCellId(); // Using the hook inside a component diff --git a/packages/joint-react/src/hooks/use-cell-id.ts b/packages/joint-react/src/hooks/use-cell-id.ts index c16c28a3a6..ee533bdc39 100644 --- a/packages/joint-react/src/hooks/use-cell-id.ts +++ b/packages/joint-react/src/hooks/use-cell-id.ts @@ -1,26 +1,22 @@ import type { dia } from '@joint/core'; import { useContext } from 'react'; -import { CellIdContext } from '../context/cell-id.context'; +import { CellIdContext } from '../context'; /** - * Return cell id from the paper (paper item). - * It must be used inside `renderElement` function. - * @returns - The cell id. - * @throws - If the hook is not used inside the paper context. + * Returns the current cell id within `Paper` element rendering. + * Use this only inside the `renderElement` function. + * @returns The current cell id. + * @throws If called outside the view context. * @group hooks - * @description - * This hook is used to get the cell id from the paper `RenderElement`. - * It must be used inside the `renderElement` function. * @example * ```ts * const cellId = useCellId(); - * console.log(cellId); * ``` */ export function useCellId(): dia.Cell.ID { const id = useContext(CellIdContext); if (id === undefined) { - throw new Error('useCellId is not used inside paper context'); + throw new Error('useCellId must be used inside Paper renderElement'); } return id; } diff --git a/packages/joint-react/src/hooks/use-create-element.stories.tsx b/packages/joint-react/src/hooks/use-create-element.stories.tsx deleted file mode 100644 index 7ebfbcd5ed..0000000000 --- a/packages/joint-react/src/hooks/use-create-element.stories.tsx +++ /dev/null @@ -1,92 +0,0 @@ -/* eslint-disable react-perf/jsx-no-new-object-as-prop */ -/* eslint-disable react-perf/jsx-no-new-function-as-prop */ -/* eslint-disable react-hooks/rules-of-hooks */ -import type { Meta, StoryObj } from '@storybook/react'; -import { useCreateElement } from './use-create-element'; -import { makeRootDocumentation, makeStory } from '@joint/react/src/stories/utils/make-story'; -import { getAPILink } from '@joint/react/src/stories/utils/get-api-documentation-link'; -import type { SimpleElement } from '../../.storybook/decorators/with-simple-data'; -import { HTMLNode, SimpleGraphDecorator } from '../../.storybook/decorators/with-simple-data'; -import '../stories/examples/index.css'; -import { Paper } from '../components'; -import { BUTTON_CLASSNAME, PAPER_CLASSNAME } from 'storybook-config/theme'; - -const API_URL = getAPILink('useCreateElement'); - -export type Story = StoryObj; - -const meta: Meta = { - title: 'Hooks/useCreateElement', - component: Hook, - decorators: [SimpleGraphDecorator], - render: () => { - const addElement = useCreateElement(); - return ( -
-
- -
-
- -
-
- ); - }, - parameters: makeRootDocumentation({ - apiURL: API_URL, - description: `\`useCreateElement\` is a hook to add elements to the graph. It returns a function to add an element. It must be used inside the GraphProvider.`, - code: `import { useCreateElement } from '@joint/react' - -function Component() { - const addElement = useCreateElement(); - return ; -}`, - }), -}; - -export default meta; - -function Hook({ label }: SimpleElement) { - return {label}; -} - -export const Default: Story = makeStory({ - args: {}, - code: `import { useCreateElement } from '@joint/react' - -function Hook() { - const addElement = useCreateElement(); - - return ( -
- - -
- ); -}`, - description: 'Add elements to the graph.', -}); diff --git a/packages/joint-react/src/hooks/use-create-element.ts b/packages/joint-react/src/hooks/use-create-element.ts deleted file mode 100644 index 8cb92c075b..0000000000 --- a/packages/joint-react/src/hooks/use-create-element.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { dia } from '@joint/core'; -import type { GraphElement } from '../types/element-types'; -import { useGraph } from './use-graph'; -import { useCallback } from 'react'; -import { processElement } from '../utils/cell/set-cells'; - -type SetElement = Omit< - Partial & { id: dia.Cell.ID }, - 'isElement' | 'isLink' ->; - -/** - * A custom hook that adds an element to the graph. - * @group Hooks - * @returns A function that adds the element to the graph. - * @example - * ```ts - * const addElement = useCreateElement(); - * addElement({ id: '1', label: 'Node 1' }); - * ``` - */ -export function useCreateElement() { - const graph = useGraph(); - return useCallback( - (element: SetElement) => { - graph.addCell(processElement(element)); - }, - [graph] - ); -} diff --git a/packages/joint-react/src/hooks/use-create-link.ts b/packages/joint-react/src/hooks/use-create-link.ts deleted file mode 100644 index cf0bf17581..0000000000 --- a/packages/joint-react/src/hooks/use-create-link.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { dia } from '@joint/core'; -import { useGraph } from './use-graph'; -import { useCallback } from 'react'; -import { processLink } from '../utils/cell/set-cells'; -import type { GraphLink } from '../types/link-types'; - -/** - * A custom hook that adds a link to the graph. - * @group Hooks - * @returns A function that adds the link to the graph. - * @example - * ```ts - * const addLink = useCreateLink(); - * addLink({ id: '1', source: { id: '2' }, target: { id: '3' } }); - * ``` - */ -export function useCreateLink() { - const graph = useGraph(); - return useCallback( - (link: T) => { - graph.addCell(processLink(link)); - }, - [graph] - ); -} diff --git a/packages/joint-react/src/hooks/use-create-paper.ts b/packages/joint-react/src/hooks/use-create-paper.ts deleted file mode 100644 index 1b4ea4bdc8..0000000000 --- a/packages/joint-react/src/hooks/use-create-paper.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { useContext, useLayoutEffect, useRef } from 'react'; -import { mvc, type dia } from '@joint/core'; -import { useGraphStore } from './use-graph-store'; -import type { PaperEventType, PaperEvents } from '../types/event.types'; -import { handleEvent } from '../utils/handle-paper-events'; -import { PaperContext } from '../context'; - -interface UseCreatePaperOptions extends PaperEvents { - /** - * On load custom element. - * If provided, it must return valid HTML or SVG element and it will be replaced with the default paper element. - * So it overwrite default paper rendering. - * It is used internally for example to render `PaperScroller` from [joint plus](https://www.jointjs.com/jointjs-plus) package. - * @param paperCtx - The paper context - * @returns - */ - readonly overwriteDefaultPaperElement?: (paperCtx: PaperContext) => HTMLElement | SVGElement; - - readonly scale?: number; -} - -/** - * Custom hook to use a JointJS paper instance. - * It retrieves the paper from the PaperContext or creates a new instance. - * Returns a reference to the paper HTML element. - * This hook must be already be defined inside `PaperProvider` - * @group Hooks - * @internal - * @param options - Options for creating the paper instance. - * @returns An object containing the paper instance and a reference to the paper HTML element. - */ -export function useCreatePaper(options: UseCreatePaperOptions = {}) { - const { overwriteDefaultPaperElement, ...restOptions } = options; - - const paperContainerElement = useRef(null); - const { graph } = useGraphStore(); - - const paperCtx = useContext(PaperContext); - useLayoutEffect(() => { - if (!paperCtx) { - return; - } - - const { paper } = paperCtx; - if (!paper) { - throw new Error('Paper is not created'); - } - - if (overwriteDefaultPaperElement) { - const customElement = overwriteDefaultPaperElement(paperCtx); - if (!customElement) { - throw new Error('overwriteDefaultPaperElement must return a valid HTML or SVG element'); - } - paperContainerElement.current?.replaceChildren(customElement); - } else { - if (!paperContainerElement.current) { - throw new Error('Paper container element is not defined'); - } - paperContainerElement.current.replaceChildren(paper.el); - } - paper.unfreeze(); - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [graph, overwriteDefaultPaperElement]); - - useLayoutEffect(() => { - if (!paperCtx) { - return; - } - const { paper } = paperCtx; - if (!paper) { - return; - } - /** - * Resize the paper container element to match the paper size. - * @param jointPaper - The paper instance. - */ - function resizePaperContainer(jointPaper: dia.Paper) { - if (paperContainerElement.current) { - paperContainerElement.current.style.width = jointPaper.el.style.width; - paperContainerElement.current.style.height = jointPaper.el.style.height; - } - } - // An object to keep track of the listeners. It's not exposed, so the users - const controller = new mvc.Listener(); - controller.listenTo(paper, 'resize', resizePaperContainer); - controller.listenTo(paper, 'all', (type: PaperEventType, ...args: unknown[]) => - handleEvent(type, restOptions, paper, ...args) - ); - return () => { - controller.stopListening(); - }; - }, [paperCtx, restOptions]); - - useLayoutEffect(() => { - if (!paperCtx) { - return; - } - const { paper } = paperCtx; - if (!paper) { - return; - } - if (options?.scale !== undefined) { - paper.scale(options.scale); - } - }, [options.scale, paperCtx]); - return { - paperCtx, - paperContainerElement, - }; -} diff --git a/packages/joint-react/src/hooks/use-element-views.ts b/packages/joint-react/src/hooks/use-element-views.ts new file mode 100644 index 0000000000..1224ec9240 --- /dev/null +++ b/packages/joint-react/src/hooks/use-element-views.ts @@ -0,0 +1,35 @@ +import type { dia } from '@joint/core'; +import { useCallback, useState } from 'react'; + +export type OnPaperRenderElement = (elementView: dia.ElementView) => void; + +/** + * A custom hook that manages the rendering of `ElementView` elements in a JointJS paper. + * @returns An object containing the rendered `ElementView` elements and a function to handle rendering. + * @group hooks + * @param idExtractor - A function to extract the ID from the `ElementView`. If not provided, the model's ID will be used. + * @description + * This hook is used to manage the rendering of `ElementView` elements in a JointJS paper. + * It provides a function to handle the rendering of elements and a state to store the rendered SVG elements. + * It can be used to trigger a callback when the `ElementView` element is ready. + * @private + * @internal + */ +export function useElementViews(idExtractor?: (elementView: dia.ElementView) => string) { + const [elementViews, setElements] = useState>({}); + + const onRenderElement: OnPaperRenderElement = useCallback( + (elementView) => { + const id = idExtractor ? idExtractor(elementView) : elementView.model.id; + return setElements((previous) => { + const newElements = { ...previous, [id]: elementView }; + return newElements; + }); + }, + // Extractor must be pure + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + return { elementViews, onRenderElement }; +} diff --git a/packages/joint-react/src/hooks/use-element.stories.tsx b/packages/joint-react/src/hooks/use-element.stories.tsx index b116184425..e902f4feee 100644 --- a/packages/joint-react/src/hooks/use-element.stories.tsx +++ b/packages/joint-react/src/hooks/use-element.stories.tsx @@ -1,9 +1,9 @@ import { useElement } from './use-element'; import { SimpleRenderItemDecorator } from '../../.storybook/decorators/with-simple-data'; -import type { Meta } from '@storybook/react/*'; +import type { Meta } from '@storybook/react'; import { HookTester, type TesterHookStory } from '../stories/utils/hook-tester'; -import { makeRootDocumentation, makeStory } from '@joint/react/src/stories/utils/make-story'; -import { getAPILink } from '@joint/react/src/stories/utils/get-api-documentation-link'; +import { makeRootDocumentation, makeStory } from '../stories/utils/make-story'; +import { getAPILink } from '../stories/utils/get-api-documentation-link'; const API_URL = getAPILink('useElement'); diff --git a/packages/joint-react/src/hooks/use-element.ts b/packages/joint-react/src/hooks/use-element.ts index eec80e4324..057b8c4ddf 100644 --- a/packages/joint-react/src/hooks/use-element.ts +++ b/packages/joint-react/src/hooks/use-element.ts @@ -6,8 +6,8 @@ import type { GraphElement } from '../types/element-types'; import { useCallback } from 'react'; /** - * A hook to access a specific graph element from the Paper context. - * It must be used inside a PaperProvider. + * A hook to access a specific graph element from the current `Paper` context. + * Use it only inside `renderElement` or components rendered from within. * This hook returns the selected element based on its cell id. It accepts: * - a selector function, which extracts the desired part from the element. * (By default, it returns the entire element.) @@ -29,7 +29,7 @@ import { useCallback } from 'react'; * (element) => element, * (prev, next) => prev.width === next.width * ); - * @param selector The selector function to pick part of the element. @default initialElementselector + * @param selector The selector function to pick part of the element. @default identity * @param isEqual The function used to check equality. @default util.isEqual * @returns The selected element based on the current cell id. */ @@ -42,8 +42,8 @@ export function useElement void) => { - return subscribe((changedIds) => { - if (changedIds?.has(id)) { + return subscribe((update) => { + if (update?.diffIds.has(id)) { subscribeCallback(); } }); diff --git a/packages/joint-react/src/hooks/use-elements.stories.tsx b/packages/joint-react/src/hooks/use-elements.stories.tsx index ad15237422..298c015c44 100644 --- a/packages/joint-react/src/hooks/use-elements.stories.tsx +++ b/packages/joint-react/src/hooks/use-elements.stories.tsx @@ -1,18 +1,17 @@ -/* eslint-disable react-perf/jsx-no-new-object-as-prop */ /* eslint-disable react-perf/jsx-no-new-function-as-prop */ import { DataRenderer, SimpleGraphDecorator } from '../../.storybook/decorators/with-simple-data'; -import type { Meta } from '@storybook/react/*'; +import type { Meta } from '@storybook/react'; import { HookTester, type TesterHookStory } from '../stories/utils/hook-tester'; import { useElements } from './use-elements'; -import { Paper } from '../components/paper/paper'; import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; -import { makeRootDocumentation, makeStory } from '@joint/react/src/stories/utils/make-story'; -import { getAPILink } from '@joint/react/src/stories/utils/get-api-documentation-link'; +import { getAPILink } from '../stories/utils/get-api-documentation-link'; +import { makeRootDocumentation, makeStory } from '../stories/utils/make-story'; +import { Paper } from '../components/paper/paper'; const API_URL = getAPILink('useElements'); const meta: Meta = { - title: 'Hooks/useElements', + title: 'Hooks/useElements useLinks', component: HookTester, decorators: [SimpleGraphDecorator], parameters: makeRootDocumentation({ @@ -88,7 +87,7 @@ function Component() { export const WithGetJustSize = makeStory({ args: { useHook: useElements, - hookArgs: [(elements) => elements.size], + hookArgs: [(elements) => elements.length], render: (result) => (
({ code: `import { useElements } from '@joint/react' function Component() { - const size = useElements((elements) => elements.size); + const size = useElements((elements) => elements.length); return
size of elements is: {JSON.stringify(size)}
; }`, description: 'Get the size of the elements.', diff --git a/packages/joint-react/src/hooks/use-elements.ts b/packages/joint-react/src/hooks/use-elements.ts index 6d469be941..3aa88fae87 100644 --- a/packages/joint-react/src/hooks/use-elements.ts +++ b/packages/joint-react/src/hooks/use-elements.ts @@ -2,7 +2,6 @@ import { useGraphStore } from './use-graph-store'; import { util } from '@joint/core'; import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/with-selector'; import type { GraphElement } from '../types/element-types'; -import type { CellMap } from '../utils/cell/cell-map'; /** * Default selector function to return all elements. @@ -10,7 +9,7 @@ import type { CellMap } from '../utils/cell/cell-map'; * @returns - The selected items. */ function defaultSelector( - items: CellMap + items: Elements[] ): Elements[] { return items.map((item) => item) as Elements[]; } @@ -60,13 +59,11 @@ export function useElements< Elements extends GraphElement = GraphElement, SelectorReturnType = Elements[], >( - selector: ( - items: CellMap - ) => SelectorReturnType = defaultSelector as () => SelectorReturnType, + selector: (items: Elements[]) => SelectorReturnType = defaultSelector as () => SelectorReturnType, isEqual: (a: SelectorReturnType, b: SelectorReturnType) => boolean = util.isEqual ): SelectorReturnType { const { subscribe, getElements } = useGraphStore(); - const typedGetElements = getElements as () => CellMap; + const typedGetElements = getElements as () => Elements[]; const elements = useSyncExternalStoreWithSelector( subscribe, typedGetElements, diff --git a/packages/joint-react/src/hooks/use-graph-store.ts b/packages/joint-react/src/hooks/use-graph-store.ts index feaf2fd292..75df462c61 100644 --- a/packages/joint-react/src/hooks/use-graph-store.ts +++ b/packages/joint-react/src/hooks/use-graph-store.ts @@ -1,18 +1,19 @@ import { useContext } from 'react'; -import { GraphStoreContext, type StoreContext } from '../context/graph-store-context'; +import { GraphStoreContext, type StoreContext } from '../context'; +import type { dia } from '@joint/core'; /** - * Custom hook to use a JointJS graph store. - * It retrieves the graph from the GraphContext. + * Custom hook to use a JointJS `GraphProvider` graph store. + * It must be used inside the `GraphProvider`. * @group Hooks * @internal * @returns The JointJS graph store. * @throws An error if the hook is used outside of a GraphProvider. */ -export function useGraphStore(): StoreContext { +export function useGraphStore(): StoreContext { const store = useContext(GraphStoreContext); if (!store) { throw new Error('useGraphStore must be used within a GraphProvider'); } - return store; + return store as StoreContext; } diff --git a/packages/joint-react/src/hooks/use-graph.ts b/packages/joint-react/src/hooks/use-graph.ts index cdedd37d86..19f9d578fe 100644 --- a/packages/joint-react/src/hooks/use-graph.ts +++ b/packages/joint-react/src/hooks/use-graph.ts @@ -1,3 +1,4 @@ +import type { dia } from '@joint/core'; import { useGraphStore } from './use-graph-store'; /** @@ -12,7 +13,7 @@ import { useGraphStore } from './use-graph-store'; * const graph = useGraph() * ``` */ -export function useGraph() { - const { graph } = useGraphStore(); +export function useGraph() { + const { graph } = useGraphStore(); return graph; } diff --git a/packages/joint-react/src/hooks/use-highlighter.ts b/packages/joint-react/src/hooks/use-highlighter.ts deleted file mode 100644 index c4f4ca00e8..0000000000 --- a/packages/joint-react/src/hooks/use-highlighter.ts +++ /dev/null @@ -1,64 +0,0 @@ -/* eslint-disable no-shadow */ -import type { dia } from '@joint/core'; -import { util } from '@joint/core'; -import { useEffect, useRef } from 'react'; - -interface HighlighterBase { - remove: () => void; -} - -/** - * Just internal util hook to manage highlighter lifecycle with automatic remove and update. - * @group Hooks - * @param create - Function to create a highlighter instance. - * @param update - Function to update the highlighter instance. - * @param options - Options to create the highlighter instance. - * @param isDisabled - Flag to disable the highlighter - * @internal - */ -export function useHighlighter< - Highlighter extends HighlighterBase, - HighlighterOptions extends dia.HighlighterView.Options, ->( - create: (options: HighlighterOptions) => Highlighter | undefined, - update: (instance: Highlighter, options: HighlighterOptions) => void, - options: HighlighterOptions, - isDisabled?: boolean -) { - const highlighter = useRef(null); - const previousOptions = useRef(null); - - // This effect is called only on mount and un-mount of the component itself - useEffect(() => { - if (isDisabled) { - highlighter.current?.remove(); - highlighter.current = null; - previousOptions.current = null; - return; - } - const instance = create(options); - if (!instance) { - return; - } - highlighter.current = instance; - return () => { - highlighter.current?.remove(); - highlighter.current = null; - previousOptions.current = null; - }; - // listen only to isDisabled change - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isDisabled]); - - // This effect is called on every options change - useEffect(() => { - if (!highlighter.current) { - return; - } - if (util.isEqual(options, previousOptions.current)) { - return; - } - update(highlighter.current, options); - previousOptions.current = options; - }, [options, update]); -} diff --git a/packages/joint-react/src/hooks/use-imperative-api.ts b/packages/joint-react/src/hooks/use-imperative-api.ts new file mode 100644 index 0000000000..893762a8be --- /dev/null +++ b/packages/joint-react/src/hooks/use-imperative-api.ts @@ -0,0 +1,130 @@ +import { + useCallback, + useEffect, + useImperativeHandle, + useLayoutEffect, + useRef, + useState, + type DependencyList, + type RefObject, +} from 'react'; + +interface OnLoadReturn { + readonly instance: Instance; + readonly cleanup: () => void; +} + +export interface UseImperativeApiOptions { + readonly onLoad: () => OnLoadReturn; + + /** + * + * @param instance + * @param reset - reset will call the onLoad function again to reset the instance + * @returns + */ + // eslint-disable-next-line @typescript-eslint/no-invalid-void-type + readonly onUpdate?: (instance: Instance, reset: () => void) => void | (() => void); + readonly isDisabled?: boolean; + readonly forwardedRef?: React.Ref; +} + +interface ResultBase { + readonly ref: RefObject; + readonly isReady: boolean; +} + +interface ResultReady extends ResultBase { + readonly ref: RefObject; + readonly isReady: true; +} + +interface ResultNotReady extends ResultBase { + readonly ref: RefObject; + readonly isReady: false; +} + +export type ImperativeStateResult = ResultReady | ResultNotReady; + +/** + * A hook that provides an imperative API for managing an instance of anything. + * It supports two modes: 'ref' and 'state'. + * In 'ref' mode, it returns a ref object that holds the instance. + * In 'state' mode, it returns the instance as state. + * @param options - The options for the hook, including onLoad, onUpdate, and type. + * @param dependencies - The dependencies array for the onUpdate effect. Only applied for `onUpdate`. + * @returns An object containing either a ref or state instance and a readiness flag. + * @private + * @group Hooks + */ +export function useImperativeApi( + options: UseImperativeApiOptions, + dependencies: DependencyList +): ImperativeStateResult { + const { onLoad, onUpdate, isDisabled, forwardedRef } = options; + const [isReady, setIsReady] = useState(false); + const instanceRef = useRef(null); + const cleanupRef = useRef<(() => void) | null>(null); + const hasMounted = useRef(false); // Track initial render + + const onLoadCallback = useCallback(() => { + if (isDisabled) { + if (cleanupRef.current) { + cleanupRef.current(); + cleanupRef.current = null; + } + if (instanceRef.current) { + instanceRef.current = null; + } + setIsReady(false); // Explicitly set isReady to false + return; + } + const { instance, cleanup } = onLoad(); + instanceRef.current = instance; + cleanupRef.current = cleanup; + setIsReady(true); + return () => { + cleanup(); + }; + // we update cache only by dependencies change and isDisabled + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isDisabled, ...dependencies]); + + // Load and cleanup + useLayoutEffect(() => { + const cleanup = onLoadCallback(); + return () => { + if (cleanup) { + cleanup(); + } + }; + // this is called only when disabled - disabled mean, we remove the instance and cleanup + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isDisabled]); + + // Update + useEffect(() => { + if (!onUpdate || !hasMounted.current) { + hasMounted.current = true; // Skip first render + return; + } + const { current: instance } = instanceRef; + if (!instance) { + return; + } + const cleanup = onUpdate(instance, onLoadCallback); + return () => { + if (typeof cleanup === 'function') { + cleanup(); + } + }; + // we update cache only by dependencies change + // eslint-disable-next-line react-hooks/exhaustive-deps + }, dependencies); + + // Expose the instance via the forwarded ref, if there is one + // eslint-disable-next-line react-hooks/exhaustive-deps + useImperativeHandle(forwardedRef, () => instanceRef.current!, [instanceRef, isReady]); + + return { ref: instanceRef, isReady } as ImperativeStateResult; +} diff --git a/packages/joint-react/src/hooks/use-links.stories.tsx b/packages/joint-react/src/hooks/use-links.stories.tsx index dc900334f2..815f2bd897 100644 --- a/packages/joint-react/src/hooks/use-links.stories.tsx +++ b/packages/joint-react/src/hooks/use-links.stories.tsx @@ -1,10 +1,9 @@ -/* eslint-disable react-perf/jsx-no-new-object-as-prop */ import type { Meta, StoryObj } from '@storybook/react'; import { DataRenderer, SimpleGraphDecorator } from '../../.storybook/decorators/with-simple-data'; import { useLinks } from './use-links'; -import { makeRootDocumentation, makeStory } from '@joint/react/src/stories/utils/make-story'; -import { getAPILink } from '@joint/react/src/stories/utils/get-api-documentation-link'; -import { HookTester } from '@joint/react/src/stories/utils/hook-tester'; +import { getAPILink } from '../stories/utils/get-api-documentation-link'; +import { HookTester } from '../stories/utils/hook-tester'; +import { makeRootDocumentation, makeStory } from '../stories/utils/make-story'; const API_URL = getAPILink('useLinks'); @@ -60,4 +59,3 @@ function Component() { }`, description: 'Get all link IDs.', }); -/* eslint-enable react-perf/jsx-no-new-object-as-prop */ diff --git a/packages/joint-react/src/hooks/use-links.ts b/packages/joint-react/src/hooks/use-links.ts index 016a1d15f2..bcc194d918 100644 --- a/packages/joint-react/src/hooks/use-links.ts +++ b/packages/joint-react/src/hooks/use-links.ts @@ -2,7 +2,6 @@ import { useGraphStore } from './use-graph-store'; import { util } from '@joint/core'; import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/with-selector'; import type { GraphLink } from '../types/link-types'; -import type { CellMap } from '../utils/cell/cell-map'; /** * Default selector function to return all links. @@ -13,7 +12,7 @@ import type { CellMap } from '../utils/cell/cell-map'; * @group utils * @description */ -function defaultSelector(items: CellMap): Link[] { +function defaultSelector(items: Link[]): Link[] { return items.map((item) => item) as Link[]; } /** @@ -52,13 +51,11 @@ function defaultSelector(items: CellMap( - selector: ( - items: CellMap - ) => SelectorReturnType = defaultSelector as () => SelectorReturnType, + selector: (items: Link[]) => SelectorReturnType = defaultSelector as () => SelectorReturnType, isEqual: (a: SelectorReturnType, b: SelectorReturnType) => boolean = util.isEqual ): SelectorReturnType { const { subscribe, getLinks } = useGraphStore(); - const typedGetLinks = getLinks as () => CellMap; + const typedGetLinks = getLinks as () => Link[]; const elements = useSyncExternalStoreWithSelector( subscribe, typedGetLinks, diff --git a/packages/joint-react/src/hooks/use-measure-node-size.tsx b/packages/joint-react/src/hooks/use-measure-node-size.tsx index 9af3539d43..390464a574 100644 --- a/packages/joint-react/src/hooks/use-measure-node-size.tsx +++ b/packages/joint-react/src/hooks/use-measure-node-size.tsx @@ -52,13 +52,6 @@ export function useMeasureNodeSize>({}); - - const onRenderElement: OnPaperRenderElement = useCallback((element, nodeSVGGElement) => { - setElements((previousState) => { - return { - ...previousState, - [element.id]: nodeSVGGElement, - }; - }); - }, []); - - return { recordOfSVGElements, onRenderElement }; -} diff --git a/packages/joint-react/src/hooks/use-paper-events.ts b/packages/joint-react/src/hooks/use-paper-events.ts new file mode 100644 index 0000000000..4c93c29a06 --- /dev/null +++ b/packages/joint-react/src/hooks/use-paper-events.ts @@ -0,0 +1,35 @@ +import { useLayoutEffect, type DependencyList } from 'react'; +import { usePaper } from './use-paper'; +import type { PaperEvents } from '../types/event.types'; +import { handlePaperEvents } from '../utils/handle-paper-events'; +import { useGraph } from './use-graph'; + +/** + * A hook that listens to view (Paper) events and triggers the corresponding callbacks. + * @param events - An object where keys are event names and values are callback functions. + * @param dependencies - An optional array of dependencies that, when changed, will re-register the event listeners. + * @group Hooks + * @example + * ```tsx + * import { usePaperEvents } from '@jointjs/react'; + * + * usePaperEvents({ + * onBlankContextMenu({ event, paper }) {}, + * }); + * ``` + */ +export function usePaperEvents(events: PaperEvents, dependencies: DependencyList = []) { + const paper = usePaper(); + const graph = useGraph(); + useLayoutEffect(() => { + if (!paper || !graph) { + return; + } + // An object to keep track of the listeners. It's not exposed, so the users + const stopListening = handlePaperEvents(graph, paper, events); + return () => { + stopListening(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [events, graph, paper, ...dependencies]); +} diff --git a/packages/joint-react/src/hooks/use-paper.ts b/packages/joint-react/src/hooks/use-paper.ts index b5e955c6f4..971aaaab41 100644 --- a/packages/joint-react/src/hooks/use-paper.ts +++ b/packages/joint-react/src/hooks/use-paper.ts @@ -1,9 +1,8 @@ -import { useContext } from 'react'; -import { PaperContext } from '../context/paper-context'; import type { dia } from '@joint/core'; +import { usePaperContext } from './use-paper-context'; /** - * Return jointjs paper instance from the paper context. + * Return JointJS `dia.Paper` instance from the current `Paper` context. * @see https://docs.jointjs.com/learn/quickstart/paper * @group Hooks * ```tsx @@ -11,12 +10,8 @@ import type { dia } from '@joint/core'; * const paper = usePaper(); * ``` * @returns - The jointjs paper instance. - * @throws - If the hook is not used inside the paper context. */ -export function usePaper(): dia.Paper { - const paperCtx = useContext(PaperContext); - if (!paperCtx) { - throw new Error('usePaper must be used within a `PaperProvider` or `Paper` component'); - } - return paperCtx.paper; +export function usePaper(): dia.Paper | undefined { + const viewConfig = usePaperContext(); + return viewConfig?.paper; } diff --git a/packages/joint-react/src/hooks/use-remove-cell.ts b/packages/joint-react/src/hooks/use-remove-cell.ts deleted file mode 100644 index 3916625a30..0000000000 --- a/packages/joint-react/src/hooks/use-remove-cell.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { useCallback } from 'react'; -import { useGraph } from './use-graph'; -import type { dia } from '@joint/core'; - -/** - * A custom hook that removes an node or link from the graph by its ID. - * @group Hooks - * @returns A function that removes the element from the graph. - * @example - * ```ts - * const removeCell = useRemoveCell(); - * removeCell('1'); - * ``` - */ -export function useRemoveCell() { - const graph = useGraph(); - return useCallback( - (id: dia.Cell.ID) => { - const cell = graph.getCell(id); - if (cell) { - cell.remove(); - } - }, - [graph] - ); -} -/** - * A custom hook that removes an element from the graph by its ID. - * @group Hooks - * @returns A function that removes the element from the graph. - * @example - * ```ts - * const removeElement = useRemoveElement(); - * removeElement('1'); - * ``` - */ -export function useRemoveElement() { - const graph = useGraph(); - return useCallback( - (id: dia.Cell.ID) => { - const cell = graph.getCell(id); - if (!cell.isElement()) { - return; - } - if (cell) { - cell.remove(); - } - }, - [graph] - ); -} - -/** - * A custom hook that removes a link from the graph by its ID. - * @group Hooks - * @returns A function that removes the link from the graph. - * @example - * ```ts - * const removeLink = useRemoveLink(); - * removeLink('1'); - * ``` - */ -export function useRemoveLink() { - const graph = useGraph(); - return useCallback( - (id: dia.Cell.ID) => { - const cell = graph.getCell(id); - if (!cell.isLink()) { - return; - } - if (cell) { - cell.remove(); - } - }, - [graph] - ); -} diff --git a/packages/joint-react/src/hooks/use-update-element.stories.tsx b/packages/joint-react/src/hooks/use-update-element.stories.tsx deleted file mode 100644 index 45ac20baf6..0000000000 --- a/packages/joint-react/src/hooks/use-update-element.stories.tsx +++ /dev/null @@ -1,300 +0,0 @@ -/* eslint-disable @eslint-react/dom/no-missing-button-type */ -/* eslint-disable react-perf/jsx-no-new-function-as-prop */ -import type { Meta, StoryObj } from '@storybook/react'; -import type { SimpleElement } from '../../.storybook/decorators/with-simple-data'; -import { HTMLNode, RenderItemDecorator } from '../../.storybook/decorators/with-simple-data'; -import { useUpdateElement } from './use-update-element'; -import { makeRootDocumentation, makeStory } from '@joint/react/src/stories/utils/make-story'; -import { getAPILink } from '@joint/react/src/stories/utils/get-api-documentation-link'; -import '../stories/examples/index.css'; -import { BUTTON_CLASSNAME } from 'storybook-config/theme'; - -const API_URL = getAPILink('useUpdateElement'); - -export type Story = StoryObj; - -const meta: Meta = { - title: 'Hooks/useUpdateElement', - component: Hook, - render: () => , - parameters: makeRootDocumentation({ - apiURL: API_URL, - description: `\`useUpdateElement\` is a hook to set element attributes. It returns a function to set the element attribute. It must be used inside the GraphProvider. - `, - code: `import { useUpdateElement } from '@joint/react' - -function Component() { - const setPosition = useUpdateElement('element-id', 'position'); - return ; -}`, - }), -}; - -export default meta; - -function Hook({ label, id }: SimpleElement) { - const setLabel = useUpdateElement(id, 'label'); - - return ( - - - label: {label} - - ); -} -export const Default: Story = makeStory({ - args: { - label: 'default', - color: 'red', - id: 'default-id', - }, - apiURL: API_URL, - code: `import { useUpdateElement } from '@joint/react' - - - function Hook({ label , id }: SimpleElement) { - const setLabel = useUpdateElement(id, 'label'); - - return ( - - - label: {label} - - ); - }`, - description: 'Set new data for the element.', -}); - -function HookSetPosition({ label, id }: SimpleElement) { - const set = useUpdateElement(id, 'position'); - - return ( - - - label: {label} - - ); -} - -export const SetPosition: Story = makeStory({ - component: () => , - apiURL: API_URL, - code: `import { useUpdateElement } from '@joint/react' - - function HookSetPosition({ label, id }: SimpleElement) { - const set = useUpdateElement(id, 'position'); - - return ( - - - label: {label} - - ); - } - `, - description: 'Set the position of the element.', -}); - -function HookSetSize({ label, id }: SimpleElement) { - const set = useUpdateElement(id, 'size'); - - return ( - - - label: {label} - - ); -} - -export const SetSize: Story = makeStory({ - component: () => , - apiURL: API_URL, - code: `import { useUpdateElement } from '@joint/react' - -function HookSetSize({ label, id }: SimpleElement) { - const set = useUpdateElement(id, 'size'); - - return ( - - - label: {label} - - ); -}`, - description: 'Set the size of the element.', -}); - -function HookSetAngle({ label, id }: SimpleElement) { - const set = useUpdateElement(id, 'angle'); - - return ( - - - label: {label} - - ); -} - -export const SetAngle: Story = makeStory({ - component: () => , - apiURL: API_URL, - code: `function HookSetAngle({ label, id }: SimpleElement) { - const set = useUpdateElement(id, 'angle'); - - return ( - - - label: {label} - - ); -}`, - description: 'Set the angle of the element.', -}); - -function HookSetAny({ label, id }: SimpleElement) { - const set = useUpdateElement(id); - - return ( - - - - label: {label} - - ); -} - -export const SetAnyProperty: Story = makeStory({ - apiURL: API_URL, - component: () => , - code: `import { useUpdateElement } from '@joint/react' - -function HookSetAny({ label , id }: SimpleElement) { - const set = useUpdateElement(id); - - return ( - - - - label: {label} - - ); -}`, - description: 'Set the markup of the element.', -}); diff --git a/packages/joint-react/src/hooks/use-update-element.ts b/packages/joint-react/src/hooks/use-update-element.ts deleted file mode 100644 index a0671c9adc..0000000000 --- a/packages/joint-react/src/hooks/use-update-element.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { useCallback } from 'react'; -import type { dia } from '@joint/core'; -import { isAttribute, isDefined, isDiaId, isSetter, type Setter } from '../utils/is'; -import { useGraphStore } from './use-graph-store'; - -export interface BaseAttributes extends dia.Cell.Attributes { - readonly markup?: string | dia.MarkupJSON; - readonly position?: dia.Point; - readonly size?: dia.Size; - readonly angle?: number; - readonly data?: Record | unknown; -} - -/** - * Helper function. - * Parameters - [graph, id, attribute, value] - * @param graph - The graph to set the element in. - * @param id - The ID of the element to set. - * @param attribute - The attribute to set. - * @param value - The value to set. - */ -function setCellHelper( - graph: dia.Graph, - id: dia.Cell.ID, - attribute: Attribute, - value: unknown -) { - const stringAttribute = attribute as string; - const element = graph.getCell(id); - - if (!element) { - return; - } - if (isSetter(value)) { - const previousValue: Attributes[Attribute] = element.get(stringAttribute); - const nextValue = value(previousValue); - if (nextValue === previousValue) { - // skip if the reference is same, same as react state does - return; - } - - element.set(stringAttribute, nextValue); - return; - } - - element.set(stringAttribute, value); -} - -/** - * Set the element attribute in the graph. - * It returns a function to set the element attribute. - * - * It must be used inside the GraphProvider. - * @group Hooks - * @param id The ID of the element. - * @param attribute The attribute to set. - * @returns The function to set the element attribute. It can be reactive. - * @experimental - * - * It can be used in three ways: - * @example - * 1. Use empty hook and define ID, attribute, and value inside the set function - * ```tsx - * const setElement = useUpdateElement(); - * setElement('element-id', 'position', { x: 100, y: 100 }); - * ``` - * @example - * 2. Provide ID and attribute, and use the returned function to set value - * ```tsx - * const setElement = useUpdateElement('element-id', 'position'); - * setElement({ x: 100, y: 100 }); - * ``` - * @example - * 3. Provide ID and use the returned function to set attribute and value - * ```tsx - * const setElement = useUpdateElement('element-id'); - * setElement('position', { x: 100, y: 100 }); - * ``` - */ - -export function useUpdateElement< - Attributes = BaseAttributes, - Attribute extends keyof Attributes = keyof Attributes, ->( - id: dia.Cell.ID, - attribute: Attribute -): (value: Attributes[Attribute] | Setter) => void; - -export function useUpdateElement( - id: dia.Cell.ID -): ( - attribute: Attribute, - value: Attributes[Attribute] | Setter -) => void; - -export function useUpdateElement(): < - Attribute extends keyof Attributes, ->( - id: dia.Cell.ID, - attribute: Attribute, - value: Attributes[Attribute] | Setter -) => void; - -// eslint-disable-next-line jsdoc/require-jsdoc -export function useUpdateElement< - Attributes = BaseAttributes, - Attribute extends keyof Attributes = keyof Attributes, ->(id?: dia.Cell.ID, attributeParameter?: Attribute) { - const { graph } = useGraphStore(); - const setElement = useCallback( - (idOrAttributeOrValue: unknown, attributeOrValue?: unknown, value?: unknown) => { - if (isDiaId(idOrAttributeOrValue) && isAttribute(attributeOrValue) && isDefined(value)) { - // this mean, there is ID, attribute, and value via this fn - setCellHelper(graph, idOrAttributeOrValue, attributeOrValue, value); - return; - } - - if (!isDiaId(id)) { - return; - } - - if (isAttribute(idOrAttributeOrValue) && isDefined(attributeOrValue)) { - // this mean, there is attribute and value via this fn - setCellHelper(graph, id, idOrAttributeOrValue, attributeOrValue); - return; - } - - if (!isAttribute(attributeParameter)) { - return; - } - // mean only value is provided - setCellHelper(graph, id, attributeParameter, idOrAttributeOrValue); - }, - [attributeParameter, graph, id] - ); - return setElement; -} - -export type SetCell = ReturnType; diff --git a/packages/joint-react/src/index.ts b/packages/joint-react/src/index.ts index 6b414de0e1..c021fef765 100644 --- a/packages/joint-react/src/index.ts +++ b/packages/joint-react/src/index.ts @@ -6,9 +6,10 @@ export * from './components'; export * from './hooks'; export * from './utils/create'; -export * from './utils/cell/cell-map'; +export * from './utils/cell/cell-utilities'; export * from './utils/joint-jsx/jsx-to-markup'; export * from './utils/link-utilities'; +export * from './utils/object-utilities'; export * from './models/react-element'; @@ -17,3 +18,4 @@ export * from './types/link-types'; export * from './types/cell.types'; export * from './context'; +export * from './data'; diff --git a/packages/joint-react/src/models/react-element.tsx b/packages/joint-react/src/models/react-element.tsx index c9b0912252..f53e3751a7 100644 --- a/packages/joint-react/src/models/react-element.tsx +++ b/packages/joint-react/src/models/react-element.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @eslint-react/dom/no-unknown-property */ import { dia } from '@joint/core'; import { jsx } from '../utils/joint-jsx/jsx-to-markup'; export const REACT_TYPE = 'ReactElement'; diff --git a/packages/joint-react/src/stories/demos/flowchart/code.tsx b/packages/joint-react/src/stories/demos/flowchart/code.tsx index 08c5381cc0..450fa3d5e1 100644 --- a/packages/joint-react/src/stories/demos/flowchart/code.tsx +++ b/packages/joint-react/src/stories/demos/flowchart/code.tsx @@ -410,7 +410,7 @@ function Main() { export default function App() { return ( - +
); diff --git a/packages/joint-react/src/stories/demos/flowchart/story.tsx b/packages/joint-react/src/stories/demos/flowchart/story.tsx index 74185edc80..a762182f56 100644 --- a/packages/joint-react/src/stories/demos/flowchart/story.tsx +++ b/packages/joint-react/src/stories/demos/flowchart/story.tsx @@ -1,6 +1,6 @@ -import type { Meta, StoryObj } from '@storybook/react/*'; +import type { Meta, StoryObj } from '@storybook/react'; import Code from './code'; -//@ts-expect-error storybook parser + import RawCode from './code?raw'; export type Story = StoryObj; diff --git a/packages/joint-react/src/stories/demos/introduction-demo/code.tsx b/packages/joint-react/src/stories/demos/introduction-demo/code.tsx index 46f0f79338..b87d06d079 100644 --- a/packages/joint-react/src/stories/demos/introduction-demo/code.tsx +++ b/packages/joint-react/src/stories/demos/introduction-demo/code.tsx @@ -19,15 +19,14 @@ import { useElements, useGraph, useLinks, - usePaper, - useUpdateElement, type GraphElement, + type PaperContext, type PaperProps, type RenderElement, } from '@joint/react'; -import { useCallback, useState } from 'react'; +import { useCallback, useRef, useState } from 'react'; import { ShowJson } from 'storybook-config/decorators/with-simple-data'; -import { PaperProvider } from '../../../components/paper-provider/paper-provider'; +import { useCellActions } from '../../../hooks/use-cell-actions'; // Define types for the elements interface ElementBase extends GraphElement { @@ -54,7 +53,7 @@ type ElementWithSelected = { readonly isSelected: boolean } & T; const BUTTON_CLASSNAME = 'bg-blue-500 cursor-pointer hover:bg-blue-700 text-white font-bold py-2 px-4 rounded text-sm flex items-center'; -// Define static properties for the paper - used by minimap and main paper +// Define static properties for the view's Paper - used by minimap and main view const PAPER_PROPS: PaperProps = { defaultRouter: { name: 'rightAngle', @@ -165,7 +164,7 @@ function MessageComponent({ } } const id = useCellId(); - const setMessage = useUpdateElement(id, 'inputText'); + const { set } = useCellActions(); return ( { - setMessage(value); + set(id, (previous) => ({ ...previous, inputText: value })); }} />
@@ -326,6 +325,7 @@ interface ToolbarProps { readonly setSelectedId: (id: dia.Cell.ID | null) => void; readonly showElementsInfo: boolean; readonly setShowElementsInfo: (show: boolean) => void; + readonly paperCtxRef: React.RefObject; } // Toolbar component with some actions function ToolBar(props: Readonly) { @@ -336,9 +336,10 @@ function ToolBar(props: Readonly) { setSelectedId, setShowElementsInfo, showElementsInfo, + paperCtxRef, } = props; const graph = useGraph(); - const paper = usePaper(); + const { paper } = paperCtxRef.current ?? {}; return (
); + + + + ``` ## 🛠️ Core Hooks and Utilities ### 🔹 Accessing Elements -- {getAPIDocumentationLink('useElements')}: Retrieve all diagram elements (requires `GraphProvider` context). +- {getAPIDocumentationLink('useElements')}: Retrieve all graph elements (requires `GraphProvider` context). - {getAPIDocumentationLink('useElement')}: Retrieve individual element data, typically used within `renderElement`. ### 🔹 Modifying Elements -- {getAPIDocumentationLink('useUpdateElement')}: Update existing elements in the diagram. +Use the graph APIs to update state. Hooks provide convenient access: +- {getAPIDocumentationLink('useGraph')}: Read/write the graph (e.g., `graph.setCells()`) ### 🔹 Graph and Paper Instances -- {getAPIDocumentationLink('useGraph')}: Access the JointJS [Graph instance](https://docs.jointjs.com/api/dia/Graph/) directly. -- {getAPIDocumentationLink('usePaper')}: Access the JointJS [Paper instance](https://docs.jointjs.com/learn/quickstart/paper/) directly. +- {getAPIDocumentationLink('useGraph')}: Access the JointJS [Graph](https://docs.jointjs.com/api/dia/Graph/) +- {getAPIDocumentationLink('usePaper')}: Access the JointJS [Paper](https://docs.jointjs.com/learn/quickstart/paper/) ### 🔹 Creating Nodes and Links - {getAPIDocumentationLink('createElements')}: Utility for creating nodes. @@ -83,9 +92,8 @@ const initialLinks = createLinks([ --- ## How It Works -Under the hood, **@joint/react** listens to changes in the `dia.Graph`, which acts as the single source of truth. When you update the graph—such as adding or modifying cells—the React components automatically observe and react to these changes, keeping the UI in sync. -Hooks like `useUpdateElement` provide a convenient way to update the graph, but you can also directly access the graph using `useGraph()` and call methods like `graph.setCells()`. +Under the hood, **@joint/react** listens to changes in `dia.Graph`, which acts as the single source of truth. When you update the graph (add/modify cells), React components subscribe and re-render accordingly. Access the graph via `useGraph()` to perform updates like `graph.setCells()`. --- @@ -111,4 +119,4 @@ If you need to use HTML inside an SVG with cross-browser support: React's asynchronous rendering can cause flickering when dynamically adding ports or resizing elements. We are aware of this issue and are working on a fix. ### Controlled Mode -Currently, **@joint/react** uses `useSyncExternalStore` to listen to graph changes. The graph is the source of truth, so `initialElements` and `initialLinks` are only used during initialization. To modify the state, update the graph directly using hooks like `useGraph`, `useUpdateElement`, or `useCreateElement`. A fully controlled mode is under development. +Currently, **@joint/react** uses `useSyncExternalStore` to listen to graph changes. The graph is the source of truth, so `initialElements` and `initialLinks` are only used during initialization. To modify state, update the graph directly via hooks like `useGraph`. A fully controlled mode is under development. diff --git a/packages/joint-react/src/stories/tutorials/redux/code.tsx b/packages/joint-react/src/stories/tutorials/redux/code.tsx new file mode 100644 index 0000000000..e71301ff08 --- /dev/null +++ b/packages/joint-react/src/stories/tutorials/redux/code.tsx @@ -0,0 +1,285 @@ +/* eslint-disable sonarjs/pseudo-random */ +/* eslint-disable react-perf/jsx-no-new-function-as-prop */ + +// Import necessary modules and components from the library and other dependencies +import { + createElements, + createLinks, + GraphProvider, + useGraph, + setElements as setElementsViaGraph, + setLinks as setLinksViaGraph, + type GraphProps, + type InferElement, + Paper, +} from '@joint/react'; +import '../../examples/index.css'; // Import custom styles +import { BUTTON_CLASSNAME, PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; // Storybook-specific styles +import { createSlice, configureStore } from '@reduxjs/toolkit'; // Redux Toolkit for state management +import { Provider, useSelector } from 'react-redux'; // React-Redux bindings +import { dia } from '@joint/plus'; +import { useRef } from 'react'; +const defaultElements = createElements([ + { id: '1', label: 'Hello', x: 100, y: 0, width: 100, height: 50 }, + { id: '2', label: 'World', x: 100, y: 200, width: 100, height: 50 }, +]); +// Define a Redux slice for managing elements (nodes) +const elementsSlice = createSlice({ + name: 'elements', + initialState: defaultElements, + reducers: { + // Add a new element to the state + addElement: (state, action) => { + state.push(action.payload); + }, + resetToDefault: () => { + return defaultElements; + }, + removeLast: (state) => { + state.pop(); + }, + // Replace all elements in the state + setElements: (_, action) => { + return action.payload; + }, + + addTwoRandomElements: (state) => { + state.push( + { + id: Math.random().toString(36).slice(7), + label: 'Random 1', + x: Math.random() * 200, + y: Math.random() * 200, + width: 100, + height: 50, + }, + { + id: Math.random().toString(36).slice(7), + label: 'Random 2', + x: Math.random() * 200, + y: Math.random() * 200, + width: 100, + height: 50, + } + ); + }, + }, +}); + +// Define a Redux slice for managing links (edges) +const linksSlice = createSlice({ + name: 'links', + initialState: createLinks([ + { + id: 'e1-2', + source: '1', + target: '2', + attrs: { + line: { + stroke: PRIMARY, // Use the primary color from the theme + }, + }, + }, + ]), + reducers: { + // Add a new link to the state + resetLinkToDefault: () => { + return createLinks([ + { + id: 'e1-2', + source: '1', + target: '2', + attrs: { + line: { + stroke: PRIMARY, // Use the primary color from the theme + }, + }, + }, + ]); + }, + // Replace all links in the state + setLinks: (state, action) => { + return action.payload; + }, + removeLinks: () => { + return []; + }, + }, +}); + +// Extract actions from the elements slice +const { addElement, setElements, resetToDefault, removeLast, addTwoRandomElements } = + elementsSlice.actions; +const { resetLinkToDefault, setLinks, removeLinks } = linksSlice.actions; + +// Configure the Redux store with the elements and links reducers +const store = configureStore({ + reducer: { + elements: elementsSlice.reducer, + links: linksSlice.reducer, + }, +}); + +// Define the RootState type for use with selectors +type RootState = ReturnType; + +// Infer the type of a custom element from the elements state +type CustomElement = InferElement; + +// Component to render a custom node (element) +function RenderItem(props: CustomElement) { + const { label, width, height } = props; + return ( + + {/* */} +
{label}
+ {/*
*/} +
+ ); +} + +// Component to render the Paper and provide a button to add elements via the graph +function PaperApp() { + const graph = useGraph(); // Access the graph instance + + const commandManager = useRef(new dia.CommandManager({ graph })); + return ( +
+ {/* Render the Paper component */} + + {/* Button to add a new element directly via the graph */} +
+ + + + + + + + + +
+
+ ); +} + +// Main component that connects the Redux store to the GraphProvider +function Main(props: Readonly) { + // Select links and elements from the Redux store + const links = useSelector((state: RootState) => state.links); + const elements = useSelector((state: RootState) => state.elements); + + return ( + <> + {/* Provide the graph context with initial elements and links */} + { + // Dispatch an action to update elements in the Redux store + store.dispatch(setElements(items)); + }} + onLinksChange={(items) => { + store.dispatch(setLinks(items)); + }} + > + + + + ); +} + +// Root component that wraps the application with the Redux Provider +export default function App() { + return ( + +
+ + ); +} diff --git a/packages/joint-react/src/stories/tutorials/redux/story.tsx b/packages/joint-react/src/stories/tutorials/redux/story.tsx new file mode 100644 index 0000000000..3be02b4232 --- /dev/null +++ b/packages/joint-react/src/stories/tutorials/redux/story.tsx @@ -0,0 +1,12 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import '../../examples/index.css'; +import Code from './code'; + +export type Story = StoryObj; + +export default { + title: 'Tutorials/Redux', + component: Code, +} satisfies Meta; + +export const Default: Story = {}; diff --git a/packages/joint-react/src/stories/tutorials/step-by-step/code-html-renderer.tsx b/packages/joint-react/src/stories/tutorials/step-by-step/code-html-renderer.tsx index 534dd9d422..5424efff64 100644 --- a/packages/joint-react/src/stories/tutorials/step-by-step/code-html-renderer.tsx +++ b/packages/joint-react/src/stories/tutorials/step-by-step/code-html-renderer.tsx @@ -1,4 +1,3 @@ -/* eslint-disable react-perf/jsx-no-new-object-as-prop */ import { useCallback, useState } from 'react'; import { createElements, @@ -44,9 +43,10 @@ function Controls() { type="button" // eslint-disable-next-line react-perf/jsx-no-new-function-as-prop onClick={() => { - const center = paper.getArea().center(); + const center = paper?.getArea().center(); + if (!center) return; zoomLevel = Math.min(3, zoomLevel + 0.2); - paper.scaleUniformAtPoint(zoomLevel, center); + paper?.scaleUniformAtPoint(zoomLevel, center); }} className={BUTTON_CLASSNAME} > @@ -56,9 +56,10 @@ function Controls() { type="button" // eslint-disable-next-line react-perf/jsx-no-new-function-as-prop onClick={() => { - const center = paper.getArea().center(); + const center = paper?.getArea().center(); + if (!center) return; zoomLevel = Math.max(0.2, zoomLevel - 0.2); - paper.scaleUniformAtPoint(zoomLevel, center); + paper?.scaleUniformAtPoint(zoomLevel, center); }} className={`${BUTTON_CLASSNAME} ml-2`} > @@ -108,7 +109,7 @@ function Main() { export default function App(props: Readonly) { return ( - +
); diff --git a/packages/joint-react/src/stories/tutorials/step-by-step/code-html.tsx b/packages/joint-react/src/stories/tutorials/step-by-step/code-html.tsx index fab8241e09..4e208af73b 100644 --- a/packages/joint-react/src/stories/tutorials/step-by-step/code-html.tsx +++ b/packages/joint-react/src/stories/tutorials/step-by-step/code-html.tsx @@ -13,8 +13,8 @@ import '../../examples/index.css'; import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; // define initial elements const initialElements = createElements([ - { id: '1', label: 'Hello', x: 100, y: 0, width: 100, height: 25 }, - { id: '2', label: 'World', x: 100, y: 200, width: 100, height: 25 }, + { id: '1', label: 'Hello', x: 100, y: 0, width: 100, height: 50 }, + { id: '2', label: 'World', x: 100, y: 200, width: 100, height: 50 }, ]); // define initial edges @@ -36,7 +36,8 @@ const initialEdges = createLinks([ // infer element type from the initial elements (this type can be used for later usage like RenderItem props) type CustomElement = InferElement; -function RenderItem({ label, width, height }: CustomElement) { +function RenderItem(props: CustomElement) { + const { label, width, height } = props; return ( @@ -56,7 +57,7 @@ function Main() { export default function App(props: Readonly) { return ( - +
); diff --git a/packages/joint-react/src/stories/tutorials/step-by-step/code-svg.tsx b/packages/joint-react/src/stories/tutorials/step-by-step/code-svg.tsx index f65fbaf117..f9d3adedf7 100644 --- a/packages/joint-react/src/stories/tutorials/step-by-step/code-svg.tsx +++ b/packages/joint-react/src/stories/tutorials/step-by-step/code-svg.tsx @@ -49,7 +49,7 @@ function Main() { export default function App(props: Readonly) { return ( - +
); diff --git a/packages/joint-react/src/stories/tutorials/step-by-step/docs.mdx b/packages/joint-react/src/stories/tutorials/step-by-step/docs.mdx index 2d2f99f43b..3cbd066527 100644 --- a/packages/joint-react/src/stories/tutorials/step-by-step/docs.mdx +++ b/packages/joint-react/src/stories/tutorials/step-by-step/docs.mdx @@ -3,11 +3,11 @@ import * as Stories from './story'; import CodeSVG from './code-svg?raw'; import CodeHTML from './code-html?raw'; import CodeHTMLRenderer from './code-html-renderer?raw'; -import { getAPIDocumentationLink, getAPIPropsLink } from '../../utils/get-api-documentation-link' +import { getAPIDocumentationLink, getAPIPropsLink } from '../../utils/get-api-documentation-link'; -# Get Started with @joint/react +# Get started with @joint/react Welcome! This guide will help you get started with the new `@joint/react` library, which brings the power of [JointJS](https://www.jointjs.com/) to React. We'll walk through the core concepts step by step, with live examples and code blocks. @@ -17,129 +17,166 @@ Welcome! This guide will help you get started with the new `@joint/react` librar - **@joint/react** is a React-first API for building interactive diagrams, powered by [JointJS](https://www.jointjs.com/). - **JointJS** is a diagramming library for creating flowcharts, BPMN, ER diagrams, and more. -- **@joint/react** wraps JointJS concepts in idiomatic React components and hooks. +- **@joint/react** wraps JointJS concepts in idiomatic React components and hooks, making it intuitive for React developers. --- -## 2. Core Concepts +## 2. Core concepts - **Element (Node):** A visual item in your diagram (e.g., a rectangle, circle, or custom shape). - **Link (Edge):** A connection between two elements. - **Graph:** The data model holding all elements and links. -- **Paper:** The UI component that renders the graph. - **GraphProvider:** React context provider for managing the graph state. +- **Paper:** The main rendering component for nodes and links. - **Ports:** Named connection points on elements for precise linking. - **MeasuredNode:** Utility for auto-sizing nodes based on their content. --- -## 3. Creating Elements and Links +## 3. Creating elements and links ### a. Elements (Nodes) -You can create elements with or without a custom type. The type is inferred automatically, but you can also specify it for type safety. +`createElements` is a convenience helper. You can also pass plain objects with at least an `id` and geometry (`x`, `y`, `width`, `height`). ```tsx -// Inferred type +// Using createElements (sugar code) +import { createElements } from '@joint/react'; + const elements = createElements([ { id: '1', label: 'Node 1', x: 100, y: 0, width: 100, height: 25 }, { id: '2', label: 'Node 2', x: 100, y: 200, width: 100, height: 25 }, ]); -// Here we can use the inferred type -type ElementType = InferElement; - -// With custom type -interface MyNode extends GraphElement { - label: string; - color: string; -} -const customElements = createElements([ - { id: 'a', label: 'A', color: 'red', x: 0, y: 0, width: 80, height: 40 }, -]); +// Without createElements +const elements = [ + { id: 'sta', label: 'Custom Node', x: 50, y: 50, width: 80, height: 40 }, +] as const; ``` ### b. Links (Edges) -Links connect elements by their `id`. You can use simple strings or objects (for advanced features like ports). +Similarly, `createLinks` is a helper for defining links. Plain objects with `id`, `source`, and `target` also work. ```tsx -// Simple link by id -const links = createLinks([{ id: 'l1', source: '1', target: '2' }]); +// Using createLinks (sugar code) +import { createLinks } from '@joint/react'; -// Link with port (advanced) -const linksWithPorts = createLinks([ - { id: 'l2', source: { id: '1', port: 'out' }, target: { id: '2', port: 'in' } } +const links = createLinks([ + { id: 'l1', source: '1', target: '2' }, ]); + +// Without createLinks +const links = [ + { id: 'l2', source: 'sta', target: '2' }, +] as const; ``` --- -## 4. Setting Up the Graph Context +## 4. Setting up the GraphProvider context + +Wrap your app with the `GraphProvider` component to provide the graph context to all child components. -Wrap your app (or diagram) with {getAPIDocumentationLink('GraphProvider')}. This provides the graph context to all child components. -- **initialElements:** Initial elements to load. -- **initialLinks:** Initial links to load. ```tsx - - {/* Your diagram components */} +import { GraphProvider } from '@joint/react'; + + + {/* Add Paper or other components here */} ``` -- You can also use `initialElements` and `initialLinks` directly on the {getAPIDocumentationLink('Paper', 'variables')} component for simple cases without need to use the GraphProvider. +For simpler use cases, you can render a standalone view without a surrounding provider: ```tsx - - {/* Your diagram components */} - + ``` --- -## 5. Rendering the Diagram with Paper - -The {getAPIDocumentationLink('Paper', 'variables')} component renders your graph. It is the main UI component. +## 5. Rendering the diagram -- **renderElement:** Function to render each node. -- **elementSelector:** (Optional) Selects which elements to render. -- **Events:** Handle user interactions (e.g., onLinkMouseEnter). +The `Paper` component renders your graph. It provides props for customizing the rendering and handling interactions. ```tsx -// MyNode is a custom type -function RenderItem({ width, height, label }: MyNode) { +function RenderNode({ width, height, label }) { return {label}; } - + { + linkView.highlight(); + }} +/> ``` --- -## 6. Using HTML in Nodes +## 6. Using HTML in nodes -By default, nodes are rendered as SVG. To use HTML, wrap your content in a `foreignObject` or use the `useHTMLOverlay` prop. +If you need full HTML support, enable the experimental `useHTMLOverlay` prop to render real HTML outside the SVG. Otherwise, use ``. ```tsx -function RenderHTMLNode({ label, width, height }) { + ( +
+ {label} +
+ )} +/> +``` + +--- + +## 7. Advanced features + +### a. Ports + +Ports allow you to define named connection points on elements for links. + +```tsx +function RenderNodeWithPorts({ label, width, height }) { return ( - - -
{label}
-
-
+ <> + + {label} + + + + + + + + + + ); } ``` -Or, with `useHTMLOverlay` (renders HTML outside SVG, no `foreignObject` needed): +### b. Customizing behavior + +Customize link behavior with props like `defaultRouter` and `defaultConnector`: ```tsx - + ``` --- -## 7. Live Examples +## 8. Live examples ### SVG Node Example @@ -150,8 +187,6 @@ ${CodeSVG} \`\`\``} ---- - ### HTML Node Example @@ -161,8 +196,6 @@ ${CodeHTML} \`\`\``} ---- - ### HTML Overlay Example @@ -174,82 +207,176 @@ ${CodeHTMLRenderer} --- -## 8. Advanced: Ports, Events, and Customization +## 9. Key terms -### a. Ports +- **GraphProvider:** React context for graph data. +- **Paper:** Renders the graph visually. +- **Element:** Node in the graph. +- **Link:** Edge between nodes. +- **Port:** Named connection point on an element. +- **MeasuredNode:** Auto-measures and updates node size. +- **useHTMLOverlay:** Renders HTML nodes outside SVG for full HTML support. -Ports allow you to define named connection points on elements for links. +--- + +## 10. More resources + +- [@joint/react API Reference](https://github.com/clientIO/joint-plus/tree/main/packages/joint-react) +- [JointJS Documentation](https://docs.jointjs.com/) + +## 11. Multiple views in a single GraphProvider + +You can render multiple `Paper` instances inside one `GraphProvider` to create features like a minimap, a read-only overview, or separate layers. When using multiple views in development, assign a unique `id` to each view. -We can also define it in declerative way, using the `ports` components. ```tsx -function RenderNodeWithPorts({ label, width, height }) { +import { GraphProvider } from '@joint/react'; + +const elements = createElements([ + { id: '1', label: 'A', x: 50, y: 50, width: 80, height: 40 }, + { id: '2', label: 'B', x: 250, y: 180, width: 80, height: 40 }, +]); + +export function MultiViews() { return ( - <> - - - {label} - - - - - - - - - - - <> + + {/* Main interactive canvas */} + + + {/* Read-only overview/minimap */} + + ); } ``` -### b. Events +Tips: +- Give each view a stable `id`. +- Use `interactive={false}` and a smaller `scale` for an overview/minimap. +- Share the same `elements`/`links` via the parent `GraphProvider`. + +--- + -Handle user interactions with events like `onLinkMouseEnter`, `onElementsSizeReady`, etc. +## 12. Accessing a view via hook or ref + +There are two ergonomic ways to reach the underlying Paper/Graph of a view for imperative needs. + +### a. Via hook (by id) + +Use an internal hook to subscribe to a named view from anywhere under `GraphProvider`. This is handy for utilities colocated outside the view subtree. ```tsx - { - // Add custom link tools or highlighters - }} - onElementsSizeReady={({ paper }) => { - // Fit content to view - paper.transformToFitContent({ padding: 40 }); - }} - // ...other props -/> +import { useEffect } from 'react'; +// import { useGraphView } from '@joint/react' // ensure it is exported in your setup + +function FitToContent() { + const view = useGraphView('main'); + useEffect(() => { + view?.paper.fitToContent({ padding: 20 }); + }, [view]); + return null; +} + +export function WithHook() { + return ( + + + + + ); +} ``` -### c. Customizing Link Behavior +### b. Via ref (accessing paperCtx) -You can change how links behave and look by setting props like `defaultRouter`, `defaultConnector`, `defaultAnchor`, etc. +Any component that accepts a `ref` (such as `Paper` or `GraphProvider`) exposes its instance/context via the ref. For `Paper`, the instance (including the underlying JointJS Paper) can be accessed via the `paperCtx` property on the ref object. ```tsx - +import { useEffect, useRef } from 'react'; +import type { PaperContext } from '@joint/react'; + +export function WithRef() { + const paperRef = useRef(null); + useEffect(() => { + // Access the Paper context (paperCtx) from the ref + const paperCtx = paperRef.current?.paperCtx; + // You can also access the JointJS Paper instance directly + const paper = paperCtx?.paper; + paper?.scale(2, 2); + }, []); + + return ( + + + + ); +} ``` --- -## 9. Key Terms +## 13. Small real‑world patterns -- **GraphProvider:** React context for graph data. -- **Paper:** Renders the graph visually. -- **Element:** Node in the graph. -- **Link:** Edge between nodes. -- **Port:** Named connection point on an element. -- **MeasuredNode:** Auto-measures and updates node size. -- **useHTMLOverlay:** Renders HTML nodes outside SVG for full HTML support. +### a. Canvas + Minimap ---- +```tsx +function GraphWithMinimap() { + return ( + + +
+ +
+
+ ); +} +``` + +### b. Read‑only preview next to an editor + +```tsx +function EditorWithPreview() { + return ( +
+ + + + + + +
+ ); +} +``` + +### c. HTML nodes with overlay + +```tsx +const renderElement = ({ label }) => ( +
+ {label} +
+); + +function HtmlNodes() { + return ( + + + + ); +} +``` -## 10. More Resources -- [@joint/react API Reference](https://github.com/clientIO/joint-plus/tree/main/packages/joint-react) -- [JointJS Documentation](https://docs.jointjs.com/) --- -Happy diagramming! 🚀 \ No newline at end of file +Happy diagramming! 🚀 + +--- \ No newline at end of file diff --git a/packages/joint-react/src/stories/tutorials/step-by-step/story.tsx b/packages/joint-react/src/stories/tutorials/step-by-step/story.tsx index a5d9ee95ec..624fc1cf9e 100644 --- a/packages/joint-react/src/stories/tutorials/step-by-step/story.tsx +++ b/packages/joint-react/src/stories/tutorials/step-by-step/story.tsx @@ -1,4 +1,4 @@ -import type { Meta, StoryObj } from '@storybook/react/*'; +import type { Meta, StoryObj } from '@storybook/react'; import '../../examples/index.css'; import CodeSVG from './code-svg'; import CodeHTML from './code-html'; diff --git a/packages/joint-react/src/stories/utils/cell-explorer.tsx b/packages/joint-react/src/stories/utils/cell-explorer.tsx deleted file mode 100644 index 1323f3d35e..0000000000 --- a/packages/joint-react/src/stories/utils/cell-explorer.tsx +++ /dev/null @@ -1,128 +0,0 @@ -/** - * This is used only for storybook stories. Internally. - */ -/* eslint-disable react-perf/jsx-no-new-function-as-prop */ -/* eslint-disable react-perf/jsx-no-new-object-as-prop */ -import type { dia } from '@joint/core'; - -interface CellExplorerProps { - readonly cell: dia.Cell.JSON; - readonly onChange?: (cell: dia.Cell.JSON) => void; -} - -interface Props { - readonly keyName: string; - readonly parentKey?: string; - readonly value: unknown; - readonly onChange: (newValue: unknown) => void; -} -const MARGIN = '8px'; -function EditableField({ keyName, parentKey, value, onChange }: Readonly) { - const handleChange = (key: string, newValue: unknown) => { - if (typeof value === 'object' && value !== null) { - onChange({ ...value, [key]: newValue }); - } else { - onChange(newValue); - } - }; - - const parseValue = (inputValue: string) => { - if (typeof value === 'number') { - return Number.isNaN(Number(inputValue)) ? value : Number(inputValue); - } - return inputValue; - }; - - if (typeof value === 'object' && value !== null) { - return ( -
-
- {Object.entries(value).map(([key, value_]) => ( - handleChange(key, newValue)} - /> - ))} -
-
- ); - } - - return ( -
- - onChange(parseValue(event.target.value))} - style={{ marginLeft: MARGIN }} - /> -
- ); -} - -function CellExplorer({ cell, onChange }: Readonly) { - const handleInputChange = (key: string, value: unknown) => { - if (onChange) { - onChange({ ...cell, [key]: value }); - } - }; - - return ( -
-

Cell ID: {cell.id}

- {Object.entries(cell).map(([key, value]) => { - return ( - handleInputChange(key, newValue)} - /> - ); - })} -
- ); -} - -interface CellsExplorerProps { - readonly elements: dia.Cell.JSON[]; - readonly onChange: (cells: dia.Cell.JSON[]) => void; -} - -function CellsExplorer({ elements: cells, onChange }: Readonly) { - return ( -
- {cells.map((cell) => { - if (!cell) { - return null; - } - return ( - { - const updatedCells = cells.map((c) => { - if (!c) { - return c; - } - if (c.id === newCell.id) { - return newCell; - } - return c; - }); - onChange(updatedCells); - }} - /> - ); - })} -
- ); -} - -export { CellExplorer, CellsExplorer }; diff --git a/packages/joint-react/src/stories/utils/get-api-docs-base-url.tsx b/packages/joint-react/src/stories/utils/get-api-docs-base-url.tsx index 3b3f61155e..5a89e48364 100644 --- a/packages/joint-react/src/stories/utils/get-api-docs-base-url.tsx +++ b/packages/joint-react/src/stories/utils/get-api-docs-base-url.tsx @@ -1,5 +1,4 @@ // eslint-disable-next-line unicorn/prevent-abbreviations export function getApiDocsBaseUrl(): string { - // @ts-expect-error vite env is not typed return import.meta.env.STORYBOOK_BASE_DOCS_URL ?? 'https://docs.official.com'; } diff --git a/packages/joint-react/src/stories/utils/hook-tester.tsx b/packages/joint-react/src/stories/utils/hook-tester.tsx index fddf904512..f768bc5c84 100644 --- a/packages/joint-react/src/stories/utils/hook-tester.tsx +++ b/packages/joint-react/src/stories/utils/hook-tester.tsx @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { JSX } from 'react'; -import type { StoryObj } from '@storybook/react/*'; +import type { StoryObj } from '@storybook/react'; import '../examples/index.css'; import { HTMLNode, ShowJson } from 'storybook-config/decorators/with-simple-data'; @@ -28,6 +28,18 @@ export function HookTester({ ); } +export function HookTesterRaw({ + useHook, + hookArgs, + render, +}: Readonly>) { + const result = useHook(...hookArgs); + if (render) { + return render(result); + } + return ; +} + export type TesterHookStory = StoryObj & { args: HookTesterProps; }; diff --git a/packages/joint-react/src/stories/utils/make-story.tsx b/packages/joint-react/src/stories/utils/make-story.tsx index f54c898991..6f6d3a74ef 100644 --- a/packages/joint-react/src/stories/utils/make-story.tsx +++ b/packages/joint-react/src/stories/utils/make-story.tsx @@ -1,15 +1,19 @@ -import type { StoryObj } from '@storybook/react/*'; +/* eslint-disable @typescript-eslint/no-unnecessary-type-constraint */ import type React from 'react'; // MakeStory utility -interface MakeStoryOptions { +// eslint-disable-next-line @typescript-eslint/no-explicit-any +interface MakeStoryOptions { readonly component?: React.FC; readonly code?: string; readonly name?: string; readonly apiURL?: string; readonly description?: string; + // @ts-expect-error we know type - its used just for story readonly args?: T['args']; + // @ts-expect-error we know type - its used just for story readonly decorators?: T['decorators']; + // @ts-expect-error we know type - its used just for story readonly play?: T['play']; } @@ -23,8 +27,8 @@ interface MakeStoryOptions { * @returns * A story object that can be used in Storybook. */ -//@ts-expect-error T is not assignable to type StoryObj -export function makeStory(options: MakeStoryOptions): T { +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function makeStory(options: MakeStoryOptions): T { const { component, code, name, apiURL, description = '', args, decorators, play } = options; return { play, @@ -62,7 +66,6 @@ interface MakeRootDocsOptions { * @returns * An object containing the docs and source code. */ -// eslint-disable-next-line unicorn/prevent-abbreviations export function makeRootDocumentation(options: MakeRootDocsOptions) { const { code, apiURL, description = '' } = options; diff --git a/packages/joint-react/src/types/element-types.ts b/packages/joint-react/src/types/element-types.ts index 342253978f..a70ab586ef 100644 --- a/packages/joint-react/src/types/element-types.ts +++ b/packages/joint-react/src/types/element-types.ts @@ -19,7 +19,7 @@ export interface StandardShapesTypeMapper { 'standard.Polygon': shapes.standard.PolygonSelectors; 'standard.Polyline': shapes.standard.PolylineSelectors; 'standard.TextBlock': shapes.standard.TextBlockSelectors; - react: ReactElementAttributes; + ReactElement: ReactElementAttributes; } export type StandardShapesType = keyof StandardShapesTypeMapper; diff --git a/packages/joint-react/src/types/event.types.ts b/packages/joint-react/src/types/event.types.ts index 0b4c1d7a3e..b1d74966f5 100644 --- a/packages/joint-react/src/types/event.types.ts +++ b/packages/joint-react/src/types/event.types.ts @@ -199,11 +199,12 @@ export type PaperEventType = keyof EventMap; export interface PaperEvents { // Paper mouse events - onPaperMouseEnter?: (args: { event: dia.Event; paper: dia.Paper }) => void; - onPaperMouseLeave?: (args: { event: dia.Event; paper: dia.Paper }) => void; + onPaperMouseEnter?: (args: { graph: dia.Graph; event: dia.Event; paper: dia.Paper }) => void; + onPaperMouseLeave?: (args: { graph: dia.Graph; event: dia.Event; paper: dia.Paper }) => void; // Click events onCellPointerClick?: (args: { + graph: dia.Graph; cellView: dia.CellView; event: dia.Event; x: number; @@ -211,6 +212,7 @@ export interface PaperEvents { paper: dia.Paper; }) => void; onElementPointerClick?: (args: { + graph: dia.Graph; elementView: dia.ElementView; event: dia.Event; x: number; @@ -218,6 +220,7 @@ export interface PaperEvents { paper: dia.Paper; }) => void; onLinkPointerClick?: (args: { + graph: dia.Graph; linkView: dia.LinkView; event: dia.Event; x: number; @@ -225,6 +228,7 @@ export interface PaperEvents { paper: dia.Paper; }) => void; onBlankPointerClick?: (args: { + graph: dia.Graph; event: dia.Event; x: number; y: number; @@ -233,6 +237,7 @@ export interface PaperEvents { // Double click events onCellPointerDblClick?: (args: { + graph: dia.Graph; cellView: dia.CellView; event: dia.Event; x: number; @@ -240,6 +245,7 @@ export interface PaperEvents { paper: dia.Paper; }) => void; onElementPointerDblClick?: (args: { + graph: dia.Graph; elementView: dia.ElementView; event: dia.Event; x: number; @@ -247,6 +253,7 @@ export interface PaperEvents { paper: dia.Paper; }) => void; onLinkPointerDblClick?: (args: { + graph: dia.Graph; linkView: dia.LinkView; event: dia.Event; x: number; @@ -254,6 +261,7 @@ export interface PaperEvents { paper: dia.Paper; }) => void; onBlankPointerDblClick?: (args: { + graph: dia.Graph; event: dia.Event; x: number; y: number; @@ -262,6 +270,7 @@ export interface PaperEvents { // Context menu events onCellContextMenu?: (args: { + graph: dia.Graph; cellView: dia.CellView; event: dia.Event; x: number; @@ -269,6 +278,7 @@ export interface PaperEvents { paper: dia.Paper; }) => void; onElementContextMenu?: (args: { + graph: dia.Graph; elementView: dia.ElementView; event: dia.Event; x: number; @@ -276,16 +286,24 @@ export interface PaperEvents { paper: dia.Paper; }) => void; onLinkContextMenu?: (args: { + graph: dia.Graph; linkView: dia.LinkView; event: dia.Event; x: number; y: number; paper: dia.Paper; }) => void; - onBlankContextMenu?: (args: { event: dia.Event; x: number; y: number; paper: dia.Paper }) => void; + onBlankContextMenu?: (args: { + graph: dia.Graph; + event: dia.Event; + x: number; + y: number; + paper: dia.Paper; + }) => void; // Pointer down events onCellPointerDown?: (args: { + graph: dia.Graph; cellView: dia.CellView; event: dia.Event; x: number; @@ -293,6 +311,7 @@ export interface PaperEvents { paper: dia.Paper; }) => void; onElementPointerDown?: (args: { + graph: dia.Graph; elementView: dia.ElementView; event: dia.Event; x: number; @@ -300,16 +319,24 @@ export interface PaperEvents { paper: dia.Paper; }) => void; onLinkPointerDown?: (args: { + graph: dia.Graph; linkView: dia.LinkView; event: dia.Event; x: number; y: number; paper: dia.Paper; }) => void; - onBlankPointerDown?: (args: { event: dia.Event; x: number; y: number; paper: dia.Paper }) => void; + onBlankPointerDown?: (args: { + graph: dia.Graph; + event: dia.Event; + x: number; + y: number; + paper: dia.Paper; + }) => void; // Pointer move events onCellPointerMove?: (args: { + graph: dia.Graph; cellView: dia.CellView; event: dia.Event; x: number; @@ -317,6 +344,7 @@ export interface PaperEvents { paper: dia.Paper; }) => void; onElementPointerMove?: (args: { + graph: dia.Graph; elementView: dia.ElementView; event: dia.Event; x: number; @@ -324,16 +352,24 @@ export interface PaperEvents { paper: dia.Paper; }) => void; onLinkPointerMove?: (args: { + graph: dia.Graph; linkView: dia.LinkView; event: dia.Event; x: number; y: number; paper: dia.Paper; }) => void; - onBlankPointerMove?: (args: { event: dia.Event; x: number; y: number; paper: dia.Paper }) => void; + onBlankPointerMove?: (args: { + graph: dia.Graph; + event: dia.Event; + x: number; + y: number; + paper: dia.Paper; + }) => void; // Pointer up events onCellPointerUp?: (args: { + graph: dia.Graph; cellView: dia.CellView; event: dia.Event; x: number; @@ -341,6 +377,7 @@ export interface PaperEvents { paper: dia.Paper; }) => void; onElementPointerUp?: (args: { + graph: dia.Graph; elementView: dia.ElementView; event: dia.Event; x: number; @@ -348,56 +385,106 @@ export interface PaperEvents { paper: dia.Paper; }) => void; onLinkPointerUp?: (args: { - linkView: dia.LinkView; + graph: dia.Graph; + event: dia.Event; + x: number; + y: number; + paper: dia.Paper; + }) => void; + onBlankPointerUp?: (args: { + graph: dia.Graph; event: dia.Event; x: number; y: number; paper: dia.Paper; }) => void; - onBlankPointerUp?: (args: { event: dia.Event; x: number; y: number; paper: dia.Paper }) => void; // Mouse over events - onCellMouseOver?: (args: { cellView: dia.CellView; event: dia.Event; paper: dia.Paper }) => void; + onCellMouseOver?: (args: { + graph: dia.Graph; + cellView: dia.CellView; + event: dia.Event; + paper: dia.Paper; + }) => void; onElementMouseOver?: (args: { elementView: dia.ElementView; event: dia.Event; paper: dia.Paper; }) => void; - onLinkMouseOver?: (args: { linkView: dia.LinkView; event: dia.Event; paper: dia.Paper }) => void; - onBlankMouseOver?: (args: { event: dia.Event; paper: dia.Paper }) => void; + onLinkMouseOver?: (args: { + graph: dia.Graph; + linkView: dia.LinkView; + event: dia.Event; + paper: dia.Paper; + }) => void; + onBlankMouseOver?: (args: { graph: dia.Graph; event: dia.Event; paper: dia.Paper }) => void; // Mouse out events - onCellMouseOut?: (args: { cellView: dia.CellView; event: dia.Event; paper: dia.Paper }) => void; + onCellMouseOut?: (args: { + graph: dia.Graph; + cellView: dia.CellView; + event: dia.Event; + paper: dia.Paper; + }) => void; onElementMouseOut?: (args: { + graph: dia.Graph; elementView: dia.ElementView; event: dia.Event; paper: dia.Paper; }) => void; - onLinkMouseOut?: (args: { linkView: dia.LinkView; event: dia.Event; paper: dia.Paper }) => void; - onBlankMouseOut?: (args: { event: dia.Event; paper: dia.Paper }) => void; + onLinkMouseOut?: (args: { + graph: dia.Graph; + linkView: dia.LinkView; + event: dia.Event; + paper: dia.Paper; + }) => void; + onBlankMouseOut?: (args: { graph: dia.Graph; event: dia.Event; paper: dia.Paper }) => void; // Mouse enter events - onCellMouseEnter?: (args: { cellView: dia.CellView; event: dia.Event; paper: dia.Paper }) => void; + onCellMouseEnter?: (args: { + graph: dia.Graph; + cellView: dia.CellView; + event: dia.Event; + paper: dia.Paper; + }) => void; onElementMouseEnter?: (args: { + graph: dia.Graph; elementView: dia.ElementView; event: dia.Event; paper: dia.Paper; }) => void; - onLinkMouseEnter?: (args: { linkView: dia.LinkView; event: dia.Event; paper: dia.Paper }) => void; - onBlankMouseEnter?: (args: { event: dia.Event; paper: dia.Paper }) => void; + onLinkMouseEnter?: (args: { + graph: dia.Graph; + linkView: dia.LinkView; + event: dia.Event; + paper: dia.Paper; + }) => void; + onBlankMouseEnter?: (args: { graph: dia.Graph; event: dia.Event; paper: dia.Paper }) => void; // Mouse leave events - onCellMouseLeave?: (args: { cellView: dia.CellView; event: dia.Event; paper: dia.Paper }) => void; + onCellMouseLeave?: (args: { + graph: dia.Graph; + cellView: dia.CellView; + event: dia.Event; + paper: dia.Paper; + }) => void; onElementMouseLeave?: (args: { + graph: dia.Graph; elementView: dia.ElementView; event: dia.Event; paper: dia.Paper; }) => void; - onLinkMouseLeave?: (args: { linkView: dia.LinkView; event: dia.Event; paper: dia.Paper }) => void; - onBlankMouseLeave?: (args: { event: dia.Event; paper: dia.Paper }) => void; + onLinkMouseLeave?: (args: { + graph: dia.Graph; + linkView: dia.LinkView; + event: dia.Event; + paper: dia.Paper; + }) => void; + onBlankMouseLeave?: (args: { graph: dia.Graph; event: dia.Event; paper: dia.Paper }) => void; // Mouse wheel events onCellMouseWheel?: (args: { + graph: dia.Graph; cellView: dia.CellView; event: dia.Event; x: number; @@ -406,6 +493,7 @@ export interface PaperEvents { paper: dia.Paper; }) => void; onElementMouseWheel?: (args: { + graph: dia.Graph; elementView: dia.ElementView; event: dia.Event; x: number; @@ -414,6 +502,7 @@ export interface PaperEvents { paper: dia.Paper; }) => void; onLinkMouseWheel?: (args: { + graph: dia.Graph; linkView: dia.LinkView; event: dia.Event; x: number; @@ -422,6 +511,7 @@ export interface PaperEvents { paper: dia.Paper; }) => void; onBlankMouseWheel?: (args: { + graph: dia.Graph; event: dia.Event; x: number; y: number; @@ -430,8 +520,15 @@ export interface PaperEvents { }) => void; // Paper gestures - onPan?: (args: { event: dia.Event; deltaX: number; deltaY: number; paper: dia.Paper }) => void; + onPan?: (args: { + graph: dia.Graph; + event: dia.Event; + deltaX: number; + deltaY: number; + paper: dia.Paper; + }) => void; onPinch?: (args: { + graph: dia.Graph; event: dia.Event; x: number; y: number; @@ -441,6 +538,7 @@ export interface PaperEvents { // Magnet events onElementMagnetPointerClick?: (args: { + graph: dia.Graph; elementView: dia.ElementView; event: dia.Event; magnetNode: SVGElement; @@ -449,6 +547,7 @@ export interface PaperEvents { paper: dia.Paper; }) => void; onElementMagnetPointerDblClick?: (args: { + graph: dia.Graph; elementView: dia.ElementView; event: dia.Event; magnetNode: SVGElement; @@ -457,6 +556,7 @@ export interface PaperEvents { paper: dia.Paper; }) => void; onElementMagnetContextMenu?: (args: { + graph: dia.Graph; elementView: dia.ElementView; event: dia.Event; magnetNode: SVGElement; @@ -467,18 +567,21 @@ export interface PaperEvents { // Highlight events onCellHighlight?: (args: { + graph: dia.Graph; cellView: dia.CellView; node: SVGElement; options: dia.CellView.EventHighlightOptions; paper: dia.Paper; }) => void; onCellUnhighlight?: (args: { + graph: dia.Graph; cellView: dia.CellView; node: SVGElement; options: dia.CellView.EventHighlightOptions; paper: dia.Paper; }) => void; onCellHighlightInvalid?: (args: { + graph: dia.Graph; cellView: dia.CellView; highlighterId: string; highlighter: dia.HighlighterView; @@ -487,6 +590,7 @@ export interface PaperEvents { // Connection events onLinkConnect?: (args: { + graph: dia.Graph; linkView: dia.LinkView; event: dia.Event; newCellView: dia.CellView; @@ -495,6 +599,7 @@ export interface PaperEvents { paper: dia.Paper; }) => void; onLinkDisconnect?: (args: { + graph: dia.Graph; linkView: dia.LinkView; event: dia.Event; previousCellView: dia.CellView; @@ -503,6 +608,7 @@ export interface PaperEvents { paper: dia.Paper; }) => void; onLinkSnapConnect?: (args: { + graph: dia.Graph; linkView: dia.LinkView; event: dia.Event; newCellView: dia.CellView; @@ -511,6 +617,7 @@ export interface PaperEvents { paper: dia.Paper; }) => void; onLinkSnapDisconnect?: (args: { + graph: dia.Graph; linkView: dia.LinkView; event: dia.Event; previousCellView: dia.CellView; @@ -520,18 +627,49 @@ export interface PaperEvents { }) => void; // Render events - onRenderDone?: (args: { stats: dia.Paper.UpdateStats; opt: unknown; paper: dia.Paper }) => void; + onRenderDone?: (args: { + graph: dia.Graph; + stats: dia.Paper.UpdateStats; + opt: unknown; + paper: dia.Paper; + }) => void; // Transform events - onTranslate?: (args: { tx: number; ty: number; data: unknown; paper: dia.Paper }) => void; - onScale?: (args: { sx: number; sy: number; data: unknown; paper: dia.Paper }) => void; - onResize?: (args: { width: number; height: number; data: unknown; paper: dia.Paper }) => void; - onTransform?: (args: { matrix: SVGMatrix; data: unknown; paper: dia.Paper }) => void; - - // Custom events - onCustomEvent?: (args: { - eventName: string; - args: Parameters; + onTranslate?: (args: { + graph: dia.Graph; + tx: number; + ty: number; + data: unknown; + paper: dia.Paper; + }) => void; + onScale?: (args: { + graph: dia.Graph; + sx: number; + sy: number; + data: unknown; + paper: dia.Paper; + }) => void; + onResize?: (args: { + graph: dia.Graph; + width: number; + height: number; + data: unknown; + paper: dia.Paper; + }) => void; + onTransform?: (args: { + graph: dia.Graph; + matrix: SVGMatrix; + data: unknown; paper: dia.Paper; }) => void; + + customEvents?: Record< + string, + (args: { + graph: dia.Graph; + eventName: string; + args: Parameters; + paper: dia.Paper; + }) => void + >; } diff --git a/packages/joint-react/src/utils/__tests__/create-element-size-observer.test.ts b/packages/joint-react/src/utils/__tests__/create-element-size-observer.test.ts index 43a952ce5a..359be52f0a 100644 --- a/packages/joint-react/src/utils/__tests__/create-element-size-observer.test.ts +++ b/packages/joint-react/src/utils/__tests__/create-element-size-observer.test.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import { waitFor } from '@testing-library/react'; import { createElementSizeObserver } from '../create-element-size-observer'; describe('createElementSizeObserver', () => { @@ -35,10 +36,12 @@ describe('createElementSizeObserver', () => { jest.clearAllMocks(); }); - it('should call onResize immediately with element size', () => { + it('should call onResize immediately with element size', async () => { const onResize = jest.fn(); createElementSizeObserver(mockElement, onResize); - expect(onResize).toHaveBeenCalledWith({ width: 123, height: 456 }); + await waitFor(() => { + expect(onResize).toHaveBeenCalledWith({ width: 123, height: 456 }); + }); }); it('should observe the element with border-box', () => { @@ -62,7 +65,7 @@ describe('createElementSizeObserver', () => { expect(onResize).toHaveBeenCalledWith({ width: 200, height: 100 }); }); - it('should ignore entries with missing or empty borderBoxSize', () => { + it('should ignore entries with missing or empty borderBoxSize', async () => { const onResize = jest.fn(); createElementSizeObserver(mockElement, onResize); @@ -72,7 +75,9 @@ describe('createElementSizeObserver', () => { cb([{ borderBoxSize: undefined }]); cb([{ borderBoxSize: [] }]); // Only the initial call should be present - expect(onResize).toHaveBeenCalledTimes(1); + await waitFor(() => { + expect(onResize).toHaveBeenCalledTimes(1); + }); }); it('should cleanup and disconnect observer', () => { diff --git a/packages/joint-react/src/utils/__tests__/create.test.ts b/packages/joint-react/src/utils/__tests__/create.test.ts index b854faeaeb..0d74c6ae1a 100644 --- a/packages/joint-react/src/utils/__tests__/create.test.ts +++ b/packages/joint-react/src/utils/__tests__/create.test.ts @@ -33,7 +33,7 @@ describe('create', () => { width: 100, height: 100, somethingElse: 'test', - type: 'react', + type: 'ReactElement', attrs: { rect: { 'alignment-baseline': 'middle' }, }, diff --git a/packages/joint-react/src/utils/__tests__/get-cell.test.ts b/packages/joint-react/src/utils/__tests__/get-cell.test.ts index 7e0df860ad..c10f90744e 100644 --- a/packages/joint-react/src/utils/__tests__/get-cell.test.ts +++ b/packages/joint-react/src/utils/__tests__/get-cell.test.ts @@ -1,4 +1,4 @@ -import { getCell, getElement, getLink } from '../cell/get-cell'; +import { getElement, getLink } from '../cell/get-cell'; import type { dia } from '@joint/core'; describe('getCell', () => { @@ -14,8 +14,6 @@ describe('getCell', () => { type: 'mock-type', ports: { items: [] }, }, - isElement: jest.fn(), - isLink: jest.fn(), get: jest.fn((key) => { const mockData = { source: 'source-id', @@ -39,8 +37,6 @@ describe('getCell', () => { const element = getElement(mockCell); expect(element).toEqual({ id: 'mock-id', - isElement: true, - isLink: false, data: { key: 'value' }, type: 'mock-type', ports: { items: [] }, @@ -57,8 +53,6 @@ describe('getCell', () => { const link = getLink(mockCell); expect(link).toEqual({ id: 'mock-id', - isElement: false, - isLink: true, source: 'source-id', target: 'target-id', type: 'mock-type', @@ -72,18 +66,4 @@ describe('getCell', () => { }); }); }); - - describe('getCell', () => { - it('should return an element when the cell is an element', () => { - (mockCell.isElement as unknown as jest.Mock).mockReturnValue(true); - const result = getCell(mockCell); - expect(result).toEqual(expect.objectContaining({ isElement: true, isLink: false })); - }); - - it('should return a link when the cell is a link', () => { - (mockCell.isElement as unknown as jest.Mock).mockReturnValue(false); - const result = getCell(mockCell); - expect(result).toEqual(expect.objectContaining({ isElement: false, isLink: true })); - }); - }); }); diff --git a/packages/joint-react/src/utils/__tests__/handle-paper-events.test.ts b/packages/joint-react/src/utils/__tests__/handle-paper-events.test.ts index a198dc7cb3..8df88251f8 100644 --- a/packages/joint-react/src/utils/__tests__/handle-paper-events.test.ts +++ b/packages/joint-react/src/utils/__tests__/handle-paper-events.test.ts @@ -1,454 +1,201 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +// @ts-expect-error we mocking this import +import { __mocks as jointMocks } from '@joint/core'; +import { handlePaperEvents } from '../handle-paper-events'; -import type { dia } from '@joint/core'; -import type { PaperEvents, PaperEventType } from '../../types/event.types'; -import { handleEvent } from '../handle-paper-events'; +// ---- Mock @joint/core so we can spy on mvc.Listener.listenTo/stopListening ---- +jest.mock('@joint/core', () => { + // In your monorepo this should exist; we extend it with a mock Listener + const actual = jest.requireActual('@joint/core'); -describe('handle-paper-events', () => { - let mockPaper: dia.Paper; - let mockEvents: PaperEvents; + const listenTo = jest.fn(); + const stopListening = jest.fn(); + const Listener = jest.fn().mockImplementation(() => ({ + listenTo, + stopListening, + })); + return { + ...actual, + mvc: { Listener }, + __mocks: { listenTo, stopListening, Listener }, + }; +}); + +// All supported PaperEvents -> underlying JointJS event names. +// Keep this list in sync with ../handle-paper-events.ts +const CASES: Array<{ name: string; jointEvent: string }> = [ + // --- render --- + { name: 'onRenderDone', jointEvent: 'render:done' }, + + // --- pointer click --- + { name: 'onCellPointerClick', jointEvent: 'cell:pointerclick' }, + { name: 'onElementPointerClick', jointEvent: 'element:pointerclick' }, + { name: 'onLinkPointerClick', jointEvent: 'link:pointerclick' }, + { name: 'onBlankPointerClick', jointEvent: 'blank:pointerclick' }, + + // --- dblclick --- + { name: 'onCellPointerDblClick', jointEvent: 'cell:pointerdblclick' }, + { name: 'onElementPointerDblClick', jointEvent: 'element:pointerdblclick' }, + { name: 'onLinkPointerDblClick', jointEvent: 'link:pointerdblclick' }, + { name: 'onBlankPointerDblClick', jointEvent: 'blank:pointerdblclick' }, + + // --- contextmenu --- + { name: 'onCellContextMenu', jointEvent: 'cell:contextmenu' }, + { name: 'onElementContextMenu', jointEvent: 'element:contextmenu' }, + { name: 'onLinkContextMenu', jointEvent: 'link:contextmenu' }, + { name: 'onBlankContextMenu', jointEvent: 'blank:contextmenu' }, + + // --- pointer down/move/up --- + { name: 'onCellPointerDown', jointEvent: 'cell:pointerdown' }, + { name: 'onElementPointerDown', jointEvent: 'element:pointerdown' }, + { name: 'onLinkPointerDown', jointEvent: 'link:pointerdown' }, + { name: 'onBlankPointerDown', jointEvent: 'blank:pointerdown' }, + + { name: 'onCellPointerMove', jointEvent: 'cell:pointermove' }, + { name: 'onElementPointerMove', jointEvent: 'element:pointermove' }, + { name: 'onLinkPointerMove', jointEvent: 'link:pointermove' }, + { name: 'onBlankPointerMove', jointEvent: 'blank:pointermove' }, + + { name: 'onCellPointerUp', jointEvent: 'cell:pointerup' }, + { name: 'onElementPointerUp', jointEvent: 'element:pointerup' }, + { name: 'onLinkPointerUp', jointEvent: 'link:pointerup' }, + { name: 'onBlankPointerUp', jointEvent: 'blank:pointerup' }, + + // --- mouse over/out --- + { name: 'onCellMouseOver', jointEvent: 'cell:mouseover' }, + { name: 'onElementMouseOver', jointEvent: 'element:mouseover' }, + { name: 'onLinkMouseOver', jointEvent: 'link:mouseover' }, + { name: 'onBlankMouseOver', jointEvent: 'blank:mouseover' }, + + { name: 'onCellMouseOut', jointEvent: 'cell:mouseout' }, + { name: 'onElementMouseOut', jointEvent: 'element:mouseout' }, + { name: 'onLinkMouseOut', jointEvent: 'link:mouseout' }, + { name: 'onBlankMouseOut', jointEvent: 'blank:mouseout' }, + + // --- mouse enter/leave --- + { name: 'onCellMouseEnter', jointEvent: 'cell:mouseenter' }, + { name: 'onElementMouseEnter', jointEvent: 'element:mouseenter' }, + { name: 'onLinkMouseEnter', jointEvent: 'link:mouseenter' }, + { name: 'onBlankMouseEnter', jointEvent: 'blank:mouseenter' }, + + { name: 'onCellMouseLeave', jointEvent: 'cell:mouseleave' }, + { name: 'onElementMouseLeave', jointEvent: 'element:mouseleave' }, + { name: 'onLinkMouseLeave', jointEvent: 'link:mouseleave' }, + { name: 'onBlankMouseLeave', jointEvent: 'blank:mouseleave' }, + + // --- mouse wheel --- + { name: 'onCellMouseWheel', jointEvent: 'cell:mousewheel' }, + { name: 'onElementMouseWheel', jointEvent: 'element:mousewheel' }, + { name: 'onLinkMouseWheel', jointEvent: 'link:mousewheel' }, + { name: 'onBlankMouseWheel', jointEvent: 'blank:mousewheel' }, + + // --- paper gestures --- + { name: 'onPan', jointEvent: 'paper:pan' }, + { name: 'onPinch', jointEvent: 'paper:pinch' }, + + // --- paper mouse enter/leave --- + { name: 'onPaperMouseEnter', jointEvent: 'paper:mouseenter' }, + { name: 'onPaperMouseLeave', jointEvent: 'paper:mouseleave' }, + + // --- magnet events --- + { name: 'onElementMagnetPointerClick', jointEvent: 'element:magnet:pointerclick' }, + { name: 'onElementMagnetPointerDblClick', jointEvent: 'element:magnet:pointerdblclick' }, + { name: 'onElementMagnetContextMenu', jointEvent: 'element:magnet:contextmenu' }, + + // --- highlight events --- + { name: 'onCellHighlight', jointEvent: 'cell:highlight' }, + { name: 'onCellUnhighlight', jointEvent: 'cell:unhighlight' }, + { name: 'onCellHighlightInvalid', jointEvent: 'cell:highlight:invalid' }, + + // --- link connection events --- + { name: 'onLinkConnect', jointEvent: 'link:connect' }, + { name: 'onLinkDisconnect', jointEvent: 'link:disconnect' }, + { name: 'onLinkSnapConnect', jointEvent: 'link:snap:connect' }, + { name: 'onLinkSnapDisconnect', jointEvent: 'link:snap:disconnect' }, + + // --- transform events --- + { name: 'onTranslate', jointEvent: 'translate' }, + { name: 'onScale', jointEvent: 'scale' }, + { name: 'onResize', jointEvent: 'resize' }, + { name: 'onTransform', jointEvent: 'transform' }, +]; + +describe('handlePaperEvents', () => { beforeEach(() => { - mockPaper = {} as dia.Paper; - mockEvents = {}; + jest.clearAllMocks(); }); - const eventTestCases: Array<{ - type: PaperEventType | string; - args: unknown[]; - handler: keyof PaperEvents; - expected: Record; - }> = [ - // --- render --- - { - type: 'render:done', - args: [{}, {}], - handler: 'onRenderDone', - expected: { stats: {}, opt: {} }, - }, - // --- pointer click --- - { - type: 'cell:pointerclick', - args: [{}, {}, 10, 20], - handler: 'onCellPointerClick', - expected: { cellView: {}, event: {}, x: 10, y: 20 }, - }, - { - type: 'element:pointerclick', - args: [{}, {}, 15, 25], - handler: 'onElementPointerClick', - expected: { elementView: {}, event: {}, x: 15, y: 25 }, - }, - { - type: 'link:pointerclick', - args: [{}, {}, 30, 40], - handler: 'onLinkPointerClick', - expected: { linkView: {}, event: {}, x: 30, y: 40 }, - }, - { - type: 'blank:pointerclick', - args: [{}, 50, 60], - handler: 'onBlankPointerClick', - expected: { event: {}, x: 50, y: 60 }, - }, - // --- pointer double-click --- - { - type: 'cell:pointerdblclick', - args: [{}, {}, 70, 80], - handler: 'onCellPointerDblClick', - expected: { cellView: {}, event: {}, x: 70, y: 80 }, - }, - { - type: 'element:pointerdblclick', - args: [{}, {}, 90, 100], - handler: 'onElementPointerDblClick', - expected: { elementView: {}, event: {}, x: 90, y: 100 }, - }, - { - type: 'link:pointerdblclick', - args: [{}, {}, 110, 120], - handler: 'onLinkPointerDblClick', - expected: { linkView: {}, event: {}, x: 110, y: 120 }, - }, - { - type: 'blank:pointerdblclick', - args: [{}, 130, 140], - handler: 'onBlankPointerDblClick', - expected: { event: {}, x: 130, y: 140 }, - }, - // --- context menu --- - { - type: 'cell:contextmenu', - args: [{}, {}, 150, 160], - handler: 'onCellContextMenu', - expected: { cellView: {}, event: {}, x: 150, y: 160 }, - }, - { - type: 'element:contextmenu', - args: [{}, {}, 170, 180], - handler: 'onElementContextMenu', - expected: { elementView: {}, event: {}, x: 170, y: 180 }, - }, - { - type: 'link:contextmenu', - args: [{}, {}, 190, 200], - handler: 'onLinkContextMenu', - expected: { linkView: {}, event: {}, x: 190, y: 200 }, - }, - { - type: 'blank:contextmenu', - args: [{}, 210, 220], - handler: 'onBlankContextMenu', - expected: { event: {}, x: 210, y: 220 }, - }, - // --- pointer down --- - { - type: 'cell:pointerdown', - args: [{}, {}, 1, 2], - handler: 'onCellPointerDown', - expected: { cellView: {}, event: {}, x: 1, y: 2 }, - }, - { - type: 'element:pointerdown', - args: [{}, {}, 3, 4], - handler: 'onElementPointerDown', - expected: { elementView: {}, event: {}, x: 3, y: 4 }, - }, - { - type: 'link:pointerdown', - args: [{}, {}, 5, 6], - handler: 'onLinkPointerDown', - expected: { linkView: {}, event: {}, x: 5, y: 6 }, - }, - { - type: 'blank:pointerdown', - args: [{}, 7, 8], - handler: 'onBlankPointerDown', - expected: { event: {}, x: 7, y: 8 }, - }, - // --- pointer move --- - { - type: 'cell:pointermove', - args: [{}, {}, 9, 10], - handler: 'onCellPointerMove', - expected: { cellView: {}, event: {}, x: 9, y: 10 }, - }, - { - type: 'element:pointermove', - args: [{}, {}, 11, 12], - handler: 'onElementPointerMove', - expected: { elementView: {}, event: {}, x: 11, y: 12 }, - }, - { - type: 'link:pointermove', - args: [{}, {}, 13, 14], - handler: 'onLinkPointerMove', - expected: { linkView: {}, event: {}, x: 13, y: 14 }, - }, - { - type: 'blank:pointermove', - args: [{}, 15, 16], - handler: 'onBlankPointerMove', - expected: { event: {}, x: 15, y: 16 }, - }, - // --- pointer up --- - { - type: 'cell:pointerup', - args: [{}, {}, 17, 18], - handler: 'onCellPointerUp', - expected: { cellView: {}, event: {}, x: 17, y: 18 }, - }, - { - type: 'element:pointerup', - args: [{}, {}, 19, 20], - handler: 'onElementPointerUp', - expected: { elementView: {}, event: {}, x: 19, y: 20 }, - }, - { - type: 'link:pointerup', - args: [{}, {}, 21, 22], - handler: 'onLinkPointerUp', - expected: { linkView: {}, event: {}, x: 21, y: 22 }, - }, - { - type: 'blank:pointerup', - args: [{}, 23, 24], - handler: 'onBlankPointerUp', - expected: { event: {}, x: 23, y: 24 }, - }, - // --- mouse over --- - { - type: 'cell:mouseover', - args: [{}, {}], - handler: 'onCellMouseOver', - expected: { cellView: {}, event: {} }, - }, - { - type: 'element:mouseover', - args: [{}, {}], - handler: 'onElementMouseOver', - expected: { elementView: {}, event: {} }, - }, - { - type: 'link:mouseover', - args: [{}, {}], - handler: 'onLinkMouseOver', - expected: { linkView: {}, event: {} }, - }, - { - type: 'blank:mouseover', - args: [{}], - handler: 'onBlankMouseOver', - expected: { event: {} }, - }, - // --- mouse out --- - { - type: 'cell:mouseout', - args: [{}, {}], - handler: 'onCellMouseOut', - expected: { cellView: {}, event: {} }, - }, - { - type: 'element:mouseout', - args: [{}, {}], - handler: 'onElementMouseOut', - expected: { elementView: {}, event: {} }, - }, - { - type: 'link:mouseout', - args: [{}, {}], - handler: 'onLinkMouseOut', - expected: { linkView: {}, event: {} }, - }, - { - type: 'blank:mouseout', - args: [{}], - handler: 'onBlankMouseOut', - expected: { event: {} }, - }, - // --- mouse enter/leave --- - { - type: 'cell:mouseenter', - args: [{}, {}], - handler: 'onCellMouseEnter', - expected: { cellView: {}, event: {} }, - }, - { - type: 'element:mouseenter', - args: [{}, {}], - handler: 'onElementMouseEnter', - expected: { elementView: {}, event: {} }, - }, - { - type: 'link:mouseenter', - args: [{}, {}], - handler: 'onLinkMouseEnter', - expected: { linkView: {}, event: {} }, - }, - { - type: 'blank:mouseenter', - args: [{}], - handler: 'onBlankMouseEnter', - expected: { event: {} }, - }, - { - type: 'cell:mouseleave', - args: [{}, {}], - handler: 'onCellMouseLeave', - expected: { cellView: {}, event: {} }, - }, - { - type: 'element:mouseleave', - args: [{}, {}], - handler: 'onElementMouseLeave', - expected: { elementView: {}, event: {} }, - }, - { - type: 'link:mouseleave', - args: [{}, {}], - handler: 'onLinkMouseLeave', - expected: { linkView: {}, event: {} }, - }, - { - type: 'blank:mouseleave', - args: [{}], - handler: 'onBlankMouseLeave', - expected: { event: {} }, - }, - // --- mouse wheel --- - { - type: 'cell:mousewheel', - args: [{}, {}, 1, 2, 3], - handler: 'onCellMouseWheel', - expected: { cellView: {}, event: {}, x: 1, y: 2, delta: 3 }, - }, - { - type: 'element:mousewheel', - args: [{}, {}, 4, 5, 6], - handler: 'onElementMouseWheel', - expected: { elementView: {}, event: {}, x: 4, y: 5, delta: 6 }, - }, - { - type: 'link:mousewheel', - args: [{}, {}, 7, 8, 9], - handler: 'onLinkMouseWheel', - expected: { linkView: {}, event: {}, x: 7, y: 8, delta: 9 }, - }, - { - type: 'blank:mousewheel', - args: [{}, 10, 11, 12], - handler: 'onBlankMouseWheel', - expected: { event: {}, x: 10, y: 11, delta: 12 }, - }, - // --- paper gestures --- - { - type: 'paper:pan', - args: [{}, 13, 14], - handler: 'onPan', - expected: { event: {}, deltaX: 13, deltaY: 14 }, - }, - { - type: 'paper:pinch', - args: [{}, 15, 16, 1.5], - handler: 'onPinch', - expected: { event: {}, x: 15, y: 16, scale: 1.5 }, - }, - // --- paper mouse enter/leave --- - { - type: 'paper:mouseenter', - args: [{}], - handler: 'onPaperMouseEnter', - expected: { event: {} }, - }, - { - type: 'paper:mouseleave', - args: [{}], - handler: 'onPaperMouseLeave', - expected: { event: {} }, - }, - // --- magnet events --- - { - type: 'element:magnet:pointerclick', - args: [{}, {}, {}, 17, 18], - handler: 'onElementMagnetPointerClick', - expected: { elementView: {}, event: {}, magnetNode: {}, x: 17, y: 18 }, - }, - { - type: 'element:magnet:pointerdblclick', - args: [{}, {}, {}, 19, 20], - handler: 'onElementMagnetPointerDblClick', - expected: { elementView: {}, event: {}, magnetNode: {}, x: 19, y: 20 }, - }, - { - type: 'element:magnet:contextmenu', - args: [{}, {}, {}, 21, 22], - handler: 'onElementMagnetContextMenu', - expected: { elementView: {}, event: {}, magnetNode: {}, x: 21, y: 22 }, - }, - // --- highlight events --- - { - type: 'cell:highlight', - args: [{}, {}, {}], - handler: 'onCellHighlight', - expected: { cellView: {}, node: {}, options: {} }, - }, - { - type: 'cell:unhighlight', - args: [{}, {}, {}], - handler: 'onCellUnhighlight', - expected: { cellView: {}, node: {}, options: {} }, - }, - { - type: 'cell:highlight:invalid', - args: [{}, 'highlighterId', {}], - handler: 'onCellHighlightInvalid', - expected: { cellView: {}, highlighterId: 'highlighterId', highlighter: {} }, - }, - // --- link connection events --- - { - type: 'link:connect', - args: [{}, {}, {}, {}, {}], - handler: 'onLinkConnect', - expected: { linkView: {}, event: {}, newCellView: {}, newCellViewMagnet: {}, arrowhead: {} }, - }, - { - type: 'link:disconnect', - args: [{}, {}, {}, {}, {}], - handler: 'onLinkDisconnect', - expected: { - linkView: {}, - event: {}, - previousCellView: {}, - previousCellViewMagnet: {}, - arrowhead: {}, - }, - }, - { - type: 'link:snap:connect', - args: [{}, {}, {}, {}, {}], - handler: 'onLinkSnapConnect', - expected: { linkView: {}, event: {}, newCellView: {}, newCellViewMagnet: {}, arrowhead: {} }, - }, - { - type: 'link:snap:disconnect', - args: [{}, {}, {}, {}, {}], - handler: 'onLinkSnapDisconnect', - expected: { - linkView: {}, - event: {}, - previousCellView: {}, - previousCellViewMagnet: {}, - arrowhead: {}, - }, - }, - // --- transform events --- - { - type: 'translate', - args: [1, 2, {}], - handler: 'onTranslate', - expected: { tx: 1, ty: 2, data: {} }, - }, - { - type: 'scale', - args: [1.1, 2.2, {}], - handler: 'onScale', - expected: { sx: 1.1, sy: 2.2, data: {} }, - }, - { - type: 'resize', - args: [100, 200, {}], - handler: 'onResize', - expected: { width: 100, height: 200, data: {} }, - }, - { - type: 'transform', - args: [{}, {}], - handler: 'onTransform', - expected: { matrix: {}, data: {} }, - }, - // --- catch-all custom event --- - { - type: 'custom:event', - args: [{}, {}, {}], - handler: 'onCustomEvent', - expected: { eventName: 'custom:event', args: [{}, {}, {}] }, - }, - { - type: 'something:else', - args: [1, 2, 3], - handler: 'onCustomEvent', - expected: { eventName: 'something:else', args: [1, 2, 3] }, - }, - ]; - - for (const { type, args, handler, expected } of eventTestCases) { - it(`should call ${handler} for ${type}`, () => { - const mockHandler = jest.fn(); - (mockEvents as any)[handler] = mockHandler; - - handleEvent(type as PaperEventType, mockEvents, mockPaper, ...args); - - expect(mockHandler).toHaveBeenCalledWith({ ...expected, paper: mockPaper }); - }); - } + it('creates an mvc.Listener controller', () => { + const paper: any = {}; + const events: any = { onRenderDone: jest.fn() }; + const graph: any = {}; + + handlePaperEvents(graph, paper, events); + + expect(jointMocks.Listener).toHaveBeenCalledTimes(1); + }); + + it('registers listeners for every provided PaperEvent', () => { + const paper: any = {}; + const events: Record = {}; + const graph: any = {}; + // Provide all events + for (const c of CASES) events[c.name] = jest.fn(); + + handlePaperEvents(graph, paper, events as any); + + // One listenTo per provided event + expect(jointMocks.listenTo).toHaveBeenCalledTimes(CASES.length); + + // Each call matches [paper, jointEvent, function] + for (const { jointEvent } of CASES) { + expect(jointMocks.listenTo).toHaveBeenCalledWith(paper, jointEvent, expect.any(Function)); + } + }); + + it('registers only for specified events and skips missing ones', () => { + const paper: any = {}; + const events: any = { + onRenderDone: jest.fn(), + onCellPointerClick: jest.fn(), + }; + const graph: any = {}; + + handlePaperEvents(graph, paper, events); + + expect(jointMocks.listenTo).toHaveBeenCalledTimes(2); + expect(jointMocks.listenTo).toHaveBeenCalledWith(paper, 'render:done', expect.any(Function)); + expect(jointMocks.listenTo).toHaveBeenCalledWith( + paper, + 'cell:pointerclick', + expect.any(Function) + ); + }); + + it('ignores unknown event keys gracefully', () => { + const paper: any = {}; + const events: any = { + onRenderDone: jest.fn(), + notARealEvent: jest.fn(), + }; + const graph: any = {}; + handlePaperEvents(graph, paper, events); + + // Only real event was registered + expect(jointMocks.listenTo).toHaveBeenCalledTimes(1); + expect(jointMocks.listenTo).toHaveBeenCalledWith(paper, 'render:done', expect.any(Function)); + }); + + it('returns a disposer that calls stopListening()', () => { + const paper: any = {}; + const events: any = { onRenderDone: jest.fn() }; + const graph: any = {}; + const dispose = handlePaperEvents(graph, paper, events); + expect(jointMocks.stopListening).not.toHaveBeenCalled(); + + dispose(); + + expect(jointMocks.stopListening).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/joint-react/src/utils/__tests__/is.test.ts b/packages/joint-react/src/utils/__tests__/is.test.ts index e908ab47b7..35a34d559c 100644 --- a/packages/joint-react/src/utils/__tests__/is.test.ts +++ b/packages/joint-react/src/utils/__tests__/is.test.ts @@ -42,16 +42,6 @@ describe('is.ts utility functions', () => { expect(is.isGraphCell(null)).toBe(false); }); - test('isGraphElement', () => { - expect(is.isGraphElement({ isElement: true, isLink: false })).toBe(true); - expect(is.isGraphElement({ isElement: false, isLink: true })).toBe(false); - }); - - test('isGraphLink', () => { - expect(is.isGraphLink({ isElement: false, isLink: true })).toBe(true); - expect(is.isGraphLink({ isElement: true, isLink: false })).toBe(false); - }); - test('isLinkInstance', () => { const link = new dia.Link(); expect(is.isLinkInstance(link)).toBe(true); diff --git a/packages/joint-react/src/utils/__tests__/link-utilities.test.ts b/packages/joint-react/src/utils/__tests__/link-utilities.test.ts index 3c1b4165a8..f32ab03b64 100644 --- a/packages/joint-react/src/utils/__tests__/link-utilities.test.ts +++ b/packages/joint-react/src/utils/__tests__/link-utilities.test.ts @@ -3,13 +3,13 @@ import * as linkUtilities from '../link-utilities'; describe('link-utilities', () => { describe('getLinkId', () => { it('returns id if passed a string', () => { - expect(linkUtilities.getLinkId('foo')).toBe('foo'); + expect(linkUtilities.getCellId('foo')).toBe('foo'); }); it('returns id property if passed an object', () => { - expect(linkUtilities.getLinkId({ id: 'bar' })).toBe('bar'); + expect(linkUtilities.getCellId({ id: 'bar' })).toBe('bar'); }); it('returns undefined if object has no id', () => { - expect(linkUtilities.getLinkId({})).toBeUndefined(); + expect(linkUtilities.getCellId({})).toBeUndefined(); }); }); diff --git a/packages/joint-react/src/utils/cell/__tests__/cell-map.test.ts b/packages/joint-react/src/utils/cell/__tests__/cell-map.test.ts deleted file mode 100644 index ba4cdcbb46..0000000000 --- a/packages/joint-react/src/utils/cell/__tests__/cell-map.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { CellMap } from '../cell-map'; - -describe('cell-map', () => { - it('should test cell map and wrapping back to array', () => { - const cellMap = new CellMap(); - cellMap.set(0, { id: 0 }); - cellMap.set(1, { id: 1 }); - expect(cellMap.get(0)).toEqual({ id: 0 }); - expect(cellMap.get(1)).toEqual({ id: 1 }); - const cellArray = cellMap.map((items) => items); - expect(cellArray).toHaveLength(2); - expect(cellArray[0]).toEqual({ id: 0 }); - expect(cellArray[1]).toEqual({ id: 1 }); - }); - it('should test cell map and wrapping back to array with constructor', () => { - const cellMap = new CellMap([ - [0, { id: 0 }], - [1, { id: 1 }], - ]); - expect(cellMap.get(0)).toEqual({ id: 0 }); - expect(cellMap.get(1)).toEqual({ id: 1 }); - const cellArray = cellMap.map((items) => items); - expect(cellArray).toHaveLength(2); - expect(cellArray[0]).toEqual({ id: 0 }); - expect(cellArray[1]).toEqual({ id: 1 }); - }); -}); diff --git a/packages/joint-react/src/utils/cell/__tests__/cell-utilities.test.ts b/packages/joint-react/src/utils/cell/__tests__/cell-utilities.test.ts new file mode 100644 index 0000000000..e76ca36afe --- /dev/null +++ b/packages/joint-react/src/utils/cell/__tests__/cell-utilities.test.ts @@ -0,0 +1,31 @@ +import { ReactElement } from '../../../models/react-element'; +import { createElements } from '../../create'; +import { setElements } from '../cell-utilities'; +import { dia } from '@joint/core'; + +// Mocks + +describe('cell-utilities', () => { + it('set elements', () => { + const graph = new dia.Graph({}, { cellNamespace: { ReactElement } }); + const elements = createElements([{ id: '1' }, { id: '2' }]); + setElements({ graph, elements }); + expect(graph.getElements().length).toBe(2); + setElements({ graph, elements: [] }); + expect(graph.getElements().length).toBe(0); + // add new again + setElements({ graph, elements }); + expect(graph.getElements().length).toBe(2); + + // update + + const updatedElements = createElements([ + { id: '1', color: 'red' }, + { id: '2', color: 'blue' }, + ]); + setElements({ graph, elements: updatedElements }); + expect(graph.getElements().length).toBe(2); + expect(graph.getCell('1').get('color')).toBe('red'); + expect(graph.getCell('2').get('color')).toBe('blue'); + }); +}); diff --git a/packages/joint-react/src/utils/cell/cell-map.ts b/packages/joint-react/src/utils/cell/cell-map.ts deleted file mode 100644 index 3fcaf89959..0000000000 --- a/packages/joint-react/src/utils/cell/cell-map.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { dia } from '@joint/core'; - -export interface CellBase { - readonly id?: dia.Cell.ID; -} - -/** - * CellMap is a custom Map implementation that extends the native Map class. - * It provides additional utility methods for working with working with nodes & edges. - * @group Utils - */ - -export class CellMap extends Map { - map(selector: (item: V) => Item): Item[] { - return [...this.values()].map(selector); - } - - filter(predicate: (item: V) => boolean): V[] { - return [...this.values()].filter(predicate); - } - - toJSON(): string { - return JSON.stringify([...this.entries()]); - } -} diff --git a/packages/joint-react/src/utils/cell/set-cells.ts b/packages/joint-react/src/utils/cell/cell-utilities.ts similarity index 79% rename from packages/joint-react/src/utils/cell/set-cells.ts rename to packages/joint-react/src/utils/cell/cell-utilities.ts index f34e82d323..1cdd77b460 100644 --- a/packages/joint-react/src/utils/cell/set-cells.ts +++ b/packages/joint-react/src/utils/cell/cell-utilities.ts @@ -5,13 +5,9 @@ import type { GraphElement } from '../../types/element-types'; import { isCellInstance, isLinkInstance, isUnsized } from '../is'; import { getTargetOrSource } from './get-link-targe-and-source-ids'; import { isReactElement } from '../is-react-element'; +import { updateGraph } from '../graph/update-graph'; -interface Options { - readonly graph: dia.Graph; - readonly initialLinks?: Array; - readonly initialElements?: Array; -} - +export type CellOrJsonCell = dia.Cell | dia.Cell.JSON; /** * Process a link: convert GraphLink to a standard JointJS link if needed. * @param link - The link to process. @@ -22,7 +18,7 @@ interface Options { * @returns * A standard JointJS link or a JSON representation of the link. */ -export function processLink(link: dia.Link | GraphLink): dia.Link | dia.Cell.JSON { +export function processLink(link: dia.Link | GraphLink): CellOrJsonCell { if (isLinkInstance(link)) { const json = link.toJSON(); @@ -37,6 +33,7 @@ export function processLink(link: dia.Link | GraphLink): dia.Link | dia.Cell.JSO const source = getTargetOrSource(link.source); const target = getTargetOrSource(link.target); + return { ...link, type: link.type ?? 'standard.Link', @@ -45,6 +42,11 @@ export function processLink(link: dia.Link | GraphLink): dia.Link | dia.Cell.JSO } as dia.Cell.JSON; } +export interface SetLinksOptions { + readonly graph: dia.Graph; + readonly links?: Array; +} + /** * Set links to the graph. * @param options - The options for setting links. @@ -54,22 +56,23 @@ export function processLink(link: dia.Link | GraphLink): dia.Link | dia.Cell.JSO * It processes the links and adds them to the graph. * It also converts the source and target of the links to a standard format. */ -export function setLinks(options: Options) { - const { graph, initialLinks } = options; - if (initialLinks === undefined) { +export function setLinks(options: SetLinksOptions) { + const { graph, links } = options; + if (links === undefined) { return; } - // Process links if provided. - graph.addCells( - initialLinks.map((item) => { + updateGraph({ + graph, + cells: links.map((item) => { const link = processLink(item); if (link.z === undefined) { link.z = 0; } return link; - }) - ); + }), + isLink: true, + }); } /** @@ -87,7 +90,7 @@ export function setLinks(options: Options) { export function processElement( element: T, unsizedIds?: Set -): dia.Element | dia.Cell.JSON { +): CellOrJsonCell { const stringId = String(element.id); if (isCellInstance(element)) { const size = element.size(); @@ -108,6 +111,12 @@ export function processElement( ...element, } as dia.Cell.JSON; } + +export interface SetElementsOptions { + readonly graph: dia.Graph; + readonly elements?: Array; +} + /** * Set elements to the graph. * @param options - The options for setting elements. @@ -118,14 +127,16 @@ export function processElement( * It processes the elements and adds them to the graph. * It also checks for unsized elements and returns their IDs. */ -export function setElements(options: Options) { - const { graph, initialElements } = options; - if (initialElements === undefined) { +export function setElements(options: SetElementsOptions) { + const { graph, elements } = options; + if (elements === undefined) { return new Set(); } const unsizedIds = new Set(); - - // Process elements if provided. - graph.addCells(initialElements.map((item) => processElement(item, unsizedIds))); + updateGraph({ + graph, + cells: elements.map((item) => processElement(item, unsizedIds)), + isLink: false, + }); return unsizedIds; } diff --git a/packages/joint-react/src/utils/cell/get-cell.ts b/packages/joint-react/src/utils/cell/get-cell.ts index ab4af9e6d0..c32a5eaa93 100644 --- a/packages/joint-react/src/utils/cell/get-cell.ts +++ b/packages/joint-react/src/utils/cell/get-cell.ts @@ -36,8 +36,6 @@ export function getElement( ...position, ...size, id: cell.id, - isElement: true, - isLink: false, data: cell.attributes.data, type: cell.attributes.type, ports: cell.get('ports'), @@ -64,8 +62,6 @@ export function getLink(cell: dia.Cell): GraphLink { return { ...cell.attributes, id: cell.id, - isElement: false, - isLink: true, source: cell.get('source') as dia.Cell.ID, target: cell.get('target') as dia.Cell.ID, type: cell.attributes.type, diff --git a/packages/joint-react/src/utils/cell/listen-to-cell-change.ts b/packages/joint-react/src/utils/cell/listen-to-cell-change.ts index f4fcc1af64..99453a8aad 100644 --- a/packages/joint-react/src/utils/cell/listen-to-cell-change.ts +++ b/packages/joint-react/src/utils/cell/listen-to-cell-change.ts @@ -15,7 +15,9 @@ export function listenToCellChange( const controller = new mvc.Listener(); controller.listenTo(graph, 'change', (cell: dia.Cell) => handleCellsChange(cell, 'change')); controller.listenTo(graph, 'add', (cell: dia.Cell) => handleCellsChange(cell, 'add')); - controller.listenTo(graph, 'remove', (cell: dia.Cell) => handleCellsChange(cell, 'remove')); + controller.listenTo(graph, 'remove', (cell: dia.Cell) => { + handleCellsChange(cell, 'remove'); + }); return () => controller.stopListening(); } diff --git a/packages/joint-react/src/utils/create-element-size-observer.ts b/packages/joint-react/src/utils/create-element-size-observer.ts index 391bd7a998..9b2daf021e 100644 --- a/packages/joint-react/src/utils/create-element-size-observer.ts +++ b/packages/joint-react/src/utils/create-element-size-observer.ts @@ -35,12 +35,15 @@ export function createElementSizeObserver { + const { width, height } = element.getBoundingClientRect(); + if (width > 0 && height > 0) onResize({ width, height }); + }); // Start observing the HTML element. observer.observe(element, { box: 'border-box' }); diff --git a/packages/joint-react/src/utils/create.ts b/packages/joint-react/src/utils/create.ts index 0f2b474f91..1dca70d065 100644 --- a/packages/joint-react/src/utils/create.ts +++ b/packages/joint-react/src/utils/create.ts @@ -10,7 +10,45 @@ type ElementWithAttributes = T extends keyof StandardShapesTypeMapper ? { type?: T; attrs?: StandardShapesTypeMapper[T] } : // eslint-disable-next-line sonarjs/no-redundant-optional - { type?: undefined; attrs?: StandardShapesTypeMapper['react'] }; + { type?: undefined; attrs?: StandardShapesTypeMapper['ReactElement'] }; + +/** + * Create a single element helper function. + * @group Utils + * @param item - Element to create. + * @returns The created element. (Node) + * @example + * without custom data + * ```ts + * const element = createElementItem({ + * id: '1', + * type: 'rect', + * x: 10, + * y: 10, + * width: 100, + * height: 100, + * }); + * ``` + * @example + * with custom data + * ```ts + * const element = createElementItem({ + * id: '1', + * type: 'rect', + * x: 10, + * y: 10, + * data: { label: 'Node 1' }, + * width: 100, + * height: 100, + * }); + * ``` + */ +export function createElementItem< + Element extends GraphElement, + Type extends string | undefined = 'ReactElement', +>(item: Element & ElementWithAttributes): Element & RequiredElementProps { + return { ...item } as Element & RequiredElementProps; +} /** * Create elements helper function. @@ -36,7 +74,7 @@ type ElementWithAttributes = */ export function createElements< Element extends GraphElement, - Type extends string | undefined = 'react', + Type extends string | undefined = 'ReactElement', >(items: Array>): Array { return items.map((item) => ({ ...item })) as Array; } @@ -75,3 +113,20 @@ export function createLinks< >(data: Array>): Array { return data.map((link) => ({ ...link, isElement: false, isLink: true })); } + +/** + * Create a single link helper function. + * @group Utils + * @param link - Link to create. + * @returns The created link. (Edge) + * @example + * ```ts + * const link = createLinkItem({ id: '1', source: '1', target: '2' }); + * ``` + */ +export function createLinkItem< + Link extends GraphLink, + Type extends StandardLinkShapesType | string = 'standard.Link', +>(link: Link & GraphLink): Link & GraphLink { + return { ...link, isElement: false, isLink: true }; +} diff --git a/packages/joint-react/src/utils/diff-update.ts b/packages/joint-react/src/utils/diff-update.ts deleted file mode 100644 index 6220d92d35..0000000000 --- a/packages/joint-react/src/utils/diff-update.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Helper function to update the store data more efficiently. - * It compares the original map with the diff map and updates the original map in place. - * If the diff map is empty, it returns the original map. - * If the diff map is not empty, it creates a new map with the original map and the diff map. - * @param original - The original map to update. - * @param diff - The diff map to apply to the original map. - * @param newDataContainKey - A function that checks if a key exists in the new map. - * @returns - The updated map. - * @description - */ -export function diffUpdate>( - original: M, - diff: Map, - newDataContainKey: (key: K) => boolean -): M { - let hasDelete = false; - - for (const [key] of original) { - if (!newDataContainKey(key)) { - original.delete(key); - hasDelete = true; - } - } - - if (diff.size === 0 && !hasDelete) { - return original; - } - - const NewMapConstructor = original.constructor as new (entries?: Iterable<[K, V]>) => M; - const newMap = new NewMapConstructor(original); // shallow copy to preserve type - - for (const [key, value] of diff) { - newMap.set(key, value); - } - - return newMap; -} diff --git a/packages/joint-react/src/utils/graph/update-graph.ts b/packages/joint-react/src/utils/graph/update-graph.ts new file mode 100644 index 0000000000..140682e558 --- /dev/null +++ b/packages/joint-react/src/utils/graph/update-graph.ts @@ -0,0 +1,97 @@ +import type { dia } from '@joint/core'; +import type { CellOrJsonCell } from '../cell/cell-utilities'; +import { getCellId } from '../link-utilities'; +import { isCellInstance } from '../is'; + +export const CONTROLLED_MODE_BATCH_NAME = 'controlled-mode'; +export const GRAPH_UPDATE_BATCH_NAME = 'update-graph'; +/** + * Get the value of a specific attribute from a cell or JSON cell. + * @param cell - The cell or JSON cell to get the value from. + * @param attributeName - The name of the attribute to get the value of. + * @returns The value of the attribute. + * @group utils + */ +function getType(cell: CellOrJsonCell, attributeName: string) { + if (isCellInstance(cell)) { + return cell.get(attributeName); + } + return cell[attributeName]; +} +/** + * Get the attributes of a cell or JSON cell. + * @param cell - The cell or JSON cell to get the attributes from. + * @returns The attributes of the cell. + * @group utils + */ +function getAttributes(cell: CellOrJsonCell) { + if (isCellInstance(cell)) { + return cell.attributes; + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { id, type, ...attributes } = cell; + return attributes; +} + +interface UpdateCellOptions { + readonly graph: dia.Graph; + readonly newCell: CellOrJsonCell; + readonly newCellsMap?: Record; + readonly isLink?: boolean; + readonly isSilenced?: boolean; +} +/** + * Update a cell in the graph or add it if it does not exist. + * @param options - The options for updating the cell. + */ +export function updateCell(options: UpdateCellOptions) { + const { graph, newCell, newCellsMap = {}, isSilenced } = options; + const id = getCellId(newCell.id); + if (!id) return; + + newCellsMap[id] = newCell; + const originalCell = graph.getCell(newCell.id); + + if (originalCell) { + const isLink = originalCell.isLink(); + if (originalCell.get('type') === getType(newCell, 'type') && !isLink) { + originalCell.set(getAttributes(newCell), { silent: isSilenced }); + } else { + originalCell.remove({ disconnectLinks: true, silent: isSilenced }); + graph.addCell(newCell, { silent: isSilenced }); + } + } else { + graph.addCell(newCell, { silent: isSilenced }); + } +} + +interface Options { + readonly graph: dia.Graph; + readonly cells: CellOrJsonCell[]; + readonly isLink: boolean; + readonly isSilenced?: boolean; +} + +/** + * Update the graph with new cells. + * @param options - The options for updating the graph. + */ +export function updateGraph(options: Options) { + const { graph, cells, isLink, isSilenced } = options; + const originalCells = isLink ? graph.getLinks() : graph.getElements(); + const newCellsMap: Record = {}; + + // Here we do not want to remove the existing elements but only update them if they exist. + // e.g. Using resetCells() would remove all elements from the graph and add new ones. + for (const newCell of cells) { + updateCell({ graph, newCell, newCellsMap, isLink, isSilenced }); + } + + if (originalCells) { + for (const cell of originalCells) { + if (!newCellsMap[cell.id]) { + cell.remove(); + } + } + } +} diff --git a/packages/joint-react/src/utils/handle-paper-events.ts b/packages/joint-react/src/utils/handle-paper-events.ts index d7362b36e0..78a245d2a2 100644 --- a/packages/joint-react/src/utils/handle-paper-events.ts +++ b/packages/joint-react/src/utils/handle-paper-events.ts @@ -1,501 +1,855 @@ -/* eslint-disable sonarjs/max-switch-cases */ -import type { dia, mvc } from '@joint/core'; -import type { PaperEventType, PaperEvents } from '../types/event.types'; +import type { dia } from '@joint/core'; +import { mvc } from '@joint/core'; +import type { EventMap, PaperEvents } from '../types/event.types'; -/** - * Calls the matching PaperEvents handler if it exists. - * @param type - The event type. - * @param events - The PaperEvents object. - * @param paper - The paper instance. - * @param args - The arguments to pass to the event handler. - */ -export function handleEvent( - type: PaperEventType, - events: PaperEvents, - paper: dia.Paper, - ...args: unknown[] -): void { - switch (type) { - // --- render --- - case 'render:done': { - const [stats, opt] = args as [dia.Paper.UpdateStats, unknown]; - events.onRenderDone?.({ stats, opt, paper }); - break; - } +export const PAPER_EVENTS_MAPPER: { + [K in keyof PaperEvents]?: { + jointEvent: keyof EventMap; + handler: (graph: dia.Graph, paper: dia.Paper, ...args: never[]) => unknown; + }; +} = { + // --- render --- + onRenderDone: { + jointEvent: 'render:done', + handler: (graph, paper, stats: dia.Paper.UpdateStats, opt: unknown) => ({ + graph, + paper, + stats, + opt, + }), + }, - // --- pointer click --- - case 'cell:pointerclick': { - const [cellView, event, x, y] = args as [dia.CellView, dia.Event, number, number]; - events.onCellPointerClick?.({ cellView, event, x, y, paper }); - break; - } - case 'element:pointerclick': { - const [elementView, event, x, y] = args as [dia.ElementView, dia.Event, number, number]; - events.onElementPointerClick?.({ elementView, event, x, y, paper }); - break; - } - case 'link:pointerclick': { - const [linkView, event, x, y] = args as [dia.LinkView, dia.Event, number, number]; - events.onLinkPointerClick?.({ linkView, event, x, y, paper }); - break; - } - case 'blank:pointerclick': { - const [event, x, y] = args as [dia.Event, number, number]; - events.onBlankPointerClick?.({ event, x, y, paper }); - break; - } + // --- pointer click --- + onCellPointerClick: { + jointEvent: 'cell:pointerclick', + handler: (graph, paper, cellView: dia.CellView, event: dia.Event, x: number, y: number) => ({ + graph, + paper, + cellView, + event, + x, + y, + }), + }, + onElementPointerClick: { + jointEvent: 'element:pointerclick', + handler: ( + graph, + paper, + elementView: dia.ElementView, + event: dia.Event, + x: number, + y: number + ) => ({ + graph, + paper, + elementView, + event, + x, + y, + }), + }, + onLinkPointerClick: { + jointEvent: 'link:pointerclick', + handler: (graph, paper, linkView: dia.LinkView, event: dia.Event, x: number, y: number) => ({ + graph, + paper, + linkView, + event, + x, + y, + }), + }, + onBlankPointerClick: { + jointEvent: 'blank:pointerclick', + handler: (graph, paper, event: dia.Event, x: number, y: number) => ({ + graph, + paper, + event, + x, + y, + }), + }, - // --- pointer double-click --- - case 'cell:pointerdblclick': { - const [cellView, event, x, y] = args as [dia.CellView, dia.Event, number, number]; - events.onCellPointerDblClick?.({ cellView, event, x, y, paper }); - break; - } - case 'element:pointerdblclick': { - const [elementView, event, x, y] = args as [dia.ElementView, dia.Event, number, number]; - events.onElementPointerDblClick?.({ elementView, event, x, y, paper }); - break; - } - case 'link:pointerdblclick': { - const [linkView, event, x, y] = args as [dia.LinkView, dia.Event, number, number]; - events.onLinkPointerDblClick?.({ linkView, event, x, y, paper }); - break; - } - case 'blank:pointerdblclick': { - const [event, x, y] = args as [dia.Event, number, number]; - events.onBlankPointerDblClick?.({ event, x, y, paper }); - break; - } + // --- pointer dblclick --- + onCellPointerDblClick: { + jointEvent: 'cell:pointerdblclick', + handler: (graph, paper, cellView: dia.CellView, event: dia.Event, x: number, y: number) => ({ + graph, + paper, + cellView, + event, + x, + y, + }), + }, + onElementPointerDblClick: { + jointEvent: 'element:pointerdblclick', + handler: ( + graph, + paper, + elementView: dia.ElementView, + event: dia.Event, + x: number, + y: number + ) => ({ + graph, + paper, + elementView, + event, + x, + y, + }), + }, + onLinkPointerDblClick: { + jointEvent: 'link:pointerdblclick', + handler: (graph, paper, linkView: dia.LinkView, event: dia.Event, x: number, y: number) => ({ + graph, + paper, + linkView, + event, + x, + y, + }), + }, + onBlankPointerDblClick: { + jointEvent: 'blank:pointerdblclick', + handler: (graph, paper, event: dia.Event, x: number, y: number) => ({ + graph, + paper, + event, + x, + y, + }), + }, - // --- context menu --- - case 'cell:contextmenu': { - const [cellView, event, x, y] = args as [dia.CellView, dia.Event, number, number]; - events.onCellContextMenu?.({ cellView, event, x, y, paper }); - break; - } - case 'element:contextmenu': { - const [elementView, event, x, y] = args as [dia.ElementView, dia.Event, number, number]; - events.onElementContextMenu?.({ elementView, event, x, y, paper }); - break; - } - case 'link:contextmenu': { - const [linkView, event, x, y] = args as [dia.LinkView, dia.Event, number, number]; - events.onLinkContextMenu?.({ linkView, event, x, y, paper }); - break; - } - case 'blank:contextmenu': { - const [event, x, y] = args as [dia.Event, number, number]; - events.onBlankContextMenu?.({ event, x, y, paper }); - break; - } + // --- contextmenu --- + onCellContextMenu: { + jointEvent: 'cell:contextmenu', + handler: (graph, paper, cellView: dia.CellView, event: dia.Event, x: number, y: number) => ({ + graph, + paper, + cellView, + event, + x, + y, + }), + }, + onElementContextMenu: { + jointEvent: 'element:contextmenu', + handler: ( + graph, + paper, + elementView: dia.ElementView, + event: dia.Event, + x: number, + y: number + ) => ({ + graph, + paper, + elementView, + event, + x, + y, + }), + }, + onLinkContextMenu: { + jointEvent: 'link:contextmenu', + handler: (graph, paper, linkView: dia.LinkView, event: dia.Event, x: number, y: number) => ({ + graph, + paper, + linkView, + event, + x, + y, + }), + }, + onBlankContextMenu: { + jointEvent: 'blank:contextmenu', + handler: (graph, paper, event: dia.Event, x: number, y: number) => ({ + graph, + paper, + event, + x, + y, + }), + }, - // --- pointer down --- - case 'cell:pointerdown': { - const [cellView, event, x, y] = args as [dia.CellView, dia.Event, number, number]; - events.onCellPointerDown?.({ cellView, event, x, y, paper }); - break; - } - case 'element:pointerdown': { - const [elementView, event, x, y] = args as [dia.ElementView, dia.Event, number, number]; - events.onElementPointerDown?.({ elementView, event, x, y, paper }); - break; - } - case 'link:pointerdown': { - const [linkView, event, x, y] = args as [dia.LinkView, dia.Event, number, number]; - events.onLinkPointerDown?.({ linkView, event, x, y, paper }); - break; - } - case 'blank:pointerdown': { - const [event, x, y] = args as [dia.Event, number, number]; - events.onBlankPointerDown?.({ event, x, y, paper }); - break; - } + // --- pointer down --- + onCellPointerDown: { + jointEvent: 'cell:pointerdown', + handler: (graph, paper, cellView: dia.CellView, event: dia.Event, x: number, y: number) => ({ + graph, + paper, + cellView, + event, + x, + y, + }), + }, + onElementPointerDown: { + jointEvent: 'element:pointerdown', + handler: ( + graph, + paper, + elementView: dia.ElementView, + event: dia.Event, + x: number, + y: number + ) => ({ + graph, + paper, + elementView, + event, + x, + y, + }), + }, + onLinkPointerDown: { + jointEvent: 'link:pointerdown', + handler: (graph, paper, linkView: dia.LinkView, event: dia.Event, x: number, y: number) => ({ + graph, + paper, + linkView, + event, + x, + y, + }), + }, + onBlankPointerDown: { + jointEvent: 'blank:pointerdown', + handler: (graph, paper, event: dia.Event, x: number, y: number) => ({ + graph, + paper, + event, + x, + y, + }), + }, - // --- pointer move --- - case 'cell:pointermove': { - const [cellView, event, x, y] = args as [dia.CellView, dia.Event, number, number]; - events.onCellPointerMove?.({ cellView, event, x, y, paper }); - break; - } - case 'element:pointermove': { - const [elementView, event, x, y] = args as [dia.ElementView, dia.Event, number, number]; - events.onElementPointerMove?.({ elementView, event, x, y, paper }); - break; - } - case 'link:pointermove': { - const [linkView, event, x, y] = args as [dia.LinkView, dia.Event, number, number]; - events.onLinkPointerMove?.({ linkView, event, x, y, paper }); - break; - } - case 'blank:pointermove': { - const [event, x, y] = args as [dia.Event, number, number]; - events.onBlankPointerMove?.({ event, x, y, paper }); - break; - } + // --- pointer move --- + onCellPointerMove: { + jointEvent: 'cell:pointermove', + handler: (graph, paper, cellView: dia.CellView, event: dia.Event, x: number, y: number) => ({ + graph, + paper, + cellView, + event, + x, + y, + }), + }, + onElementPointerMove: { + jointEvent: 'element:pointermove', + handler: ( + graph, + paper, + elementView: dia.ElementView, + event: dia.Event, + x: number, + y: number + ) => ({ + graph, + paper, + elementView, + event, + x, + y, + }), + }, + onLinkPointerMove: { + jointEvent: 'link:pointermove', + handler: (graph, paper, linkView: dia.LinkView, event: dia.Event, x: number, y: number) => ({ + graph, + paper, + linkView, + event, + x, + y, + }), + }, + onBlankPointerMove: { + jointEvent: 'blank:pointermove', + handler: (graph, paper, event: dia.Event, x: number, y: number) => ({ + graph, + paper, + event, + x, + y, + }), + }, - // --- pointer up --- - case 'cell:pointerup': { - const [cellView, event, x, y] = args as [dia.CellView, dia.Event, number, number]; - events.onCellPointerUp?.({ cellView, event, x, y, paper }); - break; - } - case 'element:pointerup': { - const [elementView, event, x, y] = args as [dia.ElementView, dia.Event, number, number]; - events.onElementPointerUp?.({ elementView, event, x, y, paper }); - break; - } - case 'link:pointerup': { - const [linkView, event, x, y] = args as [dia.LinkView, dia.Event, number, number]; - events.onLinkPointerUp?.({ linkView, event, x, y, paper }); - break; - } - case 'blank:pointerup': { - const [event, x, y] = args as [dia.Event, number, number]; - events.onBlankPointerUp?.({ event, x, y, paper }); - break; - } + // --- pointer up --- + onCellPointerUp: { + jointEvent: 'cell:pointerup', + handler: (graph, paper, cellView: dia.CellView, event: dia.Event, x: number, y: number) => ({ + graph, + paper, + cellView, + event, + x, + y, + }), + }, + onElementPointerUp: { + jointEvent: 'element:pointerup', + handler: ( + graph, + paper, + elementView: dia.ElementView, + event: dia.Event, + x: number, + y: number + ) => ({ + graph, + paper, + elementView, + event, + x, + y, + }), + }, + onLinkPointerUp: { + jointEvent: 'link:pointerup', + handler: (graph, paper, linkView: dia.LinkView, event: dia.Event, x: number, y: number) => ({ + graph, + paper, + linkView, + event, + x, + y, + }), + }, + onBlankPointerUp: { + jointEvent: 'blank:pointerup', + handler: (graph, paper, event: dia.Event, x: number, y: number) => ({ + graph, + paper, + event, + x, + y, + }), + }, - // --- mouse over --- - case 'cell:mouseover': { - const [cellView, event] = args as [dia.CellView, dia.Event]; - events.onCellMouseOver?.({ cellView, event, paper }); - break; - } - case 'element:mouseover': { - const [elementView, event] = args as [dia.ElementView, dia.Event]; - events.onElementMouseOver?.({ elementView, event, paper }); - break; - } - case 'link:mouseover': { - const [linkView, event] = args as [dia.LinkView, dia.Event]; - events.onLinkMouseOver?.({ linkView, event, paper }); - break; - } - case 'blank:mouseover': { - const [event] = args as [dia.Event]; - events.onBlankMouseOver?.({ event, paper }); - break; - } + // --- mouse over/out --- + onCellMouseOver: { + jointEvent: 'cell:mouseover', + handler: (graph, paper, cellView: dia.CellView, event: dia.Event) => ({ + graph, + paper, + cellView, + event, + }), + }, + onElementMouseOver: { + jointEvent: 'element:mouseover', + handler: (graph, paper, elementView: dia.ElementView, event: dia.Event) => ({ + graph, + paper, + elementView, + event, + }), + }, + onLinkMouseOver: { + jointEvent: 'link:mouseover', + handler: (graph, paper, linkView: dia.LinkView, event: dia.Event) => ({ + graph, + paper, + linkView, + event, + }), + }, + onBlankMouseOver: { + jointEvent: 'blank:mouseover', + handler: (graph, paper, event: dia.Event) => ({ graph, paper, event }), + }, - // --- mouse out --- - case 'cell:mouseout': { - const [cellView, event] = args as [dia.CellView, dia.Event]; - events.onCellMouseOut?.({ cellView, event, paper }); - break; - } - case 'element:mouseout': { - const [elementView, event] = args as [dia.ElementView, dia.Event]; - events.onElementMouseOut?.({ elementView, event, paper }); - break; - } - case 'link:mouseout': { - const [linkView, event] = args as [dia.LinkView, dia.Event]; - events.onLinkMouseOut?.({ linkView, event, paper }); - break; - } - case 'blank:mouseout': { - const [event] = args as [dia.Event]; - events.onBlankMouseOut?.({ event, paper }); - break; - } + onCellMouseOut: { + jointEvent: 'cell:mouseout', + handler: (graph, paper, cellView: dia.CellView, event: dia.Event) => ({ + graph, + paper, + cellView, + event, + }), + }, + onElementMouseOut: { + jointEvent: 'element:mouseout', + handler: (graph, paper, elementView: dia.ElementView, event: dia.Event) => ({ + graph, + paper, + elementView, + event, + }), + }, + onLinkMouseOut: { + jointEvent: 'link:mouseout', + handler: (graph, paper, linkView: dia.LinkView, event: dia.Event) => ({ + graph, + paper, + linkView, + event, + }), + }, + onBlankMouseOut: { + jointEvent: 'blank:mouseout', + handler: (graph, paper, event: dia.Event) => ({ graph, paper, event }), + }, - // --- mouse enter/leave --- - case 'cell:mouseenter': { - const [cellView, event] = args as [dia.CellView, dia.Event]; - events.onCellMouseEnter?.({ cellView, event, paper }); - break; - } - case 'element:mouseenter': { - const [elementView, event] = args as [dia.ElementView, dia.Event]; - events.onElementMouseEnter?.({ elementView, event, paper }); - break; - } - case 'link:mouseenter': { - const [linkView, event] = args as [dia.LinkView, dia.Event]; - events.onLinkMouseEnter?.({ linkView, event, paper }); - break; - } - case 'blank:mouseenter': { - const [event] = args as [dia.Event]; - events.onBlankMouseEnter?.({ event, paper }); - break; - } - case 'cell:mouseleave': { - const [cellView, event] = args as [dia.CellView, dia.Event]; - events.onCellMouseLeave?.({ cellView, event, paper }); - break; - } - case 'element:mouseleave': { - const [elementView, event] = args as [dia.ElementView, dia.Event]; - events.onElementMouseLeave?.({ elementView, event, paper }); - break; - } - case 'link:mouseleave': { - const [linkView, event] = args as [dia.LinkView, dia.Event]; - events.onLinkMouseLeave?.({ linkView, event, paper }); - break; - } - case 'blank:mouseleave': { - const [event] = args as [dia.Event]; - events.onBlankMouseLeave?.({ event, paper }); - break; - } + // --- mouse enter/leave --- + onCellMouseEnter: { + jointEvent: 'cell:mouseenter', + handler: (graph, paper, cellView: dia.CellView, event: dia.Event) => ({ + graph, + paper, + cellView, + event, + }), + }, + onElementMouseEnter: { + jointEvent: 'element:mouseenter', + handler: (graph, paper, elementView: dia.ElementView, event: dia.Event) => ({ + graph, + paper, + elementView, + event, + }), + }, + onLinkMouseEnter: { + jointEvent: 'link:mouseenter', + handler: (graph, paper, linkView: dia.LinkView, event: dia.Event) => ({ + graph, + paper, + linkView, + event, + }), + }, + onBlankMouseEnter: { + jointEvent: 'blank:mouseenter', + handler: (graph, paper, event: dia.Event) => ({ graph, paper, event }), + }, - // --- mouse wheel --- - case 'cell:mousewheel': { - const [cellView, event, x, y, delta] = args as [ - dia.CellView, - dia.Event, - number, - number, - number, - ]; - events.onCellMouseWheel?.({ cellView, event, x, y, delta, paper }); - break; - } - case 'element:mousewheel': { - const [elementView, event, x, y, delta] = args as [ - dia.ElementView, - dia.Event, - number, - number, - number, - ]; - events.onElementMouseWheel?.({ elementView, event, x, y, delta, paper }); - break; - } - case 'link:mousewheel': { - const [linkView, event, x, y, delta] = args as [ - dia.LinkView, - dia.Event, - number, - number, - number, - ]; - events.onLinkMouseWheel?.({ linkView, event, x, y, delta, paper }); - break; - } - case 'blank:mousewheel': { - const [event, x, y, delta] = args as [dia.Event, number, number, number]; - events.onBlankMouseWheel?.({ event, x, y, delta, paper }); - break; - } + onCellMouseLeave: { + jointEvent: 'cell:mouseleave', + handler: (graph, paper, cellView: dia.CellView, event: dia.Event) => ({ + graph, + paper, + cellView, + event, + }), + }, + onElementMouseLeave: { + jointEvent: 'element:mouseleave', + handler: (graph, paper, elementView: dia.ElementView, event: dia.Event) => ({ + graph, + paper, + elementView, + event, + }), + }, + onLinkMouseLeave: { + jointEvent: 'link:mouseleave', + handler: (graph, paper, linkView: dia.LinkView, event: dia.Event) => ({ + graph, + paper, + linkView, + event, + }), + }, + onBlankMouseLeave: { + jointEvent: 'blank:mouseleave', + handler: (graph, paper, event: dia.Event) => ({ graph, paper, event }), + }, - // --- paper gestures --- - case 'paper:pan': { - const [event, deltaX, deltaY] = args as [dia.Event, number, number]; - events.onPan?.({ event, deltaX, deltaY, paper }); - break; - } - case 'paper:pinch': { - const [event, x, y, scale] = args as [dia.Event, number, number, number]; - events.onPinch?.({ event, x, y, scale, paper }); - break; - } + // --- mouse wheel --- + onCellMouseWheel: { + jointEvent: 'cell:mousewheel', + handler: ( + graph, + paper, + cellView: dia.CellView, + event: dia.Event, + x: number, + y: number, + delta: number + ) => ({ + graph, + paper, + cellView, + event, + x, + y, + delta, + }), + }, + onElementMouseWheel: { + jointEvent: 'element:mousewheel', + handler: ( + graph, + paper, + elementView: dia.ElementView, + event: dia.Event, + x: number, + y: number, + delta: number + ) => ({ + graph, + paper, + elementView, + event, + x, + y, + delta, + }), + }, + onLinkMouseWheel: { + jointEvent: 'link:mousewheel', + handler: ( + graph, + paper, + linkView: dia.LinkView, + event: dia.Event, + x: number, + y: number, + delta: number + ) => ({ + graph, + paper, + linkView, + event, + x, + y, + delta, + }), + }, + onBlankMouseWheel: { + jointEvent: 'blank:mousewheel', + handler: (graph, paper, event: dia.Event, x: number, y: number, delta: number) => ({ + graph, + paper, + event, + x, + y, + delta, + }), + }, - // --- paper mouse enter/leave --- - case 'paper:mouseenter': { - const [event] = args as [dia.Event]; - events.onPaperMouseEnter?.({ event, paper }); - break; - } - case 'paper:mouseleave': { - const [event] = args as [dia.Event]; - events.onPaperMouseLeave?.({ event, paper }); - break; - } + // --- paper gestures --- + onPan: { + jointEvent: 'paper:pan', + handler: (graph, paper, event: dia.Event, deltaX: number, deltaY: number) => ({ + graph, + paper, + event, + deltaX, + deltaY, + }), + }, + onPinch: { + jointEvent: 'paper:pinch', + handler: (graph, paper, event: dia.Event, x: number, y: number, scale: number) => ({ + graph, + paper, + event, + x, + y, + scale, + }), + }, - // --- magnet events --- - case 'element:magnet:pointerclick': { - const [elementView, event, magnetNode, x, y] = args as [ - dia.ElementView, - dia.Event, - SVGElement, - number, - number, - ]; - events.onElementMagnetPointerClick?.({ - elementView, - event, - magnetNode, - x, - y, - paper, - }); - break; - } - case 'element:magnet:pointerdblclick': { - const [elementView, event, magnetNode, x, y] = args as [ - dia.ElementView, - dia.Event, - SVGElement, - number, - number, - ]; - events.onElementMagnetPointerDblClick?.({ - elementView, - event, - magnetNode, - x, - y, - paper, - }); - break; - } - case 'element:magnet:contextmenu': { - const [elementView, event, magnetNode, x, y] = args as [ - dia.ElementView, - dia.Event, - SVGElement, - number, - number, - ]; - events.onElementMagnetContextMenu?.({ - elementView, - event, - magnetNode, - x, - y, - paper, - }); - break; - } + // --- paper mouse enter/leave --- + onPaperMouseEnter: { + jointEvent: 'paper:mouseenter', + handler: (graph, paper, event: dia.Event) => ({ graph, paper, event }), + }, + onPaperMouseLeave: { + jointEvent: 'paper:mouseleave', + handler: (graph, paper, event: dia.Event) => ({ graph, paper, event }), + }, - // --- highlight events --- - case 'cell:highlight': { - const [cellView, node, options] = args as [ - dia.CellView, - SVGElement, - dia.CellView.EventHighlightOptions, - ]; - events.onCellHighlight?.({ cellView, node, options, paper }); - break; - } - case 'cell:unhighlight': { - const [cellView, node, options] = args as [ - dia.CellView, - SVGElement, - dia.CellView.EventHighlightOptions, - ]; - events.onCellUnhighlight?.({ cellView, node, options, paper }); - break; - } - case 'cell:highlight:invalid': { - const [cellView, highlighterId, highlighter] = args as [ - dia.CellView, - string, - dia.HighlighterView, - ]; - events.onCellHighlightInvalid?.({ - cellView, - highlighterId, - highlighter, - paper, - }); - break; - } + // --- magnet events --- + onElementMagnetPointerClick: { + jointEvent: 'element:magnet:pointerclick', + handler: ( + graph, + paper, + elementView: dia.ElementView, + event: dia.Event, + magnetNode: SVGElement, + x: number, + y: number + ) => ({ + graph, + paper, + elementView, + event, + magnetNode, + x, + y, + }), + }, + onElementMagnetPointerDblClick: { + jointEvent: 'element:magnet:pointerdblclick', + handler: ( + graph, + paper, + elementView: dia.ElementView, + event: dia.Event, + magnetNode: SVGElement, + x: number, + y: number + ) => ({ + graph, + paper, + elementView, + event, + magnetNode, + x, + y, + }), + }, + onElementMagnetContextMenu: { + jointEvent: 'element:magnet:contextmenu', + handler: ( + graph, + paper, + elementView: dia.ElementView, + event: dia.Event, + magnetNode: SVGElement, + x: number, + y: number + ) => ({ + graph, + paper, + elementView, + event, + magnetNode, + x, + y, + }), + }, - // --- link connection events --- - case 'link:connect': { - const [linkView, event, newCellView, newCellViewMagnet, arrowhead] = args as [ - dia.LinkView, - dia.Event, - dia.CellView, - SVGElement, - dia.LinkEnd, - ]; - events.onLinkConnect?.({ - linkView, - event, - newCellView, - newCellViewMagnet, - arrowhead, - paper, - }); - break; - } - case 'link:disconnect': { - const [linkView, event, previousCellView, previousCellViewMagnet, arrowhead] = args as [ - dia.LinkView, - dia.Event, - dia.CellView, - SVGElement, - dia.LinkEnd, - ]; - events.onLinkDisconnect?.({ - linkView, - event, - previousCellView, - previousCellViewMagnet, - arrowhead, - paper, - }); - break; - } - case 'link:snap:connect': { - const [linkView, event, newCellView, newCellViewMagnet, arrowhead] = args as [ - dia.LinkView, - dia.Event, - dia.CellView, - SVGElement, - dia.LinkEnd, - ]; - events.onLinkSnapConnect?.({ - linkView, - event, - newCellView, - newCellViewMagnet, - arrowhead, - paper, - }); - break; - } - case 'link:snap:disconnect': { - const [linkView, event, previousCellView, previousCellViewMagnet, arrowhead] = args as [ - dia.LinkView, - dia.Event, - dia.CellView, - SVGElement, - dia.LinkEnd, - ]; - events.onLinkSnapDisconnect?.({ - linkView, - event, - previousCellView, - previousCellViewMagnet, - arrowhead, - paper, - }); - break; - } + // --- highlight events --- + onCellHighlight: { + jointEvent: 'cell:highlight', + handler: ( + graph, + paper, + cellView: dia.CellView, + node: SVGElement, + options: dia.CellView.EventHighlightOptions + ) => ({ + graph, + paper, + cellView, + node, + options, + }), + }, + onCellUnhighlight: { + jointEvent: 'cell:unhighlight', + handler: ( + graph, + paper, + cellView: dia.CellView, + node: SVGElement, + options: dia.CellView.EventHighlightOptions + ) => ({ + graph, + paper, + cellView, + node, + options, + }), + }, + onCellHighlightInvalid: { + jointEvent: 'cell:highlight:invalid', + handler: ( + graph, + paper, + cellView: dia.CellView, + highlighterId: string, + highlighter: dia.HighlighterView + ) => ({ + graph, + paper, + cellView, + highlighterId, + highlighter, + }), + }, - // --- transform events --- - case 'translate': { - const [tx, ty, data] = args as [number, number, unknown]; - events.onTranslate?.({ tx, ty, data, paper }); - break; - } - case 'scale': { - const [sx, sy, data] = args as [number, number, unknown]; - events.onScale?.({ sx, sy, data, paper }); - break; - } - case 'resize': { - const [width, height, data] = args as [number, number, unknown]; - events.onResize?.({ width, height, data, paper }); - break; - } - case 'transform': { - const [matrix, data] = args as [SVGMatrix, unknown]; - events.onTransform?.({ matrix, data, paper }); - break; - } + // --- link connection events --- + onLinkConnect: { + jointEvent: 'link:connect', + handler: ( + graph, + paper, + linkView: dia.LinkView, + event: dia.Event, + newCellView: dia.CellView, + newCellViewMagnet: SVGElement, + arrowhead: dia.LinkEnd + ) => ({ + graph, + paper, + linkView, + event, + newCellView, + newCellViewMagnet, + arrowhead, + }), + }, + onLinkDisconnect: { + jointEvent: 'link:disconnect', + handler: ( + graph, + paper, + linkView: dia.LinkView, + event: dia.Event, + previousCellView: dia.CellView, + previousCellViewMagnet: SVGElement, + arrowhead: dia.LinkEnd + ) => ({ + graph, + paper, + linkView, + event, + previousCellView, + previousCellViewMagnet, + arrowhead, + }), + }, + onLinkSnapConnect: { + jointEvent: 'link:snap:connect', + handler: ( + graph, + paper, + linkView: dia.LinkView, + event: dia.Event, + newCellView: dia.CellView, + newCellViewMagnet: SVGElement, + arrowhead: dia.LinkEnd + ) => ({ + graph, + paper, + linkView, + event, + newCellView, + newCellViewMagnet, + arrowhead, + }), + }, + onLinkSnapDisconnect: { + jointEvent: 'link:snap:disconnect', + handler: ( + graph, + paper, + linkView: dia.LinkView, + event: dia.Event, + previousCellView: dia.CellView, + previousCellViewMagnet: SVGElement, + arrowhead: dia.LinkEnd + ) => ({ + graph, + paper, + linkView, + event, + previousCellView, + previousCellViewMagnet, + arrowhead, + }), + }, - // --- catch-all custom event --- - default: { - const eventArgs = args as [string, ...Parameters]; - events.onCustomEvent?.({ eventName: type, args: eventArgs, paper }); - break; - } + // --- transform events --- + onTranslate: { + jointEvent: 'translate', + handler: (graph, paper, tx: number, ty: number, data: unknown) => ({ + graph, + paper, + tx, + ty, + data, + }), + }, + onScale: { + jointEvent: 'scale', + handler: (graph, paper, sx: number, sy: number, data: unknown) => ({ + graph, + paper, + sx, + sy, + data, + }), + }, + onResize: { + jointEvent: 'resize', + handler: (graph, paper, width: number, height: number, data: unknown) => ({ + graph, + paper, + width, + height, + data, + }), + }, + onTransform: { + jointEvent: 'transform', + handler: (graph, paper, matrix: SVGMatrix, data: unknown) => ({ graph, paper, matrix, data }), + }, + customEvents: { + jointEvent: 'custom', + handler: (graph, paper: dia.Paper, eventName: string, args: unknown[]) => ({ + graph, + paper, + eventName, + args, + }), + }, +}; + +export const PAPER_EVENT_KEYS: Set = new Set( + Object.keys(PAPER_EVENTS_MAPPER) as Array +); + +/** + * Handles paper events by listening to the specified event types and invoking the corresponding handlers. + * @param graph - The graph instance associated with the paper. + * @param paper - The paper instance to listen for events on. + * @param events - An object containing event names and their associated handlers. + * @returns A function to stop listening for the events. + */ +export function handlePaperEvents( + graph: dia.Graph, + paper: dia.Paper, + events: PaperEvents +): () => void { + const controller = new mvc.Listener(); + + for (const name in events) { + const eventName = name as keyof PaperEvents; + if (eventName === 'customEvents' && events.customEvents) { + for (const customEventName in events.customEvents) { + const customEventHandler = events.customEvents[customEventName]; + if (customEventHandler) { + controller.listenTo(paper, customEventName, (...args: Parameters) => { + customEventHandler({ eventName: customEventName, args, paper, graph }); + }); + } + } + continue; + } + const event = events[eventName]; + if (!event) continue; + const listener = PAPER_EVENTS_MAPPER[eventName]; + if (!listener) continue; + + controller.listenTo(paper, listener.jointEvent, (...args: never[]) => { + const objectResult = listener.handler(graph, paper, ...args); + if (typeof event === 'function') { + event(objectResult as never); + } + }); } + + return () => controller.stopListening(); } diff --git a/packages/joint-react/src/utils/is.ts b/packages/joint-react/src/utils/is.ts index cf5bbaea08..b281e98197 100644 --- a/packages/joint-react/src/utils/is.ts +++ b/packages/joint-react/src/utils/is.ts @@ -1,7 +1,6 @@ /* eslint-disable jsdoc/require-jsdoc */ import { dia, util } from '@joint/core'; import type { GraphCell } from './cell/get-cell'; -import type { GraphLink } from '../types/link-types'; import type { GraphElement } from '../types/element-types'; import type { FunctionComponent, JSX } from 'react'; @@ -33,14 +32,6 @@ export function isGraphCell( return isRecord(value) && 'isElement' in value && 'isLink' in value; } -export function isGraphElement(value: unknown): value is GraphElement { - return isGraphCell(value) && value.isElement; -} - -export function isGraphLink(value: unknown): value is GraphLink { - return isGraphCell(value) && value.isLink; -} - export function isLinkInstance(value: unknown): value is dia.Link { return value instanceof dia.Link; } diff --git a/packages/joint-react/src/utils/joint-jsx/__tests__/jsx-to-markup.test.tsx b/packages/joint-react/src/utils/joint-jsx/__tests__/jsx-to-markup.test.tsx index e9947d0b4b..82244870a0 100644 --- a/packages/joint-react/src/utils/joint-jsx/__tests__/jsx-to-markup.test.tsx +++ b/packages/joint-react/src/utils/joint-jsx/__tests__/jsx-to-markup.test.tsx @@ -199,21 +199,6 @@ describe('jsx-to-markup', () => { expect(jsx(undefined as never)).toEqual([]); }); - it('should skip function type that is not a React component', () => { - const markup = jsx( - // @ts-expect-error we use internal api here ($$typeof) - { type: () =>
, props: {}, $$typeof: Symbol.for('react.element') } - ); - // The function returns
, so the result is markup for a div - expect(markup).toEqual([ - { - tagName: 'div', - children: [], - attributes: {}, - }, - ]); - }); - it('should handle React component function returning a primitive', () => { // eslint-disable-next-line unicorn/consistent-function-scoping function PrimitiveComponent() { diff --git a/packages/joint-react/src/utils/joint-jsx/jsx-to-markup.stories.tsx b/packages/joint-react/src/utils/joint-jsx/jsx-to-markup.stories.tsx index d794494978..d4c2f17fde 100644 --- a/packages/joint-react/src/utils/joint-jsx/jsx-to-markup.stories.tsx +++ b/packages/joint-react/src/utils/joint-jsx/jsx-to-markup.stories.tsx @@ -4,10 +4,10 @@ import { dia } from '@joint/core'; import '../../stories/examples/index.css'; import { GraphProvider, jsx, Paper } from '@joint/react'; import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; -import type { Meta, StoryObj } from '@storybook/react/*'; +import type { Meta, StoryObj } from '@storybook/react'; import { SimpleGraphDecorator } from 'storybook-config/decorators/with-simple-data'; -import { makeRootDocumentation } from '@joint/react/src/stories/utils/make-story'; -import { getAPILink } from '@joint/react/src/stories/utils/get-api-documentation-link'; +import { getAPILink } from '../../stories/utils/get-api-documentation-link'; +import { makeRootDocumentation } from '../../stories/utils/make-story'; const API_URL = getAPILink('jsx'); @@ -51,11 +51,7 @@ const initialElements = [ function App() { return ( - + ); diff --git a/packages/joint-react/src/utils/link-utilities.ts b/packages/joint-react/src/utils/link-utilities.ts index 231490d496..2e94dcc808 100644 --- a/packages/joint-react/src/utils/link-utilities.ts +++ b/packages/joint-react/src/utils/link-utilities.ts @@ -5,7 +5,7 @@ import type { dia } from '@joint/core'; * @param id - The id to get the link id from. * @returns The link id or undefined if not found. */ -export function getLinkId(id: dia.Cell.ID | dia.Link.EndJSON): dia.Cell.ID | undefined { +export function getCellId(id: dia.Cell.ID | dia.Link.EndJSON): dia.Cell.ID | undefined { if (typeof id === 'object') { return id.id; } diff --git a/packages/joint-react/src/utils/object-utilities.ts b/packages/joint-react/src/utils/object-utilities.ts new file mode 100644 index 0000000000..f9b9e157cc --- /dev/null +++ b/packages/joint-react/src/utils/object-utilities.ts @@ -0,0 +1,67 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/** + * Make options and avoid to generate undefined values. + * @param options - An object containing options where keys are strings and values can be of any type. + * @returns - A new object with the same properties as the input options, but without any properties that have undefined + */ +export function makeOptions>(options: T): T { + const result: T = {} as T; + for (const key in options) { + if (options[key] !== undefined) { + result[key] = options[key]; + } + } + return result; +} + +/** + * Assign new properties to an instance, ignoring undefined values. + * @param instance - The instance to which new properties will be assigned. + * @param newProperties - An object containing new properties to assign to the instance. + * @returns - The updated instance with the new properties assigned, excluding any properties that were undefined. + */ +export function assignOptions>( + instance: T, + newProperties: Partial +): T { + for (const key in newProperties) { + if (newProperties[key] !== undefined) { + instance[key] = newProperties[key] as T[Extract]; + } + } + return instance; +} + +/** + * Extracts the values of specified keys from an object and returns them as an array. + * @param object - The source object from which to extract values. + * @param picked - An array of keys whose corresponding values need to be extracted from the object. + * @returns - An array containing the values associated with the specified keys in the same order as the keys array. + */ +export function dependencyExtract, K extends keyof T = keyof T>( + object: T, + picked?: Set +): unknown[] { + if (!object) return []; + + const allKeys = Object.keys(object) as K[]; + + // Fast path: no Set → return all values + if (!picked) { + const { length } = allKeys; + const result = Array.from({ length }); + for (let index = 0; index < length; index++) { + result[index] = object[allKeys[index]]; + } + return result; + } + + // Filter by Set membership (O(1) lookup per key) + const result: Array = []; + for (const key of allKeys) { + if (picked.has(key)) { + result.push(object[key]); + } + } + return result; +} diff --git a/packages/joint-react/src/utils/subscriber-handler.ts b/packages/joint-react/src/utils/subscriber-handler.ts index 40041542ed..e646a6c379 100644 --- a/packages/joint-react/src/utils/subscriber-handler.ts +++ b/packages/joint-react/src/utils/subscriber-handler.ts @@ -1,41 +1,74 @@ -import type { dia } from '@joint/core'; +import type { UpdateResult } from '../data/create-store-data'; export interface SubscribeHandler { - readonly subscribe: (onStoreChange: (changedIds?: Set) => void) => () => void; - readonly notifySubscribers: () => void; + readonly subscribe: (onStoreChange: (changedIds?: UpdateResult) => void) => () => void; + readonly notifySubscribers: (batchName?: string) => void; } /** - * Subscribe handler for managing subscribers and notifying them. - * This handler allows you to subscribe to changes and notify subscribers when changes occur. - * @param beforeSubscribe - Optional callback to be called before notifying subscribers. - * @returns - An object with subscribe and notifySubscribers methods. - * @group utils + * Per-batch scheduler: coalesces multiple notify calls in the same frame, + * but runs once per unique batchName (including undefined as its own batch). + * Calls to `beforeSubscribe` can return an `UpdateResult` to pass to subscribers. + * @param beforeSubscribe - Optional function to call before notifying subscribers. + * @returns A SubscribeHandler with subscribe and notifySubscribers methods. */ export function subscribeHandler( - beforeSubscribe?: () => Set | undefined + beforeSubscribe?: (batchName?: string) => UpdateResult | undefined ): SubscribeHandler { - let isScheduled = false; - const subscribers = new Set<(changedIds?: Set) => void>(); + const subscribers = new Set<(changedIds?: UpdateResult) => void>(); + + // Treat "no batch name" as its own distinct key via a Symbol + const DEFAULT_BATCH = Symbol('default-batch'); + type BatchKey = string | symbol; + + const pending = new Set(); // preserves insertion order + let frameScheduled = false; + + const raf = + typeof requestAnimationFrame === 'function' + ? requestAnimationFrame + : (cb: FrameRequestCallback) => setTimeout(() => cb(performance.now()), 0); + + /** + * Schedules a flush of pending batches to notify subscribers. + * Ensures that the flush happens in the next animation frame. + */ + function scheduleFlush() { + if (frameScheduled) return; + frameScheduled = true; + + // microtask → next frame + Promise.resolve().then(() => { + raf(() => { + frameScheduled = false; + + // snapshot current batches and clear, so newly queued batches go to next frame + const batches = [...pending]; + pending.clear(); + + for (const key of batches) { + const batchName = key === DEFAULT_BATCH ? undefined : (key as string); + const changedIds = beforeSubscribe?.(batchName); + for (const subscriber of subscribers) { + subscriber(changedIds); + } + } + }); + }); + } return { - subscribe: (onStoreChange: (changedIds?: Set) => void) => { + subscribe(onStoreChange) { subscribers.add(onStoreChange); return () => { subscribers.delete(onStoreChange); }; }, - notifySubscribers: () => { - if (!isScheduled) { - isScheduled = true; - requestAnimationFrame(() => { - const changedIds = beforeSubscribe?.(); - for (const subscriber of subscribers) { - subscriber(changedIds); - } - isScheduled = false; - }); - } + + notifySubscribers(batchName?: string) { + const key: BatchKey = batchName ?? DEFAULT_BATCH; + pending.add(key); // de-dupe per batch per frame + scheduleFlush(); }, }; } diff --git a/packages/joint-react/src/utils/test-wrappers.tsx b/packages/joint-react/src/utils/test-wrappers.tsx index e40e4287ce..95d7c0bb6a 100644 --- a/packages/joint-react/src/utils/test-wrappers.tsx +++ b/packages/joint-react/src/utils/test-wrappers.tsx @@ -2,14 +2,11 @@ import { useCallback } from 'react'; import { GraphProvider, Paper, type GraphProps, type PaperProps } from '../components'; /** - * This wrapper is used to render a graph provider. - * It is used in the tests to render a graph provider. - * @param props - The props for the graph provider. - * @returns - The wrapper. + * Testing helper to render a `GraphProvider` provider. + * @param props - Props forwarded to the `GraphProvider` root component. + * @returns A component that wraps children with `GraphProvider`. * @internal * @group utils - * @description - * This wrapper is used to render a graph provider. */ export function graphProviderWrapper(props: GraphProps): React.JSXElementConstructor<{ children: React.ReactNode; @@ -21,42 +18,48 @@ export function graphProviderWrapper(props: GraphProps): React.JSXElementConstru interface Options { paperProps?: PaperProps; - graphProps?: GraphProps; + graphProviderProps?: GraphProps; } /** - * This wrapper is used to render a paper with a graph provider. - * It is used in the tests to render a paper with a graph provider. - * @param options - The options for the wrapper. - * @param options.paperProps - The props for the paper. - * @param options.graphProps - The props for the graph provider. - * @returns - The wrapper. + * Testing helper to render a `Paper` inside a `GraphProvider` provider. + * @param options - Wrapper options. + * @param options.paperProps - Props for `Paper`. + * @param options.graphProps - Props for the `GraphProvider` root. + * @returns A component that wraps children inside `GraphProvider` + `Paper`. * @internal * @group utils */ export function paperRenderElementWrapper(options: Options): React.JSXElementConstructor<{ children: React.ReactNode; }> { - const { paperProps, graphProps } = options; + const { paperProps, graphProviderProps } = options; return function GraphProviderWrapper({ children }) { const renderElement = useCallback(() => { return children; }, [children]); return ( - - + + ); }; } export const simpleRenderElementWrapper = paperRenderElementWrapper({ - graphProps: { - initialElements: [ + graphProviderProps: { + elements: [ { id: '1', width: 97, height: 99, }, ], + links: [ + { + id: '3', + source: '1', + target: '2', + }, + ], }, }); diff --git a/packages/joint-react/tsconfig.json b/packages/joint-react/tsconfig.json index 291a44db95..8916486142 100644 --- a/packages/joint-react/tsconfig.json +++ b/packages/joint-react/tsconfig.json @@ -1,28 +1,32 @@ { "compilerOptions": { - "target": "ESNEXT", - "lib": ["dom", "dom.iterable", "esnext"], - "allowJs": true, - "skipLibCheck": true, + "target": "ES2020", // stable JS output + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "module": "ESNext", // let Vite handle bundling + "moduleResolution": "bundler", // <- **important** + "jsx": "react-jsx", // modern JSX transform + "declaration": true, // emit types for consumers + "declarationMap": true, // useful if publishing + "emitDeclarationOnly": false, + "outDir": "lib", "esModuleInterop": true, "allowSyntheticDefaultImports": true, + "isolatedModules": true, // good for Vite "strict": true, - "module": "ESNext", - "outDir": "lib", - "incremental": false, - "declaration": true, - "forceConsistentCasingInFileNames": true, "noFallthroughCasesInSwitch": true, - "moduleResolution": "node", + "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, - "isolatedModules": true, - "jsx": "react-jsx", - "downlevelIteration": true, - "sourceMap": false, + "skipLibCheck": true, + + // For modern TS + Vite + "types": ["vite/client", "jest", "node"], + + // Optional path aliases "paths": { "@joint/react": ["./src/index.ts"], "storybook-config/*": ["./.storybook/*"] } }, - "include": ["**/*.ts", "**/*.tsx", ".storybook/**/*"] + "include": ["src", ".storybook/*", "vite.config.ts"], + "exclude": ["node_modules", "dist", "lib"] } diff --git a/packages/joint-react/vite.config.ts b/packages/joint-react/vite.config.ts index d05b6907cb..c1b11273ae 100644 --- a/packages/joint-react/vite.config.ts +++ b/packages/joint-react/vite.config.ts @@ -1,10 +1,11 @@ -/* eslint-disable unicorn/prefer-module */ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import mdPlugin from 'vite-plugin-md'; import path from 'node:path'; import tsconfigPaths from 'vite-tsconfig-paths'; + export default defineConfig({ + // @ts-expect-error - vite defaults plugins: [react(), mdPlugin(), tsconfigPaths()], assetsInclude: ['**/*.md'], build: { diff --git a/packages/joint-vitest-plugin-mock-svg/package.json b/packages/joint-vitest-plugin-mock-svg/package.json index fbe7132368..e61b705d33 100644 --- a/packages/joint-vitest-plugin-mock-svg/package.json +++ b/packages/joint-vitest-plugin-mock-svg/package.json @@ -60,8 +60,8 @@ "@testing-library/react": "^16.1.0", "@tsconfig/node18": "^18.2.0", "@types/node": "^24.2.0", - "@types/react": "^19.1.9", - "@types/react-dom": "^19.1.7", + "@types/react": "19.1.12", + "@types/react-dom": "19.1.9", "@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-vue": "^5.2.4", "@vue/eslint-config-typescript": "^14.5.0", @@ -69,8 +69,8 @@ "eslint": "^9.0.0", "eslint-plugin-vue": "^9.24.0", "jsdom": "^26.0.0", - "react": "^18.2.0", - "react-dom": "^18.2.0", + "react": "19.1.1", + "react-dom": "19.1.1", "rollup": "4.36.0", "sass": "^1.66.1", "typescript": "5.8.2", diff --git a/yarn.lock b/yarn.lock index 98844180e4..7b15e20a78 100644 --- a/yarn.lock +++ b/yarn.lock @@ -42,7 +42,7 @@ __metadata: languageName: node linkType: hard -"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.10.4, @babel/code-frame@npm:^7.12.13, @babel/code-frame@npm:^7.26.2, @babel/code-frame@npm:^7.27.1": +"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.10.4, @babel/code-frame@npm:^7.26.2, @babel/code-frame@npm:^7.27.1": version: 7.27.1 resolution: "@babel/code-frame@npm:7.27.1" dependencies: @@ -107,7 +107,7 @@ __metadata: languageName: node linkType: hard -"@babel/core@npm:^7.11.6, @babel/core@npm:^7.12.3, @babel/core@npm:^7.18.9, @babel/core@npm:^7.23.9, @babel/core@npm:^7.28.0": +"@babel/core@npm:^7.12.3, @babel/core@npm:^7.18.9, @babel/core@npm:^7.23.9, @babel/core@npm:^7.28.0": version: 7.28.0 resolution: "@babel/core@npm:7.28.0" dependencies: @@ -130,7 +130,30 @@ __metadata: languageName: node linkType: hard -"@babel/generator@npm:^7.10.4, @babel/generator@npm:^7.26.10, @babel/generator@npm:^7.28.0, @babel/generator@npm:^7.7.2": +"@babel/core@npm:^7.27.4, @babel/core@npm:^7.28.4": + version: 7.28.4 + resolution: "@babel/core@npm:7.28.4" + dependencies: + "@babel/code-frame": "npm:^7.27.1" + "@babel/generator": "npm:^7.28.3" + "@babel/helper-compilation-targets": "npm:^7.27.2" + "@babel/helper-module-transforms": "npm:^7.28.3" + "@babel/helpers": "npm:^7.28.4" + "@babel/parser": "npm:^7.28.4" + "@babel/template": "npm:^7.27.2" + "@babel/traverse": "npm:^7.28.4" + "@babel/types": "npm:^7.28.4" + "@jridgewell/remapping": "npm:^2.3.5" + convert-source-map: "npm:^2.0.0" + debug: "npm:^4.1.0" + gensync: "npm:^1.0.0-beta.2" + json5: "npm:^2.2.3" + semver: "npm:^6.3.1" + checksum: 10/0593295241fac9be567145ef16f3858d34fc91390a9438c6d47476be9823af4cc0488c851c59702dd46b968e9fd46d17ddf0105ea30195ca85f5a66b4044c519 + languageName: node + linkType: hard + +"@babel/generator@npm:^7.10.4, @babel/generator@npm:^7.26.10, @babel/generator@npm:^7.28.0": version: 7.28.0 resolution: "@babel/generator@npm:7.28.0" dependencies: @@ -143,6 +166,19 @@ __metadata: languageName: node linkType: hard +"@babel/generator@npm:^7.27.5, @babel/generator@npm:^7.28.3": + version: 7.28.3 + resolution: "@babel/generator@npm:7.28.3" + dependencies: + "@babel/parser": "npm:^7.28.3" + "@babel/types": "npm:^7.28.2" + "@jridgewell/gen-mapping": "npm:^0.3.12" + "@jridgewell/trace-mapping": "npm:^0.3.28" + jsesc: "npm:^3.0.2" + checksum: 10/d00d1e6b51059e47594aab7920b88ec6fcef6489954a9172235ab57ad2e91b39c95376963a6e2e4cc7e8b88fa4f931018f71f9ab32bbc9c0bc0de35a0231f26c + languageName: node + linkType: hard + "@babel/helper-annotate-as-pure@npm:^7.27.1, @babel/helper-annotate-as-pure@npm:^7.27.3": version: 7.27.3 resolution: "@babel/helper-annotate-as-pure@npm:7.27.3" @@ -259,6 +295,19 @@ __metadata: languageName: node linkType: hard +"@babel/helper-module-transforms@npm:^7.28.3": + version: 7.28.3 + resolution: "@babel/helper-module-transforms@npm:7.28.3" + dependencies: + "@babel/helper-module-imports": "npm:^7.27.1" + "@babel/helper-validator-identifier": "npm:^7.27.1" + "@babel/traverse": "npm:^7.28.3" + peerDependencies: + "@babel/core": ^7.0.0 + checksum: 10/598fdd8aa5b91f08542d0ba62a737847d0e752c8b95ae2566bc9d11d371856d6867d93e50db870fb836a6c44cfe481c189d8a2b35ca025a224f070624be9fa87 + languageName: node + linkType: hard + "@babel/helper-optimise-call-expression@npm:^7.27.1": version: 7.27.1 resolution: "@babel/helper-optimise-call-expression@npm:7.27.1" @@ -318,7 +367,7 @@ __metadata: languageName: node linkType: hard -"@babel/helper-validator-identifier@npm:^7.25.9, @babel/helper-validator-identifier@npm:^7.27.1": +"@babel/helper-validator-identifier@npm:^7.27.1": version: 7.27.1 resolution: "@babel/helper-validator-identifier@npm:7.27.1" checksum: 10/75041904d21bdc0cd3b07a8ac90b11d64cd3c881e89cb936fa80edd734bf23c35e6bd1312611e8574c4eab1f3af0f63e8a5894f4699e9cfdf70c06fcf4252320 @@ -353,6 +402,16 @@ __metadata: languageName: node linkType: hard +"@babel/helpers@npm:^7.28.4": + version: 7.28.4 + resolution: "@babel/helpers@npm:7.28.4" + dependencies: + "@babel/template": "npm:^7.27.2" + "@babel/types": "npm:^7.28.4" + checksum: 10/5a70a82e196cf8808f8a449cc4780c34d02edda2bb136d39ce9d26e63b615f18e89a95472230c3ce7695db0d33e7026efeee56f6454ed43480f223007ed205eb + languageName: node + linkType: hard + "@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.10.4, @babel/parser@npm:^7.14.7, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.23.9, @babel/parser@npm:^7.26.10, @babel/parser@npm:^7.27.2, @babel/parser@npm:^7.28.0": version: 7.28.0 resolution: "@babel/parser@npm:7.28.0" @@ -364,6 +423,17 @@ __metadata: languageName: node linkType: hard +"@babel/parser@npm:^7.28.3, @babel/parser@npm:^7.28.4": + version: 7.28.4 + resolution: "@babel/parser@npm:7.28.4" + dependencies: + "@babel/types": "npm:^7.28.4" + bin: + parser: ./bin/babel-parser.js + checksum: 10/f54c46213ef180b149f6a17ea765bf40acc1aebe2009f594e2a283aec69a190c6dda1fdf24c61a258dbeb903abb8ffb7a28f1a378f8ab5d333846ce7b7e23bf1 + languageName: node + linkType: hard + "@babel/plugin-bugfix-firefox-class-in-computed-class-key@npm:^7.25.9": version: 7.27.1 resolution: "@babel/plugin-bugfix-firefox-class-in-computed-class-key@npm:7.27.1" @@ -669,7 +739,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-syntax-jsx@npm:^7.7.2": +"@babel/plugin-syntax-jsx@npm:^7.27.1": version: 7.27.1 resolution: "@babel/plugin-syntax-jsx@npm:7.27.1" dependencies: @@ -768,7 +838,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-syntax-typescript@npm:^7.7.2": +"@babel/plugin-syntax-typescript@npm:^7.27.1": version: 7.27.1 resolution: "@babel/plugin-syntax-typescript@npm:7.27.1" dependencies: @@ -1590,7 +1660,7 @@ __metadata: languageName: node linkType: hard -"@babel/template@npm:^7.10.4, @babel/template@npm:^7.26.9, @babel/template@npm:^7.27.1, @babel/template@npm:^7.27.2, @babel/template@npm:^7.3.3": +"@babel/template@npm:^7.10.4, @babel/template@npm:^7.26.9, @babel/template@npm:^7.27.1, @babel/template@npm:^7.27.2": version: 7.27.2 resolution: "@babel/template@npm:7.27.2" dependencies: @@ -1616,7 +1686,22 @@ __metadata: languageName: node linkType: hard -"@babel/types@npm:^7.0.0, @babel/types@npm:^7.10.4, @babel/types@npm:^7.18.9, @babel/types@npm:^7.20.7, @babel/types@npm:^7.24.7, @babel/types@npm:^7.26.10, @babel/types@npm:^7.27.1, @babel/types@npm:^7.27.3, @babel/types@npm:^7.28.0, @babel/types@npm:^7.28.2, @babel/types@npm:^7.3.3, @babel/types@npm:^7.4.4": +"@babel/traverse@npm:^7.28.3, @babel/traverse@npm:^7.28.4": + version: 7.28.4 + resolution: "@babel/traverse@npm:7.28.4" + dependencies: + "@babel/code-frame": "npm:^7.27.1" + "@babel/generator": "npm:^7.28.3" + "@babel/helper-globals": "npm:^7.28.0" + "@babel/parser": "npm:^7.28.4" + "@babel/template": "npm:^7.27.2" + "@babel/types": "npm:^7.28.4" + debug: "npm:^4.3.1" + checksum: 10/c3099364b7b1c36bcd111099195d4abeef16499e5defb1e56766b754e8b768c252e856ed9041665158aa1b31215fc6682632756803c8fa53405381ec08c4752b + languageName: node + linkType: hard + +"@babel/types@npm:^7.0.0, @babel/types@npm:^7.10.4, @babel/types@npm:^7.18.9, @babel/types@npm:^7.20.7, @babel/types@npm:^7.24.7, @babel/types@npm:^7.26.10, @babel/types@npm:^7.27.1, @babel/types@npm:^7.27.3, @babel/types@npm:^7.28.0, @babel/types@npm:^7.28.2, @babel/types@npm:^7.4.4": version: 7.28.2 resolution: "@babel/types@npm:7.28.2" dependencies: @@ -1626,6 +1711,16 @@ __metadata: languageName: node linkType: hard +"@babel/types@npm:^7.28.4": + version: 7.28.4 + resolution: "@babel/types@npm:7.28.4" + dependencies: + "@babel/helper-string-parser": "npm:^7.27.1" + "@babel/helper-validator-identifier": "npm:^7.27.1" + checksum: 10/db50bf257aafa5d845ad16dae0587f57d596e4be4cbb233ea539976a4c461f9fbcc0bf3d37adae3f8ce5dcb4001462aa608f3558161258b585f6ce6ce21a2e45 + languageName: node + linkType: hard + "@bcoe/v8-coverage@npm:^0.2.3": version: 0.2.3 resolution: "@bcoe/v8-coverage@npm:0.2.3" @@ -1740,6 +1835,34 @@ __metadata: languageName: node linkType: hard +"@emnapi/core@npm:^1.4.3, @emnapi/core@npm:^1.5.0": + version: 1.5.0 + resolution: "@emnapi/core@npm:1.5.0" + dependencies: + "@emnapi/wasi-threads": "npm:1.1.0" + tslib: "npm:^2.4.0" + checksum: 10/b500a69df001580731b0d355298b58832d44ab176937c0db7d10073a396f7a801ebcca10581f125a1cd88af4e6ecd6fbb04b78629cc703a424218b3a36d7bf50 + languageName: node + linkType: hard + +"@emnapi/runtime@npm:^1.4.3, @emnapi/runtime@npm:^1.5.0": + version: 1.5.0 + resolution: "@emnapi/runtime@npm:1.5.0" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10/5311ce854306babc77f4bd94c2f973722714a0fab93c126239104ad52dea16a147bfed4c4cff3ca1eb32709607221c25d2f747ae8524cbeb9088058f02ff962b + languageName: node + linkType: hard + +"@emnapi/wasi-threads@npm:1.1.0": + version: 1.1.0 + resolution: "@emnapi/wasi-threads@npm:1.1.0" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10/0d557e75262d2f4c95cb2a456ba0785ef61f919ce488c1d76e5e3acfd26e00c753ef928cd80068363e0c166ba8cc0141305daf0f81aad5afcd421f38f11e0f4e + languageName: node + linkType: hard + "@emotion/use-insertion-effect-with-fallbacks@npm:^1.0.0": version: 1.2.0 resolution: "@emotion/use-insertion-effect-with-fallbacks@npm:1.2.0" @@ -1749,16 +1872,16 @@ __metadata: languageName: node linkType: hard -"@es-joy/jsdoccomment@npm:~0.50.2": - version: 0.50.2 - resolution: "@es-joy/jsdoccomment@npm:0.50.2" +"@es-joy/jsdoccomment@npm:~0.52.0": + version: 0.52.0 + resolution: "@es-joy/jsdoccomment@npm:0.52.0" dependencies: - "@types/estree": "npm:^1.0.6" - "@typescript-eslint/types": "npm:^8.11.0" + "@types/estree": "npm:^1.0.8" + "@typescript-eslint/types": "npm:^8.34.1" comment-parser: "npm:1.4.1" esquery: "npm:^1.6.0" jsdoc-type-pratt-parser: "npm:~4.1.0" - checksum: 10/a309f01bd1691c6991e5efb78057ec9122ef33208fec2464d7b9e5838964b948fa46c9c944a09218a752b49267f05ac15b557018c8a1897fd8df47b944b4537f + checksum: 10/e0d349fcaca0fc27e53c685f20836fd7f2a8eeb04462a23dea73eff8cf620c49fd847cb32f86ae8767ae86c0576116451d16478d13d6f45d6ca3f0395b0df9b0 languageName: node linkType: hard @@ -2259,7 +2382,7 @@ __metadata: languageName: node linkType: hard -"@eslint-community/eslint-utils@npm:^4.2.0, @eslint-community/eslint-utils@npm:^4.4.0, @eslint-community/eslint-utils@npm:^4.5.1, @eslint-community/eslint-utils@npm:^4.7.0": +"@eslint-community/eslint-utils@npm:^4.2.0, @eslint-community/eslint-utils@npm:^4.4.0, @eslint-community/eslint-utils@npm:^4.7.0": version: 4.7.0 resolution: "@eslint-community/eslint-utils@npm:4.7.0" dependencies: @@ -2277,63 +2400,63 @@ __metadata: languageName: node linkType: hard -"@eslint-react/ast@npm:1.52.3": - version: 1.52.3 - resolution: "@eslint-react/ast@npm:1.52.3" +"@eslint-react/ast@npm:1.52.4": + version: 1.52.4 + resolution: "@eslint-react/ast@npm:1.52.4" dependencies: - "@eslint-react/eff": "npm:1.52.3" - "@typescript-eslint/types": "npm:^8.36.0" - "@typescript-eslint/typescript-estree": "npm:^8.36.0" - "@typescript-eslint/utils": "npm:^8.36.0" + "@eslint-react/eff": "npm:1.52.4" + "@typescript-eslint/types": "npm:^8.39.1" + "@typescript-eslint/typescript-estree": "npm:^8.39.1" + "@typescript-eslint/utils": "npm:^8.39.1" string-ts: "npm:^2.2.1" - ts-pattern: "npm:^5.7.1" - checksum: 10/bcda055d2740a26f1dcd92c448ec64ed5c29cc947e03d42d7268fe7fff7789cd8b4c8b8fad6a41c2a49903376335c2021325e41e5bab1f37a17e8d1250ac0c8c + ts-pattern: "npm:^5.8.0" + checksum: 10/21148b8bbe4abac6573efac606321ce9b07aacee3c33cfacad9c01315578998f6a098b5636e1a2ef1982736bd804702bf8f3ea4edf4d456f065fb02992b8d3df languageName: node linkType: hard -"@eslint-react/core@npm:1.52.3": - version: 1.52.3 - resolution: "@eslint-react/core@npm:1.52.3" +"@eslint-react/core@npm:1.52.4": + version: 1.52.4 + resolution: "@eslint-react/core@npm:1.52.4" dependencies: - "@eslint-react/ast": "npm:1.52.3" - "@eslint-react/eff": "npm:1.52.3" - "@eslint-react/kit": "npm:1.52.3" - "@eslint-react/shared": "npm:1.52.3" - "@eslint-react/var": "npm:1.52.3" - "@typescript-eslint/scope-manager": "npm:^8.36.0" - "@typescript-eslint/type-utils": "npm:^8.36.0" - "@typescript-eslint/types": "npm:^8.36.0" - "@typescript-eslint/utils": "npm:^8.36.0" + "@eslint-react/ast": "npm:1.52.4" + "@eslint-react/eff": "npm:1.52.4" + "@eslint-react/kit": "npm:1.52.4" + "@eslint-react/shared": "npm:1.52.4" + "@eslint-react/var": "npm:1.52.4" + "@typescript-eslint/scope-manager": "npm:^8.39.1" + "@typescript-eslint/type-utils": "npm:^8.39.1" + "@typescript-eslint/types": "npm:^8.39.1" + "@typescript-eslint/utils": "npm:^8.39.1" birecord: "npm:^0.1.1" - ts-pattern: "npm:^5.7.1" - checksum: 10/1a96dd0bf6be3be84c2d0a6e601a09655b8786e28aeb07d03fbfedfc134161de280db66a88aaba701b639758053cf2bb7c55cfd885bb45740badd5890b56b794 + ts-pattern: "npm:^5.8.0" + checksum: 10/9b17ef066914c65b8a93857dd9b8b579ad60d264111dc704190c29ca4bc64066ebc4c4e012c14a070a8cc60dcdbac242ab4da8719a9405893a674fa9bedaf1c9 languageName: node linkType: hard -"@eslint-react/eff@npm:1.52.3": - version: 1.52.3 - resolution: "@eslint-react/eff@npm:1.52.3" - checksum: 10/b94d6dcdfc100104adfcc62169485de6717cf8a790e1ac93a3308c36c2be5d989712d62bbdece433a2a33ba1d93d1c763ed1374679c35cbb5a1a53e7a5356025 +"@eslint-react/eff@npm:1.52.4": + version: 1.52.4 + resolution: "@eslint-react/eff@npm:1.52.4" + checksum: 10/ad2a9277d1d5869d5c5a7c1ddefbde809ec35d2787e1e6f069a52bd33b10297979ac409c70e9e1c1d0baae5e5cac543129ef5414bfa7c0c6f1720101713d5158 languageName: node linkType: hard -"@eslint-react/eslint-plugin@npm:^1.28.0": - version: 1.52.3 - resolution: "@eslint-react/eslint-plugin@npm:1.52.3" +"@eslint-react/eslint-plugin@npm:1.52.4": + version: 1.52.4 + resolution: "@eslint-react/eslint-plugin@npm:1.52.4" dependencies: - "@eslint-react/eff": "npm:1.52.3" - "@eslint-react/kit": "npm:1.52.3" - "@eslint-react/shared": "npm:1.52.3" - "@typescript-eslint/scope-manager": "npm:^8.36.0" - "@typescript-eslint/type-utils": "npm:^8.36.0" - "@typescript-eslint/types": "npm:^8.36.0" - "@typescript-eslint/utils": "npm:^8.36.0" - eslint-plugin-react-debug: "npm:1.52.3" - eslint-plugin-react-dom: "npm:1.52.3" - eslint-plugin-react-hooks-extra: "npm:1.52.3" - eslint-plugin-react-naming-convention: "npm:1.52.3" - eslint-plugin-react-web-api: "npm:1.52.3" - eslint-plugin-react-x: "npm:1.52.3" + "@eslint-react/eff": "npm:1.52.4" + "@eslint-react/kit": "npm:1.52.4" + "@eslint-react/shared": "npm:1.52.4" + "@typescript-eslint/scope-manager": "npm:^8.39.1" + "@typescript-eslint/type-utils": "npm:^8.39.1" + "@typescript-eslint/types": "npm:^8.39.1" + "@typescript-eslint/utils": "npm:^8.39.1" + eslint-plugin-react-debug: "npm:1.52.4" + eslint-plugin-react-dom: "npm:1.52.4" + eslint-plugin-react-hooks-extra: "npm:1.52.4" + eslint-plugin-react-naming-convention: "npm:1.52.4" + eslint-plugin-react-web-api: "npm:1.52.4" + eslint-plugin-react-x: "npm:1.52.4" peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: ^4.9.5 || ^5.3.3 @@ -2342,63 +2465,65 @@ __metadata: optional: false typescript: optional: true - checksum: 10/921ed19d4fec6d1c3531042a7627082ca5fb9cf0a7e11ecc06d436cb0955f540f9e111b1495ed06638f76ce44fa83e25185bee7c6dd786d5090af623368ac338 + checksum: 10/7e60cc06d920212348d7204a14a39cd15f8d541a3f9f4a3364cb417e5a3adde39745e8ac41c9d9cc06efdebeec2f423d584cd8101e63383a2cd1790c115462f6 languageName: node linkType: hard -"@eslint-react/kit@npm:1.52.3": - version: 1.52.3 - resolution: "@eslint-react/kit@npm:1.52.3" +"@eslint-react/kit@npm:1.52.4": + version: 1.52.4 + resolution: "@eslint-react/kit@npm:1.52.4" dependencies: - "@eslint-react/eff": "npm:1.52.3" - "@typescript-eslint/utils": "npm:^8.36.0" - ts-pattern: "npm:^5.7.1" - zod: "npm:^4.0.5" - checksum: 10/8224ce791c7ff1f48fc6b989f50787c87c5dc7e99dfd10b68bf69ec5b0484192e65c69b651556a68f9de5bed2109cdd7ba98b692dc27d7916bd45637640b66f5 + "@eslint-react/eff": "npm:1.52.4" + "@typescript-eslint/utils": "npm:^8.39.1" + ts-pattern: "npm:^5.8.0" + zod: "npm:^4.0.17" + checksum: 10/17ece52d0ea0307856683519894e58066c65ac8c608afcc06167c8944c4ecd864748dd6691af26991dd2e39c7847fd8d076e016016d9ad037138f55347c170af languageName: node linkType: hard -"@eslint-react/shared@npm:1.52.3": - version: 1.52.3 - resolution: "@eslint-react/shared@npm:1.52.3" +"@eslint-react/shared@npm:1.52.4": + version: 1.52.4 + resolution: "@eslint-react/shared@npm:1.52.4" dependencies: - "@eslint-react/eff": "npm:1.52.3" - "@eslint-react/kit": "npm:1.52.3" - "@typescript-eslint/utils": "npm:^8.36.0" - ts-pattern: "npm:^5.7.1" - zod: "npm:^4.0.5" - checksum: 10/dcece228ff378620bb4b6efa5b8748c48913c4242aa4ff217467b0f38554bb480c58e9ce59dcdd16d52ddb0832998d00a908311d23a9387128acdb016de71ce2 + "@eslint-react/eff": "npm:1.52.4" + "@eslint-react/kit": "npm:1.52.4" + "@typescript-eslint/utils": "npm:^8.39.1" + ts-pattern: "npm:^5.8.0" + zod: "npm:^4.0.17" + checksum: 10/4b412baf8351f66953050a15a97ad4cd06ad3331c4895e5b28d33a35d815832c85d7968bb5268ba1874952ad4dcfc055482c751db3568649124d6c54e0738dbb languageName: node linkType: hard -"@eslint-react/var@npm:1.52.3": - version: 1.52.3 - resolution: "@eslint-react/var@npm:1.52.3" +"@eslint-react/var@npm:1.52.4": + version: 1.52.4 + resolution: "@eslint-react/var@npm:1.52.4" dependencies: - "@eslint-react/ast": "npm:1.52.3" - "@eslint-react/eff": "npm:1.52.3" - "@typescript-eslint/scope-manager": "npm:^8.36.0" - "@typescript-eslint/types": "npm:^8.36.0" - "@typescript-eslint/utils": "npm:^8.36.0" + "@eslint-react/ast": "npm:1.52.4" + "@eslint-react/eff": "npm:1.52.4" + "@typescript-eslint/scope-manager": "npm:^8.39.1" + "@typescript-eslint/types": "npm:^8.39.1" + "@typescript-eslint/utils": "npm:^8.39.1" string-ts: "npm:^2.2.1" - ts-pattern: "npm:^5.7.1" - checksum: 10/cd9862543780adcf2c9dddb51dd1210c82d95c9091606ed40a8794081181b8fb1d1ed801e2fbac16672f3eba352321563c72801e4396ebaddd4a40856fb1dfe4 + ts-pattern: "npm:^5.8.0" + checksum: 10/1c3abab4e854ff9799807a23174751c4fc67495ed93b315be8ddd4f3c137ed166fdbdd5b2712a77898265555bf6e2590424b2cec5ea00638cc30f69fb89f3325 languageName: node linkType: hard -"@eslint/compat@npm:^1.1.1": - version: 1.3.2 - resolution: "@eslint/compat@npm:1.3.2" +"@eslint/compat@npm:^1.3.2": + version: 1.4.0 + resolution: "@eslint/compat@npm:1.4.0" + dependencies: + "@eslint/core": "npm:^0.16.0" peerDependencies: eslint: ^8.40 || 9 peerDependenciesMeta: eslint: optional: true - checksum: 10/e710ec5ed89de54ec03ab4206876701e2f93d53e710a41babb105d291d8b2b076aef4e2f4dd43b8aca3fca19bb11230388fb6dea9179e510509d91ff316cb14f + checksum: 10/204f80bfde839f13bf1febe1a2de101e88ec5fdb29d9539239ccfc12b25b4edd81c2109fe642551e9ca3b8869f259d5ee08a67bbc6350ab4fde91c7231aad85b languageName: node linkType: hard -"@eslint/config-array@npm:^0.19.0, @eslint/config-array@npm:^0.19.2": +"@eslint/config-array@npm:^0.19.2": version: 0.19.2 resolution: "@eslint/config-array@npm:0.19.2" dependencies: @@ -2409,17 +2534,6 @@ __metadata: languageName: node linkType: hard -"@eslint/config-array@npm:^0.20.0": - version: 0.20.1 - resolution: "@eslint/config-array@npm:0.20.1" - dependencies: - "@eslint/object-schema": "npm:^2.1.6" - debug: "npm:^4.3.1" - minimatch: "npm:^3.1.2" - checksum: 10/d72cc90f516c5730da5f37fa04aa8ba26ea0d92c7457ee77980902158f844f3483518272ccfe16f273c3313c3bfec8da713d4e51d3da49bdeccd34e919a2b903 - languageName: node - linkType: hard - "@eslint/config-array@npm:^0.21.0": version: 0.21.0 resolution: "@eslint/config-array@npm:0.21.0" @@ -2438,22 +2552,13 @@ __metadata: languageName: node linkType: hard -"@eslint/config-helpers@npm:^0.3.0": +"@eslint/config-helpers@npm:^0.3.0, @eslint/config-helpers@npm:^0.3.1": version: 0.3.1 resolution: "@eslint/config-helpers@npm:0.3.1" checksum: 10/fc1a90ef6180aa4b5187cee04cfc566abb2a32b77ca3e7eeb4312c7388f6898221adaf8451d9ddb22e0b8860d900fefb1eb1435e4f32f8d8732de87f14605f8f languageName: node linkType: hard -"@eslint/core@npm:^0.10.0": - version: 0.10.0 - resolution: "@eslint/core@npm:0.10.0" - dependencies: - "@types/json-schema": "npm:^7.0.15" - checksum: 10/de41d7fa5dc468b70fb15c72829096939fc0217c41b8519af4620bc1089cb42539a15325c4c3ee3832facac1836c8c944c4a0c4d0cc8b33ffd8e95962278ae14 - languageName: node - linkType: hard - "@eslint/core@npm:^0.12.0": version: 0.12.0 resolution: "@eslint/core@npm:0.12.0" @@ -2481,7 +2586,16 @@ __metadata: languageName: node linkType: hard -"@eslint/eslintrc@npm:3.3.1, @eslint/eslintrc@npm:^3.2.0, @eslint/eslintrc@npm:^3.3.1": +"@eslint/core@npm:^0.16.0": + version: 0.16.0 + resolution: "@eslint/core@npm:0.16.0" + dependencies: + "@types/json-schema": "npm:^7.0.15" + checksum: 10/3cea45971b2d0114267b6101b673270b5d8047448cc7a8cbfdca0b0245e9d5e081cb25f13551dc7d55a090f98c13b33f0c4999f8ee8ab058537e6037629a0f71 + languageName: node + linkType: hard + +"@eslint/eslintrc@npm:3.3.1, @eslint/eslintrc@npm:^3.3.1": version: 3.3.1 resolution: "@eslint/eslintrc@npm:3.3.1" dependencies: @@ -2498,13 +2612,6 @@ __metadata: languageName: node linkType: hard -"@eslint/js@npm:9.19.0": - version: 9.19.0 - resolution: "@eslint/js@npm:9.19.0" - checksum: 10/d8133a83330676d5f0827713af2e9bbf35530631a93520fb59ead6b827a325c54fdd7ad99f2158f895fb393c47bbc55dfdaa945998a647f3b9230f1d5324a626 - languageName: node - linkType: hard - "@eslint/js@npm:9.23.0": version: 9.23.0 resolution: "@eslint/js@npm:9.23.0" @@ -2512,13 +2619,6 @@ __metadata: languageName: node linkType: hard -"@eslint/js@npm:9.24.0": - version: 9.24.0 - resolution: "@eslint/js@npm:9.24.0" - checksum: 10/d210114c147a1c1ebfaed5f32734e7c1f8ef551a5ea48ea67f9469668aa4079565ccd038412437bca87515d51dc9e8b8c788473dcf3d08e35dfb27e92cb3ce1b - languageName: node - linkType: hard - "@eslint/js@npm:9.32.0, @eslint/js@npm:^9.25.0": version: 9.32.0 resolution: "@eslint/js@npm:9.32.0" @@ -2526,6 +2626,13 @@ __metadata: languageName: node linkType: hard +"@eslint/js@npm:9.33.0": + version: 9.33.0 + resolution: "@eslint/js@npm:9.33.0" + checksum: 10/415031162eeee4ed67457585f3a3f3442521e75dd352932582683452c393d837da81cf9726a2cf097b444119ae2e405951e6b5d84546f67b6370fc36f27d8321 + languageName: node + linkType: hard + "@eslint/object-schema@npm:^2.1.6": version: 2.1.6 resolution: "@eslint/object-schema@npm:2.1.6" @@ -2533,7 +2640,7 @@ __metadata: languageName: node linkType: hard -"@eslint/plugin-kit@npm:^0.2.5, @eslint/plugin-kit@npm:^0.2.7": +"@eslint/plugin-kit@npm:^0.2.7": version: 0.2.8 resolution: "@eslint/plugin-kit@npm:0.2.8" dependencies: @@ -2543,7 +2650,7 @@ __metadata: languageName: node linkType: hard -"@eslint/plugin-kit@npm:^0.3.4": +"@eslint/plugin-kit@npm:^0.3.3, @eslint/plugin-kit@npm:^0.3.4, @eslint/plugin-kit@npm:^0.3.5": version: 0.3.5 resolution: "@eslint/plugin-kit@npm:0.3.5" dependencies: @@ -2597,7 +2704,7 @@ __metadata: languageName: node linkType: hard -"@humanwhocodes/retry@npm:^0.4.1, @humanwhocodes/retry@npm:^0.4.2": +"@humanwhocodes/retry@npm:^0.4.2": version: 0.4.3 resolution: "@humanwhocodes/retry@npm:0.4.3" checksum: 10/0b32cfd362bea7a30fbf80bb38dcaf77fee9c2cae477ee80b460871d03590110ac9c77d654f04ec5beaf71b6f6a89851bdf6c1e34ccdf2f686bd86fcd97d9e61 @@ -2663,233 +2770,314 @@ __metadata: languageName: node linkType: hard -"@jest/console@npm:^29.7.0": - version: 29.7.0 - resolution: "@jest/console@npm:29.7.0" +"@jest/console@npm:30.1.2": + version: 30.1.2 + resolution: "@jest/console@npm:30.1.2" dependencies: - "@jest/types": "npm:^29.6.3" + "@jest/types": "npm:30.0.5" "@types/node": "npm:*" - chalk: "npm:^4.0.0" - jest-message-util: "npm:^29.7.0" - jest-util: "npm:^29.7.0" + chalk: "npm:^4.1.2" + jest-message-util: "npm:30.1.0" + jest-util: "npm:30.0.5" slash: "npm:^3.0.0" - checksum: 10/4a80c750e8a31f344233cb9951dee9b77bf6b89377cb131f8b3cde07ff218f504370133a5963f6a786af4d2ce7f85642db206ff7a15f99fe58df4c38ac04899e + checksum: 10/11a117370700ea1a058abbbc4ef605e3a9789e0b7754a74c5e5b32d5def93446bcca77c1345f4b323669b2d3a5b84a91d21354f21e5d6f9c5dbc56bdf9df733f languageName: node linkType: hard -"@jest/core@npm:^29.7.0": - version: 29.7.0 - resolution: "@jest/core@npm:29.7.0" +"@jest/core@npm:30.1.2": + version: 30.1.2 + resolution: "@jest/core@npm:30.1.2" dependencies: - "@jest/console": "npm:^29.7.0" - "@jest/reporters": "npm:^29.7.0" - "@jest/test-result": "npm:^29.7.0" - "@jest/transform": "npm:^29.7.0" - "@jest/types": "npm:^29.6.3" + "@jest/console": "npm:30.1.2" + "@jest/pattern": "npm:30.0.1" + "@jest/reporters": "npm:30.1.2" + "@jest/test-result": "npm:30.1.2" + "@jest/transform": "npm:30.1.2" + "@jest/types": "npm:30.0.5" "@types/node": "npm:*" - ansi-escapes: "npm:^4.2.1" - chalk: "npm:^4.0.0" - ci-info: "npm:^3.2.0" - exit: "npm:^0.1.2" - graceful-fs: "npm:^4.2.9" - jest-changed-files: "npm:^29.7.0" - jest-config: "npm:^29.7.0" - jest-haste-map: "npm:^29.7.0" - jest-message-util: "npm:^29.7.0" - jest-regex-util: "npm:^29.6.3" - jest-resolve: "npm:^29.7.0" - jest-resolve-dependencies: "npm:^29.7.0" - jest-runner: "npm:^29.7.0" - jest-runtime: "npm:^29.7.0" - jest-snapshot: "npm:^29.7.0" - jest-util: "npm:^29.7.0" - jest-validate: "npm:^29.7.0" - jest-watcher: "npm:^29.7.0" - micromatch: "npm:^4.0.4" - pretty-format: "npm:^29.7.0" + ansi-escapes: "npm:^4.3.2" + chalk: "npm:^4.1.2" + ci-info: "npm:^4.2.0" + exit-x: "npm:^0.2.2" + graceful-fs: "npm:^4.2.11" + jest-changed-files: "npm:30.0.5" + jest-config: "npm:30.1.2" + jest-haste-map: "npm:30.1.0" + jest-message-util: "npm:30.1.0" + jest-regex-util: "npm:30.0.1" + jest-resolve: "npm:30.1.0" + jest-resolve-dependencies: "npm:30.1.2" + jest-runner: "npm:30.1.2" + jest-runtime: "npm:30.1.2" + jest-snapshot: "npm:30.1.2" + jest-util: "npm:30.0.5" + jest-validate: "npm:30.1.0" + jest-watcher: "npm:30.1.2" + micromatch: "npm:^4.0.8" + pretty-format: "npm:30.0.5" slash: "npm:^3.0.0" - strip-ansi: "npm:^6.0.0" peerDependencies: node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 peerDependenciesMeta: node-notifier: optional: true - checksum: 10/ab6ac2e562d083faac7d8152ec1cc4eccc80f62e9579b69ed40aedf7211a6b2d57024a6cd53c4e35fd051c39a236e86257d1d99ebdb122291969a0a04563b51e + checksum: 10/bc0743247c76ba94c6f3f96177e9e440ee5ae1156f04360d97d1a01cf013b6b91bff8c47f7ab164697086f250c002601a05b2534c752bb82bebb76e8ec9dc326 + languageName: node + linkType: hard + +"@jest/diff-sequences@npm:30.0.1": + version: 30.0.1 + resolution: "@jest/diff-sequences@npm:30.0.1" + checksum: 10/0ddb7c7ba92d6057a2ee51a9cfc2155b77cca707fe959167466ea02dcb0687018cc3c22b9622f25f3a417d6ad370e2d4dcfedf9f1410dc9c02954a7484423cc7 + languageName: node + linkType: hard + +"@jest/environment-jsdom-abstract@npm:30.1.2": + version: 30.1.2 + resolution: "@jest/environment-jsdom-abstract@npm:30.1.2" + dependencies: + "@jest/environment": "npm:30.1.2" + "@jest/fake-timers": "npm:30.1.2" + "@jest/types": "npm:30.0.5" + "@types/jsdom": "npm:^21.1.7" + "@types/node": "npm:*" + jest-mock: "npm:30.0.5" + jest-util: "npm:30.0.5" + peerDependencies: + canvas: ^3.0.0 + jsdom: "*" + peerDependenciesMeta: + canvas: + optional: true + checksum: 10/f9d0f32daaca80ed750218457d319eee0c743a3f8e30ab1b72ab9fad1c51bd297b45af65000e19feac3f0a8cb5c941043389f68194a1cc2b08b148b392f10c75 languageName: node linkType: hard -"@jest/environment@npm:^29.7.0": - version: 29.7.0 - resolution: "@jest/environment@npm:29.7.0" +"@jest/environment@npm:30.1.2": + version: 30.1.2 + resolution: "@jest/environment@npm:30.1.2" dependencies: - "@jest/fake-timers": "npm:^29.7.0" - "@jest/types": "npm:^29.6.3" + "@jest/fake-timers": "npm:30.1.2" + "@jest/types": "npm:30.0.5" "@types/node": "npm:*" - jest-mock: "npm:^29.7.0" - checksum: 10/90b5844a9a9d8097f2cf107b1b5e57007c552f64315da8c1f51217eeb0a9664889d3f145cdf8acf23a84f4d8309a6675e27d5b059659a004db0ea9546d1c81a8 + jest-mock: "npm:30.0.5" + checksum: 10/9a4be2db64b0fce1dd3222b5f7a5b8da4ed75bd6e8e058fda960f6bc68696545b8cc6f5e40de245a1635e47ced2405f1d55304e2cb581d27cc50d703fadbe9d9 + languageName: node + linkType: hard + +"@jest/expect-utils@npm:30.1.2": + version: 30.1.2 + resolution: "@jest/expect-utils@npm:30.1.2" + dependencies: + "@jest/get-type": "npm:30.1.0" + checksum: 10/89e85fe15f18a58e4c6d20c513ac22b7e7e7d62b6b4988bf89ad9a11f532226705a29d4e3d0271e835ada20f03f6a9888eaa8ce987d7e7ce2cd38c105b6f0bc6 languageName: node linkType: hard -"@jest/expect-utils@npm:^29.7.0": - version: 29.7.0 - resolution: "@jest/expect-utils@npm:29.7.0" +"@jest/expect-utils@npm:30.2.0": + version: 30.2.0 + resolution: "@jest/expect-utils@npm:30.2.0" dependencies: - jest-get-type: "npm:^29.6.3" - checksum: 10/ef8d379778ef574a17bde2801a6f4469f8022a46a5f9e385191dc73bb1fc318996beaed4513fbd7055c2847227a1bed2469977821866534593a6e52a281499ee + "@jest/get-type": "npm:30.1.0" + checksum: 10/f2442f1bceb3411240d0f16fd0074377211b4373d3b8b2dc28929e861b6527a6deb403a362c25afa511d933cda4dfbdc98d4a08eeb51ee4968f7cb0299562349 languageName: node linkType: hard -"@jest/expect@npm:^29.7.0": - version: 29.7.0 - resolution: "@jest/expect@npm:29.7.0" +"@jest/expect@npm:30.1.2": + version: 30.1.2 + resolution: "@jest/expect@npm:30.1.2" dependencies: - expect: "npm:^29.7.0" - jest-snapshot: "npm:^29.7.0" - checksum: 10/fea6c3317a8da5c840429d90bfe49d928e89c9e89fceee2149b93a11b7e9c73d2f6e4d7cdf647163da938fc4e2169e4490be6bae64952902bc7a701033fd4880 + expect: "npm:30.1.2" + jest-snapshot: "npm:30.1.2" + checksum: 10/2636008e2c72cdf5d5432cc2182d387cbf03ded77e79de015d32b5b6147c249f930c35590ba05e2e7ed6e312687c06cbb8b2ef0badc6bb2bfaa3540ebec8da74 languageName: node linkType: hard -"@jest/fake-timers@npm:^29.7.0": - version: 29.7.0 - resolution: "@jest/fake-timers@npm:29.7.0" +"@jest/fake-timers@npm:30.1.2": + version: 30.1.2 + resolution: "@jest/fake-timers@npm:30.1.2" dependencies: - "@jest/types": "npm:^29.6.3" - "@sinonjs/fake-timers": "npm:^10.0.2" + "@jest/types": "npm:30.0.5" + "@sinonjs/fake-timers": "npm:^13.0.0" "@types/node": "npm:*" - jest-message-util: "npm:^29.7.0" - jest-mock: "npm:^29.7.0" - jest-util: "npm:^29.7.0" - checksum: 10/9b394e04ffc46f91725ecfdff34c4e043eb7a16e1d78964094c9db3fde0b1c8803e45943a980e8c740d0a3d45661906de1416ca5891a538b0660481a3a828c27 + jest-message-util: "npm:30.1.0" + jest-mock: "npm:30.0.5" + jest-util: "npm:30.0.5" + checksum: 10/b338ebb87ed1d9b8617ad0bb7b8994543b1423f1313537c6858cdbe86d0f3a9c7f27b687ef959a57202d9d589bcfb57db84e249e4d7b4a23d52015a0de7962b8 languageName: node linkType: hard -"@jest/globals@npm:^29.7.0": - version: 29.7.0 - resolution: "@jest/globals@npm:29.7.0" +"@jest/get-type@npm:30.1.0": + version: 30.1.0 + resolution: "@jest/get-type@npm:30.1.0" + checksum: 10/e2a95fbb49ce2d15547db8af5602626caf9b05f62a5e583b4a2de9bd93a2bfe7175f9bbb2b8a5c3909ce261d467b6991d7265bb1d547cb60e7e97f571f361a70 + languageName: node + linkType: hard + +"@jest/globals@npm:30.1.2": + version: 30.1.2 + resolution: "@jest/globals@npm:30.1.2" + dependencies: + "@jest/environment": "npm:30.1.2" + "@jest/expect": "npm:30.1.2" + "@jest/types": "npm:30.0.5" + jest-mock: "npm:30.0.5" + checksum: 10/5896b0f85d3735199af8ba47d9adaddc290d2f0fdb99afd23893a0d4a9e6855514b2555ed3f379bd13d84e026be05132dbb90af8bc2393e97c3847efa6d25ee5 + languageName: node + linkType: hard + +"@jest/pattern@npm:30.0.1": + version: 30.0.1 + resolution: "@jest/pattern@npm:30.0.1" dependencies: - "@jest/environment": "npm:^29.7.0" - "@jest/expect": "npm:^29.7.0" - "@jest/types": "npm:^29.6.3" - jest-mock: "npm:^29.7.0" - checksum: 10/97dbb9459135693ad3a422e65ca1c250f03d82b2a77f6207e7fa0edd2c9d2015fbe4346f3dc9ebff1678b9d8da74754d4d440b7837497f8927059c0642a22123 + "@types/node": "npm:*" + jest-regex-util: "npm:30.0.1" + checksum: 10/afd03b4d3eadc9c9970cf924955dee47984a7e767901fe6fa463b17b246f0ddeec07b3e82c09715c54bde3c8abb92074160c0d79967bd23778724f184e7f5b7b languageName: node linkType: hard -"@jest/reporters@npm:^29.7.0": - version: 29.7.0 - resolution: "@jest/reporters@npm:29.7.0" +"@jest/reporters@npm:30.1.2": + version: 30.1.2 + resolution: "@jest/reporters@npm:30.1.2" dependencies: "@bcoe/v8-coverage": "npm:^0.2.3" - "@jest/console": "npm:^29.7.0" - "@jest/test-result": "npm:^29.7.0" - "@jest/transform": "npm:^29.7.0" - "@jest/types": "npm:^29.6.3" - "@jridgewell/trace-mapping": "npm:^0.3.18" + "@jest/console": "npm:30.1.2" + "@jest/test-result": "npm:30.1.2" + "@jest/transform": "npm:30.1.2" + "@jest/types": "npm:30.0.5" + "@jridgewell/trace-mapping": "npm:^0.3.25" "@types/node": "npm:*" - chalk: "npm:^4.0.0" - collect-v8-coverage: "npm:^1.0.0" - exit: "npm:^0.1.2" - glob: "npm:^7.1.3" - graceful-fs: "npm:^4.2.9" + chalk: "npm:^4.1.2" + collect-v8-coverage: "npm:^1.0.2" + exit-x: "npm:^0.2.2" + glob: "npm:^10.3.10" + graceful-fs: "npm:^4.2.11" istanbul-lib-coverage: "npm:^3.0.0" istanbul-lib-instrument: "npm:^6.0.0" istanbul-lib-report: "npm:^3.0.0" - istanbul-lib-source-maps: "npm:^4.0.0" + istanbul-lib-source-maps: "npm:^5.0.0" istanbul-reports: "npm:^3.1.3" - jest-message-util: "npm:^29.7.0" - jest-util: "npm:^29.7.0" - jest-worker: "npm:^29.7.0" + jest-message-util: "npm:30.1.0" + jest-util: "npm:30.0.5" + jest-worker: "npm:30.1.0" slash: "npm:^3.0.0" - string-length: "npm:^4.0.1" - strip-ansi: "npm:^6.0.0" + string-length: "npm:^4.0.2" v8-to-istanbul: "npm:^9.0.1" peerDependencies: node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 peerDependenciesMeta: node-notifier: optional: true - checksum: 10/a17d1644b26dea14445cedd45567f4ba7834f980be2ef74447204e14238f121b50d8b858fde648083d2cd8f305f81ba434ba49e37a5f4237a6f2a61180cc73dc + checksum: 10/1eabf8c8e1ef49259a091067c9b3763713cadee348f36f546792f3c9d71d955283c3216e5a969eb9e9a1462a0c78b8d577bfe74f6e018a7e8a195b178eb7dfda languageName: node linkType: hard -"@jest/schemas@npm:^29.6.3": - version: 29.6.3 - resolution: "@jest/schemas@npm:29.6.3" +"@jest/schemas@npm:30.0.5": + version: 30.0.5 + resolution: "@jest/schemas@npm:30.0.5" dependencies: - "@sinclair/typebox": "npm:^0.27.8" - checksum: 10/910040425f0fc93cd13e68c750b7885590b8839066dfa0cd78e7def07bbb708ad869381f725945d66f2284de5663bbecf63e8fdd856e2ae6e261ba30b1687e93 + "@sinclair/typebox": "npm:^0.34.0" + checksum: 10/40df4db55d4aeed09d1c7e19caf23788309cea34490a1c5d584c913494195e698b9967e996afc27226cac6d76e7512fe73ae6b9584480695c60dd18a5459cdba languageName: node linkType: hard -"@jest/source-map@npm:^29.6.3": - version: 29.6.3 - resolution: "@jest/source-map@npm:29.6.3" +"@jest/snapshot-utils@npm:30.1.2": + version: 30.1.2 + resolution: "@jest/snapshot-utils@npm:30.1.2" dependencies: - "@jridgewell/trace-mapping": "npm:^0.3.18" - callsites: "npm:^3.0.0" - graceful-fs: "npm:^4.2.9" - checksum: 10/bcc5a8697d471396c0003b0bfa09722c3cd879ad697eb9c431e6164e2ea7008238a01a07193dfe3cbb48b1d258eb7251f6efcea36f64e1ebc464ea3c03ae2deb + "@jest/types": "npm:30.0.5" + chalk: "npm:^4.1.2" + graceful-fs: "npm:^4.2.11" + natural-compare: "npm:^1.4.0" + checksum: 10/e9e7d57d3b8935aa278a295d9f9f7bf7cfca3a66612ba0f7a31f7c8db447bfd0419521eebb17a5d53c479fb1c30c7a38309efec7f058b00e715f0c87a8f5449c + languageName: node + linkType: hard + +"@jest/source-map@npm:30.0.1": + version: 30.0.1 + resolution: "@jest/source-map@npm:30.0.1" + dependencies: + "@jridgewell/trace-mapping": "npm:^0.3.25" + callsites: "npm:^3.1.0" + graceful-fs: "npm:^4.2.11" + checksum: 10/161b27cdf8d9d80fd99374d55222b90478864c6990514be6ebee72b7184a034224c9aceed12c476f3a48d48601bf8ed2e0c047a5a81bd907dc192ebe71365ed4 languageName: node linkType: hard -"@jest/test-result@npm:^29.7.0": - version: 29.7.0 - resolution: "@jest/test-result@npm:29.7.0" +"@jest/test-result@npm:30.1.2": + version: 30.1.2 + resolution: "@jest/test-result@npm:30.1.2" dependencies: - "@jest/console": "npm:^29.7.0" - "@jest/types": "npm:^29.6.3" - "@types/istanbul-lib-coverage": "npm:^2.0.0" - collect-v8-coverage: "npm:^1.0.0" - checksum: 10/c073ab7dfe3c562bff2b8fee6cc724ccc20aa96bcd8ab48ccb2aa309b4c0c1923a9e703cea386bd6ae9b71133e92810475bb9c7c22328fc63f797ad3324ed189 + "@jest/console": "npm:30.1.2" + "@jest/types": "npm:30.0.5" + "@types/istanbul-lib-coverage": "npm:^2.0.6" + collect-v8-coverage: "npm:^1.0.2" + checksum: 10/e655938e57a3522303df496eff5733103bdba8aed11a33f851aafa8a0608b0ca654f7a21b48af6bc427f7b3ed693147cafea5ae41b31d88fc1f65392a1225b2d languageName: node linkType: hard -"@jest/test-sequencer@npm:^29.7.0": - version: 29.7.0 - resolution: "@jest/test-sequencer@npm:29.7.0" +"@jest/test-sequencer@npm:30.1.2": + version: 30.1.2 + resolution: "@jest/test-sequencer@npm:30.1.2" dependencies: - "@jest/test-result": "npm:^29.7.0" - graceful-fs: "npm:^4.2.9" - jest-haste-map: "npm:^29.7.0" + "@jest/test-result": "npm:30.1.2" + graceful-fs: "npm:^4.2.11" + jest-haste-map: "npm:30.1.0" slash: "npm:^3.0.0" - checksum: 10/4420c26a0baa7035c5419b0892ff8ffe9a41b1583ec54a10db3037cd46a7e29dd3d7202f8aa9d376e9e53be5f8b1bc0d16e1de6880a6d319b033b01dc4c8f639 + checksum: 10/496cf2cb9301e63274f099326e98214dc47fe2533fdafb9ca0746e15a604fd117af8f9dd45977ca6e1f5c2fbc1bf9fe9a49b6baa287cb89d196baa36e2437144 languageName: node linkType: hard -"@jest/transform@npm:^29.7.0": - version: 29.7.0 - resolution: "@jest/transform@npm:29.7.0" +"@jest/transform@npm:30.1.2": + version: 30.1.2 + resolution: "@jest/transform@npm:30.1.2" dependencies: - "@babel/core": "npm:^7.11.6" - "@jest/types": "npm:^29.6.3" - "@jridgewell/trace-mapping": "npm:^0.3.18" - babel-plugin-istanbul: "npm:^6.1.1" - chalk: "npm:^4.0.0" + "@babel/core": "npm:^7.27.4" + "@jest/types": "npm:30.0.5" + "@jridgewell/trace-mapping": "npm:^0.3.25" + babel-plugin-istanbul: "npm:^7.0.0" + chalk: "npm:^4.1.2" convert-source-map: "npm:^2.0.0" fast-json-stable-stringify: "npm:^2.1.0" - graceful-fs: "npm:^4.2.9" - jest-haste-map: "npm:^29.7.0" - jest-regex-util: "npm:^29.6.3" - jest-util: "npm:^29.7.0" - micromatch: "npm:^4.0.4" - pirates: "npm:^4.0.4" + graceful-fs: "npm:^4.2.11" + jest-haste-map: "npm:30.1.0" + jest-regex-util: "npm:30.0.1" + jest-util: "npm:30.0.5" + micromatch: "npm:^4.0.8" + pirates: "npm:^4.0.7" slash: "npm:^3.0.0" - write-file-atomic: "npm:^4.0.2" - checksum: 10/30f42293545ab037d5799c81d3e12515790bb58513d37f788ce32d53326d0d72ebf5b40f989e6896739aa50a5f77be44686e510966370d58511d5ad2637c68c1 + write-file-atomic: "npm:^5.0.1" + checksum: 10/aec6c6a46fbce1cc1d60b7f4d29bbdf69db9d1f4003f75754efe601d26db667b3fb445c337be22acfb7de797cae4ab11d61221cc6fff3aa4e2c1d4d0d61ecb6c languageName: node linkType: hard -"@jest/types@npm:^29.6.3": - version: 29.6.3 - resolution: "@jest/types@npm:29.6.3" +"@jest/types@npm:30.0.5": + version: 30.0.5 + resolution: "@jest/types@npm:30.0.5" dependencies: - "@jest/schemas": "npm:^29.6.3" - "@types/istanbul-lib-coverage": "npm:^2.0.0" - "@types/istanbul-reports": "npm:^3.0.0" + "@jest/pattern": "npm:30.0.1" + "@jest/schemas": "npm:30.0.5" + "@types/istanbul-lib-coverage": "npm:^2.0.6" + "@types/istanbul-reports": "npm:^3.0.4" "@types/node": "npm:*" - "@types/yargs": "npm:^17.0.8" - chalk: "npm:^4.0.0" - checksum: 10/f74bf512fd09bbe2433a2ad460b04668b7075235eea9a0c77d6a42222c10a79b9747dc2b2a623f140ed40d6865a2ed8f538f3cbb75169120ea863f29a7ed76cd + "@types/yargs": "npm:^17.0.33" + chalk: "npm:^4.1.2" + checksum: 10/6bf18f4e899ff9cf6bd88b1e3348aeb944db4d5ad7e9c683bb0188daeb2d11d10a1463a4dc494e92145eafcbc2656fe31adb6f1fd0bf5928cf73ddc8b2f215bf + languageName: node + linkType: hard + +"@jest/types@npm:30.2.0": + version: 30.2.0 + resolution: "@jest/types@npm:30.2.0" + dependencies: + "@jest/pattern": "npm:30.0.1" + "@jest/schemas": "npm:30.0.5" + "@types/istanbul-lib-coverage": "npm:^2.0.6" + "@types/istanbul-reports": "npm:^3.0.4" + "@types/node": "npm:*" + "@types/yargs": "npm:^17.0.33" + chalk: "npm:^4.1.2" + checksum: 10/f50fcaea56f873a51d19254ab16762f2ea8ca88e3e08da2e496af5da2b67c322915a4fcd0153803cc05063ffe87ebef2ab4330e0a1b06ab984a26c916cbfc26b languageName: node linkType: hard @@ -3224,28 +3412,28 @@ __metadata: version: 0.0.0-use.local resolution: "@joint/react-eslint@workspace:packages/joint-react-eslint" dependencies: - "@eslint-react/eslint-plugin": "npm:^1.28.0" - "@eslint/compat": "npm:^1.1.1" - "@eslint/js": "npm:9.24.0" - "@stylistic/eslint-plugin": "npm:4.2.0" - "@stylistic/eslint-plugin-jsx": "npm:4.2.0" - "@stylistic/eslint-plugin-ts": "npm:4.2.0" - "@typescript-eslint/eslint-plugin": "npm:8.29.0" - "@typescript-eslint/parser": "npm:8.29.0" - eslint: "npm:9.24.0" - eslint-plugin-depend: "npm:0.12.0" - eslint-plugin-jest: "npm:^28.8.3" - eslint-plugin-jsdoc: "npm:^50.6.9" - eslint-plugin-react: "npm:^7.37.4" - eslint-plugin-react-hooks: "npm:5.1.0" + "@eslint-react/eslint-plugin": "npm:1.52.4" + "@eslint/compat": "npm:^1.3.2" + "@eslint/js": "npm:9.33.0" + "@stylistic/eslint-plugin": "npm:5.2.3" + "@stylistic/eslint-plugin-jsx": "npm:4.4.1" + "@stylistic/eslint-plugin-ts": "npm:4.4.1" + "@typescript-eslint/eslint-plugin": "npm:8.39.1" + "@typescript-eslint/parser": "npm:8.39.1" + eslint: "npm:9.33.0" + eslint-plugin-depend: "npm:1.2.0" + eslint-plugin-jest: "npm:29.0.1" + eslint-plugin-jsdoc: "npm:54.0.0" + eslint-plugin-react: "npm:7.37.5" + eslint-plugin-react-hooks: "npm:5.2.0" eslint-plugin-react-perf: "npm:^3.3.3" eslint-plugin-security: "npm:3.0.1" - eslint-plugin-sonarjs: "npm:3.0.2" - eslint-plugin-unicorn: "npm:58.0.0" - typescript-eslint: "npm:8.29.0" + eslint-plugin-sonarjs: "npm:3.0.4" + eslint-plugin-unicorn: "npm:60.0.0" + typescript-eslint: "npm:8.39.1" peerDependencies: - eslint: 9.24.0 - typescript: ^5.0.0 + eslint: 9.33.0 + typescript: ^5.9.2 languageName: unknown linkType: soft @@ -3258,6 +3446,7 @@ __metadata: "@joint/core": "workspace:*" "@joint/layout-directed-graph": "workspace:*" "@joint/react-eslint": "npm:*" + "@reduxjs/toolkit": "npm:^2.8.2" "@storybook/addon-a11y": "npm:^8.6.12" "@storybook/addon-docs": "npm:8.6.12" "@storybook/addon-essentials": "npm:8.6.12" @@ -3272,27 +3461,31 @@ __metadata: "@testing-library/jest-dom": "npm:^6.6.3" "@testing-library/react": "npm:^16.0.1" "@testing-library/react-hooks": "npm:^8.0.1" - "@types/jest": "npm:29.5.14" - "@types/react": "npm:19.0.8" - "@types/react-dom": "npm:^19.0.3" - "@types/react-test-renderer": "npm:19.0.0" - "@types/use-sync-external-store": "npm:^0.0.6" - "@vitejs/plugin-react": "npm:^4.3.4" - "@welldone-software/why-did-you-render": "npm:8" + "@types/jest": "npm:30.0.0" + "@types/node": "npm:^24.3.0" + "@types/react": "npm:19.1.12" + "@types/react-dom": "npm:19.1.9" + "@types/react-test-renderer": "npm:19.1.0" + "@types/use-sync-external-store": "npm:1.5.0" + "@vitejs/plugin-react": "npm:^5.0.2" + "@welldone-software/why-did-you-render": "npm:10.0.1" canvas: "npm:^3.1.0" - eslint: "npm:9.19.0" + eslint: "npm:9.33.0" glob: "npm:^11.0.1" - jest: "npm:^29.7.0" - jest-environment-jsdom: "npm:^29.7.0" + jest: "npm:30.1.2" + jest-environment-jsdom: "npm:30.1.2" + knip: "npm:5.63.0" prettier: "npm:3.3.3" prettier-plugin-tailwindcss: "npm:^0.6.5" - react: "npm:18.x" + react: "npm:^19.1.1" react-docgen-typescript-plugin: "npm:^1.0.8" - react-dom: "npm:18.x" - react-test-renderer: "npm:19.0.0" - storybook: "npm:^8.6.12" - storybook-addon-performance: "npm:^0.17.3" - storybook-multilevel-sort: "npm:^2.0.1" + react-dom: "npm:^19.1.1" + react-redux: "npm:^9.2.0" + react-test-renderer: "npm:^19.1.1" + redux: "npm:^5.0.1" + storybook: "npm:8.6.14" + storybook-addon-performance: "npm:0.17.3" + storybook-multilevel-sort: "npm:2.0.1" ts-jest: "npm:^29.2.5" ts-node: "npm:^10.9.2" typedoc: "npm:^0.28.5" @@ -3300,9 +3493,10 @@ __metadata: typedoc-plugin-external-module-name: "npm:^4.0.6" typedoc-plugin-markdown: "npm:^4.6.4" typedoc-plugin-mdn-links: "npm:5.0.1" - typescript: "npm:5.7.3" + typescript: "npm:^5.9.2" use-sync-external-store: "npm:^1.4.0" - vite-plugin-md: "npm:^0.21.5" + vite-plugin-md: "npm:0.22.5" + vite-plugin-node-polyfills: "npm:^0.24.0" vite-tsconfig-paths: "npm:^5.1.4" vitest: "npm:^3.0.4" peerDependencies: @@ -3342,8 +3536,8 @@ __metadata: "@testing-library/react": "npm:^16.1.0" "@tsconfig/node18": "npm:^18.2.0" "@types/node": "npm:^24.2.0" - "@types/react": "npm:^19.1.9" - "@types/react-dom": "npm:^19.1.7" + "@types/react": "npm:19.1.12" + "@types/react-dom": "npm:19.1.9" "@vitejs/plugin-react": "npm:^4.3.4" "@vitejs/plugin-vue": "npm:^5.2.4" "@vue/eslint-config-typescript": "npm:^14.5.0" @@ -3351,8 +3545,8 @@ __metadata: eslint: "npm:^9.0.0" eslint-plugin-vue: "npm:^9.24.0" jsdom: "npm:^26.0.0" - react: "npm:^18.2.0" - react-dom: "npm:^18.2.0" + react: "npm:19.1.1" + react-dom: "npm:19.1.1" rollup: "npm:4.36.0" sass: "npm:^1.66.1" typescript: "npm:5.8.2" @@ -3390,6 +3584,16 @@ __metadata: languageName: node linkType: hard +"@jridgewell/remapping@npm:^2.3.5": + version: 2.3.5 + resolution: "@jridgewell/remapping@npm:2.3.5" + dependencies: + "@jridgewell/gen-mapping": "npm:^0.3.5" + "@jridgewell/trace-mapping": "npm:^0.3.24" + checksum: 10/c2bb01856e65b506d439455f28aceacf130d6c023d1d4e3b48705e88def3571753e1a887daa04b078b562316c92d26ce36408a60534bceca3f830aec88a339ad + languageName: node + linkType: hard + "@jridgewell/resolve-uri@npm:^3.0.3, @jridgewell/resolve-uri@npm:^3.1.0": version: 3.1.2 resolution: "@jridgewell/resolve-uri@npm:3.1.2" @@ -3414,6 +3618,13 @@ __metadata: languageName: node linkType: hard +"@jridgewell/sourcemap-codec@npm:^1.5.5": + version: 1.5.5 + resolution: "@jridgewell/sourcemap-codec@npm:1.5.5" + checksum: 10/5d9d207b462c11e322d71911e55e21a4e2772f71ffe8d6f1221b8eb5ae6774458c1d242f897fb0814e8714ca9a6b498abfa74dfe4f434493342902b1a48b33a5 + languageName: node + linkType: hard + "@jridgewell/trace-mapping@npm:0.3.9": version: 0.3.9 resolution: "@jridgewell/trace-mapping@npm:0.3.9" @@ -3424,7 +3635,7 @@ __metadata: languageName: node linkType: hard -"@jridgewell/trace-mapping@npm:^0.3.12, @jridgewell/trace-mapping@npm:^0.3.18, @jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.25, @jridgewell/trace-mapping@npm:^0.3.28": +"@jridgewell/trace-mapping@npm:^0.3.12, @jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.25, @jridgewell/trace-mapping@npm:^0.3.28": version: 0.3.29 resolution: "@jridgewell/trace-mapping@npm:0.3.29" dependencies: @@ -3434,6 +3645,16 @@ __metadata: languageName: node linkType: hard +"@jridgewell/trace-mapping@npm:^0.3.23": + version: 0.3.31 + resolution: "@jridgewell/trace-mapping@npm:0.3.31" + dependencies: + "@jridgewell/resolve-uri": "npm:^3.1.0" + "@jridgewell/sourcemap-codec": "npm:^1.4.14" + checksum: 10/da0283270e691bdb5543806077548532791608e52386cfbbf3b9e8fb00457859d1bd01d512851161c886eb3a2f3ce6fd9bcf25db8edf3bddedd275bd4a88d606 + languageName: node + linkType: hard + "@jsdevtools/ez-spawn@npm:^3.0.4": version: 3.0.4 resolution: "@jsdevtools/ez-spawn@npm:3.0.4" @@ -3585,6 +3806,28 @@ __metadata: languageName: node linkType: hard +"@napi-rs/wasm-runtime@npm:^0.2.11": + version: 0.2.12 + resolution: "@napi-rs/wasm-runtime@npm:0.2.12" + dependencies: + "@emnapi/core": "npm:^1.4.3" + "@emnapi/runtime": "npm:^1.4.3" + "@tybys/wasm-util": "npm:^0.10.0" + checksum: 10/5fd518182427980c28bc724adf06c5f32f9a8915763ef560b5f7d73607d30cd15ac86d0cbd2eb80d4cfab23fc80d0876d89ca36a9daadcb864bc00917c94187c + languageName: node + linkType: hard + +"@napi-rs/wasm-runtime@npm:^1.0.5": + version: 1.0.7 + resolution: "@napi-rs/wasm-runtime@npm:1.0.7" + dependencies: + "@emnapi/core": "npm:^1.5.0" + "@emnapi/runtime": "npm:^1.5.0" + "@tybys/wasm-util": "npm:^0.10.1" + checksum: 10/6bc32d32d486d07b83220a9b7b2b715e39acacbacef0011ebca05c00b41d80a0535123da10fea7a7d6d7e206712bb50dc50ac3cf88b770754d44378570fb5c05 + languageName: node + linkType: hard + "@nodelib/fs.scandir@npm:2.1.5": version: 2.1.5 resolution: "@nodelib/fs.scandir@npm:2.1.5" @@ -3641,102 +3884,237 @@ __metadata: languageName: node linkType: hard -"@parcel/watcher-android-arm64@npm:2.5.1": - version: 2.5.1 - resolution: "@parcel/watcher-android-arm64@npm:2.5.1" +"@oxc-resolver/binding-android-arm-eabi@npm:11.9.0": + version: 11.9.0 + resolution: "@oxc-resolver/binding-android-arm-eabi@npm:11.9.0" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + +"@oxc-resolver/binding-android-arm64@npm:11.9.0": + version: 11.9.0 + resolution: "@oxc-resolver/binding-android-arm64@npm:11.9.0" conditions: os=android & cpu=arm64 languageName: node linkType: hard -"@parcel/watcher-darwin-arm64@npm:2.5.1": - version: 2.5.1 - resolution: "@parcel/watcher-darwin-arm64@npm:2.5.1" +"@oxc-resolver/binding-darwin-arm64@npm:11.9.0": + version: 11.9.0 + resolution: "@oxc-resolver/binding-darwin-arm64@npm:11.9.0" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@parcel/watcher-darwin-x64@npm:2.5.1": - version: 2.5.1 - resolution: "@parcel/watcher-darwin-x64@npm:2.5.1" +"@oxc-resolver/binding-darwin-x64@npm:11.9.0": + version: 11.9.0 + resolution: "@oxc-resolver/binding-darwin-x64@npm:11.9.0" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@parcel/watcher-freebsd-x64@npm:2.5.1": - version: 2.5.1 - resolution: "@parcel/watcher-freebsd-x64@npm:2.5.1" +"@oxc-resolver/binding-freebsd-x64@npm:11.9.0": + version: 11.9.0 + resolution: "@oxc-resolver/binding-freebsd-x64@npm:11.9.0" conditions: os=freebsd & cpu=x64 languageName: node linkType: hard -"@parcel/watcher-linux-arm-glibc@npm:2.5.1": - version: 2.5.1 - resolution: "@parcel/watcher-linux-arm-glibc@npm:2.5.1" - conditions: os=linux & cpu=arm & libc=glibc +"@oxc-resolver/binding-linux-arm-gnueabihf@npm:11.9.0": + version: 11.9.0 + resolution: "@oxc-resolver/binding-linux-arm-gnueabihf@npm:11.9.0" + conditions: os=linux & cpu=arm languageName: node linkType: hard -"@parcel/watcher-linux-arm-musl@npm:2.5.1": - version: 2.5.1 - resolution: "@parcel/watcher-linux-arm-musl@npm:2.5.1" - conditions: os=linux & cpu=arm & libc=musl +"@oxc-resolver/binding-linux-arm-musleabihf@npm:11.9.0": + version: 11.9.0 + resolution: "@oxc-resolver/binding-linux-arm-musleabihf@npm:11.9.0" + conditions: os=linux & cpu=arm languageName: node linkType: hard -"@parcel/watcher-linux-arm64-glibc@npm:2.5.1": - version: 2.5.1 - resolution: "@parcel/watcher-linux-arm64-glibc@npm:2.5.1" +"@oxc-resolver/binding-linux-arm64-gnu@npm:11.9.0": + version: 11.9.0 + resolution: "@oxc-resolver/binding-linux-arm64-gnu@npm:11.9.0" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@parcel/watcher-linux-arm64-musl@npm:2.5.1": - version: 2.5.1 - resolution: "@parcel/watcher-linux-arm64-musl@npm:2.5.1" +"@oxc-resolver/binding-linux-arm64-musl@npm:11.9.0": + version: 11.9.0 + resolution: "@oxc-resolver/binding-linux-arm64-musl@npm:11.9.0" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@parcel/watcher-linux-x64-glibc@npm:2.5.1": - version: 2.5.1 - resolution: "@parcel/watcher-linux-x64-glibc@npm:2.5.1" - conditions: os=linux & cpu=x64 & libc=glibc +"@oxc-resolver/binding-linux-ppc64-gnu@npm:11.9.0": + version: 11.9.0 + resolution: "@oxc-resolver/binding-linux-ppc64-gnu@npm:11.9.0" + conditions: os=linux & cpu=ppc64 & libc=glibc languageName: node linkType: hard -"@parcel/watcher-linux-x64-musl@npm:2.5.1": - version: 2.5.1 - resolution: "@parcel/watcher-linux-x64-musl@npm:2.5.1" - conditions: os=linux & cpu=x64 & libc=musl +"@oxc-resolver/binding-linux-riscv64-gnu@npm:11.9.0": + version: 11.9.0 + resolution: "@oxc-resolver/binding-linux-riscv64-gnu@npm:11.9.0" + conditions: os=linux & cpu=riscv64 & libc=glibc languageName: node linkType: hard -"@parcel/watcher-win32-arm64@npm:2.5.1": - version: 2.5.1 - resolution: "@parcel/watcher-win32-arm64@npm:2.5.1" - conditions: os=win32 & cpu=arm64 +"@oxc-resolver/binding-linux-riscv64-musl@npm:11.9.0": + version: 11.9.0 + resolution: "@oxc-resolver/binding-linux-riscv64-musl@npm:11.9.0" + conditions: os=linux & cpu=riscv64 & libc=musl languageName: node linkType: hard -"@parcel/watcher-win32-ia32@npm:2.5.1": - version: 2.5.1 - resolution: "@parcel/watcher-win32-ia32@npm:2.5.1" - conditions: os=win32 & cpu=ia32 +"@oxc-resolver/binding-linux-s390x-gnu@npm:11.9.0": + version: 11.9.0 + resolution: "@oxc-resolver/binding-linux-s390x-gnu@npm:11.9.0" + conditions: os=linux & cpu=s390x & libc=glibc languageName: node linkType: hard -"@parcel/watcher-win32-x64@npm:2.5.1": - version: 2.5.1 - resolution: "@parcel/watcher-win32-x64@npm:2.5.1" - conditions: os=win32 & cpu=x64 +"@oxc-resolver/binding-linux-x64-gnu@npm:11.9.0": + version: 11.9.0 + resolution: "@oxc-resolver/binding-linux-x64-gnu@npm:11.9.0" + conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@parcel/watcher@npm:^2.4.1": - version: 2.5.1 - resolution: "@parcel/watcher@npm:2.5.1" - dependencies: - "@parcel/watcher-android-arm64": "npm:2.5.1" +"@oxc-resolver/binding-linux-x64-musl@npm:11.9.0": + version: 11.9.0 + resolution: "@oxc-resolver/binding-linux-x64-musl@npm:11.9.0" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@oxc-resolver/binding-wasm32-wasi@npm:11.9.0": + version: 11.9.0 + resolution: "@oxc-resolver/binding-wasm32-wasi@npm:11.9.0" + dependencies: + "@napi-rs/wasm-runtime": "npm:^1.0.5" + conditions: cpu=wasm32 + languageName: node + linkType: hard + +"@oxc-resolver/binding-win32-arm64-msvc@npm:11.9.0": + version: 11.9.0 + resolution: "@oxc-resolver/binding-win32-arm64-msvc@npm:11.9.0" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@oxc-resolver/binding-win32-ia32-msvc@npm:11.9.0": + version: 11.9.0 + resolution: "@oxc-resolver/binding-win32-ia32-msvc@npm:11.9.0" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@oxc-resolver/binding-win32-x64-msvc@npm:11.9.0": + version: 11.9.0 + resolution: "@oxc-resolver/binding-win32-x64-msvc@npm:11.9.0" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@parcel/watcher-android-arm64@npm:2.5.1": + version: 2.5.1 + resolution: "@parcel/watcher-android-arm64@npm:2.5.1" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@parcel/watcher-darwin-arm64@npm:2.5.1": + version: 2.5.1 + resolution: "@parcel/watcher-darwin-arm64@npm:2.5.1" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@parcel/watcher-darwin-x64@npm:2.5.1": + version: 2.5.1 + resolution: "@parcel/watcher-darwin-x64@npm:2.5.1" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@parcel/watcher-freebsd-x64@npm:2.5.1": + version: 2.5.1 + resolution: "@parcel/watcher-freebsd-x64@npm:2.5.1" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@parcel/watcher-linux-arm-glibc@npm:2.5.1": + version: 2.5.1 + resolution: "@parcel/watcher-linux-arm-glibc@npm:2.5.1" + conditions: os=linux & cpu=arm & libc=glibc + languageName: node + linkType: hard + +"@parcel/watcher-linux-arm-musl@npm:2.5.1": + version: 2.5.1 + resolution: "@parcel/watcher-linux-arm-musl@npm:2.5.1" + conditions: os=linux & cpu=arm & libc=musl + languageName: node + linkType: hard + +"@parcel/watcher-linux-arm64-glibc@npm:2.5.1": + version: 2.5.1 + resolution: "@parcel/watcher-linux-arm64-glibc@npm:2.5.1" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@parcel/watcher-linux-arm64-musl@npm:2.5.1": + version: 2.5.1 + resolution: "@parcel/watcher-linux-arm64-musl@npm:2.5.1" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@parcel/watcher-linux-x64-glibc@npm:2.5.1": + version: 2.5.1 + resolution: "@parcel/watcher-linux-x64-glibc@npm:2.5.1" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@parcel/watcher-linux-x64-musl@npm:2.5.1": + version: 2.5.1 + resolution: "@parcel/watcher-linux-x64-musl@npm:2.5.1" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@parcel/watcher-win32-arm64@npm:2.5.1": + version: 2.5.1 + resolution: "@parcel/watcher-win32-arm64@npm:2.5.1" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@parcel/watcher-win32-ia32@npm:2.5.1": + version: 2.5.1 + resolution: "@parcel/watcher-win32-ia32@npm:2.5.1" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@parcel/watcher-win32-x64@npm:2.5.1": + version: 2.5.1 + resolution: "@parcel/watcher-win32-x64@npm:2.5.1" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@parcel/watcher@npm:^2.4.1": + version: 2.5.1 + resolution: "@parcel/watcher@npm:2.5.1" + dependencies: + "@parcel/watcher-android-arm64": "npm:2.5.1" "@parcel/watcher-darwin-arm64": "npm:2.5.1" "@parcel/watcher-darwin-x64": "npm:2.5.1" "@parcel/watcher-freebsd-x64": "npm:2.5.1" @@ -3792,6 +4170,13 @@ __metadata: languageName: node linkType: hard +"@pkgr/core@npm:^0.2.9": + version: 0.2.9 + resolution: "@pkgr/core@npm:0.2.9" + checksum: 10/bb2fb86977d63f836f8f5b09015d74e6af6488f7a411dcd2bfdca79d76b5a681a9112f41c45bdf88a9069f049718efc6f3900d7f1de66a2ec966068308ae517f + languageName: node + linkType: hard + "@polka/url@npm:^1.0.0-next.20": version: 1.0.0-next.29 resolution: "@polka/url@npm:1.0.0-next.29" @@ -3865,6 +4250,28 @@ __metadata: languageName: node linkType: hard +"@reduxjs/toolkit@npm:^2.8.2": + version: 2.9.0 + resolution: "@reduxjs/toolkit@npm:2.9.0" + dependencies: + "@standard-schema/spec": "npm:^1.0.0" + "@standard-schema/utils": "npm:^0.3.0" + immer: "npm:^10.0.3" + redux: "npm:^5.0.1" + redux-thunk: "npm:^3.1.0" + reselect: "npm:^5.1.0" + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 || ^19 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + checksum: 10/d2f1b59c96638d9a36c3709f31f24ec1ce25604f2010eebb89506e6782142dfa3a97bd64f6b4650d9230d1a8a7f09a2dfed5ee2eb7a4454cbeb08fcb8cc6cf36 + languageName: node + linkType: hard + "@rolldown/pluginutils@npm:1.0.0-beta.27": version: 1.0.0-beta.27 resolution: "@rolldown/pluginutils@npm:1.0.0-beta.27" @@ -3872,6 +4279,13 @@ __metadata: languageName: node linkType: hard +"@rolldown/pluginutils@npm:1.0.0-beta.38": + version: 1.0.0-beta.38 + resolution: "@rolldown/pluginutils@npm:1.0.0-beta.38" + checksum: 10/c6876551c1633b59ce17d91fe26c4572f4a9cb62f8df96ff99a75f4b8606ded7fa354edd0d2ba36aac8e5c5b041175dae4d7d1d67fb3cdb7164fc2da8abb3a73 + languageName: node + linkType: hard + "@rollup/plugin-babel@npm:^6.0.4": version: 6.0.4 resolution: "@rollup/plugin-babel@npm:6.0.4" @@ -3891,6 +4305,22 @@ __metadata: languageName: node linkType: hard +"@rollup/plugin-inject@npm:^5.0.5": + version: 5.0.5 + resolution: "@rollup/plugin-inject@npm:5.0.5" + dependencies: + "@rollup/pluginutils": "npm:^5.0.1" + estree-walker: "npm:^2.0.2" + magic-string: "npm:^0.30.3" + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + checksum: 10/1d0e68dff0a8785398a1b6a7dac0dc0a7f2ded22319c0b4c411053f34cbe237ca897d1fc97e5150fddbc3486480f21cbeeb69f0ae7f44ab1ae7307c164c7e704 + languageName: node + linkType: hard + "@rollup/plugin-node-resolve@npm:^15.2.3": version: 15.3.1 resolution: "@rollup/plugin-node-resolve@npm:15.3.1" @@ -4317,10 +4747,10 @@ __metadata: languageName: node linkType: hard -"@sinclair/typebox@npm:^0.27.8": - version: 0.27.8 - resolution: "@sinclair/typebox@npm:0.27.8" - checksum: 10/297f95ff77c82c54de8c9907f186076e715ff2621c5222ba50b8d40a170661c0c5242c763cba2a4791f0f91cb1d8ffa53ea1d7294570cf8cd4694c0e383e484d +"@sinclair/typebox@npm:^0.34.0": + version: 0.34.41 + resolution: "@sinclair/typebox@npm:0.34.41" + checksum: 10/5c04a7f42156a7813a159947a0c3fe7e9f11aa722141ac3ff32242faf031b443ef71763d8791ce8d01bd5856770de51fd6fcda94b3a51558ba1f6d5112fa33f4 languageName: node linkType: hard @@ -4340,7 +4770,7 @@ __metadata: languageName: node linkType: hard -"@sinonjs/commons@npm:^3.0.0": +"@sinonjs/commons@npm:^3.0.1": version: 3.0.1 resolution: "@sinonjs/commons@npm:3.0.1" dependencies: @@ -4349,12 +4779,12 @@ __metadata: languageName: node linkType: hard -"@sinonjs/fake-timers@npm:^10.0.2": - version: 10.3.0 - resolution: "@sinonjs/fake-timers@npm:10.3.0" +"@sinonjs/fake-timers@npm:^13.0.0": + version: 13.0.5 + resolution: "@sinonjs/fake-timers@npm:13.0.5" dependencies: - "@sinonjs/commons": "npm:^3.0.0" - checksum: 10/78155c7bd866a85df85e22028e046b8d46cf3e840f72260954f5e3ed5bd97d66c595524305a6841ffb3f681a08f6e5cef572a2cce5442a8a232dc29fb409b83e + "@sinonjs/commons": "npm:^3.0.1" + checksum: 10/11ee417968fc4dce1896ab332ac13f353866075a9d2a88ed1f6258f17cc4f7d93e66031b51fcddb8c203aa4d53fd980b0ae18aba06269f4682164878a992ec3f languageName: node linkType: hard @@ -4393,6 +4823,20 @@ __metadata: languageName: node linkType: hard +"@standard-schema/spec@npm:^1.0.0": + version: 1.0.0 + resolution: "@standard-schema/spec@npm:1.0.0" + checksum: 10/aee780cc1431888ca4b9aba9b24ffc8f3073fc083acc105e3951481478a2f4dc957796931b2da9e2d8329584cf211e4542275f188296c1cdff3ed44fd93a8bc8 + languageName: node + linkType: hard + +"@standard-schema/utils@npm:^0.3.0": + version: 0.3.0 + resolution: "@standard-schema/utils@npm:0.3.0" + checksum: 10/7084f875d322792f2e0a5904009434c8374b9345b09ba89828b68fd56fa3c2b366d35bf340d9e8c72736ef01793c2f70d350c372ed79845dc3566c58d34b4b51 + languageName: node + linkType: hard + "@storybook/addon-a11y@npm:^8.6.12": version: 8.6.14 resolution: "@storybook/addon-a11y@npm:8.6.14" @@ -4968,9 +5412,9 @@ __metadata: languageName: node linkType: hard -"@stylistic/eslint-plugin-jsx@npm:4.2.0": - version: 4.2.0 - resolution: "@stylistic/eslint-plugin-jsx@npm:4.2.0" +"@stylistic/eslint-plugin-jsx@npm:4.4.1": + version: 4.4.1 + resolution: "@stylistic/eslint-plugin-jsx@npm:4.4.1" dependencies: eslint-visitor-keys: "npm:^4.2.0" espree: "npm:^10.3.0" @@ -4978,35 +5422,36 @@ __metadata: picomatch: "npm:^4.0.2" peerDependencies: eslint: ">=9.0.0" - checksum: 10/c30dacaa8e66d5205d3c0d160ecf885ebfb5bbb112dc0b6cd08b6f30a73c1f67c723ff77e5192db9d685434cd5f785625b03ade502e31f336bf62302cd4f0180 + checksum: 10/6c41b331e348bfdfb4bbdd885930d1f288169016f1c674c542e7451c1b932000eae3a4e720bb186440b99c652312fa923e9f313dc424d6184e5e8ad59e7f636f languageName: node linkType: hard -"@stylistic/eslint-plugin-ts@npm:4.2.0": - version: 4.2.0 - resolution: "@stylistic/eslint-plugin-ts@npm:4.2.0" +"@stylistic/eslint-plugin-ts@npm:4.4.1": + version: 4.4.1 + resolution: "@stylistic/eslint-plugin-ts@npm:4.4.1" dependencies: - "@typescript-eslint/utils": "npm:^8.23.0" + "@typescript-eslint/utils": "npm:^8.32.1" eslint-visitor-keys: "npm:^4.2.0" espree: "npm:^10.3.0" peerDependencies: eslint: ">=9.0.0" - checksum: 10/f6997006c2c1001199d17feb252fa9234fdf853bf950c3a6db771f2ceb618a44e88a1fb314c3bddccd2a40c8ff8edbf1b3496e2592ec2e46d8439c64750e077e + checksum: 10/d945c9f804ca488e1da01569cb9be2bfc76761018aeab830a5fde2c833b55b1193dbc405e7412015a598af6cc2dfdb59f271a8389db04c0b0a45d2097317b2f2 languageName: node linkType: hard -"@stylistic/eslint-plugin@npm:4.2.0": - version: 4.2.0 - resolution: "@stylistic/eslint-plugin@npm:4.2.0" +"@stylistic/eslint-plugin@npm:5.2.3": + version: 5.2.3 + resolution: "@stylistic/eslint-plugin@npm:5.2.3" dependencies: - "@typescript-eslint/utils": "npm:^8.23.0" - eslint-visitor-keys: "npm:^4.2.0" - espree: "npm:^10.3.0" + "@eslint-community/eslint-utils": "npm:^4.7.0" + "@typescript-eslint/types": "npm:^8.38.0" + eslint-visitor-keys: "npm:^4.2.1" + espree: "npm:^10.4.0" estraverse: "npm:^5.3.0" - picomatch: "npm:^4.0.2" + picomatch: "npm:^4.0.3" peerDependencies: eslint: ">=9.0.0" - checksum: 10/e7913327038f3eac31f10859d3c407b06949bc9660d6a9ee4c132536587af93aca1ddfde5ee152ce5ef75690ecca515c0abf07a1192d62bc14b7d8d8d6430b83 + checksum: 10/6015a0b8027da97380244c497824d319831eb59a5c71b509e6e90b0e8100b1489b9cbca1f14f0fa8768d6a60c9985f72ef35f84eb3ec21771199dfcbdd2e9474 languageName: node linkType: hard @@ -5132,13 +5577,6 @@ __metadata: languageName: node linkType: hard -"@tootallnate/once@npm:2": - version: 2.0.0 - resolution: "@tootallnate/once@npm:2.0.0" - checksum: 10/ad87447820dd3f24825d2d947ebc03072b20a42bfc96cbafec16bff8bbda6c1a81fcb0be56d5b21968560c5359a0af4038a68ba150c3e1694fe4c109a063bed8 - languageName: node - linkType: hard - "@tootallnate/quickjs-emscripten@npm:^0.23.0": version: 0.23.0 resolution: "@tootallnate/quickjs-emscripten@npm:0.23.0" @@ -5181,6 +5619,15 @@ __metadata: languageName: node linkType: hard +"@tybys/wasm-util@npm:^0.10.0, @tybys/wasm-util@npm:^0.10.1": + version: 0.10.1 + resolution: "@tybys/wasm-util@npm:0.10.1" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10/7fe0d239397aebb002ac4855d30c197c06a05ea8df8511350a3a5b1abeefe26167c60eda8a5508337571161e4c4b53d7c1342296123f9607af8705369de9fa7f + languageName: node + linkType: hard + "@types/argparse@npm:1.0.38": version: 1.0.38 resolution: "@types/argparse@npm:1.0.38" @@ -5195,7 +5642,7 @@ __metadata: languageName: node linkType: hard -"@types/babel__core@npm:^7.0.0, @types/babel__core@npm:^7.1.14, @types/babel__core@npm:^7.18.0, @types/babel__core@npm:^7.20.5": +"@types/babel__core@npm:^7.0.0, @types/babel__core@npm:^7.18.0, @types/babel__core@npm:^7.20.5": version: 7.20.5 resolution: "@types/babel__core@npm:7.20.5" dependencies: @@ -5227,7 +5674,7 @@ __metadata: languageName: node linkType: hard -"@types/babel__traverse@npm:*, @types/babel__traverse@npm:^7.0.6, @types/babel__traverse@npm:^7.18.0": +"@types/babel__traverse@npm:*, @types/babel__traverse@npm:^7.18.0": version: 7.28.0 resolution: "@types/babel__traverse@npm:7.28.0" dependencies: @@ -5415,15 +5862,6 @@ __metadata: languageName: node linkType: hard -"@types/graceful-fs@npm:^4.1.3": - version: 4.1.9 - resolution: "@types/graceful-fs@npm:4.1.9" - dependencies: - "@types/node": "npm:*" - checksum: 10/79d746a8f053954bba36bd3d94a90c78de995d126289d656fb3271dd9f1229d33f678da04d10bce6be440494a5a73438e2e363e92802d16b8315b051036c5256 - languageName: node - linkType: hard - "@types/hast@npm:^3.0.4": version: 3.0.4 resolution: "@types/hast@npm:3.0.4" @@ -5456,7 +5894,7 @@ __metadata: languageName: node linkType: hard -"@types/istanbul-lib-coverage@npm:*, @types/istanbul-lib-coverage@npm:^2.0.0, @types/istanbul-lib-coverage@npm:^2.0.1": +"@types/istanbul-lib-coverage@npm:*, @types/istanbul-lib-coverage@npm:^2.0.1, @types/istanbul-lib-coverage@npm:^2.0.6": version: 2.0.6 resolution: "@types/istanbul-lib-coverage@npm:2.0.6" checksum: 10/3feac423fd3e5449485afac999dcfcb3d44a37c830af898b689fadc65d26526460bedb889db278e0d4d815a670331796494d073a10ee6e3a6526301fe7415778 @@ -5472,7 +5910,7 @@ __metadata: languageName: node linkType: hard -"@types/istanbul-reports@npm:^3.0.0": +"@types/istanbul-reports@npm:^3.0.4": version: 3.0.4 resolution: "@types/istanbul-reports@npm:3.0.4" dependencies: @@ -5481,24 +5919,24 @@ __metadata: languageName: node linkType: hard -"@types/jest@npm:29.5.14": - version: 29.5.14 - resolution: "@types/jest@npm:29.5.14" +"@types/jest@npm:30.0.0": + version: 30.0.0 + resolution: "@types/jest@npm:30.0.0" dependencies: - expect: "npm:^29.0.0" - pretty-format: "npm:^29.0.0" - checksum: 10/59ec7a9c4688aae8ee529316c43853468b6034f453d08a2e1064b281af9c81234cec986be796288f1bbb29efe943bc950e70c8fa8faae1e460d50e3cf9760f9b + expect: "npm:^30.0.0" + pretty-format: "npm:^30.0.0" + checksum: 10/cdeaa924c68b5233d9ff92861a89e7042df2b0f197633729bcf3a31e65bd4e9426e751c5665b5ac2de0b222b33f100a5502da22aefce3d2c62931c715e88f209 languageName: node linkType: hard -"@types/jsdom@npm:^20.0.0": - version: 20.0.1 - resolution: "@types/jsdom@npm:20.0.1" +"@types/jsdom@npm:^21.1.7": + version: 21.1.7 + resolution: "@types/jsdom@npm:21.1.7" dependencies: "@types/node": "npm:*" "@types/tough-cookie": "npm:*" parse5: "npm:^7.0.0" - checksum: 10/15fbb9a0bfb4a5845cf6e795f2fd12400aacfca53b8c7e5bca4a3e5e8fa8629f676327964d64258aefb127d2d8a2be86dad46359efbfca0e8c9c2b790e7f8a88 + checksum: 10/a5ee54aec813ac928ef783f69828213af4d81325f584e1fe7573a9ae139924c40768d1d5249237e62d51b9a34ed06bde059c86c6b0248d627457ec5e5d532dfa languageName: node linkType: hard @@ -5590,10 +6028,12 @@ __metadata: languageName: node linkType: hard -"@types/normalize-package-data@npm:^2.4.3": - version: 2.4.4 - resolution: "@types/normalize-package-data@npm:2.4.4" - checksum: 10/65dff72b543997b7be8b0265eca7ace0e34b75c3e5fee31de11179d08fa7124a7a5587265d53d0409532ecb7f7fba662c2012807963e1f9b059653ec2c83ee05 +"@types/node@npm:^24.3.0": + version: 24.7.2 + resolution: "@types/node@npm:24.7.2" + dependencies: + undici-types: "npm:~7.14.0" + checksum: 10/62073022f7fc11363683e6ce82332193129c3053e7f973127a83b0daa5d755ecf5806d310f5a7c78375e6c3d309aa15b4ae87c4c1797a9eec091425d36098b60 languageName: node linkType: hard @@ -5611,7 +6051,16 @@ __metadata: languageName: node linkType: hard -"@types/react-dom@npm:^19.0.3, @types/react-dom@npm:^19.1.2, @types/react-dom@npm:^19.1.7": +"@types/react-dom@npm:19.1.9": + version: 19.1.9 + resolution: "@types/react-dom@npm:19.1.9" + peerDependencies: + "@types/react": ^19.0.0 + checksum: 10/207acb79f6c3c9704938138960e21429efdf2db2184f17c166e8ec3f3180dfe6445b282c5302f559a71b2d09ab2fafef7735f3d24fd01cda4e5c7bf0cea1d5b9 + languageName: node + linkType: hard + +"@types/react-dom@npm:^19.1.2": version: 19.1.7 resolution: "@types/react-dom@npm:19.1.7" peerDependencies: @@ -5620,16 +6069,16 @@ __metadata: languageName: node linkType: hard -"@types/react-test-renderer@npm:19.0.0": - version: 19.0.0 - resolution: "@types/react-test-renderer@npm:19.0.0" +"@types/react-test-renderer@npm:19.1.0": + version: 19.1.0 + resolution: "@types/react-test-renderer@npm:19.1.0" dependencies: "@types/react": "npm:*" - checksum: 10/a22c4401e3af216a8c2cded22702bb6f928e22af7fbcaca7e0cc28447ec34bea5a94c6d6de7ed5734c527a1b53868393e67b7b4815623802e29abb1d8d378d61 + checksum: 10/2ef3aec0f2fd638902cda606d70c8531d66f8e8944334427986b99dcac9755ee60b700c5c3a19ac354680f9c45669e98077b84f79cac60e950bdb7d38aebffde languageName: node linkType: hard -"@types/react@npm:*, @types/react@npm:^19.1.2, @types/react@npm:^19.1.9": +"@types/react@npm:*": version: 19.1.9 resolution: "@types/react@npm:19.1.9" dependencies: @@ -5638,12 +6087,21 @@ __metadata: languageName: node linkType: hard -"@types/react@npm:19.0.8": - version: 19.0.8 - resolution: "@types/react@npm:19.0.8" +"@types/react@npm:19.1.12": + version: 19.1.12 + resolution: "@types/react@npm:19.1.12" dependencies: csstype: "npm:^3.0.2" - checksum: 10/1080d5b96ee0b4395f8f167ae6952f570088ee03bdce69f8237aab82c32d9bd2b71106f787bac17ba351acc4aba5e3454bafca51f2eb11d1562073b821e63d15 + checksum: 10/c03d595b84faecb15079757555c96871e84ea6eef9a5eddb13ec1f648f718f9624bebc4199e86267edb825b23df97758324ea39ff840d9ad328386f96971f588 + languageName: node + linkType: hard + +"@types/react@npm:^19.1.10": + version: 19.2.2 + resolution: "@types/react@npm:19.2.2" + dependencies: + csstype: "npm:^3.0.2" + checksum: 10/d6adf8fd4bb23a7e04da5700d96b15dc0f59653727a9c6e940c151d7232fa1dbbab98417d5ac830dcfb6cba3f206efbd4cd83647e6f9a688d7363a90e607f6bf languageName: node linkType: hard @@ -5739,7 +6197,7 @@ __metadata: languageName: node linkType: hard -"@types/stack-utils@npm:^2.0.0": +"@types/stack-utils@npm:^2.0.3": version: 2.0.3 resolution: "@types/stack-utils@npm:2.0.3" checksum: 10/72576cc1522090fe497337c2b99d9838e320659ac57fa5560fcbdcbafcf5d0216c6b3a0a8a4ee4fdb3b1f5e3420aa4f6223ab57b82fef3578bec3206425c6cf5 @@ -5760,6 +6218,13 @@ __metadata: languageName: node linkType: hard +"@types/use-sync-external-store@npm:1.5.0": + version: 1.5.0 + resolution: "@types/use-sync-external-store@npm:1.5.0" + checksum: 10/39e5be8dc2cca080b490f2f79fed4381ae7eebee3f981208e359856733eafb2479d229db07a552f6c99fe0b5c09b3e46a3e6a870e00a88b50f3e690e73d2649b + languageName: node + linkType: hard + "@types/use-sync-external-store@npm:^0.0.6": version: 0.0.6 resolution: "@types/use-sync-external-store@npm:0.0.6" @@ -5797,7 +6262,7 @@ __metadata: languageName: node linkType: hard -"@types/yargs@npm:^17.0.8": +"@types/yargs@npm:^17.0.33": version: 17.0.33 resolution: "@types/yargs@npm:17.0.33" dependencies: @@ -5836,27 +6301,6 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/eslint-plugin@npm:8.29.0": - version: 8.29.0 - resolution: "@typescript-eslint/eslint-plugin@npm:8.29.0" - dependencies: - "@eslint-community/regexpp": "npm:^4.10.0" - "@typescript-eslint/scope-manager": "npm:8.29.0" - "@typescript-eslint/type-utils": "npm:8.29.0" - "@typescript-eslint/utils": "npm:8.29.0" - "@typescript-eslint/visitor-keys": "npm:8.29.0" - graphemer: "npm:^1.4.0" - ignore: "npm:^5.3.1" - natural-compare: "npm:^1.4.0" - ts-api-utils: "npm:^2.0.1" - peerDependencies: - "@typescript-eslint/parser": ^8.0.0 || ^8.0.0-alpha.0 - eslint: ^8.57.0 || ^9.0.0 - typescript: ">=4.8.4 <5.9.0" - checksum: 10/1df4b43c209e40a00ec77e572b575760a9ac93967b6ebcc13f36587bf2881fc891c158f62cf25e8c2b8ca1ecd05b3eb583b30869ba6c2fa558435f0574773df8 - languageName: node - linkType: hard - "@typescript-eslint/eslint-plugin@npm:8.39.0": version: 8.39.0 resolution: "@typescript-eslint/eslint-plugin@npm:8.39.0" @@ -5878,6 +6322,27 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/eslint-plugin@npm:8.39.1": + version: 8.39.1 + resolution: "@typescript-eslint/eslint-plugin@npm:8.39.1" + dependencies: + "@eslint-community/regexpp": "npm:^4.10.0" + "@typescript-eslint/scope-manager": "npm:8.39.1" + "@typescript-eslint/type-utils": "npm:8.39.1" + "@typescript-eslint/utils": "npm:8.39.1" + "@typescript-eslint/visitor-keys": "npm:8.39.1" + graphemer: "npm:^1.4.0" + ignore: "npm:^7.0.0" + natural-compare: "npm:^1.4.0" + ts-api-utils: "npm:^2.1.0" + peerDependencies: + "@typescript-eslint/parser": ^8.39.1 + eslint: ^8.57.0 || ^9.0.0 + typescript: ">=4.8.4 <6.0.0" + checksum: 10/446050aa43d54c0107c7c927ae1f68a4384c2bba514d5c22edabbe355426cb37bd5bb5a3faf240a6be8ef06f68de6099c2a53d9cbb1849ed35a152fb156171e2 + languageName: node + linkType: hard + "@typescript-eslint/parser@npm:8.27.0": version: 8.27.0 resolution: "@typescript-eslint/parser@npm:8.27.0" @@ -5894,22 +6359,6 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/parser@npm:8.29.0": - version: 8.29.0 - resolution: "@typescript-eslint/parser@npm:8.29.0" - dependencies: - "@typescript-eslint/scope-manager": "npm:8.29.0" - "@typescript-eslint/types": "npm:8.29.0" - "@typescript-eslint/typescript-estree": "npm:8.29.0" - "@typescript-eslint/visitor-keys": "npm:8.29.0" - debug: "npm:^4.3.4" - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: ">=4.8.4 <5.9.0" - checksum: 10/d71fec12e78ac31a2faf076720c39f0e004a26672ebda4fc2f3b6f36306ff2362917dc6e0445746586f2911b4b2dd86622399dd578f002006f6c75cc9dfac013 - languageName: node - linkType: hard - "@typescript-eslint/parser@npm:8.39.0": version: 8.39.0 resolution: "@typescript-eslint/parser@npm:8.39.0" @@ -5926,6 +6375,22 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/parser@npm:8.39.1": + version: 8.39.1 + resolution: "@typescript-eslint/parser@npm:8.39.1" + dependencies: + "@typescript-eslint/scope-manager": "npm:8.39.1" + "@typescript-eslint/types": "npm:8.39.1" + "@typescript-eslint/typescript-estree": "npm:8.39.1" + "@typescript-eslint/visitor-keys": "npm:8.39.1" + debug: "npm:^4.3.4" + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: ">=4.8.4 <6.0.0" + checksum: 10/ff45ce76353ed564e0f9db47b02b4b20895c96182b3693c610ef3dbceda373c476037a99f90d9f28633c192f301e5d554c89e1ba72da216763f960648ddf1f34 + languageName: node + linkType: hard + "@typescript-eslint/project-service@npm:8.39.0": version: 8.39.0 resolution: "@typescript-eslint/project-service@npm:8.39.0" @@ -5939,6 +6404,32 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/project-service@npm:8.39.1": + version: 8.39.1 + resolution: "@typescript-eslint/project-service@npm:8.39.1" + dependencies: + "@typescript-eslint/tsconfig-utils": "npm:^8.39.1" + "@typescript-eslint/types": "npm:^8.39.1" + debug: "npm:^4.3.4" + peerDependencies: + typescript: ">=4.8.4 <6.0.0" + checksum: 10/1970633d1a338190f0125e186beaa39b3ef912f287e4815934faf64b72f140e87fdf7d861962683635a450d270dd76faf0c865d72bfd57b471a36739f943676b + languageName: node + linkType: hard + +"@typescript-eslint/project-service@npm:8.46.1": + version: 8.46.1 + resolution: "@typescript-eslint/project-service@npm:8.46.1" + dependencies: + "@typescript-eslint/tsconfig-utils": "npm:^8.46.1" + "@typescript-eslint/types": "npm:^8.46.1" + debug: "npm:^4.3.4" + peerDependencies: + typescript: ">=4.8.4 <6.0.0" + checksum: 10/d63cbb88524be85ba626c4969bdec1cd5c1ab64b6ebdd565a45698e700efb764f192db1cdc3322f4d63d3acd8d0a36e2685b89bdfa2edf50fda3c2d0cb6efdd7 + languageName: node + linkType: hard + "@typescript-eslint/scope-manager@npm:8.27.0": version: 8.27.0 resolution: "@typescript-eslint/scope-manager@npm:8.27.0" @@ -5959,17 +6450,7 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/scope-manager@npm:8.29.0": - version: 8.29.0 - resolution: "@typescript-eslint/scope-manager@npm:8.29.0" - dependencies: - "@typescript-eslint/types": "npm:8.29.0" - "@typescript-eslint/visitor-keys": "npm:8.29.0" - checksum: 10/23ce9962d57607f91a8a4a9bc43e64bd91cd933b53e61765924704614e52f39e8ccb28276b60b7472fb6dffe52fa681f114b73e4561fb4dcb74910a4e6a3629f - languageName: node - linkType: hard - -"@typescript-eslint/scope-manager@npm:8.39.0, @typescript-eslint/scope-manager@npm:^8.36.0": +"@typescript-eslint/scope-manager@npm:8.39.0": version: 8.39.0 resolution: "@typescript-eslint/scope-manager@npm:8.39.0" dependencies: @@ -5979,6 +6460,26 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/scope-manager@npm:8.39.1": + version: 8.39.1 + resolution: "@typescript-eslint/scope-manager@npm:8.39.1" + dependencies: + "@typescript-eslint/types": "npm:8.39.1" + "@typescript-eslint/visitor-keys": "npm:8.39.1" + checksum: 10/8874f7479043b3fc878f2c04b2c565051deceb7e425a8e4e79a7f40f1ee696bb979bd91fff619e016fe6793f537b30609c0ee8a5c40911c4829fa264863f7a70 + languageName: node + linkType: hard + +"@typescript-eslint/scope-manager@npm:8.46.1, @typescript-eslint/scope-manager@npm:^8.39.1": + version: 8.46.1 + resolution: "@typescript-eslint/scope-manager@npm:8.46.1" + dependencies: + "@typescript-eslint/types": "npm:8.46.1" + "@typescript-eslint/visitor-keys": "npm:8.46.1" + checksum: 10/3d73812087a17be84184cc68143d4dca7602b8cd4bf5ad334e541d4b3acf5c65c58935369dcf66ab81b38014fe0c6bc57ac2f655fdd69b3e24161a827b86bd34 + languageName: node + linkType: hard + "@typescript-eslint/tsconfig-utils@npm:8.39.0, @typescript-eslint/tsconfig-utils@npm:^8.39.0": version: 8.39.0 resolution: "@typescript-eslint/tsconfig-utils@npm:8.39.0" @@ -5988,6 +6489,24 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/tsconfig-utils@npm:8.39.1": + version: 8.39.1 + resolution: "@typescript-eslint/tsconfig-utils@npm:8.39.1" + peerDependencies: + typescript: ">=4.8.4 <6.0.0" + checksum: 10/38c1e1982504e606e525ad0ce47fdb4c7acc686a28a94c2b30fe988c439977e991ce69cb88a1724a41a8096fc2d18d7ced7fe8725e42879d841515ff36a37ecf + languageName: node + linkType: hard + +"@typescript-eslint/tsconfig-utils@npm:8.46.1, @typescript-eslint/tsconfig-utils@npm:^8.39.1, @typescript-eslint/tsconfig-utils@npm:^8.46.1": + version: 8.46.1 + resolution: "@typescript-eslint/tsconfig-utils@npm:8.46.1" + peerDependencies: + typescript: ">=4.8.4 <6.0.0" + checksum: 10/f033d68a53f62c7cc4c09e5697dd9b7fa34a3c3e79133e0b14ca582821869b77e81d3942b91535f6ef789ffaaad31eef1e1ace20518e7de0935a55a16120fae7 + languageName: node + linkType: hard + "@typescript-eslint/type-utils@npm:8.28.0": version: 8.28.0 resolution: "@typescript-eslint/type-utils@npm:8.28.0" @@ -6003,34 +6522,51 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/type-utils@npm:8.29.0": - version: 8.29.0 - resolution: "@typescript-eslint/type-utils@npm:8.29.0" +"@typescript-eslint/type-utils@npm:8.39.0, @typescript-eslint/type-utils@npm:^8.0.0": + version: 8.39.0 + resolution: "@typescript-eslint/type-utils@npm:8.39.0" dependencies: - "@typescript-eslint/typescript-estree": "npm:8.29.0" - "@typescript-eslint/utils": "npm:8.29.0" + "@typescript-eslint/types": "npm:8.39.0" + "@typescript-eslint/typescript-estree": "npm:8.39.0" + "@typescript-eslint/utils": "npm:8.39.0" debug: "npm:^4.3.4" - ts-api-utils: "npm:^2.0.1" + ts-api-utils: "npm:^2.1.0" peerDependencies: eslint: ^8.57.0 || ^9.0.0 - typescript: ">=4.8.4 <5.9.0" - checksum: 10/3b18caf6d3d16461d462b8960e1fa7fdb94f0eb2aa8afb9c95e2e458af32ffc82b14f1d26bb635b5e751bd0a7ff5c10fa1754377fff0dea760d1a96848705f88 + typescript: ">=4.8.4 <6.0.0" + checksum: 10/3efe4001b6b89bc8a32245fffc16b6aa6f1a5d571d7a103991fdb9f41afb1e7a3319ff3876182658a4296032a1475f8aa7853f6ad489e1491a1dda1d5c1da95e languageName: node linkType: hard -"@typescript-eslint/type-utils@npm:8.39.0, @typescript-eslint/type-utils@npm:^8.0.0, @typescript-eslint/type-utils@npm:^8.36.0": - version: 8.39.0 - resolution: "@typescript-eslint/type-utils@npm:8.39.0" +"@typescript-eslint/type-utils@npm:8.39.1": + version: 8.39.1 + resolution: "@typescript-eslint/type-utils@npm:8.39.1" dependencies: - "@typescript-eslint/types": "npm:8.39.0" - "@typescript-eslint/typescript-estree": "npm:8.39.0" - "@typescript-eslint/utils": "npm:8.39.0" + "@typescript-eslint/types": "npm:8.39.1" + "@typescript-eslint/typescript-estree": "npm:8.39.1" + "@typescript-eslint/utils": "npm:8.39.1" debug: "npm:^4.3.4" ts-api-utils: "npm:^2.1.0" peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: ">=4.8.4 <6.0.0" - checksum: 10/3efe4001b6b89bc8a32245fffc16b6aa6f1a5d571d7a103991fdb9f41afb1e7a3319ff3876182658a4296032a1475f8aa7853f6ad489e1491a1dda1d5c1da95e + checksum: 10/1195d65970f79f820558810f7e1edf0ea360bbeee55841fdbb71b5b40c09f1a65741b67a70b85c2834ae1f9a027b82da4234d01f42ab4e85dceef3eea84bfdaa + languageName: node + linkType: hard + +"@typescript-eslint/type-utils@npm:^8.39.1": + version: 8.46.1 + resolution: "@typescript-eslint/type-utils@npm:8.46.1" + dependencies: + "@typescript-eslint/types": "npm:8.46.1" + "@typescript-eslint/typescript-estree": "npm:8.46.1" + "@typescript-eslint/utils": "npm:8.46.1" + debug: "npm:^4.3.4" + ts-api-utils: "npm:^2.1.0" + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: ">=4.8.4 <6.0.0" + checksum: 10/db989c1f55624b34da24eaf0dc230ee696a1f2a614ea95a8dd3b8635ad47d748140be2345ed7afcee844dfabd41129f5a8ca583b1a4d6ecc7d581f89c5e508e2 languageName: node linkType: hard @@ -6048,20 +6584,27 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/types@npm:8.29.0": - version: 8.29.0 - resolution: "@typescript-eslint/types@npm:8.29.0" - checksum: 10/d65b9f2f6d87a3744788b09d9112c4a0298f1215138d8677240aae3bfa37ddc24a59315536cd9aab63c7608909ae2c5f436924c889b98986b78003b6028b5c35 - languageName: node - linkType: hard - -"@typescript-eslint/types@npm:8.39.0, @typescript-eslint/types@npm:^8.11.0, @typescript-eslint/types@npm:^8.36.0, @typescript-eslint/types@npm:^8.39.0": +"@typescript-eslint/types@npm:8.39.0, @typescript-eslint/types@npm:^8.39.0": version: 8.39.0 resolution: "@typescript-eslint/types@npm:8.39.0" checksum: 10/b08a42e8b5cc57f9b950150433386ac5da03d7f5e24b743fa0cb55f5672f314b5defa3cf9b1ed82af8e4de1265c9c79deab304910104091a24d41c70f2d98ff9 languageName: node linkType: hard +"@typescript-eslint/types@npm:8.39.1": + version: 8.39.1 + resolution: "@typescript-eslint/types@npm:8.39.1" + checksum: 10/8013f4f48a98da0de270d5fef1ff28b35407de82fce5acf3efa212fce60bc92a81bbb15b4b358d9facf4f161e49feec856fbf1a6d96f5027d013b542f2fe1bcc + languageName: node + linkType: hard + +"@typescript-eslint/types@npm:8.46.1, @typescript-eslint/types@npm:^8.34.1, @typescript-eslint/types@npm:^8.38.0, @typescript-eslint/types@npm:^8.39.1, @typescript-eslint/types@npm:^8.46.1": + version: 8.46.1 + resolution: "@typescript-eslint/types@npm:8.46.1" + checksum: 10/d162ddf6d77d8c9bdfca942da5de5fb4ba80efa740b14077482b5a71282f1d05e1b1dd393ae810eb2923ca9c845bd26b4a9d2dbf25d43dd5d9cb6e20c2a1db46 + languageName: node + linkType: hard + "@typescript-eslint/typescript-estree@npm:8.27.0": version: 8.27.0 resolution: "@typescript-eslint/typescript-estree@npm:8.27.0" @@ -6098,32 +6641,34 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/typescript-estree@npm:8.29.0": - version: 8.29.0 - resolution: "@typescript-eslint/typescript-estree@npm:8.29.0" +"@typescript-eslint/typescript-estree@npm:8.39.0": + version: 8.39.0 + resolution: "@typescript-eslint/typescript-estree@npm:8.39.0" dependencies: - "@typescript-eslint/types": "npm:8.29.0" - "@typescript-eslint/visitor-keys": "npm:8.29.0" + "@typescript-eslint/project-service": "npm:8.39.0" + "@typescript-eslint/tsconfig-utils": "npm:8.39.0" + "@typescript-eslint/types": "npm:8.39.0" + "@typescript-eslint/visitor-keys": "npm:8.39.0" debug: "npm:^4.3.4" fast-glob: "npm:^3.3.2" is-glob: "npm:^4.0.3" minimatch: "npm:^9.0.4" semver: "npm:^7.6.0" - ts-api-utils: "npm:^2.0.1" + ts-api-utils: "npm:^2.1.0" peerDependencies: - typescript: ">=4.8.4 <5.9.0" - checksum: 10/276e6ea97857ef0fd940578d4b8f1677fd68d2bb62603c85d7aa97fcf86c1f66c5da962393254b605c7025f0cda74395904053891088cbe405b899afc1180e9c + typescript: ">=4.8.4 <6.0.0" + checksum: 10/7e9dc461fe692a1b3a17fe0f8f3d9a361f3af0df115c2e3f72c82ee271d37107c1cddeeb976707d6fc3e24e87431c381d6045de5c187aff92c71847a22118ee8 languageName: node linkType: hard -"@typescript-eslint/typescript-estree@npm:8.39.0, @typescript-eslint/typescript-estree@npm:^8.36.0": - version: 8.39.0 - resolution: "@typescript-eslint/typescript-estree@npm:8.39.0" +"@typescript-eslint/typescript-estree@npm:8.39.1": + version: 8.39.1 + resolution: "@typescript-eslint/typescript-estree@npm:8.39.1" dependencies: - "@typescript-eslint/project-service": "npm:8.39.0" - "@typescript-eslint/tsconfig-utils": "npm:8.39.0" - "@typescript-eslint/types": "npm:8.39.0" - "@typescript-eslint/visitor-keys": "npm:8.39.0" + "@typescript-eslint/project-service": "npm:8.39.1" + "@typescript-eslint/tsconfig-utils": "npm:8.39.1" + "@typescript-eslint/types": "npm:8.39.1" + "@typescript-eslint/visitor-keys": "npm:8.39.1" debug: "npm:^4.3.4" fast-glob: "npm:^3.3.2" is-glob: "npm:^4.0.3" @@ -6132,7 +6677,27 @@ __metadata: ts-api-utils: "npm:^2.1.0" peerDependencies: typescript: ">=4.8.4 <6.0.0" - checksum: 10/7e9dc461fe692a1b3a17fe0f8f3d9a361f3af0df115c2e3f72c82ee271d37107c1cddeeb976707d6fc3e24e87431c381d6045de5c187aff92c71847a22118ee8 + checksum: 10/07ed9d7ab4d146ee3ce6cf82ffebf947e045a9289b01522e11b3985b64f590c00cac0ca10366df828ca213bf08216a67c7b2b76e7c8be650df2511a7e6385425 + languageName: node + linkType: hard + +"@typescript-eslint/typescript-estree@npm:8.46.1, @typescript-eslint/typescript-estree@npm:^8.39.1": + version: 8.46.1 + resolution: "@typescript-eslint/typescript-estree@npm:8.46.1" + dependencies: + "@typescript-eslint/project-service": "npm:8.46.1" + "@typescript-eslint/tsconfig-utils": "npm:8.46.1" + "@typescript-eslint/types": "npm:8.46.1" + "@typescript-eslint/visitor-keys": "npm:8.46.1" + debug: "npm:^4.3.4" + fast-glob: "npm:^3.3.2" + is-glob: "npm:^4.0.3" + minimatch: "npm:^9.0.4" + semver: "npm:^7.6.0" + ts-api-utils: "npm:^2.1.0" + peerDependencies: + typescript: ">=4.8.4 <6.0.0" + checksum: 10/af068a14d6d0b4849e9f0e52b7ddcd24c266f099528c7b62ff2bebebc0fb82d07439bf6dc565b27cf2fed0af0aaae618aae220676d0fb041c93ec2a8163f0da1 languageName: node linkType: hard @@ -6151,73 +6716,240 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/utils@npm:8.29.0": - version: 8.29.0 - resolution: "@typescript-eslint/utils@npm:8.29.0" - dependencies: - "@eslint-community/eslint-utils": "npm:^4.4.0" - "@typescript-eslint/scope-manager": "npm:8.29.0" - "@typescript-eslint/types": "npm:8.29.0" - "@typescript-eslint/typescript-estree": "npm:8.29.0" - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: ">=4.8.4 <5.9.0" - checksum: 10/1fd17a28b8b57fc73c0455dea43a8185d3a4421f4a21ece01009b5e6a2974c8d4113f90d27993f668fa97077891b4464588d380c25116d351eb12ad7ef0d468d +"@typescript-eslint/utils@npm:8.39.0, @typescript-eslint/utils@npm:^8.35.1": + version: 8.39.0 + resolution: "@typescript-eslint/utils@npm:8.39.0" + dependencies: + "@eslint-community/eslint-utils": "npm:^4.7.0" + "@typescript-eslint/scope-manager": "npm:8.39.0" + "@typescript-eslint/types": "npm:8.39.0" + "@typescript-eslint/typescript-estree": "npm:8.39.0" + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: ">=4.8.4 <6.0.0" + checksum: 10/ed340f36fa0788fbc2ca1be6676e05be8f3f91497617701f0a77b61b94622f6ea3606fff4871623dfde261811cccc30e4dbe567f9400d056185a7f47054d903c + languageName: node + linkType: hard + +"@typescript-eslint/utils@npm:8.39.1": + version: 8.39.1 + resolution: "@typescript-eslint/utils@npm:8.39.1" + dependencies: + "@eslint-community/eslint-utils": "npm:^4.7.0" + "@typescript-eslint/scope-manager": "npm:8.39.1" + "@typescript-eslint/types": "npm:8.39.1" + "@typescript-eslint/typescript-estree": "npm:8.39.1" + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: ">=4.8.4 <6.0.0" + checksum: 10/39bb105f26aa1ba234ad7d284c277cbd66df9d51e245094892db140aac80d3656d0480f133b2db54e87af3ef9c371a12973120c9cfbff71e8e85865f9e1d0077 + languageName: node + linkType: hard + +"@typescript-eslint/utils@npm:8.46.1, @typescript-eslint/utils@npm:^8.0.0, @typescript-eslint/utils@npm:^8.32.1, @typescript-eslint/utils@npm:^8.39.1": + version: 8.46.1 + resolution: "@typescript-eslint/utils@npm:8.46.1" + dependencies: + "@eslint-community/eslint-utils": "npm:^4.7.0" + "@typescript-eslint/scope-manager": "npm:8.46.1" + "@typescript-eslint/types": "npm:8.46.1" + "@typescript-eslint/typescript-estree": "npm:8.46.1" + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: ">=4.8.4 <6.0.0" + checksum: 10/a8fed8aebd34a559c5abd780649edd6be632531e4930b19642f0fdc862b77bff463ef200e8ced48ba489c3fceee7443b6735c87b918b97b98e95e842cd8a38b5 + languageName: node + linkType: hard + +"@typescript-eslint/visitor-keys@npm:8.27.0": + version: 8.27.0 + resolution: "@typescript-eslint/visitor-keys@npm:8.27.0" + dependencies: + "@typescript-eslint/types": "npm:8.27.0" + eslint-visitor-keys: "npm:^4.2.0" + checksum: 10/d62522d021760452f9c0549227ab2c603724834697c5bb51cb1e6b7b45d169c923225eb4b6d5dc822a5fbe634b7b445154f2da9f99a7d550cb288b3c2c9113d7 + languageName: node + linkType: hard + +"@typescript-eslint/visitor-keys@npm:8.28.0": + version: 8.28.0 + resolution: "@typescript-eslint/visitor-keys@npm:8.28.0" + dependencies: + "@typescript-eslint/types": "npm:8.28.0" + eslint-visitor-keys: "npm:^4.2.0" + checksum: 10/df159834ab40497f7adfa2bab973b64418d10e9dbbab92cf7d68e4b136734690e21bf6229f2770c5202e56c245eb641df3039652838bd439ce9f8e3293309662 + languageName: node + linkType: hard + +"@typescript-eslint/visitor-keys@npm:8.39.0": + version: 8.39.0 + resolution: "@typescript-eslint/visitor-keys@npm:8.39.0" + dependencies: + "@typescript-eslint/types": "npm:8.39.0" + eslint-visitor-keys: "npm:^4.2.1" + checksum: 10/2eb89b9e4d531d52de414591869bc208b45dd71b5f758302f176ef92bc3b922e60be5a046a2788cc0e16724631b2dc95aad849b866716a9c7a6361f994c97379 + languageName: node + linkType: hard + +"@typescript-eslint/visitor-keys@npm:8.39.1": + version: 8.39.1 + resolution: "@typescript-eslint/visitor-keys@npm:8.39.1" + dependencies: + "@typescript-eslint/types": "npm:8.39.1" + eslint-visitor-keys: "npm:^4.2.1" + checksum: 10/6d4e4d0b19ebb3f21b692bbb0dcf9961876ca28cdf502296888a78eb4cd802a2ec8d3d5721d19970411edfd1c06f3e272e4057014c859ee1f0546804d07945e3 + languageName: node + linkType: hard + +"@typescript-eslint/visitor-keys@npm:8.46.1": + version: 8.46.1 + resolution: "@typescript-eslint/visitor-keys@npm:8.46.1" + dependencies: + "@typescript-eslint/types": "npm:8.46.1" + eslint-visitor-keys: "npm:^4.2.1" + checksum: 10/eed1c5ce08d2743bd2ec95a33f2118a67596b1b9fa5bf6a3d84ed09ca66e09af3cc91ef3e302c2222e5882e13576340532b586030b3652ce046eb218cd4508b7 + languageName: node + linkType: hard + +"@ungap/structured-clone@npm:^1.3.0": + version: 1.3.0 + resolution: "@ungap/structured-clone@npm:1.3.0" + checksum: 10/80d6910946f2b1552a2406650051c91bbd1f24a6bf854354203d84fe2714b3e8ce4618f49cc3410494173a1c1e8e9777372fe68dce74bd45faf0a7a1a6ccf448 + languageName: node + linkType: hard + +"@unrs/resolver-binding-android-arm-eabi@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-android-arm-eabi@npm:1.11.1" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + +"@unrs/resolver-binding-android-arm64@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-android-arm64@npm:1.11.1" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@unrs/resolver-binding-darwin-arm64@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-darwin-arm64@npm:1.11.1" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@unrs/resolver-binding-darwin-x64@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-darwin-x64@npm:1.11.1" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@unrs/resolver-binding-freebsd-x64@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-freebsd-x64@npm:1.11.1" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@unrs/resolver-binding-linux-arm-gnueabihf@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-linux-arm-gnueabihf@npm:1.11.1" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@unrs/resolver-binding-linux-arm-musleabihf@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-linux-arm-musleabihf@npm:1.11.1" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@unrs/resolver-binding-linux-arm64-gnu@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-linux-arm64-gnu@npm:1.11.1" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@unrs/resolver-binding-linux-arm64-musl@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-linux-arm64-musl@npm:1.11.1" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@unrs/resolver-binding-linux-ppc64-gnu@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-linux-ppc64-gnu@npm:1.11.1" + conditions: os=linux & cpu=ppc64 & libc=glibc + languageName: node + linkType: hard + +"@unrs/resolver-binding-linux-riscv64-gnu@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-linux-riscv64-gnu@npm:1.11.1" + conditions: os=linux & cpu=riscv64 & libc=glibc + languageName: node + linkType: hard + +"@unrs/resolver-binding-linux-riscv64-musl@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-linux-riscv64-musl@npm:1.11.1" + conditions: os=linux & cpu=riscv64 & libc=musl languageName: node linkType: hard -"@typescript-eslint/utils@npm:8.39.0, @typescript-eslint/utils@npm:^6.0.0 || ^7.0.0 || ^8.0.0, @typescript-eslint/utils@npm:^8.23.0, @typescript-eslint/utils@npm:^8.35.1, @typescript-eslint/utils@npm:^8.36.0": - version: 8.39.0 - resolution: "@typescript-eslint/utils@npm:8.39.0" - dependencies: - "@eslint-community/eslint-utils": "npm:^4.7.0" - "@typescript-eslint/scope-manager": "npm:8.39.0" - "@typescript-eslint/types": "npm:8.39.0" - "@typescript-eslint/typescript-estree": "npm:8.39.0" - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: ">=4.8.4 <6.0.0" - checksum: 10/ed340f36fa0788fbc2ca1be6676e05be8f3f91497617701f0a77b61b94622f6ea3606fff4871623dfde261811cccc30e4dbe567f9400d056185a7f47054d903c +"@unrs/resolver-binding-linux-s390x-gnu@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-linux-s390x-gnu@npm:1.11.1" + conditions: os=linux & cpu=s390x & libc=glibc languageName: node linkType: hard -"@typescript-eslint/visitor-keys@npm:8.27.0": - version: 8.27.0 - resolution: "@typescript-eslint/visitor-keys@npm:8.27.0" - dependencies: - "@typescript-eslint/types": "npm:8.27.0" - eslint-visitor-keys: "npm:^4.2.0" - checksum: 10/d62522d021760452f9c0549227ab2c603724834697c5bb51cb1e6b7b45d169c923225eb4b6d5dc822a5fbe634b7b445154f2da9f99a7d550cb288b3c2c9113d7 +"@unrs/resolver-binding-linux-x64-gnu@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-linux-x64-gnu@npm:1.11.1" + conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@typescript-eslint/visitor-keys@npm:8.28.0": - version: 8.28.0 - resolution: "@typescript-eslint/visitor-keys@npm:8.28.0" - dependencies: - "@typescript-eslint/types": "npm:8.28.0" - eslint-visitor-keys: "npm:^4.2.0" - checksum: 10/df159834ab40497f7adfa2bab973b64418d10e9dbbab92cf7d68e4b136734690e21bf6229f2770c5202e56c245eb641df3039652838bd439ce9f8e3293309662 +"@unrs/resolver-binding-linux-x64-musl@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-linux-x64-musl@npm:1.11.1" + conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@typescript-eslint/visitor-keys@npm:8.29.0": - version: 8.29.0 - resolution: "@typescript-eslint/visitor-keys@npm:8.29.0" +"@unrs/resolver-binding-wasm32-wasi@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-wasm32-wasi@npm:1.11.1" dependencies: - "@typescript-eslint/types": "npm:8.29.0" - eslint-visitor-keys: "npm:^4.2.0" - checksum: 10/02e0e86ab112849a31b7d06c767be0ca7802385bf953d3b75f4ba6d06741d9492773325bc69d4c2a1c191b08f1c4c4b33f8e062d6d5d9f0f4f78f1b8b3cc2d41 + "@napi-rs/wasm-runtime": "npm:^0.2.11" + conditions: cpu=wasm32 languageName: node linkType: hard -"@typescript-eslint/visitor-keys@npm:8.39.0": - version: 8.39.0 - resolution: "@typescript-eslint/visitor-keys@npm:8.39.0" - dependencies: - "@typescript-eslint/types": "npm:8.39.0" - eslint-visitor-keys: "npm:^4.2.1" - checksum: 10/2eb89b9e4d531d52de414591869bc208b45dd71b5f758302f176ef92bc3b922e60be5a046a2788cc0e16724631b2dc95aad849b866716a9c7a6361f994c97379 +"@unrs/resolver-binding-win32-arm64-msvc@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-win32-arm64-msvc@npm:1.11.1" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@unrs/resolver-binding-win32-ia32-msvc@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-win32-ia32-msvc@npm:1.11.1" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@unrs/resolver-binding-win32-x64-msvc@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-win32-x64-msvc@npm:1.11.1" + conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -6237,6 +6969,22 @@ __metadata: languageName: node linkType: hard +"@vitejs/plugin-react@npm:^5.0.2": + version: 5.0.4 + resolution: "@vitejs/plugin-react@npm:5.0.4" + dependencies: + "@babel/core": "npm:^7.28.4" + "@babel/plugin-transform-react-jsx-self": "npm:^7.27.1" + "@babel/plugin-transform-react-jsx-source": "npm:^7.27.1" + "@rolldown/pluginutils": "npm:1.0.0-beta.38" + "@types/babel__core": "npm:^7.20.5" + react-refresh: "npm:^0.17.0" + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + checksum: 10/8985e18a629440b3f9622a032129d25b67a9e81ccfeff03b485a6ba6634b5251bc5af22eb9a8954b0307f6e460bcd3cfea0c68031ba823f6fb56be9636c7df6b + languageName: node + linkType: hard + "@vitejs/plugin-vue@npm:^5.2.4": version: 5.2.4 resolution: "@vitejs/plugin-vue@npm:5.2.4" @@ -6823,14 +7571,14 @@ __metadata: languageName: node linkType: hard -"@welldone-software/why-did-you-render@npm:8": - version: 8.0.3 - resolution: "@welldone-software/why-did-you-render@npm:8.0.3" +"@welldone-software/why-did-you-render@npm:10.0.1": + version: 10.0.1 + resolution: "@welldone-software/why-did-you-render@npm:10.0.1" dependencies: lodash: "npm:^4" peerDependencies: - react: ^18 - checksum: 10/691f8aa87d312af393a50e2ff12497ab44a6ecbdcfb9e57e88ccc2a41c197243b61969aa8960d5e112d406d0c1de74505ddd559225e282926e166f691c3c86fb + react: ^19 + checksum: 10/da37a677b7e275bf5b4b615cd45bd23c83125d04c9a292b8af5920837d5c65ebf63c8229ff963feb038787938e024dc6d3cfa6c3277e68e24b8b066292453117 languageName: node linkType: hard @@ -6867,7 +7615,7 @@ __metadata: languageName: node linkType: hard -"@yankeeinlondon/builder-api@npm:^1.2.1, @yankeeinlondon/builder-api@npm:^1.3.4": +"@yankeeinlondon/builder-api@npm:^1.3.4": version: 1.4.1 resolution: "@yankeeinlondon/builder-api@npm:1.4.1" dependencies: @@ -6881,7 +7629,7 @@ __metadata: languageName: node linkType: hard -"@yankeeinlondon/gray-matter@npm:^6.1.0, @yankeeinlondon/gray-matter@npm:^6.1.1": +"@yankeeinlondon/gray-matter@npm:^6.1.1": version: 6.2.1 resolution: "@yankeeinlondon/gray-matter@npm:6.2.1" dependencies: @@ -6936,13 +7684,6 @@ __metadata: languageName: node linkType: hard -"abab@npm:^2.0.6": - version: 2.0.6 - resolution: "abab@npm:2.0.6" - checksum: 10/ebe95d7278999e605823fc515a3b05d689bc72e7f825536e73c95ebf621636874c6de1b749b3c4bf866b96ccd4b3a2802efa313d0e45ad51a413c8c73247db20 - languageName: node - linkType: hard - "abbrev@npm:1": version: 1.1.1 resolution: "abbrev@npm:1.1.1" @@ -6983,16 +7724,6 @@ __metadata: languageName: node linkType: hard -"acorn-globals@npm:^7.0.0": - version: 7.0.1 - resolution: "acorn-globals@npm:7.0.1" - dependencies: - acorn: "npm:^8.1.0" - acorn-walk: "npm:^8.0.2" - checksum: 10/2a2998a547af6d0db5f0cdb90acaa7c3cbca6709010e02121fb8b8617c0fbd8bab0b869579903fde358ac78454356a14fadcc1a672ecb97b04b1c2ccba955ce8 - languageName: node - linkType: hard - "acorn-import-phases@npm:^1.0.3": version: 1.0.4 resolution: "acorn-import-phases@npm:1.0.4" @@ -7029,7 +7760,7 @@ __metadata: languageName: node linkType: hard -"acorn-walk@npm:^8.0.0, acorn-walk@npm:^8.0.2, acorn-walk@npm:^8.1.1, acorn-walk@npm:^8.2.0": +"acorn-walk@npm:^8.0.0, acorn-walk@npm:^8.1.1, acorn-walk@npm:^8.2.0": version: 8.3.4 resolution: "acorn-walk@npm:8.3.4" dependencies: @@ -7056,7 +7787,7 @@ __metadata: languageName: node linkType: hard -"acorn@npm:^8.0.4, acorn@npm:^8.1.0, acorn@npm:^8.10.0, acorn@npm:^8.11.0, acorn@npm:^8.14.0, acorn@npm:^8.15.0, acorn@npm:^8.4.1, acorn@npm:^8.8.1, acorn@npm:^8.9.0": +"acorn@npm:^8.0.4, acorn@npm:^8.10.0, acorn@npm:^8.11.0, acorn@npm:^8.14.0, acorn@npm:^8.15.0, acorn@npm:^8.4.1, acorn@npm:^8.8.1, acorn@npm:^8.9.0": version: 8.15.0 resolution: "acorn@npm:8.15.0" bin: @@ -7229,7 +7960,7 @@ __metadata: languageName: node linkType: hard -"ansi-escapes@npm:^4.2.1": +"ansi-escapes@npm:^4.3.2": version: 4.3.2 resolution: "ansi-escapes@npm:4.3.2" dependencies: @@ -7307,7 +8038,7 @@ __metadata: languageName: node linkType: hard -"ansi-styles@npm:^5.0.0": +"ansi-styles@npm:^5.0.0, ansi-styles@npm:^5.2.0": version: 5.2.0 resolution: "ansi-styles@npm:5.2.0" checksum: 10/d7f4e97ce0623aea6bc0d90dcd28881ee04cba06c570b97fd3391bd7a268eedfd9d5e2dd4fdcbdd82b8105df5faf6f24aaedc08eaf3da898e702db5948f63469 @@ -7331,7 +8062,7 @@ __metadata: languageName: node linkType: hard -"anymatch@npm:^3.0.3, anymatch@npm:~3.1.2": +"anymatch@npm:^3.1.3, anymatch@npm:~3.1.2": version: 3.1.3 resolution: "anymatch@npm:3.1.3" dependencies: @@ -7630,6 +8361,19 @@ __metadata: languageName: node linkType: hard +"assert@npm:^2.0.0": + version: 2.1.0 + resolution: "assert@npm:2.1.0" + dependencies: + call-bind: "npm:^1.0.2" + is-nan: "npm:^1.3.2" + object-is: "npm:^1.1.5" + object.assign: "npm:^4.1.4" + util: "npm:^0.12.5" + checksum: 10/6b9d813c8eef1c0ac13feac5553972e4bd180ae16000d4eb5c0ded2489188737c75a5aacefc97a985008b37502f62fe1bad34da1a7481a54bbfabec3964c8aa7 + languageName: node + linkType: hard + "assertion-error@npm:^1.1.0": version: 1.1.0 resolution: "assertion-error@npm:1.1.0" @@ -7722,13 +8466,6 @@ __metadata: languageName: node linkType: hard -"asynckit@npm:^0.4.0": - version: 0.4.0 - resolution: "asynckit@npm:0.4.0" - checksum: 10/3ce727cbc78f69d6a4722517a58ee926c8c21083633b1d3fdf66fd688f6c127a53a592141bd4866f9b63240a86e9d8e974b13919450bd17fa33c2d22c4558ad8 - languageName: node - linkType: hard - "atob@npm:^2.1.2": version: 2.1.2 resolution: "atob@npm:2.1.2" @@ -7761,20 +8498,20 @@ __metadata: languageName: node linkType: hard -"babel-jest@npm:^29.7.0": - version: 29.7.0 - resolution: "babel-jest@npm:29.7.0" +"babel-jest@npm:30.1.2": + version: 30.1.2 + resolution: "babel-jest@npm:30.1.2" dependencies: - "@jest/transform": "npm:^29.7.0" - "@types/babel__core": "npm:^7.1.14" - babel-plugin-istanbul: "npm:^6.1.1" - babel-preset-jest: "npm:^29.6.3" - chalk: "npm:^4.0.0" - graceful-fs: "npm:^4.2.9" + "@jest/transform": "npm:30.1.2" + "@types/babel__core": "npm:^7.20.5" + babel-plugin-istanbul: "npm:^7.0.0" + babel-preset-jest: "npm:30.0.1" + chalk: "npm:^4.1.2" + graceful-fs: "npm:^4.2.11" slash: "npm:^3.0.0" peerDependencies: - "@babel/core": ^7.8.0 - checksum: 10/8a0953bd813b3a8926008f7351611055548869e9a53dd36d6e7e96679001f71e65fd7dbfe253265c3ba6a4e630dc7c845cf3e78b17d758ef1880313ce8fba258 + "@babel/core": ^7.11.0 + checksum: 10/9697119fe41b84c95140df82d6387b264546e64c39a8e2f99e4b376d29fa1c10166c32f61a3d257ac962bcbf11c55038c8a54edc06c1e3bd886e4720dfb3c571 languageName: node linkType: hard @@ -7794,28 +8531,27 @@ __metadata: languageName: node linkType: hard -"babel-plugin-istanbul@npm:^6.1.1": - version: 6.1.1 - resolution: "babel-plugin-istanbul@npm:6.1.1" +"babel-plugin-istanbul@npm:^7.0.0": + version: 7.0.1 + resolution: "babel-plugin-istanbul@npm:7.0.1" dependencies: "@babel/helper-plugin-utils": "npm:^7.0.0" "@istanbuljs/load-nyc-config": "npm:^1.0.0" - "@istanbuljs/schema": "npm:^0.1.2" - istanbul-lib-instrument: "npm:^5.0.4" + "@istanbuljs/schema": "npm:^0.1.3" + istanbul-lib-instrument: "npm:^6.0.2" test-exclude: "npm:^6.0.0" - checksum: 10/ffd436bb2a77bbe1942a33245d770506ab2262d9c1b3c1f1da7f0592f78ee7445a95bc2efafe619dd9c1b6ee52c10033d6c7d29ddefe6f5383568e60f31dfe8d + checksum: 10/fe9f865f975aaa7a033de9ccb2b63fdcca7817266c5e98d3e02ac7ffd774c695093d215302796cb3770a71ef4574e7a9b298504c3c0c104cf4b48c8eda67b2a6 languageName: node linkType: hard -"babel-plugin-jest-hoist@npm:^29.6.3": - version: 29.6.3 - resolution: "babel-plugin-jest-hoist@npm:29.6.3" +"babel-plugin-jest-hoist@npm:30.0.1": + version: 30.0.1 + resolution: "babel-plugin-jest-hoist@npm:30.0.1" dependencies: - "@babel/template": "npm:^7.3.3" - "@babel/types": "npm:^7.3.3" - "@types/babel__core": "npm:^7.1.14" - "@types/babel__traverse": "npm:^7.0.6" - checksum: 10/9bfa86ec4170bd805ab8ca5001ae50d8afcb30554d236ba4a7ffc156c1a92452e220e4acbd98daefc12bf0216fccd092d0a2efed49e7e384ec59e0597a926d65 + "@babel/template": "npm:^7.27.2" + "@babel/types": "npm:^7.27.3" + "@types/babel__core": "npm:^7.20.5" + checksum: 10/4d8d0eb3726fb16b85322449fff15fa48404ef92dae48f9b0c956f6d504208e604e4e40fe71665433cb21f35be0faf5b2b11732330f67b3add66728edcfbcb93 languageName: node linkType: hard @@ -7855,7 +8591,7 @@ __metadata: languageName: node linkType: hard -"babel-preset-current-node-syntax@npm:^1.0.0": +"babel-preset-current-node-syntax@npm:^1.1.0": version: 1.2.0 resolution: "babel-preset-current-node-syntax@npm:1.2.0" dependencies: @@ -7880,15 +8616,15 @@ __metadata: languageName: node linkType: hard -"babel-preset-jest@npm:^29.6.3": - version: 29.6.3 - resolution: "babel-preset-jest@npm:29.6.3" +"babel-preset-jest@npm:30.0.1": + version: 30.0.1 + resolution: "babel-preset-jest@npm:30.0.1" dependencies: - babel-plugin-jest-hoist: "npm:^29.6.3" - babel-preset-current-node-syntax: "npm:^1.0.0" + babel-plugin-jest-hoist: "npm:30.0.1" + babel-preset-current-node-syntax: "npm:^1.1.0" peerDependencies: - "@babel/core": ^7.0.0 - checksum: 10/aa4ff2a8a728d9d698ed521e3461a109a1e66202b13d3494e41eea30729a5e7cc03b3a2d56c594423a135429c37bf63a9fa8b0b9ce275298be3095a88c69f6fb + "@babel/core": ^7.11.0 + checksum: 10/fa37b0fa11baffd983f42663c7a4db61d9b10704bd061333950c3d2a191457930e68e172a93f6675d85cd6a1315fd6954143bda5709a3ba38ef7bd87a13d0aa6 languageName: node linkType: hard @@ -7984,6 +8720,15 @@ __metadata: languageName: node linkType: hard +"baseline-browser-mapping@npm:^2.8.9": + version: 2.8.16 + resolution: "baseline-browser-mapping@npm:2.8.16" + bin: + baseline-browser-mapping: dist/cli.js + checksum: 10/52a5807591daeffc810b783b1afa20c4017dd94e5bb74934bcde4dd408758e492610e330cfe6e609a0f0bde5ce210dd934271540fb931389d6838db17ec8cfef + languageName: node + linkType: hard + "basic-ftp@npm:^5.0.2": version: 5.0.5 resolution: "basic-ftp@npm:5.0.5" @@ -8322,7 +9067,7 @@ __metadata: languageName: node linkType: hard -"browserify-zlib@npm:~0.2.0": +"browserify-zlib@npm:^0.2.0, browserify-zlib@npm:~0.2.0": version: 0.2.0 resolution: "browserify-zlib@npm:0.2.0" dependencies: @@ -8403,6 +9148,21 @@ __metadata: languageName: node linkType: hard +"browserslist@npm:^4.26.3": + version: 4.26.3 + resolution: "browserslist@npm:4.26.3" + dependencies: + baseline-browser-mapping: "npm:^2.8.9" + caniuse-lite: "npm:^1.0.30001746" + electron-to-chromium: "npm:^1.5.227" + node-releases: "npm:^2.0.21" + update-browserslist-db: "npm:^1.1.3" + bin: + browserslist: cli.js + checksum: 10/49add06fd753a2514d84c75a7de8d9fb3d70be675e53b72981d87f0c0ff40d8a8cd0bd92f77400381704be0bf1c9c5c65aef95d03843d69475ff55188aa12124 + languageName: node + linkType: hard + "bs-logger@npm:^0.2.6": version: 0.2.6 resolution: "bs-logger@npm:0.2.6" @@ -8449,7 +9209,7 @@ __metadata: languageName: node linkType: hard -"buffer@npm:^5.2.1, buffer@npm:^5.5.0": +"buffer@npm:^5.2.1, buffer@npm:^5.5.0, buffer@npm:^5.7.1": version: 5.7.1 resolution: "buffer@npm:5.7.1" dependencies: @@ -8645,7 +9405,7 @@ __metadata: languageName: node linkType: hard -"call-bind@npm:^1.0.7, call-bind@npm:^1.0.8": +"call-bind@npm:^1.0.0, call-bind@npm:^1.0.2, call-bind@npm:^1.0.7, call-bind@npm:^1.0.8": version: 1.0.8 resolution: "call-bind@npm:1.0.8" dependencies: @@ -8674,7 +9434,7 @@ __metadata: languageName: node linkType: hard -"callsites@npm:^3.0.0": +"callsites@npm:^3.0.0, callsites@npm:^3.1.0": version: 3.1.0 resolution: "callsites@npm:3.1.0" checksum: 10/072d17b6abb459c2ba96598918b55868af677154bec7e73d222ef95a8fdb9bbf7dae96a8421085cdad8cd190d86653b5b6dc55a4484f2e5b2e27d5e0c3fc15b3 @@ -8695,7 +9455,7 @@ __metadata: languageName: node linkType: hard -"camelcase@npm:^6.0.0, camelcase@npm:^6.2.0": +"camelcase@npm:^6.0.0, camelcase@npm:^6.3.0": version: 6.3.0 resolution: "camelcase@npm:6.3.0" checksum: 10/8c96818a9076434998511251dcb2761a94817ea17dbdc37f47ac080bd088fc62c7369429a19e2178b993497132c8cbcf5cc1f44ba963e76782ba469c0474938d @@ -8709,6 +9469,13 @@ __metadata: languageName: node linkType: hard +"caniuse-lite@npm:^1.0.30001746": + version: 1.0.30001750 + resolution: "caniuse-lite@npm:1.0.30001750" + checksum: 10/2b912758d817cd2c2c179246e282f8b598695ec733bc446183e1d381eada60889c4770a1dfd86075e046a43d55f9922e2eaed1501347fcb12a38716cc14be297 + languageName: node + linkType: hard + "canvas@npm:^3.1.0": version: 3.1.2 resolution: "canvas@npm:3.1.2" @@ -8813,6 +9580,13 @@ __metadata: languageName: node linkType: hard +"change-case@npm:^5.4.4": + version: 5.4.4 + resolution: "change-case@npm:5.4.4" + checksum: 10/446e5573f3c854290a91292afef92b957d2e43a928260c91989b482aa860caaa29711b6725fc40c200af68061cbab357b033446d16a17bc5c553636994074e92 + languageName: node + linkType: hard + "char-regex@npm:^1.0.2": version: 1.0.2 resolution: "char-regex@npm:1.0.2" @@ -8997,13 +9771,6 @@ __metadata: languageName: node linkType: hard -"ci-info@npm:^3.2.0": - version: 3.9.0 - resolution: "ci-info@npm:3.9.0" - checksum: 10/75bc67902b4d1c7b435497adeb91598f6d52a3389398e44294f6601b20cfef32cf2176f7be0eb961d9e085bb333a8a5cae121cb22f81cf238ae7f58eb80e9397 - languageName: node - linkType: hard - "ci-info@npm:^4.2.0": version: 4.3.0 resolution: "ci-info@npm:4.3.0" @@ -9011,6 +9778,13 @@ __metadata: languageName: node linkType: hard +"ci-info@npm:^4.3.0": + version: 4.3.1 + resolution: "ci-info@npm:4.3.1" + checksum: 10/9dc952bef67e665ccde2e7a552d42d5d095529d21829ece060a00925ede2dfa136160c70ef2471ea6ed6c9b133218b47c007f56955c0f1734a2e57f240aa7445 + languageName: node + linkType: hard + "cipher-base@npm:^1.0.0, cipher-base@npm:^1.0.1, cipher-base@npm:^1.0.3": version: 1.0.6 resolution: "cipher-base@npm:1.0.6" @@ -9021,10 +9795,10 @@ __metadata: languageName: node linkType: hard -"cjs-module-lexer@npm:^1.0.0": - version: 1.4.3 - resolution: "cjs-module-lexer@npm:1.4.3" - checksum: 10/d2b92f919a2dedbfd61d016964fce8da0035f827182ed6839c97cac56e8a8077cfa6a59388adfe2bc588a19cef9bbe830d683a76a6e93c51f65852062cfe2591 +"cjs-module-lexer@npm:^2.1.0": + version: 2.1.0 + resolution: "cjs-module-lexer@npm:2.1.0" + checksum: 10/97cf8e7ddbf685ce0fe1a89349f42a015e89ddf02f1f0d764ddb8a07bd642d58a036c21b5cae078cdf6a96b332b95f806948d772adcd2c346ce5a897f5feefb7 languageName: node linkType: hard @@ -9131,7 +9905,7 @@ __metadata: languageName: node linkType: hard -"collect-v8-coverage@npm:^1.0.0": +"collect-v8-coverage@npm:^1.0.2": version: 1.0.2 resolution: "collect-v8-coverage@npm:1.0.2" checksum: 10/30ea7d5c9ee51f2fdba4901d4186c5b7114a088ef98fd53eda3979da77eed96758a2cae81cc6d97e239aaea6065868cf908b24980663f7b7e96aa291b3e12fa4 @@ -9206,15 +9980,6 @@ __metadata: languageName: node linkType: hard -"combined-stream@npm:^1.0.8": - version: 1.0.8 - resolution: "combined-stream@npm:1.0.8" - dependencies: - delayed-stream: "npm:~1.0.0" - checksum: 10/2e969e637d05d09fa50b02d74c83a1186f6914aae89e6653b62595cc75a221464f884f55f231b8f4df7a49537fba60bdc0427acd2bf324c09a1dbb84837e36e4 - languageName: node - linkType: hard - "commander@npm:7.2.0, commander@npm:^7.0.0, commander@npm:^7.2.0": version: 7.2.0 resolution: "commander@npm:7.2.0" @@ -9384,7 +10149,7 @@ __metadata: languageName: node linkType: hard -"constants-browserify@npm:~1.0.0": +"constants-browserify@npm:^1.0.0, constants-browserify@npm:~1.0.0": version: 1.0.0 resolution: "constants-browserify@npm:1.0.0" checksum: 10/49ef0babd907616dddde6905b80fe44ad5948e1eaaf6cf65d5f23a8c60c029ff63a1198c364665be1d6b2cb183d7e12921f33049cc126734ade84a3cfdbc83f6 @@ -9499,7 +10264,7 @@ __metadata: languageName: node linkType: hard -"core-js-compat@npm:^3.40.0, core-js-compat@npm:^3.41.0, core-js-compat@npm:^3.6.2": +"core-js-compat@npm:^3.40.0, core-js-compat@npm:^3.6.2": version: 3.45.0 resolution: "core-js-compat@npm:3.45.0" dependencies: @@ -9508,6 +10273,15 @@ __metadata: languageName: node linkType: hard +"core-js-compat@npm:^3.44.0": + version: 3.46.0 + resolution: "core-js-compat@npm:3.46.0" + dependencies: + browserslist: "npm:^4.26.3" + checksum: 10/bee0523541d0e646c98dbff5b55bafa2e1674db82f769d851670a364bf4456b2a0364e393a70b09c4263f5dcb1fba3be32ddb4cffab11a79b53efbe32f4b76fb + languageName: node + linkType: hard + "core-js@npm:3.6.1": version: 3.6.1 resolution: "core-js@npm:3.6.1" @@ -9636,24 +10410,7 @@ __metadata: languageName: node linkType: hard -"create-jest@npm:^29.7.0": - version: 29.7.0 - resolution: "create-jest@npm:29.7.0" - dependencies: - "@jest/types": "npm:^29.6.3" - chalk: "npm:^4.0.0" - exit: "npm:^0.1.2" - graceful-fs: "npm:^4.2.9" - jest-config: "npm:^29.7.0" - jest-util: "npm:^29.7.0" - prompts: "npm:^2.0.1" - bin: - create-jest: bin/create-jest.js - checksum: 10/847b4764451672b4174be4d5c6d7d63442ec3aa5f3de52af924e4d996d87d7801c18e125504f25232fc75840f6625b3ac85860fac6ce799b5efae7bdcaf4a2b7 - languageName: node - linkType: hard - -"create-require@npm:^1.1.0": +"create-require@npm:^1.1.0, create-require@npm:^1.1.1": version: 1.1.1 resolution: "create-require@npm:1.1.1" checksum: 10/a9a1503d4390d8b59ad86f4607de7870b39cad43d929813599a23714831e81c520bddf61bcdd1f8e30f05fd3a2b71ae8538e946eb2786dc65c2bbc520f692eff @@ -9700,7 +10457,7 @@ __metadata: languageName: node linkType: hard -"crypto-browserify@npm:^3.0.0": +"crypto-browserify@npm:^3.0.0, crypto-browserify@npm:^3.12.1": version: 3.12.1 resolution: "crypto-browserify@npm:3.12.1" dependencies: @@ -9876,29 +10633,6 @@ __metadata: languageName: node linkType: hard -"cssom@npm:^0.5.0": - version: 0.5.0 - resolution: "cssom@npm:0.5.0" - checksum: 10/b502a315b1ce020a692036cc38cb36afa44157219b80deadfa040ab800aa9321fcfbecf02fd2e6ec87db169715e27978b4ab3701f916461e9cf7808899f23b54 - languageName: node - linkType: hard - -"cssom@npm:~0.3.6": - version: 0.3.8 - resolution: "cssom@npm:0.3.8" - checksum: 10/49eacc88077555e419646c0ea84ddc73c97e3a346ad7cb95e22f9413a9722d8964b91d781ce21d378bd5ae058af9a745402383fa4e35e9cdfd19654b63f892a9 - languageName: node - linkType: hard - -"cssstyle@npm:^2.3.0": - version: 2.3.0 - resolution: "cssstyle@npm:2.3.0" - dependencies: - cssom: "npm:~0.3.6" - checksum: 10/46f7f05a153446c4018b0454ee1464b50f606cb1803c90d203524834b7438eb52f3b173ba0891c618f380ced34ee12020675dc0052a7f1be755fe4ebc27ee977 - languageName: node - linkType: hard - "cssstyle@npm:^4.2.1": version: 4.6.0 resolution: "cssstyle@npm:4.6.0" @@ -9951,17 +10685,6 @@ __metadata: languageName: node linkType: hard -"data-urls@npm:^3.0.2": - version: 3.0.2 - resolution: "data-urls@npm:3.0.2" - dependencies: - abab: "npm:^2.0.6" - whatwg-mimetype: "npm:^3.0.0" - whatwg-url: "npm:^11.0.0" - checksum: 10/033fc3dd0fba6d24bc9a024ddcf9923691dd24f90a3d26f6545d6a2f71ec6956f93462f2cdf2183cc46f10dc01ed3bcb36731a8208456eb1a08147e571fe2a76 - languageName: node - linkType: hard - "data-urls@npm:^5.0.0": version: 5.0.0 resolution: "data-urls@npm:5.0.0" @@ -10108,7 +10831,7 @@ __metadata: languageName: node linkType: hard -"decimal.js@npm:^10.4.2, decimal.js@npm:^10.5.0": +"decimal.js@npm:^10.5.0": version: 10.6.0 resolution: "decimal.js@npm:10.6.0" checksum: 10/c0d45842d47c311d11b38ce7ccc911121953d4df3ebb1465d92b31970eb4f6738a065426a06094af59bee4b0d64e42e7c8984abd57b6767c64ea90cf90bb4a69 @@ -10131,15 +10854,15 @@ __metadata: languageName: node linkType: hard -"dedent@npm:^1.0.0": - version: 1.6.0 - resolution: "dedent@npm:1.6.0" +"dedent@npm:^1.6.0": + version: 1.7.0 + resolution: "dedent@npm:1.7.0" peerDependencies: babel-plugin-macros: ^3.1.0 peerDependenciesMeta: babel-plugin-macros: optional: true - checksum: 10/f100cb11001309f2185c4334c6f29e5323c1e73b7b75e3b1893bc71ef53cd13fb80534efc8fa7163a891ede633e310a9c600ba38c363cc9d14a72f238fe47078 + checksum: 10/c902f3e7e828923bd642c12c1d8996616ff5588f8279a2951790bd7c7e479fa4dd7f016b55ce2c9ea1aa2895fc503e7d6c0cde6ebc95ca683ac0230f7c911fd7 languageName: node linkType: hard @@ -10198,7 +10921,7 @@ __metadata: languageName: node linkType: hard -"deepmerge@npm:^4.2.2": +"deepmerge@npm:^4.2.2, deepmerge@npm:^4.3.1": version: 4.3.1 resolution: "deepmerge@npm:4.3.1" checksum: 10/058d9e1b0ff1a154468bf3837aea436abcfea1ba1d165ddaaf48ca93765fdd01a30d33c36173da8fbbed951dd0a267602bc782fe288b0fc4b7e1e7091afc4529 @@ -10320,13 +11043,6 @@ __metadata: languageName: node linkType: hard -"delayed-stream@npm:~1.0.0": - version: 1.0.0 - resolution: "delayed-stream@npm:1.0.0" - checksum: 10/46fe6e83e2cb1d85ba50bd52803c68be9bd953282fa7096f51fc29edd5d67ff84ff753c51966061e5ba7cb5e47ef6d36a91924eddb7f3f3483b1c560f77a0020 - languageName: node - linkType: hard - "depd@npm:2.0.0": version: 2.0.0 resolution: "depd@npm:2.0.0" @@ -10425,7 +11141,7 @@ __metadata: languageName: node linkType: hard -"detect-newline@npm:^3.0.0": +"detect-newline@npm:^3.1.0": version: 3.1.0 resolution: "detect-newline@npm:3.1.0" checksum: 10/ae6cd429c41ad01b164c59ea36f264a2c479598e61cba7c99da24175a7ab80ddf066420f2bec9a1c57a6bead411b4655ff15ad7d281c000a89791f48cbe939e7 @@ -10473,13 +11189,6 @@ __metadata: languageName: node linkType: hard -"diff-sequences@npm:^29.6.3": - version: 29.6.3 - resolution: "diff-sequences@npm:29.6.3" - checksum: 10/179daf9d2f9af5c57ad66d97cb902a538bcf8ed64963fa7aa0c329b3de3665ce2eb6ffdc2f69f29d445fa4af2517e5e55e5b6e00c00a9ae4f43645f97f7078cb - languageName: node - linkType: hard - "diff@npm:^3.5.0": version: 3.5.0 resolution: "diff@npm:3.5.0" @@ -10585,6 +11294,13 @@ __metadata: languageName: node linkType: hard +"domain-browser@npm:4.22.0": + version: 4.22.0 + resolution: "domain-browser@npm:4.22.0" + checksum: 10/3ffbaf0cae8da717698d472ca85ab52f96c538fe1fe85e5eb3351d4e7af52423ce096b8a0c51bb318e1c9ccf9c2e94b3b0f68e5923ad0aa0c623a32b641ed11c + languageName: node + linkType: hard + "domain-browser@npm:^1.2.0": version: 1.2.0 resolution: "domain-browser@npm:1.2.0" @@ -10599,15 +11315,6 @@ __metadata: languageName: node linkType: hard -"domexception@npm:^4.0.0": - version: 4.0.0 - resolution: "domexception@npm:4.0.0" - dependencies: - webidl-conversions: "npm:^7.0.0" - checksum: 10/4ed443227d2871d76c58d852b2e93c68e0443815b2741348f20881bedee8c1ad4f9bfc5d30c7dec433cd026b57da63407c010260b1682fef4c8847e7181ea43f - languageName: node - linkType: hard - "domhandler@npm:^5.0.2, domhandler@npm:^5.0.3": version: 5.0.3 resolution: "domhandler@npm:5.0.3" @@ -10740,6 +11447,13 @@ __metadata: languageName: node linkType: hard +"electron-to-chromium@npm:^1.5.227": + version: 1.5.237 + resolution: "electron-to-chromium@npm:1.5.237" + checksum: 10/d21837cfc70038f547d8bf2328e5139f5e4143e365d64633fca568772dd68ceac2cf0d802f0d16921e6df925efb3527f2ba664dbcfa7ddd75e21465d2b0d4a6c + languageName: node + linkType: hard + "elliptic@npm:^6.5.3, elliptic@npm:^6.5.5": version: 6.6.1 resolution: "elliptic@npm:6.6.1" @@ -11413,7 +12127,7 @@ __metadata: languageName: node linkType: hard -"escodegen@npm:^2.0.0, escodegen@npm:^2.1.0": +"escodegen@npm:^2.1.0": version: 2.1.0 resolution: "escodegen@npm:2.1.0" dependencies: @@ -11431,71 +12145,71 @@ __metadata: languageName: node linkType: hard -"eslint-plugin-depend@npm:0.12.0": - version: 0.12.0 - resolution: "eslint-plugin-depend@npm:0.12.0" +"eslint-plugin-depend@npm:1.2.0": + version: 1.2.0 + resolution: "eslint-plugin-depend@npm:1.2.0" dependencies: fd-package-json: "npm:^1.2.0" - module-replacements: "npm:^2.1.0" + module-replacements: "npm:^2.8.0" semver: "npm:^7.6.3" - checksum: 10/3b4077a5be76940776c12276e360eed92faf3f4518b5e6286853fe6030c15c5811f2c7bd0c17a44bd4e4fa5dfa74f53b6a354389d1ff0acdfc5823775081465c + checksum: 10/326fe6a74602afe764a3d65ac83adcf3aebc3e520f0597c26849d3ef9fc4beedab0c59674f8074c6bef8864291471a9644571d00c45d367383f8a3c343e0a26b languageName: node linkType: hard -"eslint-plugin-jest@npm:^28.8.3": - version: 28.14.0 - resolution: "eslint-plugin-jest@npm:28.14.0" +"eslint-plugin-jest@npm:29.0.1": + version: 29.0.1 + resolution: "eslint-plugin-jest@npm:29.0.1" dependencies: - "@typescript-eslint/utils": "npm:^6.0.0 || ^7.0.0 || ^8.0.0" + "@typescript-eslint/utils": "npm:^8.0.0" peerDependencies: - "@typescript-eslint/eslint-plugin": ^6.0.0 || ^7.0.0 || ^8.0.0 - eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 + "@typescript-eslint/eslint-plugin": ^8.0.0 + eslint: ^8.57.0 || ^9.0.0 jest: "*" peerDependenciesMeta: "@typescript-eslint/eslint-plugin": optional: true jest: optional: true - checksum: 10/6032497bd97d6dd010450d5fdf535b8613a2789f4f83764ae04361c48d06d92f3d9b2e4350914b8fd857b6e611ba2b5282a1133ab8ec51b3e7053f9d336058e6 + checksum: 10/d7b0a3fbdbf795225fbbff2c69c7711bb6502a3d4444d857c95a9d6578a65c80fd8a9fcd3ebc3d0634fe1cc70b4b77e887943945fadab6a974a736d2ffc5babf languageName: node linkType: hard -"eslint-plugin-jsdoc@npm:^50.6.9": - version: 50.8.0 - resolution: "eslint-plugin-jsdoc@npm:50.8.0" +"eslint-plugin-jsdoc@npm:54.0.0": + version: 54.0.0 + resolution: "eslint-plugin-jsdoc@npm:54.0.0" dependencies: - "@es-joy/jsdoccomment": "npm:~0.50.2" + "@es-joy/jsdoccomment": "npm:~0.52.0" are-docs-informative: "npm:^0.0.2" comment-parser: "npm:1.4.1" debug: "npm:^4.4.1" escape-string-regexp: "npm:^4.0.0" - espree: "npm:^10.3.0" + espree: "npm:^10.4.0" esquery: "npm:^1.6.0" parse-imports-exports: "npm:^0.2.4" semver: "npm:^7.7.2" spdx-expression-parse: "npm:^4.0.0" peerDependencies: eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 - checksum: 10/8857bb6583e04af0a1949e602e2b5b2abc5a951583bdc5a3baa0cc24f7c16db367cbc44e008c45b06dc2029685f0eb1ff6a0bb91e90fd82710ce30952d878d5d + checksum: 10/a394dd846550962bff6aa97593f8e57635c512094249074464cb5a98fcbbdc0427bbee4814ca84ec95d9a5f5b7e19f03a01c426ca0fa24809bd5ee3f87552759 languageName: node linkType: hard -"eslint-plugin-react-debug@npm:1.52.3": - version: 1.52.3 - resolution: "eslint-plugin-react-debug@npm:1.52.3" +"eslint-plugin-react-debug@npm:1.52.4": + version: 1.52.4 + resolution: "eslint-plugin-react-debug@npm:1.52.4" dependencies: - "@eslint-react/ast": "npm:1.52.3" - "@eslint-react/core": "npm:1.52.3" - "@eslint-react/eff": "npm:1.52.3" - "@eslint-react/kit": "npm:1.52.3" - "@eslint-react/shared": "npm:1.52.3" - "@eslint-react/var": "npm:1.52.3" - "@typescript-eslint/scope-manager": "npm:^8.36.0" - "@typescript-eslint/type-utils": "npm:^8.36.0" - "@typescript-eslint/types": "npm:^8.36.0" - "@typescript-eslint/utils": "npm:^8.36.0" + "@eslint-react/ast": "npm:1.52.4" + "@eslint-react/core": "npm:1.52.4" + "@eslint-react/eff": "npm:1.52.4" + "@eslint-react/kit": "npm:1.52.4" + "@eslint-react/shared": "npm:1.52.4" + "@eslint-react/var": "npm:1.52.4" + "@typescript-eslint/scope-manager": "npm:^8.39.1" + "@typescript-eslint/type-utils": "npm:^8.39.1" + "@typescript-eslint/types": "npm:^8.39.1" + "@typescript-eslint/utils": "npm:^8.39.1" string-ts: "npm:^2.2.1" - ts-pattern: "npm:^5.7.1" + ts-pattern: "npm:^5.8.0" peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: ^4.9.5 || ^5.3.3 @@ -11504,26 +12218,26 @@ __metadata: optional: false typescript: optional: true - checksum: 10/a7f8d1223d5e31360685ef694fe91138f3d177a2c4e1676f8126dc7563bd0c14be8d595f4a0aad1bac975d82eeceb19be42f00fa5d9dca0273ff36e6c5a6b681 + checksum: 10/2bdd3df51ecd530649a9f6b2589f38e9f50e75ae9fd2803bf52040312fff054667fef626f4209374220e8851019f925254d0867c5af5c94186dba6961bc5297c languageName: node linkType: hard -"eslint-plugin-react-dom@npm:1.52.3": - version: 1.52.3 - resolution: "eslint-plugin-react-dom@npm:1.52.3" +"eslint-plugin-react-dom@npm:1.52.4": + version: 1.52.4 + resolution: "eslint-plugin-react-dom@npm:1.52.4" dependencies: - "@eslint-react/ast": "npm:1.52.3" - "@eslint-react/core": "npm:1.52.3" - "@eslint-react/eff": "npm:1.52.3" - "@eslint-react/kit": "npm:1.52.3" - "@eslint-react/shared": "npm:1.52.3" - "@eslint-react/var": "npm:1.52.3" - "@typescript-eslint/scope-manager": "npm:^8.36.0" - "@typescript-eslint/types": "npm:^8.36.0" - "@typescript-eslint/utils": "npm:^8.36.0" + "@eslint-react/ast": "npm:1.52.4" + "@eslint-react/core": "npm:1.52.4" + "@eslint-react/eff": "npm:1.52.4" + "@eslint-react/kit": "npm:1.52.4" + "@eslint-react/shared": "npm:1.52.4" + "@eslint-react/var": "npm:1.52.4" + "@typescript-eslint/scope-manager": "npm:^8.39.1" + "@typescript-eslint/types": "npm:^8.39.1" + "@typescript-eslint/utils": "npm:^8.39.1" compare-versions: "npm:^6.1.1" string-ts: "npm:^2.2.1" - ts-pattern: "npm:^5.7.1" + ts-pattern: "npm:^5.8.0" peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: ^4.9.5 || ^5.3.3 @@ -11532,26 +12246,26 @@ __metadata: optional: false typescript: optional: true - checksum: 10/31db6e048bd9705b0039a35cd051901b0053b8922a5d82d3e22df04c357ce6ebd93882c5d2bb948fef95821cded2e76bdad614b18288a3e4610373eb17d93d6c + checksum: 10/66b98905c563a9fda76be3e934816c286bb8da8a47757943a1d089b670826b538272d127004897f6660c14d38c74bb82851ecfd3f8a047d8e75f6578c19a9443 languageName: node linkType: hard -"eslint-plugin-react-hooks-extra@npm:1.52.3": - version: 1.52.3 - resolution: "eslint-plugin-react-hooks-extra@npm:1.52.3" +"eslint-plugin-react-hooks-extra@npm:1.52.4": + version: 1.52.4 + resolution: "eslint-plugin-react-hooks-extra@npm:1.52.4" dependencies: - "@eslint-react/ast": "npm:1.52.3" - "@eslint-react/core": "npm:1.52.3" - "@eslint-react/eff": "npm:1.52.3" - "@eslint-react/kit": "npm:1.52.3" - "@eslint-react/shared": "npm:1.52.3" - "@eslint-react/var": "npm:1.52.3" - "@typescript-eslint/scope-manager": "npm:^8.36.0" - "@typescript-eslint/type-utils": "npm:^8.36.0" - "@typescript-eslint/types": "npm:^8.36.0" - "@typescript-eslint/utils": "npm:^8.36.0" + "@eslint-react/ast": "npm:1.52.4" + "@eslint-react/core": "npm:1.52.4" + "@eslint-react/eff": "npm:1.52.4" + "@eslint-react/kit": "npm:1.52.4" + "@eslint-react/shared": "npm:1.52.4" + "@eslint-react/var": "npm:1.52.4" + "@typescript-eslint/scope-manager": "npm:^8.39.1" + "@typescript-eslint/type-utils": "npm:^8.39.1" + "@typescript-eslint/types": "npm:^8.39.1" + "@typescript-eslint/utils": "npm:^8.39.1" string-ts: "npm:^2.2.1" - ts-pattern: "npm:^5.7.1" + ts-pattern: "npm:^5.8.0" peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: ^4.9.5 || ^5.3.3 @@ -11560,20 +12274,11 @@ __metadata: optional: false typescript: optional: true - checksum: 10/929823c59111a71b80819cdecdd7c21acdbe06d2943a04e27d342715972422657a90a929b0cca5555faa2963e5509d3a334d2e869997a2113d9e494f813f1879 - languageName: node - linkType: hard - -"eslint-plugin-react-hooks@npm:5.1.0": - version: 5.1.0 - resolution: "eslint-plugin-react-hooks@npm:5.1.0" - peerDependencies: - eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 - checksum: 10/b6778fd9e1940b06868921309e8b269426e17eda555816d4b71def4dcf0572de1199fdb627ac09ce42160b9569a93cd9b0fd81b740ab4df98205461c53997a43 + checksum: 10/de05ff0b59bd6ba6b969316b513bb8e23bd3420d504457a02a6e31042b2fbdacd7068041295f773fb4338dc7f43ad5cb679c7598ab8342b0d826afeb71133f56 languageName: node linkType: hard -"eslint-plugin-react-hooks@npm:^5.2.0": +"eslint-plugin-react-hooks@npm:5.2.0, eslint-plugin-react-hooks@npm:^5.2.0": version: 5.2.0 resolution: "eslint-plugin-react-hooks@npm:5.2.0" peerDependencies: @@ -11582,22 +12287,22 @@ __metadata: languageName: node linkType: hard -"eslint-plugin-react-naming-convention@npm:1.52.3": - version: 1.52.3 - resolution: "eslint-plugin-react-naming-convention@npm:1.52.3" +"eslint-plugin-react-naming-convention@npm:1.52.4": + version: 1.52.4 + resolution: "eslint-plugin-react-naming-convention@npm:1.52.4" dependencies: - "@eslint-react/ast": "npm:1.52.3" - "@eslint-react/core": "npm:1.52.3" - "@eslint-react/eff": "npm:1.52.3" - "@eslint-react/kit": "npm:1.52.3" - "@eslint-react/shared": "npm:1.52.3" - "@eslint-react/var": "npm:1.52.3" - "@typescript-eslint/scope-manager": "npm:^8.36.0" - "@typescript-eslint/type-utils": "npm:^8.36.0" - "@typescript-eslint/types": "npm:^8.36.0" - "@typescript-eslint/utils": "npm:^8.36.0" + "@eslint-react/ast": "npm:1.52.4" + "@eslint-react/core": "npm:1.52.4" + "@eslint-react/eff": "npm:1.52.4" + "@eslint-react/kit": "npm:1.52.4" + "@eslint-react/shared": "npm:1.52.4" + "@eslint-react/var": "npm:1.52.4" + "@typescript-eslint/scope-manager": "npm:^8.39.1" + "@typescript-eslint/type-utils": "npm:^8.39.1" + "@typescript-eslint/types": "npm:^8.39.1" + "@typescript-eslint/utils": "npm:^8.39.1" string-ts: "npm:^2.2.1" - ts-pattern: "npm:^5.7.1" + ts-pattern: "npm:^5.8.0" peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: ^4.9.5 || ^5.3.3 @@ -11606,7 +12311,7 @@ __metadata: optional: false typescript: optional: true - checksum: 10/843c79cd55f088ac9fe7373ae814a058048f60ccd86a6090a70bba2fbb6db4acfe6a4b0f63121813daefcbdcfe905a5a8298da8e34a37c15a9cb020e23f502c8 + checksum: 10/dc2d84b08c80cb40199b38a31d96e4f7bff0e5cc70a9125753ec2a51d8d9007110aaf4fef7832ff2f4166057e2fbf5245c6b0986bcf2ae22d1920b8f77013cce languageName: node linkType: hard @@ -11628,21 +12333,21 @@ __metadata: languageName: node linkType: hard -"eslint-plugin-react-web-api@npm:1.52.3": - version: 1.52.3 - resolution: "eslint-plugin-react-web-api@npm:1.52.3" +"eslint-plugin-react-web-api@npm:1.52.4": + version: 1.52.4 + resolution: "eslint-plugin-react-web-api@npm:1.52.4" dependencies: - "@eslint-react/ast": "npm:1.52.3" - "@eslint-react/core": "npm:1.52.3" - "@eslint-react/eff": "npm:1.52.3" - "@eslint-react/kit": "npm:1.52.3" - "@eslint-react/shared": "npm:1.52.3" - "@eslint-react/var": "npm:1.52.3" - "@typescript-eslint/scope-manager": "npm:^8.36.0" - "@typescript-eslint/types": "npm:^8.36.0" - "@typescript-eslint/utils": "npm:^8.36.0" + "@eslint-react/ast": "npm:1.52.4" + "@eslint-react/core": "npm:1.52.4" + "@eslint-react/eff": "npm:1.52.4" + "@eslint-react/kit": "npm:1.52.4" + "@eslint-react/shared": "npm:1.52.4" + "@eslint-react/var": "npm:1.52.4" + "@typescript-eslint/scope-manager": "npm:^8.39.1" + "@typescript-eslint/types": "npm:^8.39.1" + "@typescript-eslint/utils": "npm:^8.39.1" string-ts: "npm:^2.2.1" - ts-pattern: "npm:^5.7.1" + ts-pattern: "npm:^5.8.0" peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: ^4.9.5 || ^5.3.3 @@ -11651,28 +12356,28 @@ __metadata: optional: false typescript: optional: true - checksum: 10/47988341679be5a96f3546de900febbd35f848fd0b7086014368aa8c95855bd9dd399c8169deaa2c6afba2b1b492be4c46c06be6c824b1bfadda580c31bb18d0 + checksum: 10/bff330bd55f8a250b3fa089de0bff2cf9688c6e239ab515fecb775c169b3a2d95e1a2a9391e85913d580dfaa67bbd72afba7e040f24e027a075d50961d4fbac0 languageName: node linkType: hard -"eslint-plugin-react-x@npm:1.52.3": - version: 1.52.3 - resolution: "eslint-plugin-react-x@npm:1.52.3" +"eslint-plugin-react-x@npm:1.52.4": + version: 1.52.4 + resolution: "eslint-plugin-react-x@npm:1.52.4" dependencies: - "@eslint-react/ast": "npm:1.52.3" - "@eslint-react/core": "npm:1.52.3" - "@eslint-react/eff": "npm:1.52.3" - "@eslint-react/kit": "npm:1.52.3" - "@eslint-react/shared": "npm:1.52.3" - "@eslint-react/var": "npm:1.52.3" - "@typescript-eslint/scope-manager": "npm:^8.36.0" - "@typescript-eslint/type-utils": "npm:^8.36.0" - "@typescript-eslint/types": "npm:^8.36.0" - "@typescript-eslint/utils": "npm:^8.36.0" + "@eslint-react/ast": "npm:1.52.4" + "@eslint-react/core": "npm:1.52.4" + "@eslint-react/eff": "npm:1.52.4" + "@eslint-react/kit": "npm:1.52.4" + "@eslint-react/shared": "npm:1.52.4" + "@eslint-react/var": "npm:1.52.4" + "@typescript-eslint/scope-manager": "npm:^8.39.1" + "@typescript-eslint/type-utils": "npm:^8.39.1" + "@typescript-eslint/types": "npm:^8.39.1" + "@typescript-eslint/utils": "npm:^8.39.1" compare-versions: "npm:^6.1.1" is-immutable-type: "npm:^5.0.1" string-ts: "npm:^2.2.1" - ts-pattern: "npm:^5.7.1" + ts-pattern: "npm:^5.8.0" peerDependencies: eslint: ^8.57.0 || ^9.0.0 ts-api-utils: ^2.1.0 @@ -11684,11 +12389,11 @@ __metadata: optional: true typescript: optional: true - checksum: 10/9af12a172a8cf8a3914beb4cfec356d83b65186951b8652e2cd3a2d67413ad490949b367acf5406c988bc160dec92e49638ff10434a229370c3c051e6a4cb4ed + checksum: 10/ef0f380e3f4811f34b53ed2261082e1ce80fe19ea90bb16b110f36c1e9152b39fe5238532baf2077a75d85f64173edb735bae497f5d536545b847088191fff1c languageName: node linkType: hard -"eslint-plugin-react@npm:^7.37.4": +"eslint-plugin-react@npm:7.37.5": version: 7.37.5 resolution: "eslint-plugin-react@npm:7.37.5" dependencies: @@ -11725,49 +12430,51 @@ __metadata: languageName: node linkType: hard -"eslint-plugin-sonarjs@npm:3.0.2": - version: 3.0.2 - resolution: "eslint-plugin-sonarjs@npm:3.0.2" +"eslint-plugin-sonarjs@npm:3.0.4": + version: 3.0.4 + resolution: "eslint-plugin-sonarjs@npm:3.0.4" dependencies: "@eslint-community/regexpp": "npm:4.12.1" builtin-modules: "npm:3.3.0" bytes: "npm:3.1.2" functional-red-black-tree: "npm:1.0.1" jsx-ast-utils: "npm:3.3.5" + lodash.merge: "npm:4.6.2" minimatch: "npm:9.0.5" scslre: "npm:0.3.0" - semver: "npm:7.7.1" - typescript: "npm:^5" + semver: "npm:7.7.2" + typescript: "npm:>=5" peerDependencies: eslint: ^8.0.0 || ^9.0.0 - checksum: 10/971ed06ff2a7f24c561f9be212f89764cec9e279f6d18157df69f7f49fff56b5f7259ea00cbfb16ad261d1ed0f1eb179dd46ba760f78f95cc2c95bff37737974 + checksum: 10/c17347f92cac132b9f819a476a7e8aa0c2e0305e474c5e647b951068147217910ab8f6f326f42975a2e7c38b26edcf44c86fdd6ad5449f1a221d17be6417bf87 languageName: node linkType: hard -"eslint-plugin-unicorn@npm:58.0.0": - version: 58.0.0 - resolution: "eslint-plugin-unicorn@npm:58.0.0" +"eslint-plugin-unicorn@npm:60.0.0": + version: 60.0.0 + resolution: "eslint-plugin-unicorn@npm:60.0.0" dependencies: - "@babel/helper-validator-identifier": "npm:^7.25.9" - "@eslint-community/eslint-utils": "npm:^4.5.1" - "@eslint/plugin-kit": "npm:^0.2.7" - ci-info: "npm:^4.2.0" + "@babel/helper-validator-identifier": "npm:^7.27.1" + "@eslint-community/eslint-utils": "npm:^4.7.0" + "@eslint/plugin-kit": "npm:^0.3.3" + change-case: "npm:^5.4.4" + ci-info: "npm:^4.3.0" clean-regexp: "npm:^1.0.0" - core-js-compat: "npm:^3.41.0" + core-js-compat: "npm:^3.44.0" esquery: "npm:^1.6.0" - globals: "npm:^16.0.0" + find-up-simple: "npm:^1.0.1" + globals: "npm:^16.3.0" indent-string: "npm:^5.0.0" is-builtin-module: "npm:^5.0.0" jsesc: "npm:^3.1.0" pluralize: "npm:^8.0.0" - read-package-up: "npm:^11.0.0" regexp-tree: "npm:^0.1.27" regjsparser: "npm:^0.12.0" - semver: "npm:^7.7.1" + semver: "npm:^7.7.2" strip-indent: "npm:^4.0.0" peerDependencies: - eslint: ">=9.22.0" - checksum: 10/1e1cea0466be8fe0d41181fc4ca12f1cabb26f97a0f718ecac68f686da29381f69070f4e1f24020b0378a705ef6346294974575ace586c0750aab174d193b2f6 + eslint: ">=9.29.0" + checksum: 10/f46f9212c83fb6257ffea9359c160925fb8da817d4b12b707868886c177bc01de040ec870c2b2ee59da6c727e11d4fc4199ab1ed38ef7afe63bc5c79870a0963 languageName: node linkType: hard @@ -11859,55 +12566,6 @@ __metadata: languageName: node linkType: hard -"eslint@npm:9.19.0": - version: 9.19.0 - resolution: "eslint@npm:9.19.0" - dependencies: - "@eslint-community/eslint-utils": "npm:^4.2.0" - "@eslint-community/regexpp": "npm:^4.12.1" - "@eslint/config-array": "npm:^0.19.0" - "@eslint/core": "npm:^0.10.0" - "@eslint/eslintrc": "npm:^3.2.0" - "@eslint/js": "npm:9.19.0" - "@eslint/plugin-kit": "npm:^0.2.5" - "@humanfs/node": "npm:^0.16.6" - "@humanwhocodes/module-importer": "npm:^1.0.1" - "@humanwhocodes/retry": "npm:^0.4.1" - "@types/estree": "npm:^1.0.6" - "@types/json-schema": "npm:^7.0.15" - ajv: "npm:^6.12.4" - chalk: "npm:^4.0.0" - cross-spawn: "npm:^7.0.6" - debug: "npm:^4.3.2" - escape-string-regexp: "npm:^4.0.0" - eslint-scope: "npm:^8.2.0" - eslint-visitor-keys: "npm:^4.2.0" - espree: "npm:^10.3.0" - esquery: "npm:^1.5.0" - esutils: "npm:^2.0.2" - fast-deep-equal: "npm:^3.1.3" - file-entry-cache: "npm:^8.0.0" - find-up: "npm:^5.0.0" - glob-parent: "npm:^6.0.2" - ignore: "npm:^5.2.0" - imurmurhash: "npm:^0.1.4" - is-glob: "npm:^4.0.0" - json-stable-stringify-without-jsonify: "npm:^1.0.1" - lodash.merge: "npm:^4.6.2" - minimatch: "npm:^3.1.2" - natural-compare: "npm:^1.4.0" - optionator: "npm:^0.9.3" - peerDependencies: - jiti: "*" - peerDependenciesMeta: - jiti: - optional: true - bin: - eslint: bin/eslint.js - checksum: 10/850d19fd6a34702d1e3d9bdad6aef84a20a5c2de006a8fa6380843384b13944b180232ddd74b8725ffcdf8f296399037f0e8eb4783d5f7393f13c059112b843d - languageName: node - linkType: hard - "eslint@npm:9.23.0": version: 9.23.0 resolution: "eslint@npm:9.23.0" @@ -11958,18 +12616,18 @@ __metadata: languageName: node linkType: hard -"eslint@npm:9.24.0": - version: 9.24.0 - resolution: "eslint@npm:9.24.0" +"eslint@npm:9.33.0": + version: 9.33.0 + resolution: "eslint@npm:9.33.0" dependencies: "@eslint-community/eslint-utils": "npm:^4.2.0" "@eslint-community/regexpp": "npm:^4.12.1" - "@eslint/config-array": "npm:^0.20.0" - "@eslint/config-helpers": "npm:^0.2.0" - "@eslint/core": "npm:^0.12.0" + "@eslint/config-array": "npm:^0.21.0" + "@eslint/config-helpers": "npm:^0.3.1" + "@eslint/core": "npm:^0.15.2" "@eslint/eslintrc": "npm:^3.3.1" - "@eslint/js": "npm:9.24.0" - "@eslint/plugin-kit": "npm:^0.2.7" + "@eslint/js": "npm:9.33.0" + "@eslint/plugin-kit": "npm:^0.3.5" "@humanfs/node": "npm:^0.16.6" "@humanwhocodes/module-importer": "npm:^1.0.1" "@humanwhocodes/retry": "npm:^0.4.2" @@ -11980,9 +12638,9 @@ __metadata: cross-spawn: "npm:^7.0.6" debug: "npm:^4.3.2" escape-string-regexp: "npm:^4.0.0" - eslint-scope: "npm:^8.3.0" - eslint-visitor-keys: "npm:^4.2.0" - espree: "npm:^10.3.0" + eslint-scope: "npm:^8.4.0" + eslint-visitor-keys: "npm:^4.2.1" + espree: "npm:^10.4.0" esquery: "npm:^1.5.0" esutils: "npm:^2.0.2" fast-deep-equal: "npm:^3.1.3" @@ -12004,7 +12662,7 @@ __metadata: optional: true bin: eslint: bin/eslint.js - checksum: 10/05810e135c1f429be451a4be92283c0be204010bb0ea71edfeae1d25ff917cbc5a229144ee55853a085088c7e4092e59a28c0dae87a865ef9600ad4438861d4a + checksum: 10/3857f17460c8a245e3dd2d23b892ce844dc7d337db3fbdeb39f488d6405ae6e71f2142cc4514316d0042c85a77d9503e7dfd4637665eb389473145ad31bd8306 languageName: node linkType: hard @@ -12251,7 +12909,7 @@ __metadata: languageName: node linkType: hard -"events@npm:^3.2.0, events@npm:^3.3.0": +"events@npm:^3.0.0, events@npm:^3.2.0, events@npm:^3.3.0": version: 3.3.0 resolution: "events@npm:3.3.0" checksum: 10/a3d47e285e28d324d7180f1e493961a2bbb4cad6412090e4dec114f4db1f5b560c7696ee8e758f55e23913ede856e3689cd3aa9ae13c56b5d8314cd3b3ddd1be @@ -12304,7 +12962,14 @@ __metadata: languageName: node linkType: hard -"exit@npm:^0.1.2, exit@npm:~0.1.2": +"exit-x@npm:^0.2.2": + version: 0.2.2 + resolution: "exit-x@npm:0.2.2" + checksum: 10/ee043053e6c1e237adf5ad9c4faf9f085b606f64a4ff859e2b138fab63fe642711d00c9af452a9134c4c92c55f752e818bfabab78c24d345022db163f3137027 + languageName: node + linkType: hard + +"exit@npm:~0.1.2": version: 0.1.2 resolution: "exit@npm:0.1.2" checksum: 10/387555050c5b3c10e7a9e8df5f43194e95d7737c74532c409910e585d5554eaff34960c166643f5e23d042196529daad059c292dcf1fb61b8ca878d3677f4b87 @@ -12349,16 +13014,31 @@ __metadata: languageName: node linkType: hard -"expect@npm:^29.0.0, expect@npm:^29.7.0": - version: 29.7.0 - resolution: "expect@npm:29.7.0" +"expect@npm:30.1.2": + version: 30.1.2 + resolution: "expect@npm:30.1.2" + dependencies: + "@jest/expect-utils": "npm:30.1.2" + "@jest/get-type": "npm:30.1.0" + jest-matcher-utils: "npm:30.1.2" + jest-message-util: "npm:30.1.0" + jest-mock: "npm:30.0.5" + jest-util: "npm:30.0.5" + checksum: 10/700bc5f3042f639624aa8c18cbef0fb7ca1fda3b4072ee464c57fd3b4298e049210804839777a635f4741520ae4ffda0ba0e1698cdfc79e981eabdf737314485 + languageName: node + linkType: hard + +"expect@npm:^30.0.0": + version: 30.2.0 + resolution: "expect@npm:30.2.0" dependencies: - "@jest/expect-utils": "npm:^29.7.0" - jest-get-type: "npm:^29.6.3" - jest-matcher-utils: "npm:^29.7.0" - jest-message-util: "npm:^29.7.0" - jest-util: "npm:^29.7.0" - checksum: 10/63f97bc51f56a491950fb525f9ad94f1916e8a014947f8d8445d3847a665b5471b768522d659f5e865db20b6c2033d2ac10f35fcbd881a4d26407a4f6f18451a + "@jest/expect-utils": "npm:30.2.0" + "@jest/get-type": "npm:30.1.0" + jest-matcher-utils: "npm:30.2.0" + jest-message-util: "npm:30.2.0" + jest-mock: "npm:30.2.0" + jest-util: "npm:30.2.0" + checksum: 10/cf98ab45ab2e9f2fb9943a3ae0097f72d63a94be179a19fd2818d8fdc3b7681d31cc8ef540606eb8dd967d9c44d73fef263a614e9de260c22943ffb122ad66fd languageName: node linkType: hard @@ -12595,7 +13275,7 @@ __metadata: languageName: node linkType: hard -"fb-watchman@npm:^2.0.0": +"fb-watchman@npm:^2.0.2": version: 2.0.2 resolution: "fb-watchman@npm:2.0.2" dependencies: @@ -12613,6 +13293,15 @@ __metadata: languageName: node linkType: hard +"fd-package-json@npm:^2.0.0": + version: 2.0.0 + resolution: "fd-package-json@npm:2.0.0" + dependencies: + walk-up-path: "npm:^4.0.0" + checksum: 10/e595a1a23f8e208815cdcf26c92218240da00acce80468324408dc4a5cb6c26b6efb5076f0458a02f044562a1e60253731187a627d5416b4961468ddfc0ae426 + languageName: node + linkType: hard + "fd-slicer@npm:~1.1.0": version: 1.1.0 resolution: "fd-slicer@npm:1.1.0" @@ -12817,7 +13506,7 @@ __metadata: languageName: node linkType: hard -"find-up-simple@npm:^1.0.0": +"find-up-simple@npm:^1.0.1": version: 1.0.1 resolution: "find-up-simple@npm:1.0.1" checksum: 10/6e374bffda9f8425314eab47ef79752b6e77dcc95c0ad17d257aef48c32fe07bbc41bcafbd22941c25bb94fffaaaa8e178d928867d844c58100c7fe19ec82f72 @@ -13022,16 +13711,14 @@ __metadata: languageName: node linkType: hard -"form-data@npm:^4.0.0": - version: 4.0.4 - resolution: "form-data@npm:4.0.4" +"formatly@npm:^0.3.0": + version: 0.3.0 + resolution: "formatly@npm:0.3.0" dependencies: - asynckit: "npm:^0.4.0" - combined-stream: "npm:^1.0.8" - es-set-tostringtag: "npm:^2.1.0" - hasown: "npm:^2.0.2" - mime-types: "npm:^2.1.12" - checksum: 10/a4b62e21932f48702bc468cc26fb276d186e6b07b557e3dd7cc455872bdbb82db7db066844a64ad3cf40eaf3a753c830538183570462d3649fdfd705601cbcfb + fd-package-json: "npm:^2.0.0" + bin: + formatly: bin/index.mjs + checksum: 10/0e5a9cbb826d93171b00c283e20e6a564a16e7bc3839e695790347a1f23e3536a88d613f5cabd07403d60b7bdffe179987c88b1fc2900a9be49eea01ffbe4244 languageName: node linkType: hard @@ -13170,7 +13857,7 @@ __metadata: languageName: node linkType: hard -"fsevents@npm:^2.3.2, fsevents@npm:~2.3.2, fsevents@npm:~2.3.3": +"fsevents@npm:^2.3.3, fsevents@npm:~2.3.2, fsevents@npm:~2.3.3": version: 2.3.3 resolution: "fsevents@npm:2.3.3" dependencies: @@ -13190,7 +13877,7 @@ __metadata: languageName: node linkType: hard -"fsevents@patch:fsevents@npm%3A^2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin": +"fsevents@patch:fsevents@npm%3A^2.3.3#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin": version: 2.3.3 resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1" dependencies: @@ -13439,7 +14126,7 @@ __metadata: languageName: node linkType: hard -"glob@npm:^10.0.0, glob@npm:^10.2.2, glob@npm:^10.4.2, glob@npm:^10.4.5": +"glob@npm:^10.0.0, glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.4.2, glob@npm:^10.4.5": version: 10.4.5 resolution: "glob@npm:10.4.5" dependencies: @@ -13546,6 +14233,13 @@ __metadata: languageName: node linkType: hard +"globals@npm:^16.3.0": + version: 16.4.0 + resolution: "globals@npm:16.4.0" + checksum: 10/1627a9f42fb4c82d7af6a0c8b6cd616e00110908304d5f1ddcdf325998f3aed45a4b29d8a1e47870f328817805263e31e4f1673f00022b9c2b210552767921cf + languageName: node + linkType: hard + "globalthis@npm:^1.0.4": version: 1.0.4 resolution: "globalthis@npm:1.0.4" @@ -13621,7 +14315,7 @@ __metadata: languageName: node linkType: hard -"graceful-fs@npm:^4.1.11, graceful-fs@npm:^4.1.15, graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.11, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6, graceful-fs@npm:^4.2.9": +"graceful-fs@npm:^4.1.11, graceful-fs@npm:^4.1.15, graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.11, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6": version: 4.2.11 resolution: "graceful-fs@npm:4.2.11" checksum: 10/bf152d0ed1dc159239db1ba1f74fdbc40cb02f626770dcd5815c427ce0688c2635a06ed69af364396da4636d0408fcf7d4afdf7881724c3307e46aff30ca49e2 @@ -14166,15 +14860,6 @@ __metadata: languageName: node linkType: hard -"hosted-git-info@npm:^7.0.0": - version: 7.0.2 - resolution: "hosted-git-info@npm:7.0.2" - dependencies: - lru-cache: "npm:^10.0.1" - checksum: 10/8f085df8a4a637d995f357f48b1e3f6fc1f9f92e82b33fb406415b5741834ed431a510a09141071001e8deea2eee43ce72786463e2aa5e5a70db8648c0eedeab - languageName: node - linkType: hard - "hpack.js@npm:^2.1.6": version: 2.1.6 resolution: "hpack.js@npm:2.1.6" @@ -14187,15 +14872,6 @@ __metadata: languageName: node linkType: hard -"html-encoding-sniffer@npm:^3.0.0": - version: 3.0.0 - resolution: "html-encoding-sniffer@npm:3.0.0" - dependencies: - whatwg-encoding: "npm:^2.0.0" - checksum: 10/707a812ec2acaf8bb5614c8618dc81e2fb6b4399d03e95ff18b65679989a072f4e919b9bef472039301a1bbfba64063ba4c79ea6e851c653ac9db80dbefe8fe5 - languageName: node - linkType: hard - "html-encoding-sniffer@npm:^4.0.0": version: 4.0.0 resolution: "html-encoding-sniffer@npm:4.0.0" @@ -14291,17 +14967,6 @@ __metadata: languageName: node linkType: hard -"http-proxy-agent@npm:^5.0.0": - version: 5.0.0 - resolution: "http-proxy-agent@npm:5.0.0" - dependencies: - "@tootallnate/once": "npm:2" - agent-base: "npm:6" - debug: "npm:4" - checksum: 10/5ee19423bc3e0fd5f23ce991b0755699ad2a46a440ce9cec99e8126bb98448ad3479d2c0ea54be5519db5b19a4ffaa69616bac01540db18506dd4dac3dc418f0 - languageName: node - linkType: hard - "http-proxy-agent@npm:^7.0.0, http-proxy-agent@npm:^7.0.1, http-proxy-agent@npm:^7.0.2": version: 7.0.2 resolution: "http-proxy-agent@npm:7.0.2" @@ -14358,7 +15023,7 @@ __metadata: languageName: node linkType: hard -"https-proxy-agent@npm:5.0.1, https-proxy-agent@npm:^5.0.1": +"https-proxy-agent@npm:5.0.1": version: 5.0.1 resolution: "https-proxy-agent@npm:5.0.1" dependencies: @@ -14484,6 +15149,13 @@ __metadata: languageName: node linkType: hard +"immer@npm:^10.0.3": + version: 10.1.3 + resolution: "immer@npm:10.1.3" + checksum: 10/672faf5fc84177eecbfb49143ea8f6b5c1f981bd78c57e1562469354f6df658921ed6e0372b9ceed1e07c031e0c6d66863c4df80605928c6c774840b945aef83 + languageName: node + linkType: hard + "immutable@npm:^5.0.2": version: 5.1.3 resolution: "immutable@npm:5.1.3" @@ -14508,7 +15180,7 @@ __metadata: languageName: node linkType: hard -"import-local@npm:^3.0.2": +"import-local@npm:^3.0.2, import-local@npm:^3.2.0": version: 3.2.0 resolution: "import-local@npm:3.2.0" dependencies: @@ -14548,13 +15220,6 @@ __metadata: languageName: node linkType: hard -"index-to-position@npm:^1.1.0": - version: 1.1.0 - resolution: "index-to-position@npm:1.1.0" - checksum: 10/16703893c732a025786098fe77cb7e83109afe4b72633dd6feea1595c54f8406623fa7a0a2263a8e08adee7f639fbb1c4731982cd30b4bc30d787bf803f5f3d8 - languageName: node - linkType: hard - "infer-owner@npm:^1.0.3": version: 1.0.4 resolution: "infer-owner@npm:1.0.4" @@ -14581,7 +15246,7 @@ __metadata: languageName: node linkType: hard -"inherits@npm:2, inherits@npm:2.0.4, inherits@npm:^2.0.1, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.1, inherits@npm:~2.0.3": +"inherits@npm:2, inherits@npm:2.0.4, inherits@npm:^2.0.1, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.1, inherits@npm:~2.0.3, inherits@npm:~2.0.4": version: 2.0.4 resolution: "inherits@npm:2.0.4" checksum: 10/cd45e923bee15186c07fa4c89db0aace24824c482fb887b528304694b2aa6ff8a898da8657046a5dcf3e46cd6db6c61629551f9215f208d7c3f157cf9b290521 @@ -14974,7 +15639,7 @@ __metadata: languageName: node linkType: hard -"is-generator-fn@npm:^2.0.0": +"is-generator-fn@npm:^2.1.0": version: 2.1.0 resolution: "is-generator-fn@npm:2.1.0" checksum: 10/a6ad5492cf9d1746f73b6744e0c43c0020510b59d56ddcb78a91cbc173f09b5e6beff53d75c9c5a29feb618bfef2bf458e025ecf3a57ad2268e2fb2569f56215 @@ -15050,6 +15715,16 @@ __metadata: languageName: node linkType: hard +"is-nan@npm:^1.3.2": + version: 1.3.2 + resolution: "is-nan@npm:1.3.2" + dependencies: + call-bind: "npm:^1.0.0" + define-properties: "npm:^1.1.3" + checksum: 10/1f784d3472c09bc2e47acba7ffd4f6c93b0394479aa613311dc1d70f1bfa72eb0846c81350967722c959ba65811bae222204d6c65856fdce68f31986140c7b0e + languageName: node + linkType: hard + "is-negative-zero@npm:^2.0.3": version: 2.0.3 resolution: "is-negative-zero@npm:2.0.3" @@ -15349,6 +16024,13 @@ __metadata: languageName: node linkType: hard +"isomorphic-timers-promises@npm:^1.0.1": + version: 1.0.1 + resolution: "isomorphic-timers-promises@npm:1.0.1" + checksum: 10/2dabe397039081dbf30039f295333a7f9888b072dd0afa3aa7d8ba8f812a6db5efcbda0861a4be43ecfec207d56314ecf27150187b8d0f924a93103fa93eac73 + languageName: node + linkType: hard + "istanbul-lib-coverage@npm:^3.0.0, istanbul-lib-coverage@npm:^3.2.0": version: 3.2.2 resolution: "istanbul-lib-coverage@npm:3.2.2" @@ -15356,7 +16038,7 @@ __metadata: languageName: node linkType: hard -"istanbul-lib-instrument@npm:^5.0.4, istanbul-lib-instrument@npm:^5.1.0": +"istanbul-lib-instrument@npm:^5.1.0": version: 5.2.1 resolution: "istanbul-lib-instrument@npm:5.2.1" dependencies: @@ -15369,7 +16051,7 @@ __metadata: languageName: node linkType: hard -"istanbul-lib-instrument@npm:^6.0.0": +"istanbul-lib-instrument@npm:^6.0.0, istanbul-lib-instrument@npm:^6.0.2": version: 6.0.3 resolution: "istanbul-lib-instrument@npm:6.0.3" dependencies: @@ -15393,7 +16075,7 @@ __metadata: languageName: node linkType: hard -"istanbul-lib-source-maps@npm:^4.0.0, istanbul-lib-source-maps@npm:^4.0.1": +"istanbul-lib-source-maps@npm:^4.0.1": version: 4.0.1 resolution: "istanbul-lib-source-maps@npm:4.0.1" dependencies: @@ -15404,6 +16086,17 @@ __metadata: languageName: node linkType: hard +"istanbul-lib-source-maps@npm:^5.0.0": + version: 5.0.6 + resolution: "istanbul-lib-source-maps@npm:5.0.6" + dependencies: + "@jridgewell/trace-mapping": "npm:^0.3.23" + debug: "npm:^4.1.1" + istanbul-lib-coverage: "npm:^3.0.0" + checksum: 10/569dd0a392ee3464b1fe1accbaef5cc26de3479eacb5b91d8c67ebb7b425d39fd02247d85649c3a0e9c29b600809fa60b5af5a281a75a89c01f385b1e24823a2 + languageName: node + linkType: hard + "istanbul-reports@npm:^3.0.5, istanbul-reports@npm:^3.1.3": version: 3.1.7 resolution: "istanbul-reports@npm:3.1.7" @@ -15450,259 +16143,305 @@ __metadata: languageName: node linkType: hard -"jest-changed-files@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-changed-files@npm:29.7.0" +"jest-changed-files@npm:30.0.5": + version: 30.0.5 + resolution: "jest-changed-files@npm:30.0.5" dependencies: - execa: "npm:^5.0.0" - jest-util: "npm:^29.7.0" + execa: "npm:^5.1.1" + jest-util: "npm:30.0.5" p-limit: "npm:^3.1.0" - checksum: 10/3d93742e56b1a73a145d55b66e96711fbf87ef89b96c2fab7cfdfba8ec06612591a982111ca2b712bb853dbc16831ec8b43585a2a96b83862d6767de59cbf83d + checksum: 10/cc2df02d1c05465da4ba05dc6d0868fee69a7389ffa784f5ee2680a915886359d618b291105d46b061e74225d7d999c03701dda56e9f8df04ef815e05bff621b languageName: node linkType: hard -"jest-circus@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-circus@npm:29.7.0" +"jest-circus@npm:30.1.2": + version: 30.1.2 + resolution: "jest-circus@npm:30.1.2" dependencies: - "@jest/environment": "npm:^29.7.0" - "@jest/expect": "npm:^29.7.0" - "@jest/test-result": "npm:^29.7.0" - "@jest/types": "npm:^29.6.3" + "@jest/environment": "npm:30.1.2" + "@jest/expect": "npm:30.1.2" + "@jest/test-result": "npm:30.1.2" + "@jest/types": "npm:30.0.5" "@types/node": "npm:*" - chalk: "npm:^4.0.0" + chalk: "npm:^4.1.2" co: "npm:^4.6.0" - dedent: "npm:^1.0.0" - is-generator-fn: "npm:^2.0.0" - jest-each: "npm:^29.7.0" - jest-matcher-utils: "npm:^29.7.0" - jest-message-util: "npm:^29.7.0" - jest-runtime: "npm:^29.7.0" - jest-snapshot: "npm:^29.7.0" - jest-util: "npm:^29.7.0" + dedent: "npm:^1.6.0" + is-generator-fn: "npm:^2.1.0" + jest-each: "npm:30.1.0" + jest-matcher-utils: "npm:30.1.2" + jest-message-util: "npm:30.1.0" + jest-runtime: "npm:30.1.2" + jest-snapshot: "npm:30.1.2" + jest-util: "npm:30.0.5" p-limit: "npm:^3.1.0" - pretty-format: "npm:^29.7.0" - pure-rand: "npm:^6.0.0" + pretty-format: "npm:30.0.5" + pure-rand: "npm:^7.0.0" slash: "npm:^3.0.0" - stack-utils: "npm:^2.0.3" - checksum: 10/716a8e3f40572fd0213bcfc1da90274bf30d856e5133af58089a6ce45089b63f4d679bd44e6be9d320e8390483ebc3ae9921981993986d21639d9019b523123d + stack-utils: "npm:^2.0.6" + checksum: 10/e80133fa50a78107548573dab469fee38ea6cdbbbe733606a2551b54551a62b4c72166632ed384cd0ea7cc7c1dd24422c97defb5490d7b2d143e73a5991513c6 languageName: node linkType: hard -"jest-cli@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-cli@npm:29.7.0" +"jest-cli@npm:30.1.2": + version: 30.1.2 + resolution: "jest-cli@npm:30.1.2" dependencies: - "@jest/core": "npm:^29.7.0" - "@jest/test-result": "npm:^29.7.0" - "@jest/types": "npm:^29.6.3" - chalk: "npm:^4.0.0" - create-jest: "npm:^29.7.0" - exit: "npm:^0.1.2" - import-local: "npm:^3.0.2" - jest-config: "npm:^29.7.0" - jest-util: "npm:^29.7.0" - jest-validate: "npm:^29.7.0" - yargs: "npm:^17.3.1" + "@jest/core": "npm:30.1.2" + "@jest/test-result": "npm:30.1.2" + "@jest/types": "npm:30.0.5" + chalk: "npm:^4.1.2" + exit-x: "npm:^0.2.2" + import-local: "npm:^3.2.0" + jest-config: "npm:30.1.2" + jest-util: "npm:30.0.5" + jest-validate: "npm:30.1.0" + yargs: "npm:^17.7.2" peerDependencies: node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 peerDependenciesMeta: node-notifier: optional: true bin: - jest: bin/jest.js - checksum: 10/6cc62b34d002c034203065a31e5e9a19e7c76d9e8ef447a6f70f759c0714cb212c6245f75e270ba458620f9c7b26063cd8cf6cd1f7e3afd659a7cc08add17307 + jest: ./bin/jest.js + checksum: 10/7b13d513253dc637c08a3077dda780e3214b1f023b170e6a91760aaff3424b029aa251d39e6d542d1af9bf12185b1a395697aa256522084075f4c65e605d8fad languageName: node linkType: hard -"jest-config@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-config@npm:29.7.0" +"jest-config@npm:30.1.2": + version: 30.1.2 + resolution: "jest-config@npm:30.1.2" dependencies: - "@babel/core": "npm:^7.11.6" - "@jest/test-sequencer": "npm:^29.7.0" - "@jest/types": "npm:^29.6.3" - babel-jest: "npm:^29.7.0" - chalk: "npm:^4.0.0" - ci-info: "npm:^3.2.0" - deepmerge: "npm:^4.2.2" - glob: "npm:^7.1.3" - graceful-fs: "npm:^4.2.9" - jest-circus: "npm:^29.7.0" - jest-environment-node: "npm:^29.7.0" - jest-get-type: "npm:^29.6.3" - jest-regex-util: "npm:^29.6.3" - jest-resolve: "npm:^29.7.0" - jest-runner: "npm:^29.7.0" - jest-util: "npm:^29.7.0" - jest-validate: "npm:^29.7.0" - micromatch: "npm:^4.0.4" + "@babel/core": "npm:^7.27.4" + "@jest/get-type": "npm:30.1.0" + "@jest/pattern": "npm:30.0.1" + "@jest/test-sequencer": "npm:30.1.2" + "@jest/types": "npm:30.0.5" + babel-jest: "npm:30.1.2" + chalk: "npm:^4.1.2" + ci-info: "npm:^4.2.0" + deepmerge: "npm:^4.3.1" + glob: "npm:^10.3.10" + graceful-fs: "npm:^4.2.11" + jest-circus: "npm:30.1.2" + jest-docblock: "npm:30.0.1" + jest-environment-node: "npm:30.1.2" + jest-regex-util: "npm:30.0.1" + jest-resolve: "npm:30.1.0" + jest-runner: "npm:30.1.2" + jest-util: "npm:30.0.5" + jest-validate: "npm:30.1.0" + micromatch: "npm:^4.0.8" parse-json: "npm:^5.2.0" - pretty-format: "npm:^29.7.0" + pretty-format: "npm:30.0.5" slash: "npm:^3.0.0" strip-json-comments: "npm:^3.1.1" peerDependencies: "@types/node": "*" + esbuild-register: ">=3.4.0" ts-node: ">=9.0.0" peerDependenciesMeta: "@types/node": optional: true + esbuild-register: + optional: true ts-node: optional: true - checksum: 10/6bdf570e9592e7d7dd5124fc0e21f5fe92bd15033513632431b211797e3ab57eaa312f83cc6481b3094b72324e369e876f163579d60016677c117ec4853cf02b + checksum: 10/8deabc5d8f35d7204c60771ad3b4df374bff1564995c208d538696af2d0cd476c83fad18e533b200f195c7ca7f278cfd52088c058cd7ef9d7a3c67122410e46f languageName: node linkType: hard -"jest-diff@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-diff@npm:29.7.0" +"jest-diff@npm:30.1.2": + version: 30.1.2 + resolution: "jest-diff@npm:30.1.2" dependencies: - chalk: "npm:^4.0.0" - diff-sequences: "npm:^29.6.3" - jest-get-type: "npm:^29.6.3" - pretty-format: "npm:^29.7.0" - checksum: 10/6f3a7eb9cd9de5ea9e5aa94aed535631fa6f80221832952839b3cb59dd419b91c20b73887deb0b62230d06d02d6b6cf34ebb810b88d904bb4fe1e2e4f0905c98 + "@jest/diff-sequences": "npm:30.0.1" + "@jest/get-type": "npm:30.1.0" + chalk: "npm:^4.1.2" + pretty-format: "npm:30.0.5" + checksum: 10/5f1a66750dec98b94f3a9dfaaba32f27bd7ebcff3f4303b1bd72fc5665d70eb146c8077a0b9baeed89e42008d0f28629b2c33696e0e6faabad130bebd7a14383 languageName: node linkType: hard -"jest-docblock@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-docblock@npm:29.7.0" +"jest-diff@npm:30.2.0": + version: 30.2.0 + resolution: "jest-diff@npm:30.2.0" dependencies: - detect-newline: "npm:^3.0.0" - checksum: 10/8d48818055bc96c9e4ec2e217a5a375623c0d0bfae8d22c26e011074940c202aa2534a3362294c81d981046885c05d304376afba9f2874143025981148f3e96d + "@jest/diff-sequences": "npm:30.0.1" + "@jest/get-type": "npm:30.1.0" + chalk: "npm:^4.1.2" + pretty-format: "npm:30.2.0" + checksum: 10/1fb9e4fb7dff81814b4f69eaa7db28e184d62306a3a8ea2447d02ca53d2cfa771e83ede513f67ec5239dffacfaac32ff2b49866d211e4c7516f51c1fc06ede42 languageName: node linkType: hard -"jest-each@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-each@npm:29.7.0" +"jest-docblock@npm:30.0.1": + version: 30.0.1 + resolution: "jest-docblock@npm:30.0.1" dependencies: - "@jest/types": "npm:^29.6.3" - chalk: "npm:^4.0.0" - jest-get-type: "npm:^29.6.3" - jest-util: "npm:^29.7.0" - pretty-format: "npm:^29.7.0" - checksum: 10/bd1a077654bdaa013b590deb5f7e7ade68f2e3289180a8c8f53bc8a49f3b40740c0ec2d3a3c1aee906f682775be2bebbac37491d80b634d15276b0aa0f2e3fda + detect-newline: "npm:^3.1.0" + checksum: 10/92ebee39282e764cd64bbfffe4a1bbae323e3b01684028c7206aada198314522a8ebe6892660d2ddeeb9a4b8d270a90da8af0fc654502a428e412867d732a459 + languageName: node + linkType: hard + +"jest-each@npm:30.1.0": + version: 30.1.0 + resolution: "jest-each@npm:30.1.0" + dependencies: + "@jest/get-type": "npm:30.1.0" + "@jest/types": "npm:30.0.5" + chalk: "npm:^4.1.2" + jest-util: "npm:30.0.5" + pretty-format: "npm:30.0.5" + checksum: 10/732acd30393b8fe63a47556c0217fc2d3a4df07d66ff4aa729ded090b55fd0f3ad6fc4b9bd21114fdc95b877391ee5a242fd8bf0b6d142b009efb615908a3ba0 languageName: node linkType: hard -"jest-environment-jsdom@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-environment-jsdom@npm:29.7.0" +"jest-environment-jsdom@npm:30.1.2": + version: 30.1.2 + resolution: "jest-environment-jsdom@npm:30.1.2" dependencies: - "@jest/environment": "npm:^29.7.0" - "@jest/fake-timers": "npm:^29.7.0" - "@jest/types": "npm:^29.6.3" - "@types/jsdom": "npm:^20.0.0" + "@jest/environment": "npm:30.1.2" + "@jest/environment-jsdom-abstract": "npm:30.1.2" + "@types/jsdom": "npm:^21.1.7" "@types/node": "npm:*" - jest-mock: "npm:^29.7.0" - jest-util: "npm:^29.7.0" - jsdom: "npm:^20.0.0" + jsdom: "npm:^26.1.0" peerDependencies: - canvas: ^2.5.0 + canvas: ^3.0.0 peerDependenciesMeta: canvas: optional: true - checksum: 10/23bbfc9bca914baef4b654f7983175a4d49b0f515a5094ebcb8f819f28ec186f53c0ba06af1855eac04bab1457f4ea79dae05f70052cf899863e8096daa6e0f5 + checksum: 10/8a3e1674228452ba1746418fbc25d4f3c06546811e2f9d9cbc4586e0af8be8a45137ba2fc4d6b946ebe2a903109540ba7c009b1ea937a434b3a915dff4c2d3a3 languageName: node linkType: hard -"jest-environment-node@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-environment-node@npm:29.7.0" +"jest-environment-node@npm:30.1.2": + version: 30.1.2 + resolution: "jest-environment-node@npm:30.1.2" dependencies: - "@jest/environment": "npm:^29.7.0" - "@jest/fake-timers": "npm:^29.7.0" - "@jest/types": "npm:^29.6.3" + "@jest/environment": "npm:30.1.2" + "@jest/fake-timers": "npm:30.1.2" + "@jest/types": "npm:30.0.5" "@types/node": "npm:*" - jest-mock: "npm:^29.7.0" - jest-util: "npm:^29.7.0" - checksum: 10/9cf7045adf2307cc93aed2f8488942e39388bff47ec1df149a997c6f714bfc66b2056768973770d3f8b1bf47396c19aa564877eb10ec978b952c6018ed1bd637 - languageName: node - linkType: hard - -"jest-get-type@npm:^29.6.3": - version: 29.6.3 - resolution: "jest-get-type@npm:29.6.3" - checksum: 10/88ac9102d4679d768accae29f1e75f592b760b44277df288ad76ce5bf038c3f5ce3719dea8aa0f035dac30e9eb034b848ce716b9183ad7cc222d029f03e92205 + jest-mock: "npm:30.0.5" + jest-util: "npm:30.0.5" + jest-validate: "npm:30.1.0" + checksum: 10/347e3812cb0ed936c208d8e7289f83989ceb14693b7b134dd8b6ebfab3a487d77c5444bf3ae0d588d401dda59c9d483cab98a15a417bd1fcda08a744ad0017cc languageName: node linkType: hard -"jest-haste-map@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-haste-map@npm:29.7.0" +"jest-haste-map@npm:30.1.0": + version: 30.1.0 + resolution: "jest-haste-map@npm:30.1.0" dependencies: - "@jest/types": "npm:^29.6.3" - "@types/graceful-fs": "npm:^4.1.3" + "@jest/types": "npm:30.0.5" "@types/node": "npm:*" - anymatch: "npm:^3.0.3" - fb-watchman: "npm:^2.0.0" - fsevents: "npm:^2.3.2" - graceful-fs: "npm:^4.2.9" - jest-regex-util: "npm:^29.6.3" - jest-util: "npm:^29.7.0" - jest-worker: "npm:^29.7.0" - micromatch: "npm:^4.0.4" + anymatch: "npm:^3.1.3" + fb-watchman: "npm:^2.0.2" + fsevents: "npm:^2.3.3" + graceful-fs: "npm:^4.2.11" + jest-regex-util: "npm:30.0.1" + jest-util: "npm:30.0.5" + jest-worker: "npm:30.1.0" + micromatch: "npm:^4.0.8" walker: "npm:^1.0.8" dependenciesMeta: fsevents: optional: true - checksum: 10/8531b42003581cb18a69a2774e68c456fb5a5c3280b1b9b77475af9e346b6a457250f9d756bfeeae2fe6cbc9ef28434c205edab9390ee970a919baddfa08bb85 + checksum: 10/bd39053fe1602979917b8214b64b0ea3e2441e59a503a634cf49ceef32b647e6c829ddd7ade44ce091a6a2dfe9476ddb9f750067eae1ba36c15551a822a78653 languageName: node linkType: hard -"jest-leak-detector@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-leak-detector@npm:29.7.0" +"jest-leak-detector@npm:30.1.0": + version: 30.1.0 + resolution: "jest-leak-detector@npm:30.1.0" dependencies: - jest-get-type: "npm:^29.6.3" - pretty-format: "npm:^29.7.0" - checksum: 10/e3950e3ddd71e1d0c22924c51a300a1c2db6cf69ec1e51f95ccf424bcc070f78664813bef7aed4b16b96dfbdeea53fe358f8aeaaea84346ae15c3735758f1605 + "@jest/get-type": "npm:30.1.0" + pretty-format: "npm:30.0.5" + checksum: 10/f6e598cb21fea7edce3d40e7efa8843a8dd2c2bd4e0ae0ec3e15e8e45863f8cb642995ff230be1f1a1f21e17bba67f0290620a5936de2537f86d1c922450fa08 languageName: node linkType: hard -"jest-matcher-utils@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-matcher-utils@npm:29.7.0" +"jest-matcher-utils@npm:30.1.2": + version: 30.1.2 + resolution: "jest-matcher-utils@npm:30.1.2" dependencies: - chalk: "npm:^4.0.0" - jest-diff: "npm:^29.7.0" - jest-get-type: "npm:^29.6.3" - pretty-format: "npm:^29.7.0" - checksum: 10/981904a494299cf1e3baed352f8a3bd8b50a8c13a662c509b6a53c31461f94ea3bfeffa9d5efcfeb248e384e318c87de7e3baa6af0f79674e987482aa189af40 + "@jest/get-type": "npm:30.1.0" + chalk: "npm:^4.1.2" + jest-diff: "npm:30.1.2" + pretty-format: "npm:30.0.5" + checksum: 10/2bad65eeec699b694335e219ac8115323e6c651ca2fb625f83b6e7f5bf7940302e3b0f1683c4c7d88be0112a7892d00db42515aa2e14f71dd8c2742496d5124c languageName: node linkType: hard -"jest-message-util@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-message-util@npm:29.7.0" +"jest-matcher-utils@npm:30.2.0": + version: 30.2.0 + resolution: "jest-matcher-utils@npm:30.2.0" dependencies: - "@babel/code-frame": "npm:^7.12.13" - "@jest/types": "npm:^29.6.3" - "@types/stack-utils": "npm:^2.0.0" - chalk: "npm:^4.0.0" - graceful-fs: "npm:^4.2.9" - micromatch: "npm:^4.0.4" - pretty-format: "npm:^29.7.0" + "@jest/get-type": "npm:30.1.0" + chalk: "npm:^4.1.2" + jest-diff: "npm:30.2.0" + pretty-format: "npm:30.2.0" + checksum: 10/f3f1ecf68ca63c9d1d80a175637a8fc655edfd1ee83220f6e3f6bd464ecbe2f93148fdd440a5a5e5a2b0b2cc8ee84ddc3dcef58a6dbc66821c792f48d260c6d4 + languageName: node + linkType: hard + +"jest-message-util@npm:30.1.0": + version: 30.1.0 + resolution: "jest-message-util@npm:30.1.0" + dependencies: + "@babel/code-frame": "npm:^7.27.1" + "@jest/types": "npm:30.0.5" + "@types/stack-utils": "npm:^2.0.3" + chalk: "npm:^4.1.2" + graceful-fs: "npm:^4.2.11" + micromatch: "npm:^4.0.8" + pretty-format: "npm:30.0.5" + slash: "npm:^3.0.0" + stack-utils: "npm:^2.0.6" + checksum: 10/8006b8e6070e18d28cfd0bcdce7f7538688f59e1edc1ebf705a2435dc35ad293b37c70d97fb7624cf64e554b229f815d27c85b9bcfc9177584dad949c9375ff7 + languageName: node + linkType: hard + +"jest-message-util@npm:30.2.0": + version: 30.2.0 + resolution: "jest-message-util@npm:30.2.0" + dependencies: + "@babel/code-frame": "npm:^7.27.1" + "@jest/types": "npm:30.2.0" + "@types/stack-utils": "npm:^2.0.3" + chalk: "npm:^4.1.2" + graceful-fs: "npm:^4.2.11" + micromatch: "npm:^4.0.8" + pretty-format: "npm:30.2.0" slash: "npm:^3.0.0" - stack-utils: "npm:^2.0.3" - checksum: 10/31d53c6ed22095d86bab9d14c0fa70c4a92c749ea6ceece82cf30c22c9c0e26407acdfbdb0231435dc85a98d6d65ca0d9cbcd25cd1abb377fe945e843fb770b9 + stack-utils: "npm:^2.0.6" + checksum: 10/e29ec76e8c8e4da5f5b25198be247535626ccf3a940e93fdd51fc6a6bcf70feaa2921baae3806182a090431d90b08c939eb13fb64249b171d2e9ae3a452a8fd2 + languageName: node + linkType: hard + +"jest-mock@npm:30.0.5": + version: 30.0.5 + resolution: "jest-mock@npm:30.0.5" + dependencies: + "@jest/types": "npm:30.0.5" + "@types/node": "npm:*" + jest-util: "npm:30.0.5" + checksum: 10/a20386a9e4019c8e2957b95232a85dda6b705d810c2f9267278b40369db247bc311f84eeed72e13b227e15f40d554bd9fd66fafb4adb629dd37c9c14087a4106 languageName: node linkType: hard -"jest-mock@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-mock@npm:29.7.0" +"jest-mock@npm:30.2.0": + version: 30.2.0 + resolution: "jest-mock@npm:30.2.0" dependencies: - "@jest/types": "npm:^29.6.3" + "@jest/types": "npm:30.2.0" "@types/node": "npm:*" - jest-util: "npm:^29.7.0" - checksum: 10/ae51d1b4f898724be5e0e52b2268a68fcd876d9b20633c864a6dd6b1994cbc48d62402b0f40f3a1b669b30ebd648821f086c26c08ffde192ced951ff4670d51c + jest-util: "npm:30.2.0" + checksum: 10/cde9b56805f90bf811a9231873ee88a0fb83bf4bf50972ae76960725da65220fcb119688f2e90e1ef33fbfd662194858d7f43809d881f1c41bb55d94e62adeab languageName: node linkType: hard -"jest-pnp-resolver@npm:^1.2.2": +"jest-pnp-resolver@npm:^1.2.3": version: 1.2.3 resolution: "jest-pnp-resolver@npm:1.2.3" peerDependencies: @@ -15714,210 +16453,226 @@ __metadata: languageName: node linkType: hard -"jest-regex-util@npm:^29.6.3": - version: 29.6.3 - resolution: "jest-regex-util@npm:29.6.3" - checksum: 10/0518beeb9bf1228261695e54f0feaad3606df26a19764bc19541e0fc6e2a3737191904607fb72f3f2ce85d9c16b28df79b7b1ec9443aa08c3ef0e9efda6f8f2a +"jest-regex-util@npm:30.0.1": + version: 30.0.1 + resolution: "jest-regex-util@npm:30.0.1" + checksum: 10/fa8dac80c3e94db20d5e1e51d1bdf101cf5ede8f4e0b8f395ba8b8ea81e71804ffd747452a6bb6413032865de98ac656ef8ae43eddd18d980b6442a2764ed562 languageName: node linkType: hard -"jest-resolve-dependencies@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-resolve-dependencies@npm:29.7.0" +"jest-resolve-dependencies@npm:30.1.2": + version: 30.1.2 + resolution: "jest-resolve-dependencies@npm:30.1.2" dependencies: - jest-regex-util: "npm:^29.6.3" - jest-snapshot: "npm:^29.7.0" - checksum: 10/1e206f94a660d81e977bcfb1baae6450cb4a81c92e06fad376cc5ea16b8e8c6ea78c383f39e95591a9eb7f925b6a1021086c38941aa7c1b8a6a813c2f6e93675 + jest-regex-util: "npm:30.0.1" + jest-snapshot: "npm:30.1.2" + checksum: 10/07c92f833eb397caf13872ad37025f22fbee959a5de4aa62a14fe7d283449a5a2c3b24ca2d73f814e83596e5d5a5f17cc2253740021a7aa81254cec898b041a6 languageName: node linkType: hard -"jest-resolve@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-resolve@npm:29.7.0" +"jest-resolve@npm:30.1.0": + version: 30.1.0 + resolution: "jest-resolve@npm:30.1.0" dependencies: - chalk: "npm:^4.0.0" - graceful-fs: "npm:^4.2.9" - jest-haste-map: "npm:^29.7.0" - jest-pnp-resolver: "npm:^1.2.2" - jest-util: "npm:^29.7.0" - jest-validate: "npm:^29.7.0" - resolve: "npm:^1.20.0" - resolve.exports: "npm:^2.0.0" + chalk: "npm:^4.1.2" + graceful-fs: "npm:^4.2.11" + jest-haste-map: "npm:30.1.0" + jest-pnp-resolver: "npm:^1.2.3" + jest-util: "npm:30.0.5" + jest-validate: "npm:30.1.0" slash: "npm:^3.0.0" - checksum: 10/faa466fd9bc69ea6c37a545a7c6e808e073c66f46ab7d3d8a6ef084f8708f201b85d5fe1799789578b8b47fa1de47b9ee47b414d1863bc117a49e032ba77b7c7 + unrs-resolver: "npm:^1.7.11" + checksum: 10/543cf7410cf6f02ea15ed3519484b249a9f3f7be1675e1055f1260464eac9c03d2f7e9bae045dcda0f66b837fb0ddb913f9817c96173c10f533bf161f50d6bb4 languageName: node linkType: hard -"jest-runner@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-runner@npm:29.7.0" +"jest-runner@npm:30.1.2": + version: 30.1.2 + resolution: "jest-runner@npm:30.1.2" dependencies: - "@jest/console": "npm:^29.7.0" - "@jest/environment": "npm:^29.7.0" - "@jest/test-result": "npm:^29.7.0" - "@jest/transform": "npm:^29.7.0" - "@jest/types": "npm:^29.6.3" + "@jest/console": "npm:30.1.2" + "@jest/environment": "npm:30.1.2" + "@jest/test-result": "npm:30.1.2" + "@jest/transform": "npm:30.1.2" + "@jest/types": "npm:30.0.5" "@types/node": "npm:*" - chalk: "npm:^4.0.0" + chalk: "npm:^4.1.2" emittery: "npm:^0.13.1" - graceful-fs: "npm:^4.2.9" - jest-docblock: "npm:^29.7.0" - jest-environment-node: "npm:^29.7.0" - jest-haste-map: "npm:^29.7.0" - jest-leak-detector: "npm:^29.7.0" - jest-message-util: "npm:^29.7.0" - jest-resolve: "npm:^29.7.0" - jest-runtime: "npm:^29.7.0" - jest-util: "npm:^29.7.0" - jest-watcher: "npm:^29.7.0" - jest-worker: "npm:^29.7.0" + exit-x: "npm:^0.2.2" + graceful-fs: "npm:^4.2.11" + jest-docblock: "npm:30.0.1" + jest-environment-node: "npm:30.1.2" + jest-haste-map: "npm:30.1.0" + jest-leak-detector: "npm:30.1.0" + jest-message-util: "npm:30.1.0" + jest-resolve: "npm:30.1.0" + jest-runtime: "npm:30.1.2" + jest-util: "npm:30.0.5" + jest-watcher: "npm:30.1.2" + jest-worker: "npm:30.1.0" p-limit: "npm:^3.1.0" source-map-support: "npm:0.5.13" - checksum: 10/9d8748a494bd90f5c82acea99be9e99f21358263ce6feae44d3f1b0cd90991b5df5d18d607e73c07be95861ee86d1cbab2a3fc6ca4b21805f07ac29d47c1da1e + checksum: 10/9c3769667883e7b952019832c08366a1ac464fe12a877260d39469c8e0bb16bdec49c6d5bd96dac0bf52747e507300dce06a21eab0d488ce4740934ff7b76b3b languageName: node linkType: hard -"jest-runtime@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-runtime@npm:29.7.0" +"jest-runtime@npm:30.1.2": + version: 30.1.2 + resolution: "jest-runtime@npm:30.1.2" dependencies: - "@jest/environment": "npm:^29.7.0" - "@jest/fake-timers": "npm:^29.7.0" - "@jest/globals": "npm:^29.7.0" - "@jest/source-map": "npm:^29.6.3" - "@jest/test-result": "npm:^29.7.0" - "@jest/transform": "npm:^29.7.0" - "@jest/types": "npm:^29.6.3" + "@jest/environment": "npm:30.1.2" + "@jest/fake-timers": "npm:30.1.2" + "@jest/globals": "npm:30.1.2" + "@jest/source-map": "npm:30.0.1" + "@jest/test-result": "npm:30.1.2" + "@jest/transform": "npm:30.1.2" + "@jest/types": "npm:30.0.5" "@types/node": "npm:*" - chalk: "npm:^4.0.0" - cjs-module-lexer: "npm:^1.0.0" - collect-v8-coverage: "npm:^1.0.0" - glob: "npm:^7.1.3" - graceful-fs: "npm:^4.2.9" - jest-haste-map: "npm:^29.7.0" - jest-message-util: "npm:^29.7.0" - jest-mock: "npm:^29.7.0" - jest-regex-util: "npm:^29.6.3" - jest-resolve: "npm:^29.7.0" - jest-snapshot: "npm:^29.7.0" - jest-util: "npm:^29.7.0" + chalk: "npm:^4.1.2" + cjs-module-lexer: "npm:^2.1.0" + collect-v8-coverage: "npm:^1.0.2" + glob: "npm:^10.3.10" + graceful-fs: "npm:^4.2.11" + jest-haste-map: "npm:30.1.0" + jest-message-util: "npm:30.1.0" + jest-mock: "npm:30.0.5" + jest-regex-util: "npm:30.0.1" + jest-resolve: "npm:30.1.0" + jest-snapshot: "npm:30.1.2" + jest-util: "npm:30.0.5" slash: "npm:^3.0.0" strip-bom: "npm:^4.0.0" - checksum: 10/59eb58eb7e150e0834a2d0c0d94f2a0b963ae7182cfa6c63f2b49b9c6ef794e5193ef1634e01db41420c36a94cefc512cdd67a055cd3e6fa2f41eaf0f82f5a20 + checksum: 10/1d79c9f625de0b9f56b06eb50ef4e8931547a507b8e01aa8f0d300d09e1099f0ff1b8a40416ea0b47545de056997e2ccc0830fd95fd610a12664f2263c67fe6c languageName: node linkType: hard -"jest-snapshot@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-snapshot@npm:29.7.0" +"jest-snapshot@npm:30.1.2": + version: 30.1.2 + resolution: "jest-snapshot@npm:30.1.2" dependencies: - "@babel/core": "npm:^7.11.6" - "@babel/generator": "npm:^7.7.2" - "@babel/plugin-syntax-jsx": "npm:^7.7.2" - "@babel/plugin-syntax-typescript": "npm:^7.7.2" - "@babel/types": "npm:^7.3.3" - "@jest/expect-utils": "npm:^29.7.0" - "@jest/transform": "npm:^29.7.0" - "@jest/types": "npm:^29.6.3" - babel-preset-current-node-syntax: "npm:^1.0.0" - chalk: "npm:^4.0.0" - expect: "npm:^29.7.0" - graceful-fs: "npm:^4.2.9" - jest-diff: "npm:^29.7.0" - jest-get-type: "npm:^29.6.3" - jest-matcher-utils: "npm:^29.7.0" - jest-message-util: "npm:^29.7.0" - jest-util: "npm:^29.7.0" - natural-compare: "npm:^1.4.0" - pretty-format: "npm:^29.7.0" - semver: "npm:^7.5.3" - checksum: 10/cb19a3948256de5f922d52f251821f99657339969bf86843bd26cf3332eae94883e8260e3d2fba46129a27c3971c1aa522490e460e16c7fad516e82d10bbf9f8 + "@babel/core": "npm:^7.27.4" + "@babel/generator": "npm:^7.27.5" + "@babel/plugin-syntax-jsx": "npm:^7.27.1" + "@babel/plugin-syntax-typescript": "npm:^7.27.1" + "@babel/types": "npm:^7.27.3" + "@jest/expect-utils": "npm:30.1.2" + "@jest/get-type": "npm:30.1.0" + "@jest/snapshot-utils": "npm:30.1.2" + "@jest/transform": "npm:30.1.2" + "@jest/types": "npm:30.0.5" + babel-preset-current-node-syntax: "npm:^1.1.0" + chalk: "npm:^4.1.2" + expect: "npm:30.1.2" + graceful-fs: "npm:^4.2.11" + jest-diff: "npm:30.1.2" + jest-matcher-utils: "npm:30.1.2" + jest-message-util: "npm:30.1.0" + jest-util: "npm:30.0.5" + pretty-format: "npm:30.0.5" + semver: "npm:^7.7.2" + synckit: "npm:^0.11.8" + checksum: 10/31cd67a0021518d76bcf9ad802125172d0abfd9b62e05c93afab09b4350c8cb218fc5294005b477c068a1eb0fc1eada08be180ad7bff706b31c8e98a2def7c6c languageName: node linkType: hard -"jest-util@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-util@npm:29.7.0" +"jest-util@npm:30.0.5": + version: 30.0.5 + resolution: "jest-util@npm:30.0.5" dependencies: - "@jest/types": "npm:^29.6.3" + "@jest/types": "npm:30.0.5" "@types/node": "npm:*" - chalk: "npm:^4.0.0" - ci-info: "npm:^3.2.0" - graceful-fs: "npm:^4.2.9" - picomatch: "npm:^2.2.3" - checksum: 10/30d58af6967e7d42bd903ccc098f3b4d3859ed46238fbc88d4add6a3f10bea00c226b93660285f058bc7a65f6f9529cf4eb80f8d4707f79f9e3a23686b4ab8f3 + chalk: "npm:^4.1.2" + ci-info: "npm:^4.2.0" + graceful-fs: "npm:^4.2.11" + picomatch: "npm:^4.0.2" + checksum: 10/44207c4b8c27b0cce809c76280c8a949514badef6af875edafd153f1df638727235b472f8790953045214ce3f17ad77a9dfd5c1826444c0431fe64bd580ba2d6 languageName: node linkType: hard -"jest-validate@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-validate@npm:29.7.0" +"jest-util@npm:30.2.0": + version: 30.2.0 + resolution: "jest-util@npm:30.2.0" dependencies: - "@jest/types": "npm:^29.6.3" - camelcase: "npm:^6.2.0" - chalk: "npm:^4.0.0" - jest-get-type: "npm:^29.6.3" + "@jest/types": "npm:30.2.0" + "@types/node": "npm:*" + chalk: "npm:^4.1.2" + ci-info: "npm:^4.2.0" + graceful-fs: "npm:^4.2.11" + picomatch: "npm:^4.0.2" + checksum: 10/cf2f2fb83417ea69f9992121561c95cf4e9aad7946819b771b8b52addf78811101b33b51d0a39fa0c305f2751dab262feed7699de052659ff03d51827c8862f5 + languageName: node + linkType: hard + +"jest-validate@npm:30.1.0": + version: 30.1.0 + resolution: "jest-validate@npm:30.1.0" + dependencies: + "@jest/get-type": "npm:30.1.0" + "@jest/types": "npm:30.0.5" + camelcase: "npm:^6.3.0" + chalk: "npm:^4.1.2" leven: "npm:^3.1.0" - pretty-format: "npm:^29.7.0" - checksum: 10/8ee1163666d8eaa16d90a989edba2b4a3c8ab0ffaa95ad91b08ca42b015bfb70e164b247a5b17f9de32d096987cada63ed8491ab82761bfb9a28bc34b27ae161 + pretty-format: "npm:30.0.5" + checksum: 10/24e01e3aca0fad16ec3aec3bf656c2e7d6c4c9be13dcb443fbad7035a5f9acab8bc736fb3ab3c3c63740682107643717ba82333b95225199acd309838f2fbd19 languageName: node linkType: hard -"jest-watcher@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-watcher@npm:29.7.0" +"jest-watcher@npm:30.1.2": + version: 30.1.2 + resolution: "jest-watcher@npm:30.1.2" dependencies: - "@jest/test-result": "npm:^29.7.0" - "@jest/types": "npm:^29.6.3" + "@jest/test-result": "npm:30.1.2" + "@jest/types": "npm:30.0.5" "@types/node": "npm:*" - ansi-escapes: "npm:^4.2.1" - chalk: "npm:^4.0.0" + ansi-escapes: "npm:^4.3.2" + chalk: "npm:^4.1.2" emittery: "npm:^0.13.1" - jest-util: "npm:^29.7.0" - string-length: "npm:^4.0.1" - checksum: 10/4f616e0345676631a7034b1d94971aaa719f0cd4a6041be2aa299be437ea047afd4fe05c48873b7963f5687a2f6c7cbf51244be8b14e313b97bfe32b1e127e55 + jest-util: "npm:30.0.5" + string-length: "npm:^4.0.2" + checksum: 10/41612c90cffd325cdfb9c7551986fc9ecf42092aa25f3e2cf307996f110670f1f83e04a0becd50e1c89bce462b2dd2ac9d564497dbf7b5167cf5a625e9c66ad1 languageName: node linkType: hard -"jest-worker@npm:^27.4.5": - version: 27.5.1 - resolution: "jest-worker@npm:27.5.1" +"jest-worker@npm:30.1.0": + version: 30.1.0 + resolution: "jest-worker@npm:30.1.0" dependencies: "@types/node": "npm:*" + "@ungap/structured-clone": "npm:^1.3.0" + jest-util: "npm:30.0.5" merge-stream: "npm:^2.0.0" - supports-color: "npm:^8.0.0" - checksum: 10/06c6e2a84591d9ede704d5022fc13791e8876e83397c89d481b0063332abbb64c0f01ef4ca7de520b35c7a1058556078d6bdc3631376f4e9ffb42316c1a8488e + supports-color: "npm:^8.1.1" + checksum: 10/cc09d2ce8601d3e356540c09c80265554fdd96ed82e989568a5454018e9e4863337898d3e8c19c3b03c6047eb531629224a91f3c62682222059929acb4e0c5cc languageName: node linkType: hard -"jest-worker@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-worker@npm:29.7.0" +"jest-worker@npm:^27.4.5": + version: 27.5.1 + resolution: "jest-worker@npm:27.5.1" dependencies: "@types/node": "npm:*" - jest-util: "npm:^29.7.0" merge-stream: "npm:^2.0.0" supports-color: "npm:^8.0.0" - checksum: 10/364cbaef00d8a2729fc760227ad34b5e60829e0869bd84976bdfbd8c0d0f9c2f22677b3e6dd8afa76ed174765351cd12bae3d4530c62eefb3791055127ca9745 + checksum: 10/06c6e2a84591d9ede704d5022fc13791e8876e83397c89d481b0063332abbb64c0f01ef4ca7de520b35c7a1058556078d6bdc3631376f4e9ffb42316c1a8488e languageName: node linkType: hard -"jest@npm:^29.7.0": - version: 29.7.0 - resolution: "jest@npm:29.7.0" +"jest@npm:30.1.2": + version: 30.1.2 + resolution: "jest@npm:30.1.2" dependencies: - "@jest/core": "npm:^29.7.0" - "@jest/types": "npm:^29.6.3" - import-local: "npm:^3.0.2" - jest-cli: "npm:^29.7.0" + "@jest/core": "npm:30.1.2" + "@jest/types": "npm:30.0.5" + import-local: "npm:^3.2.0" + jest-cli: "npm:30.1.2" peerDependencies: node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 peerDependenciesMeta: node-notifier: optional: true bin: - jest: bin/jest.js - checksum: 10/97023d78446098c586faaa467fbf2c6b07ff06e2c85a19e3926adb5b0effe9ac60c4913ae03e2719f9c01ae8ffd8d92f6b262cedb9555ceeb5d19263d8c6362a + jest: ./bin/jest.js + checksum: 10/4d564f061b879b37f58d97b4941dea6acf10621869a07eaa2602f1efc8d3606f7026d9f11470fa8294019b2fbccde9e77767828b781c78058ee5fd6e0531754a languageName: node linkType: hard @@ -15930,6 +16685,15 @@ __metadata: languageName: node linkType: hard +"jiti@npm:^2.5.1": + version: 2.6.1 + resolution: "jiti@npm:2.6.1" + bin: + jiti: lib/jiti-cli.mjs + checksum: 10/8cd72c5fd03a0502564c3f46c49761090f6dadead21fa191b73535724f095ad86c2fa89ee6fe4bc3515337e8d406cc8fb2d37b73fa0c99a34584bac35cd4a4de + languageName: node + linkType: hard + "jju@npm:~1.4.0": version: 1.4.0 resolution: "jju@npm:1.4.0" @@ -15943,7 +16707,7 @@ __metadata: dependencies: "@eslint/js": "npm:^9.25.0" "@joint/react": "workspace:^" - "@types/react": "npm:^19.1.2" + "@types/react": "npm:^19.1.10" "@types/react-dom": "npm:^19.1.2" "@vitejs/plugin-react": "npm:^4.4.1" eslint: "npm:^9.25.0" @@ -16055,46 +16819,7 @@ __metadata: languageName: node linkType: hard -"jsdom@npm:^20.0.0": - version: 20.0.3 - resolution: "jsdom@npm:20.0.3" - dependencies: - abab: "npm:^2.0.6" - acorn: "npm:^8.8.1" - acorn-globals: "npm:^7.0.0" - cssom: "npm:^0.5.0" - cssstyle: "npm:^2.3.0" - data-urls: "npm:^3.0.2" - decimal.js: "npm:^10.4.2" - domexception: "npm:^4.0.0" - escodegen: "npm:^2.0.0" - form-data: "npm:^4.0.0" - html-encoding-sniffer: "npm:^3.0.0" - http-proxy-agent: "npm:^5.0.0" - https-proxy-agent: "npm:^5.0.1" - is-potential-custom-element-name: "npm:^1.0.1" - nwsapi: "npm:^2.2.2" - parse5: "npm:^7.1.1" - saxes: "npm:^6.0.0" - symbol-tree: "npm:^3.2.4" - tough-cookie: "npm:^4.1.2" - w3c-xmlserializer: "npm:^4.0.0" - webidl-conversions: "npm:^7.0.0" - whatwg-encoding: "npm:^2.0.0" - whatwg-mimetype: "npm:^3.0.0" - whatwg-url: "npm:^11.0.0" - ws: "npm:^8.11.0" - xml-name-validator: "npm:^4.0.0" - peerDependencies: - canvas: ^2.5.0 - peerDependenciesMeta: - canvas: - optional: true - checksum: 10/a4cdcff5b07eed87da90b146b82936321533b5efe8124492acf7160ebd5b9cf2b3c2435683592bf1cffb479615245756efb6c173effc1906f845a86ed22af985 - languageName: node - linkType: hard - -"jsdom@npm:^26.0.0": +"jsdom@npm:^26.0.0, jsdom@npm:^26.1.0": version: 26.1.0 resolution: "jsdom@npm:26.1.0" dependencies: @@ -16418,6 +17143,33 @@ __metadata: languageName: node linkType: hard +"knip@npm:5.63.0": + version: 5.63.0 + resolution: "knip@npm:5.63.0" + dependencies: + "@nodelib/fs.walk": "npm:^1.2.3" + fast-glob: "npm:^3.3.3" + formatly: "npm:^0.3.0" + jiti: "npm:^2.5.1" + js-yaml: "npm:^4.1.0" + minimist: "npm:^1.2.8" + oxc-resolver: "npm:^11.6.2" + picocolors: "npm:^1.1.1" + picomatch: "npm:^4.0.1" + smol-toml: "npm:^1.4.1" + strip-json-comments: "npm:5.0.2" + zod: "npm:^3.22.4" + zod-validation-error: "npm:^3.0.3" + peerDependencies: + "@types/node": ">=18" + typescript: ">=5.0.4" + bin: + knip: bin/knip.js + knip-bun: bin/knip-bun.js + checksum: 10/a4289909b408f21d08651c48af61e55855ffe3998a2cbae0c5ce20e444f2b2d6356e28dcd845c60156fd3015bab03e0f50ed7ecdaed5921672487513b0b1d0af + languageName: node + linkType: hard + "labeled-stream-splicer@npm:^2.0.0": version: 2.0.2 resolution: "labeled-stream-splicer@npm:2.0.2" @@ -16693,7 +17445,7 @@ __metadata: languageName: node linkType: hard -"lodash.merge@npm:^4.6.2": +"lodash.merge@npm:4.6.2, lodash.merge@npm:^4.6.2": version: 4.6.2 resolution: "lodash.merge@npm:4.6.2" checksum: 10/d0ea2dd0097e6201be083865d50c3fb54fbfbdb247d9cc5950e086c991f448b7ab0cdab0d57eacccb43473d3f2acd21e134db39f22dac2d6c9ba6bf26978e3d6 @@ -16774,7 +17526,7 @@ __metadata: languageName: node linkType: hard -"loose-envify@npm:^1.0.0, loose-envify@npm:^1.1.0, loose-envify@npm:^1.4.0": +"loose-envify@npm:^1.0.0, loose-envify@npm:^1.4.0": version: 1.4.0 resolution: "loose-envify@npm:1.4.0" dependencies: @@ -16890,6 +17642,15 @@ __metadata: languageName: node linkType: hard +"magic-string@npm:^0.30.3": + version: 0.30.19 + resolution: "magic-string@npm:0.30.19" + dependencies: + "@jridgewell/sourcemap-codec": "npm:^1.5.5" + checksum: 10/5045467fad59ddfba6ccfb00fde6edbc0f841089f0da07d844cf513c73de289bbbf933bde16168cba2c9ef38d75ac68e1617a5ce74aae16d6f39285bda1d51c4 + languageName: node + linkType: hard + "make-dir@npm:^2.0.0": version: 2.1.0 resolution: "make-dir@npm:2.1.0" @@ -17193,7 +17954,7 @@ __metadata: languageName: node linkType: hard -"mime-types@npm:^2.1.12, mime-types@npm:^2.1.27, mime-types@npm:^2.1.31, mime-types@npm:~2.1.17, mime-types@npm:~2.1.24, mime-types@npm:~2.1.34": +"mime-types@npm:^2.1.27, mime-types@npm:^2.1.31, mime-types@npm:~2.1.17, mime-types@npm:~2.1.24, mime-types@npm:~2.1.34": version: 2.1.35 resolution: "mime-types@npm:2.1.35" dependencies: @@ -17332,7 +18093,7 @@ __metadata: languageName: node linkType: hard -"minimist@npm:^1.1.0, minimist@npm:^1.2.0, minimist@npm:^1.2.3, minimist@npm:^1.2.5, minimist@npm:^1.2.6": +"minimist@npm:^1.1.0, minimist@npm:^1.2.0, minimist@npm:^1.2.3, minimist@npm:^1.2.5, minimist@npm:^1.2.6, minimist@npm:^1.2.8": version: 1.2.8 resolution: "minimist@npm:1.2.8" checksum: 10/908491b6cc15a6c440ba5b22780a0ba89b9810e1aea684e253e43c4e3b8d56ec1dcdd7ea96dde119c29df59c936cde16062159eae4225c691e19c70b432b6e6f @@ -17549,7 +18310,7 @@ __metadata: languageName: node linkType: hard -"module-replacements@npm:^2.1.0": +"module-replacements@npm:^2.8.0": version: 2.9.0 resolution: "module-replacements@npm:2.9.0" checksum: 10/ada2de0c9850e43f3179fd76d9ac8d66a91c6aabb74500e5d4c86f5bd90b27ab38df962b7aeaa5ee1c55db4d9050e5c66b17b94af5cb7c41ee9f5adf28239c43 @@ -17674,6 +18435,15 @@ __metadata: languageName: node linkType: hard +"napi-postinstall@npm:^0.3.0": + version: 0.3.4 + resolution: "napi-postinstall@npm:0.3.4" + bin: + napi-postinstall: lib/cli.js + checksum: 10/5541381508f9e1051ff3518701c7130ebac779abb3a1ffe9391fcc3cab4cc0569b0ba0952357db3f6b12909c3bb508359a7a60261ffd795feebbdab967175832 + languageName: node + linkType: hard + "native-dash@npm:^1.24.0": version: 1.25.0 resolution: "native-dash@npm:1.25.0" @@ -17860,6 +18630,48 @@ __metadata: languageName: node linkType: hard +"node-releases@npm:^2.0.21": + version: 2.0.23 + resolution: "node-releases@npm:2.0.23" + checksum: 10/f937b23e279b791bc7842d71536e8520ea12efa2c307e5803227ecbba1c807a932bbe3c26d6a8c0e12e21cb3f9c6a6d4ed14beee489ff9e9be49f6d4cf0c7aa4 + languageName: node + linkType: hard + +"node-stdlib-browser@npm:^1.2.0": + version: 1.3.1 + resolution: "node-stdlib-browser@npm:1.3.1" + dependencies: + assert: "npm:^2.0.0" + browser-resolve: "npm:^2.0.0" + browserify-zlib: "npm:^0.2.0" + buffer: "npm:^5.7.1" + console-browserify: "npm:^1.1.0" + constants-browserify: "npm:^1.0.0" + create-require: "npm:^1.1.1" + crypto-browserify: "npm:^3.12.1" + domain-browser: "npm:4.22.0" + events: "npm:^3.0.0" + https-browserify: "npm:^1.0.0" + isomorphic-timers-promises: "npm:^1.0.1" + os-browserify: "npm:^0.3.0" + path-browserify: "npm:^1.0.1" + pkg-dir: "npm:^5.0.0" + process: "npm:^0.11.10" + punycode: "npm:^1.4.1" + querystring-es3: "npm:^0.2.1" + readable-stream: "npm:^3.6.0" + stream-browserify: "npm:^3.0.0" + stream-http: "npm:^3.2.0" + string_decoder: "npm:^1.0.0" + timers-browserify: "npm:^2.0.4" + tty-browserify: "npm:0.0.1" + url: "npm:^0.11.4" + util: "npm:^0.12.4" + vm-browserify: "npm:^1.0.1" + checksum: 10/5d5ace50868ef1a8ce9718a5fc64e4b6712f8be75bf6ab71f2eb7b5815f55f20507e427eac2fdb384e372f58891eb34089af3b55d3f9b5b60b547c8581a1c30e + languageName: node + linkType: hard + "node-watch@npm:0.7.3": version: 0.7.3 resolution: "node-watch@npm:0.7.3" @@ -17912,17 +18724,6 @@ __metadata: languageName: node linkType: hard -"normalize-package-data@npm:^6.0.0": - version: 6.0.2 - resolution: "normalize-package-data@npm:6.0.2" - dependencies: - hosted-git-info: "npm:^7.0.0" - semver: "npm:^7.3.5" - validate-npm-package-license: "npm:^3.0.4" - checksum: 10/7c4216a2426aa76c0197f8372f06b23a0484d62b3518fb5c0f6ebccb16376bdfab29ceba96f95c75f60506473198f1337fe337b945c8df0541fe32b8049ab4c9 - languageName: node - linkType: hard - "normalize-path@npm:^2.1.1": version: 2.1.1 resolution: "normalize-path@npm:2.1.1" @@ -17980,7 +18781,7 @@ __metadata: languageName: node linkType: hard -"nwsapi@npm:^2.2.16, nwsapi@npm:^2.2.2": +"nwsapi@npm:^2.2.16": version: 2.2.21 resolution: "nwsapi@npm:2.2.21" checksum: 10/3d84e7e0691640028fd7b1e93f3368cb1b5958332cecdcb31f335178177a6efdd00a07fb68b99cc476f0ca835bed5bd79b1010a16b97d33ce6c3c3c94bebd05c @@ -18012,6 +18813,16 @@ __metadata: languageName: node linkType: hard +"object-is@npm:^1.1.5": + version: 1.1.6 + resolution: "object-is@npm:1.1.6" + dependencies: + call-bind: "npm:^1.0.7" + define-properties: "npm:^1.2.1" + checksum: 10/4f6f544773a595da21c69a7531e0e1d6250670f4e09c55f47eb02c516035cfcb1b46ceb744edfd3ecb362309dbccb6d7f88e43bf42e4d4595ac10a329061053a + languageName: node + linkType: hard + "object-keys@npm:^1.1.1": version: 1.1.1 resolution: "object-keys@npm:1.1.1" @@ -18228,7 +19039,7 @@ __metadata: languageName: node linkType: hard -"os-browserify@npm:~0.3.0": +"os-browserify@npm:^0.3.0, os-browserify@npm:~0.3.0": version: 0.3.0 resolution: "os-browserify@npm:0.3.0" checksum: 10/16e37ba3c0e6a4c63443c7b55799ce4066d59104143cb637ecb9fce586d5da319cdca786ba1c867abbe3890d2cbf37953f2d51eea85e20dd6c4570d6c54bfebf @@ -18279,6 +19090,72 @@ __metadata: languageName: node linkType: hard +"oxc-resolver@npm:^11.6.2": + version: 11.9.0 + resolution: "oxc-resolver@npm:11.9.0" + dependencies: + "@oxc-resolver/binding-android-arm-eabi": "npm:11.9.0" + "@oxc-resolver/binding-android-arm64": "npm:11.9.0" + "@oxc-resolver/binding-darwin-arm64": "npm:11.9.0" + "@oxc-resolver/binding-darwin-x64": "npm:11.9.0" + "@oxc-resolver/binding-freebsd-x64": "npm:11.9.0" + "@oxc-resolver/binding-linux-arm-gnueabihf": "npm:11.9.0" + "@oxc-resolver/binding-linux-arm-musleabihf": "npm:11.9.0" + "@oxc-resolver/binding-linux-arm64-gnu": "npm:11.9.0" + "@oxc-resolver/binding-linux-arm64-musl": "npm:11.9.0" + "@oxc-resolver/binding-linux-ppc64-gnu": "npm:11.9.0" + "@oxc-resolver/binding-linux-riscv64-gnu": "npm:11.9.0" + "@oxc-resolver/binding-linux-riscv64-musl": "npm:11.9.0" + "@oxc-resolver/binding-linux-s390x-gnu": "npm:11.9.0" + "@oxc-resolver/binding-linux-x64-gnu": "npm:11.9.0" + "@oxc-resolver/binding-linux-x64-musl": "npm:11.9.0" + "@oxc-resolver/binding-wasm32-wasi": "npm:11.9.0" + "@oxc-resolver/binding-win32-arm64-msvc": "npm:11.9.0" + "@oxc-resolver/binding-win32-ia32-msvc": "npm:11.9.0" + "@oxc-resolver/binding-win32-x64-msvc": "npm:11.9.0" + dependenciesMeta: + "@oxc-resolver/binding-android-arm-eabi": + optional: true + "@oxc-resolver/binding-android-arm64": + optional: true + "@oxc-resolver/binding-darwin-arm64": + optional: true + "@oxc-resolver/binding-darwin-x64": + optional: true + "@oxc-resolver/binding-freebsd-x64": + optional: true + "@oxc-resolver/binding-linux-arm-gnueabihf": + optional: true + "@oxc-resolver/binding-linux-arm-musleabihf": + optional: true + "@oxc-resolver/binding-linux-arm64-gnu": + optional: true + "@oxc-resolver/binding-linux-arm64-musl": + optional: true + "@oxc-resolver/binding-linux-ppc64-gnu": + optional: true + "@oxc-resolver/binding-linux-riscv64-gnu": + optional: true + "@oxc-resolver/binding-linux-riscv64-musl": + optional: true + "@oxc-resolver/binding-linux-s390x-gnu": + optional: true + "@oxc-resolver/binding-linux-x64-gnu": + optional: true + "@oxc-resolver/binding-linux-x64-musl": + optional: true + "@oxc-resolver/binding-wasm32-wasi": + optional: true + "@oxc-resolver/binding-win32-arm64-msvc": + optional: true + "@oxc-resolver/binding-win32-ia32-msvc": + optional: true + "@oxc-resolver/binding-win32-x64-msvc": + optional: true + checksum: 10/46bf7f08f4d5d3ddd4f0c2023e7367e5718718e48c1c728a5ab787f5f91a16d13c5acef33e70fda8857681eafd41160b9c24e5eaf75a6c7a0fbb08b2ac158c1f + languageName: node + linkType: hard + "p-cancelable@npm:^2.0.0": version: 2.1.1 resolution: "p-cancelable@npm:2.1.1" @@ -18495,17 +19372,6 @@ __metadata: languageName: node linkType: hard -"parse-json@npm:^8.0.0": - version: 8.3.0 - resolution: "parse-json@npm:8.3.0" - dependencies: - "@babel/code-frame": "npm:^7.26.2" - index-to-position: "npm:^1.1.0" - type-fest: "npm:^4.39.1" - checksum: 10/23812dd66a8ceedfeb0fd8a92c96b88b18bc1030cf1f07cd29146b711a208ef91ac995cf14517422f908fa930f84324086bf22fdcc1013029776cc01d589bae4 - languageName: node - linkType: hard - "parse-ms@npm:^1.0.0": version: 1.0.1 resolution: "parse-ms@npm:1.0.1" @@ -18546,7 +19412,7 @@ __metadata: languageName: node linkType: hard -"parse5@npm:^7.0.0, parse5@npm:^7.1.1, parse5@npm:^7.2.1, parse5@npm:^7.3.0": +"parse5@npm:^7.0.0, parse5@npm:^7.2.1, parse5@npm:^7.3.0": version: 7.3.0 resolution: "parse5@npm:7.3.0" dependencies: @@ -18569,6 +19435,13 @@ __metadata: languageName: node linkType: hard +"path-browserify@npm:^1.0.1": + version: 1.0.1 + resolution: "path-browserify@npm:1.0.1" + checksum: 10/7e7368a5207e7c6b9051ef045711d0dc3c2b6203e96057e408e6e74d09f383061010d2be95cb8593fe6258a767c3e9fc6b2bfc7ce8d48ae8c3d9f6994cca9ad8 + languageName: node + linkType: hard + "path-browserify@npm:~0.0.0": version: 0.0.1 resolution: "path-browserify@npm:0.0.1" @@ -18770,14 +19643,14 @@ __metadata: languageName: node linkType: hard -"picomatch@npm:^2.0.4, picomatch@npm:^2.2.1, picomatch@npm:^2.2.3, picomatch@npm:^2.3.1": +"picomatch@npm:^2.0.4, picomatch@npm:^2.2.1, picomatch@npm:^2.3.1": version: 2.3.1 resolution: "picomatch@npm:2.3.1" checksum: 10/60c2595003b05e4535394d1da94850f5372c9427ca4413b71210f437f7b2ca091dbd611c45e8b37d10036fa8eade25c1b8951654f9d3973bfa66a2ff4d3b08bc languageName: node linkType: hard -"picomatch@npm:^4.0.2, picomatch@npm:^4.0.3": +"picomatch@npm:^4.0.1, picomatch@npm:^4.0.2, picomatch@npm:^4.0.3": version: 4.0.3 resolution: "picomatch@npm:4.0.3" checksum: 10/57b99055f40b16798f2802916d9c17e9744e620a0db136554af01d19598b96e45e2f00014c91d1b8b13874b80caa8c295b3d589a3f72373ec4aaf54baa5962d5 @@ -18807,7 +19680,7 @@ __metadata: languageName: node linkType: hard -"pirates@npm:^4.0.4": +"pirates@npm:^4.0.7": version: 4.0.7 resolution: "pirates@npm:4.0.7" checksum: 10/2427f371366081ae42feb58214f04805d6b41d6b84d74480ebcc9e0ddbd7105a139f7c653daeaf83ad8a1a77214cf07f64178e76de048128fec501eab3305a96 @@ -18832,6 +19705,15 @@ __metadata: languageName: node linkType: hard +"pkg-dir@npm:^5.0.0": + version: 5.0.0 + resolution: "pkg-dir@npm:5.0.0" + dependencies: + find-up: "npm:^5.0.0" + checksum: 10/b167bb8dac7bbf22b1d5e30ec223e6b064b84b63010c9d49384619a36734caf95ed23ad23d4f9bd975e8e8082b60a83395f43a89bb192df53a7c25a38ecb57d9 + languageName: node + linkType: hard + "pkg-up@npm:^3.1.0": version: 3.1.0 resolution: "pkg-up@npm:3.1.0" @@ -19138,6 +20020,28 @@ __metadata: languageName: node linkType: hard +"pretty-format@npm:30.0.5": + version: 30.0.5 + resolution: "pretty-format@npm:30.0.5" + dependencies: + "@jest/schemas": "npm:30.0.5" + ansi-styles: "npm:^5.2.0" + react-is: "npm:^18.3.1" + checksum: 10/bb65e53092f321257d80cd2c0165e65123805c9d4c4ada1ddac15b08c8879d6d031e6f01ac80e2685ef95ac35d302065196a036c63cd8729747f6e0fa21a55bf + languageName: node + linkType: hard + +"pretty-format@npm:30.2.0, pretty-format@npm:^30.0.0": + version: 30.2.0 + resolution: "pretty-format@npm:30.2.0" + dependencies: + "@jest/schemas": "npm:30.0.5" + ansi-styles: "npm:^5.2.0" + react-is: "npm:^18.3.1" + checksum: 10/725890d648e3400575eebc99a334a4cd1498e0d36746313913706bbeea20ada27e17c184a3cd45c50f705c16111afa829f3450233fc0fda5eed293c69757e926 + languageName: node + linkType: hard + "pretty-format@npm:^27.0.2": version: 27.5.1 resolution: "pretty-format@npm:27.5.1" @@ -19149,17 +20053,6 @@ __metadata: languageName: node linkType: hard -"pretty-format@npm:^29.0.0, pretty-format@npm:^29.7.0": - version: 29.7.0 - resolution: "pretty-format@npm:29.7.0" - dependencies: - "@jest/schemas": "npm:^29.6.3" - ansi-styles: "npm:^5.0.0" - react-is: "npm:^18.0.0" - checksum: 10/dea96bc83c83cd91b2bfc55757b6b2747edcaac45b568e46de29deee80742f17bc76fe8898135a70d904f4928eafd8bb693cd1da4896e8bdd3c5e82cadf1d2bb - languageName: node - linkType: hard - "pretty-ms@npm:^2.1.0": version: 2.1.0 resolution: "pretty-ms@npm:2.1.0" @@ -19225,7 +20118,7 @@ __metadata: languageName: node linkType: hard -"prompts@npm:^2.0.1, prompts@npm:^2.4.1": +"prompts@npm:^2.4.1": version: 2.4.2 resolution: "prompts@npm:2.4.2" dependencies: @@ -19300,15 +20193,6 @@ __metadata: languageName: node linkType: hard -"psl@npm:^1.1.33": - version: 1.15.0 - resolution: "psl@npm:1.15.0" - dependencies: - punycode: "npm:^2.3.1" - checksum: 10/5e7467eb5196eb7900d156783d12907d445c0122f76c73203ce96b148a6ccf8c5450cc805887ffada38ff92d634afcf33720c24053cb01d5b6598d1c913c5caf - languageName: node - linkType: hard - "public-encrypt@npm:^4.0.3": version: 4.0.3 resolution: "public-encrypt@npm:4.0.3" @@ -19368,7 +20252,7 @@ __metadata: languageName: node linkType: hard -"punycode@npm:^2.1.0, punycode@npm:^2.1.1, punycode@npm:^2.3.1": +"punycode@npm:^2.1.0, punycode@npm:^2.3.1": version: 2.3.1 resolution: "punycode@npm:2.3.1" checksum: 10/febdc4362bead22f9e2608ff0171713230b57aff9dddc1c273aa2a651fbd366f94b7d6a71d78342a7c0819906750351ca7f2edd26ea41b626d87d6a13d1bd059 @@ -19444,10 +20328,10 @@ __metadata: languageName: node linkType: hard -"pure-rand@npm:^6.0.0": - version: 6.1.0 - resolution: "pure-rand@npm:6.1.0" - checksum: 10/256aa4bcaf9297256f552914e03cbdb0039c8fe1db11fa1e6d3f80790e16e563eb0a859a1e61082a95e224fc0c608661839439f8ecc6a3db4e48d46d99216ee4 +"pure-rand@npm:^7.0.0": + version: 7.0.1 + resolution: "pure-rand@npm:7.0.1" + checksum: 10/c61a576fda5032ec9763ecb000da4a8f19263b9e2f9ae9aa2759c8fbd9dc6b192b2ce78391ebd41abb394a5fedb7bcc4b03c9e6141ac8ab20882dd5717698b80 languageName: node linkType: hard @@ -19483,20 +20367,13 @@ __metadata: languageName: node linkType: hard -"querystring-es3@npm:~0.2.0": +"querystring-es3@npm:^0.2.1, querystring-es3@npm:~0.2.0": version: 0.2.1 resolution: "querystring-es3@npm:0.2.1" checksum: 10/c99fccfe1a9c4c25ea6194fa7a559fdb83d2628f118f898af6f0ac02c4ffcd7e0576997bb80e7dfa892d193988b60e23d4968122426351819f87051862af991c languageName: node linkType: hard -"querystringify@npm:^2.1.1": - version: 2.2.0 - resolution: "querystringify@npm:2.2.0" - checksum: 10/46ab16f252fd892fc29d6af60966d338cdfeea68a231e9457631ffd22d67cec1e00141e0a5236a2eb16c0d7d74175d9ec1d6f963660c6f2b1c2fc85b194c5680 - languageName: node - linkType: hard - "queue-microtask@npm:^1.2.2": version: 1.2.3 resolution: "queue-microtask@npm:1.2.3" @@ -19660,19 +20537,7 @@ __metadata: languageName: node linkType: hard -"react-dom@npm:18.x, react-dom@npm:^18.2.0": - version: 18.3.1 - resolution: "react-dom@npm:18.3.1" - dependencies: - loose-envify: "npm:^1.1.0" - scheduler: "npm:^0.23.2" - peerDependencies: - react: ^18.3.1 - checksum: 10/3f4b73a3aa083091173b29812b10394dd06f4ac06aff410b74702cfb3aa29d7b0ced208aab92d5272919b612e5cda21aeb1d54191848cf6e46e9e354f3541f81 - languageName: node - linkType: hard - -"react-dom@npm:^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0, react-dom@npm:^19.1.0": +"react-dom@npm:19.1.1, react-dom@npm:^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0, react-dom@npm:^19.1.0": version: 19.1.1 resolution: "react-dom@npm:19.1.1" dependencies: @@ -19683,6 +20548,17 @@ __metadata: languageName: node linkType: hard +"react-dom@npm:^19.1.1": + version: 19.2.0 + resolution: "react-dom@npm:19.2.0" + dependencies: + scheduler: "npm:^0.27.0" + peerDependencies: + react: ^19.2.0 + checksum: 10/3dbba071b9b1e7a19eae55f05c100f6b44f88c0aee72397d719ae338248ca66ed5028e6964c1c14870cc3e1abcecc91b22baba6dc2072f819dea81a9fd72f2fd + languageName: node + linkType: hard + "react-error-boundary@npm:^3.1.0": version: 3.1.4 resolution: "react-error-boundary@npm:3.1.4" @@ -19708,17 +20584,36 @@ __metadata: languageName: node linkType: hard -"react-is@npm:^18.0.0": +"react-is@npm:^18.3.1": version: 18.3.1 resolution: "react-is@npm:18.3.1" checksum: 10/d5f60c87d285af24b1e1e7eaeb123ec256c3c8bdea7061ab3932e3e14685708221bf234ec50b21e10dd07f008f1b966a2730a0ce4ff67905b3872ff2042aec22 languageName: node linkType: hard -"react-is@npm:^19.0.0": - version: 19.1.1 - resolution: "react-is@npm:19.1.1" - checksum: 10/44e0937da1f0da1d5dbd4f01972870768ef207f8a49717f4491f5022454f34c956cb66be560aee5286387169853b0283a81e1f419b51dc62654c8710dc98065a +"react-is@npm:^19.2.0": + version: 19.2.0 + resolution: "react-is@npm:19.2.0" + checksum: 10/5cf0230571da0b446c64c0ff7b0e6992b7a8b12b39542db4003de1611e3f108e26f30b93a85ded5cd89c5bcce97f57639524ae40e57bb2f4f1ebd0935b624abf + languageName: node + linkType: hard + +"react-redux@npm:^9.2.0": + version: 9.2.0 + resolution: "react-redux@npm:9.2.0" + dependencies: + "@types/use-sync-external-store": "npm:^0.0.6" + use-sync-external-store: "npm:^1.4.0" + peerDependencies: + "@types/react": ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + redux: + optional: true + checksum: 10/b3d2f89f469169475ab0a9f8914d54a336ac9bc6a31af6e8dcfe9901e6fe2cfd8c1a3f6ce7a2f7f3e0928a93fbab833b668804155715598b7f2ad89927d3ff50 languageName: node linkType: hard @@ -19729,34 +20624,32 @@ __metadata: languageName: node linkType: hard -"react-test-renderer@npm:19.0.0": - version: 19.0.0 - resolution: "react-test-renderer@npm:19.0.0" +"react-test-renderer@npm:^19.1.1": + version: 19.2.0 + resolution: "react-test-renderer@npm:19.2.0" dependencies: - react-is: "npm:^19.0.0" - scheduler: "npm:^0.25.0" + react-is: "npm:^19.2.0" + scheduler: "npm:^0.27.0" peerDependencies: - react: ^19.0.0 - checksum: 10/b95a90331e1dedeff2bbdcdc57b9cd1cd8d7cd620f9b29a4efd31a961c8e5b660fe55129ffc72f2bbf0c21fec34e6a498b9f07b6c65c22bf10ae87b68e124f91 - languageName: node - linkType: hard - -"react@npm:18.x, react@npm:^18.2.0": - version: 18.3.1 - resolution: "react@npm:18.3.1" - dependencies: - loose-envify: "npm:^1.1.0" - checksum: 10/261137d3f3993eaa2368a83110466fc0e558bc2c7f7ae7ca52d94f03aac945f45146bd85e5f481044db1758a1dbb57879e2fcdd33924e2dde1bdc550ce73f7bf + react: ^19.2.0 + checksum: 10/1a072bf5c383ee9cec1eed5872114a25d8029e8fabe17a9154cbb7b5d6e5570711efc3e80e336d170bb1f60e29d395087147891fa198743d17c29ff86782cde3 languageName: node linkType: hard -"react@npm:^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0, react@npm:^19.1.0": +"react@npm:19.1.1, react@npm:^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0, react@npm:^19.1.0": version: 19.1.1 resolution: "react@npm:19.1.1" checksum: 10/9801530fdc939e1a7a499422e930515b2400809cb39c2872984e99f832d233f61659a693871183dac3155c2f9b2c9dcf4440a56bd18983277ae92860e38c3a61 languageName: node linkType: hard +"react@npm:^19.1.1": + version: 19.2.0 + resolution: "react@npm:19.2.0" + checksum: 10/e13bcdb8e994c3cfa922743cb75ca8deb60531bf02f584d2d8dab940a8132ce8a2e6ef16f8ed7f372b4072e7a7eeff589b2812dabbedfa73e6e46201dac8a9d0 + languageName: node + linkType: hard + "read-only-stream@npm:^2.0.0": version: 2.0.0 resolution: "read-only-stream@npm:2.0.0" @@ -19766,30 +20659,6 @@ __metadata: languageName: node linkType: hard -"read-package-up@npm:^11.0.0": - version: 11.0.0 - resolution: "read-package-up@npm:11.0.0" - dependencies: - find-up-simple: "npm:^1.0.0" - read-pkg: "npm:^9.0.0" - type-fest: "npm:^4.6.0" - checksum: 10/535b7554d47fae5fb5c2e7aceebd48b5de4142cdfe7b21f942fa9a0f56db03d3b53cce298e19438e1149292279c285e6ba6722eca741d590fd242519c4bdbc17 - languageName: node - linkType: hard - -"read-pkg@npm:^9.0.0": - version: 9.0.1 - resolution: "read-pkg@npm:9.0.1" - dependencies: - "@types/normalize-package-data": "npm:^2.4.3" - normalize-package-data: "npm:^6.0.0" - parse-json: "npm:^8.0.0" - type-fest: "npm:^4.6.0" - unicorn-magic: "npm:^0.1.0" - checksum: 10/5544bea2a58c6e5706db49a96137e8f0768c69395f25363f934064fbba00bdcdaa326fcd2f4281741df38cf81dbf27b76138240dc6de0ed718cf650475e0de3c - languageName: node - linkType: hard - "readable-stream@npm:1 || 2, readable-stream@npm:^2.0.0, readable-stream@npm:^2.0.1, readable-stream@npm:^2.0.2, readable-stream@npm:^2.0.5, readable-stream@npm:^2.1.5, readable-stream@npm:^2.2.2, readable-stream@npm:^2.3.6, readable-stream@npm:^2.3.8, readable-stream@npm:~2.3.6": version: 2.3.8 resolution: "readable-stream@npm:2.3.8" @@ -19805,7 +20674,7 @@ __metadata: languageName: node linkType: hard -"readable-stream@npm:^3.0.6, readable-stream@npm:^3.1.1, readable-stream@npm:^3.4.0, readable-stream@npm:^3.6.0": +"readable-stream@npm:^3.0.6, readable-stream@npm:^3.1.1, readable-stream@npm:^3.4.0, readable-stream@npm:^3.5.0, readable-stream@npm:^3.6.0": version: 3.6.2 resolution: "readable-stream@npm:3.6.2" dependencies: @@ -19915,6 +20784,22 @@ __metadata: languageName: node linkType: hard +"redux-thunk@npm:^3.1.0": + version: 3.1.0 + resolution: "redux-thunk@npm:3.1.0" + peerDependencies: + redux: ^5.0.0 + checksum: 10/38c563db5f0bbec90d2e65cc27f3c870c1b6102e0c071258734fac41cb0e51d31d894125815c2f4133b20aff231f51f028ad99bccc05a7e3249f1a5d5a959ed3 + languageName: node + linkType: hard + +"redux@npm:^5.0.1": + version: 5.0.1 + resolution: "redux@npm:5.0.1" + checksum: 10/a373f9ed65693ead58bea5ef61c1d6bef39da9f2706db3be6f84815f3a1283230ecd1184efb1b3daa7f807d8211b0181564ca8f336fc6ee0b1e2fa0ba06737c2 + languageName: node + linkType: hard + "refa@npm:^0.12.0, refa@npm:^0.12.1": version: 0.12.1 resolution: "refa@npm:0.12.1" @@ -20113,6 +20998,13 @@ __metadata: languageName: node linkType: hard +"reselect@npm:^5.1.0": + version: 5.1.1 + resolution: "reselect@npm:5.1.1" + checksum: 10/1fdae11a39ed9c8d85a24df19517c8372ee24fefea9cce3fae9eaad8e9cefbba5a3d4940c6fe31296b6addf76e035588c55798f7e6e147e1b7c0855f119e7fa5 + languageName: node + linkType: hard + "resolve-alpn@npm:^1.0.0": version: 1.2.1 resolution: "resolve-alpn@npm:1.2.1" @@ -20169,13 +21061,6 @@ __metadata: languageName: node linkType: hard -"resolve.exports@npm:^2.0.0": - version: 2.0.3 - resolution: "resolve.exports@npm:2.0.3" - checksum: 10/536efee0f30a10fac8604e6cdc7844dbc3f4313568d09f06db4f7ed8a5b8aeb8585966fe975083d1f2dfbc87cf5f8bc7ab65a5c23385c14acbb535ca79f8398a - languageName: node - linkType: hard - "resolve@npm:^1.1.4, resolve@npm:^1.1.6, resolve@npm:^1.11.0, resolve@npm:^1.11.1, resolve@npm:^1.17.0, resolve@npm:^1.19.0, resolve@npm:^1.20.0, resolve@npm:^1.22.1, resolve@npm:^1.22.10, resolve@npm:^1.22.8, resolve@npm:^1.3.2, resolve@npm:^1.4.0, resolve@npm:^1.9.0, resolve@npm:~1.22.1, resolve@npm:~1.22.2": version: 1.22.10 resolution: "resolve@npm:1.22.10" @@ -20808,22 +21693,6 @@ __metadata: languageName: node linkType: hard -"scheduler@npm:^0.23.2": - version: 0.23.2 - resolution: "scheduler@npm:0.23.2" - dependencies: - loose-envify: "npm:^1.1.0" - checksum: 10/e8d68b89d18d5b028223edf090092846868a765a591944760942b77ea1f69b17235f7e956696efbb62c8130ab90af7e0949bfb8eba7896335507317236966bc9 - languageName: node - linkType: hard - -"scheduler@npm:^0.25.0": - version: 0.25.0 - resolution: "scheduler@npm:0.25.0" - checksum: 10/e661e38503ab29a153429a99203fefa764f28b35c079719eb5efdd2c1c1086522f6653d8ffce388209682c23891a6d1d32fa6badf53c35fb5b9cd0c55ace42de - languageName: node - linkType: hard - "scheduler@npm:^0.26.0": version: 0.26.0 resolution: "scheduler@npm:0.26.0" @@ -20831,6 +21700,13 @@ __metadata: languageName: node linkType: hard +"scheduler@npm:^0.27.0": + version: 0.27.0 + resolution: "scheduler@npm:0.27.0" + checksum: 10/eab3c3a8373195173e59c147224fc30dabe6dd453f248f5e610e8458512a5a2ee3a06465dc400ebfe6d35c9f5b7f3bb6b2e41c88c86fd177c25a73e7286a1e06 + languageName: node + linkType: hard + "schema-utils@npm:^1.0.0": version: 1.0.0 resolution: "schema-utils@npm:1.0.0" @@ -20943,12 +21819,12 @@ __metadata: languageName: node linkType: hard -"semver@npm:7.7.1": - version: 7.7.1 - resolution: "semver@npm:7.7.1" +"semver@npm:7.7.2, semver@npm:^7.1.1, semver@npm:^7.3.4, semver@npm:^7.3.5, semver@npm:^7.3.6, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0, semver@npm:^7.6.2, semver@npm:^7.6.3, semver@npm:^7.7.2": + version: 7.7.2 + resolution: "semver@npm:7.7.2" bin: semver: bin/semver.js - checksum: 10/4cfa1eb91ef3751e20fc52e47a935a0118d56d6f15a837ab814da0c150778ba2ca4f1a4d9068b33070ea4273629e615066664c2cfcd7c272caf7a8a0f6518b2c + checksum: 10/7a24cffcaa13f53c09ce55e05efe25cd41328730b2308678624f8b9f5fc3093fc4d189f47950f0b811ff8f3c3039c24a2c36717ba7961615c682045bf03e1dda languageName: node linkType: hard @@ -20970,15 +21846,6 @@ __metadata: languageName: node linkType: hard -"semver@npm:^7.1.1, semver@npm:^7.3.4, semver@npm:^7.3.5, semver@npm:^7.3.6, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0, semver@npm:^7.6.2, semver@npm:^7.6.3, semver@npm:^7.7.1, semver@npm:^7.7.2": - version: 7.7.2 - resolution: "semver@npm:7.7.2" - bin: - semver: bin/semver.js - checksum: 10/7a24cffcaa13f53c09ce55e05efe25cd41328730b2308678624f8b9f5fc3093fc4d189f47950f0b811ff8f3c3039c24a2c36717ba7961615c682045bf03e1dda - languageName: node - linkType: hard - "semver@npm:~7.5.4": version: 7.5.4 resolution: "semver@npm:7.5.4" @@ -21145,7 +22012,7 @@ __metadata: languageName: node linkType: hard -"setimmediate@npm:^1.0.5": +"setimmediate@npm:^1.0.4, setimmediate@npm:^1.0.5": version: 1.0.5 resolution: "setimmediate@npm:1.0.5" checksum: 10/76e3f5d7f4b581b6100ff819761f04a984fa3f3990e72a6554b57188ded53efce2d3d6c0932c10f810b7c59414f85e2ab3c11521877d1dea1ce0b56dc906f485 @@ -21370,7 +22237,7 @@ __metadata: languageName: node linkType: hard -"signal-exit@npm:^3.0.2, signal-exit@npm:^3.0.3, signal-exit@npm:^3.0.7": +"signal-exit@npm:^3.0.2, signal-exit@npm:^3.0.3": version: 3.0.7 resolution: "signal-exit@npm:3.0.7" checksum: 10/a2f098f247adc367dffc27845853e9959b9e88b01cb301658cfe4194352d8d2bb32e18467c786a7fe15f1d44b233ea35633d076d5e737870b7139949d1ab6318 @@ -21474,6 +22341,13 @@ __metadata: languageName: node linkType: hard +"smol-toml@npm:^1.4.1": + version: 1.4.2 + resolution: "smol-toml@npm:1.4.2" + checksum: 10/00c3c45859b44a8a9624cd15d4210c1b85dfa27cf432ade832d7c8a4ee96a79c415594cafe42a6708299de57d3b56e74e645396a0e0e1076f8f7b6302a5520b3 + languageName: node + linkType: hard + "snapdragon-node@npm:^2.0.1": version: 2.1.1 resolution: "snapdragon-node@npm:2.1.1" @@ -21659,16 +22533,6 @@ __metadata: languageName: node linkType: hard -"spdx-correct@npm:^3.0.0": - version: 3.2.0 - resolution: "spdx-correct@npm:3.2.0" - dependencies: - spdx-expression-parse: "npm:^3.0.0" - spdx-license-ids: "npm:^3.0.0" - checksum: 10/cc2e4dbef822f6d12142116557d63f5facf3300e92a6bd24e907e4865e17b7e1abd0ee6b67f305cae6790fc2194175a24dc394bfcc01eea84e2bdad728e9ae9a - languageName: node - linkType: hard - "spdx-exceptions@npm:^2.1.0": version: 2.5.0 resolution: "spdx-exceptions@npm:2.5.0" @@ -21676,16 +22540,6 @@ __metadata: languageName: node linkType: hard -"spdx-expression-parse@npm:^3.0.0": - version: 3.0.1 - resolution: "spdx-expression-parse@npm:3.0.1" - dependencies: - spdx-exceptions: "npm:^2.1.0" - spdx-license-ids: "npm:^3.0.0" - checksum: 10/a1c6e104a2cbada7a593eaa9f430bd5e148ef5290d4c0409899855ce8b1c39652bcc88a725259491a82601159d6dc790bedefc9016c7472f7de8de7361f8ccde - languageName: node - linkType: hard - "spdx-expression-parse@npm:^4.0.0": version: 4.0.0 resolution: "spdx-expression-parse@npm:4.0.0" @@ -21778,7 +22632,7 @@ __metadata: languageName: node linkType: hard -"stack-utils@npm:^2.0.3": +"stack-utils@npm:^2.0.6": version: 2.0.6 resolution: "stack-utils@npm:2.0.6" dependencies: @@ -21849,7 +22703,7 @@ __metadata: languageName: node linkType: hard -"storybook-addon-performance@npm:^0.17.3": +"storybook-addon-performance@npm:0.17.3": version: 0.17.3 resolution: "storybook-addon-performance@npm:0.17.3" dependencies: @@ -21865,14 +22719,14 @@ __metadata: languageName: node linkType: hard -"storybook-multilevel-sort@npm:^2.0.1": +"storybook-multilevel-sort@npm:2.0.1": version: 2.0.1 resolution: "storybook-multilevel-sort@npm:2.0.1" checksum: 10/fcb9c9769f5d5b6df04ab86821a03483af955a85e5eb51f3aea25b72b50973be9f0410464c23532a937156be8d299e245624dbcf9db5765af2ccf16d3fdd7446 languageName: node linkType: hard -"storybook@npm:^8.6.12": +"storybook@npm:8.6.14": version: 8.6.14 resolution: "storybook@npm:8.6.14" dependencies: @@ -21900,6 +22754,16 @@ __metadata: languageName: node linkType: hard +"stream-browserify@npm:^3.0.0": + version: 3.0.0 + resolution: "stream-browserify@npm:3.0.0" + dependencies: + inherits: "npm:~2.0.4" + readable-stream: "npm:^3.5.0" + checksum: 10/05a3cd0a0ce2d568dbdeb69914557c26a1b0a9d871839666b692eae42b96189756a3ed685affc90dab64ff588a8524c8aec6d85072c07905a1f0d941ea68f956 + languageName: node + linkType: hard + "stream-combiner2@npm:^1.1.1": version: 1.1.1 resolution: "stream-combiner2@npm:1.1.1" @@ -21920,7 +22784,7 @@ __metadata: languageName: node linkType: hard -"stream-http@npm:^3.0.0": +"stream-http@npm:^3.0.0, stream-http@npm:^3.2.0": version: 3.2.0 resolution: "stream-http@npm:3.2.0" dependencies: @@ -21981,7 +22845,7 @@ __metadata: languageName: node linkType: hard -"string-length@npm:^4.0.1": +"string-length@npm:^4.0.2": version: 4.0.2 resolution: "string-length@npm:4.0.2" dependencies: @@ -22124,7 +22988,7 @@ __metadata: languageName: node linkType: hard -"string_decoder@npm:^1.1.1, string_decoder@npm:^1.3.0": +"string_decoder@npm:^1.0.0, string_decoder@npm:^1.1.1, string_decoder@npm:^1.3.0": version: 1.3.0 resolution: "string_decoder@npm:1.3.0" dependencies: @@ -22242,6 +23106,13 @@ __metadata: languageName: node linkType: hard +"strip-json-comments@npm:5.0.2": + version: 5.0.2 + resolution: "strip-json-comments@npm:5.0.2" + checksum: 10/986064b73898edc77113cd6147b32f36e299869f3675ed81c3166492a6d8f02d918a492604d1982dab40ca727a86969cb91aa44d6632626f8d7c3c6ead1216bb + languageName: node + linkType: hard + "strip-json-comments@npm:^2.0.1, strip-json-comments@npm:~2.0.1": version: 2.0.1 resolution: "strip-json-comments@npm:2.0.1" @@ -22371,6 +23242,15 @@ __metadata: languageName: node linkType: hard +"synckit@npm:^0.11.8": + version: 0.11.11 + resolution: "synckit@npm:0.11.11" + dependencies: + "@pkgr/core": "npm:^0.2.9" + checksum: 10/6ecd88212b5be80004376b6ea74babcba284566ff59a50d8803afcaa78c165b5d268635c1dd84532ee3f690a979409e1eda225a8a35bed2d135ffdcea06ce7b0 + languageName: node + linkType: hard + "syntax-error@npm:^1.1.1": version: 1.4.0 resolution: "syntax-error@npm:1.4.0" @@ -22624,6 +23504,15 @@ __metadata: languageName: node linkType: hard +"timers-browserify@npm:^2.0.4": + version: 2.0.12 + resolution: "timers-browserify@npm:2.0.12" + dependencies: + setimmediate: "npm:^1.0.4" + checksum: 10/ec37ae299066bef6c464dcac29c7adafba1999e7227a9bdc4e105a459bee0f0b27234a46bfd7ab4041da79619e06a58433472867a913d01c26f8a203f87cee70 + languageName: node + linkType: hard + "tiny-glob@npm:0.2.9": version: 0.2.9 resolution: "tiny-glob@npm:0.2.9" @@ -22834,18 +23723,6 @@ __metadata: languageName: node linkType: hard -"tough-cookie@npm:^4.1.2": - version: 4.1.4 - resolution: "tough-cookie@npm:4.1.4" - dependencies: - psl: "npm:^1.1.33" - punycode: "npm:^2.1.1" - universalify: "npm:^0.2.0" - url-parse: "npm:^1.5.3" - checksum: 10/75663f4e2cd085f16af0b217e4218772adf0617fb3227171102618a54ce0187a164e505d61f773ed7d65988f8ff8a8f935d381f87da981752c1171b076b4afac - languageName: node - linkType: hard - "tough-cookie@npm:^5.1.1": version: 5.1.2 resolution: "tough-cookie@npm:5.1.2" @@ -22855,15 +23732,6 @@ __metadata: languageName: node linkType: hard -"tr46@npm:^3.0.0": - version: 3.0.0 - resolution: "tr46@npm:3.0.0" - dependencies: - punycode: "npm:^2.1.1" - checksum: 10/b09a15886cbfaee419a3469081223489051ce9dca3374dd9500d2378adedbee84a3c73f83bfdd6bb13d53657753fc0d4e20a46bfcd3f1b9057ef528426ad7ce4 - languageName: node - linkType: hard - "tr46@npm:^5.1.0": version: 5.1.1 resolution: "tr46@npm:5.1.1" @@ -23010,7 +23878,7 @@ __metadata: languageName: node linkType: hard -"ts-pattern@npm:^5.7.1": +"ts-pattern@npm:^5.8.0": version: 5.8.0 resolution: "ts-pattern@npm:5.8.0" checksum: 10/fdfa0ea99463c4bb91fee6ade4c31dd93af6760759c302c04dfacab29e556025d656a65f5e855e77015e3fcfc627dd05662d5fc89eb02df024708b2b96d6e00e @@ -23049,7 +23917,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.0.0, tslib@npm:^2.0.1, tslib@npm:^2.6.2": +"tslib@npm:^2.0.0, tslib@npm:^2.0.1, tslib@npm:^2.4.0, tslib@npm:^2.6.2": version: 2.8.1 resolution: "tslib@npm:2.8.1" checksum: 10/3e2e043d5c2316461cb54e5c7fe02c30ef6dccb3384717ca22ae5c6b5bc95232a6241df19c622d9c73b809bea33b187f6dbc73030963e29950c2141bc32a79f7 @@ -23139,7 +24007,7 @@ __metadata: languageName: node linkType: hard -"type-fest@npm:^4.39.1, type-fest@npm:^4.41.0, type-fest@npm:^4.6.0": +"type-fest@npm:^4.41.0": version: 4.41.0 resolution: "type-fest@npm:4.41.0" checksum: 10/617ace794ac0893c2986912d28b3065ad1afb484cad59297835a0807dc63286c39e8675d65f7de08fafa339afcb8fe06a36e9a188b9857756ae1e92ee8bda212 @@ -23286,17 +24154,18 @@ __metadata: languageName: node linkType: hard -"typescript-eslint@npm:8.29.0": - version: 8.29.0 - resolution: "typescript-eslint@npm:8.29.0" +"typescript-eslint@npm:8.39.1": + version: 8.39.1 + resolution: "typescript-eslint@npm:8.39.1" dependencies: - "@typescript-eslint/eslint-plugin": "npm:8.29.0" - "@typescript-eslint/parser": "npm:8.29.0" - "@typescript-eslint/utils": "npm:8.29.0" + "@typescript-eslint/eslint-plugin": "npm:8.39.1" + "@typescript-eslint/parser": "npm:8.39.1" + "@typescript-eslint/typescript-estree": "npm:8.39.1" + "@typescript-eslint/utils": "npm:8.39.1" peerDependencies: eslint: ^8.57.0 || ^9.0.0 - typescript: ">=4.8.4 <5.9.0" - checksum: 10/c4ca331261302c72bf83c1c128d5f20a7974f5472db8a554fabdd741c0eb9eda60c72fcf94d45a8633109a4c295b81cc5d1965aedac1022a739388f3b3fac871 + typescript: ">=4.8.4 <6.0.0" + checksum: 10/1c29c18f2e93b8b74d019590196b158006d7c65be87a56a4c953e52a9c4c40280a42f8ff1464fea870b3a1a4d54925b5cb7d54b4dc86b23cdf65a9b7787585b5 languageName: node linkType: hard @@ -23315,27 +24184,27 @@ __metadata: languageName: node linkType: hard -"typescript@npm:5.7.3": - version: 5.7.3 - resolution: "typescript@npm:5.7.3" +"typescript@npm:5.8.2": + version: 5.8.2 + resolution: "typescript@npm:5.8.2" bin: tsc: bin/tsc tsserver: bin/tsserver - checksum: 10/6a7e556de91db3d34dc51cd2600e8e91f4c312acd8e52792f243c7818dfadb27bae677175fad6947f9c81efb6c57eb6b2d0c736f196a6ee2f1f7d57b74fc92fa + checksum: 10/dbc2168a55d56771f4d581997be52bab5cbc09734fec976cfbaabd787e61fb4c6cf9125fd48c6f98054ce549c77ecedefc7f64252a830dd8e9c3381f61fbeb78 languageName: node linkType: hard -"typescript@npm:5.8.2": - version: 5.8.2 - resolution: "typescript@npm:5.8.2" +"typescript@npm:>=5, typescript@npm:^5.9.2": + version: 5.9.3 + resolution: "typescript@npm:5.9.3" bin: tsc: bin/tsc tsserver: bin/tsserver - checksum: 10/dbc2168a55d56771f4d581997be52bab5cbc09734fec976cfbaabd787e61fb4c6cf9125fd48c6f98054ce549c77ecedefc7f64252a830dd8e9c3381f61fbeb78 + checksum: 10/c089d9d3da2729fd4ac517f9b0e0485914c4b3c26f80dc0cffcb5de1719a17951e92425d55db59515c1a7ddab65808466debb864d0d56dcf43f27007d0709594 languageName: node linkType: hard -"typescript@npm:^5, typescript@npm:^5.7.3": +"typescript@npm:^5.7.3": version: 5.9.2 resolution: "typescript@npm:5.9.2" bin: @@ -23355,27 +24224,27 @@ __metadata: languageName: node linkType: hard -"typescript@patch:typescript@npm%3A5.7.3#optional!builtin": - version: 5.7.3 - resolution: "typescript@patch:typescript@npm%3A5.7.3#optional!builtin::version=5.7.3&hash=5786d5" +"typescript@patch:typescript@npm%3A5.8.2#optional!builtin": + version: 5.8.2 + resolution: "typescript@patch:typescript@npm%3A5.8.2#optional!builtin::version=5.8.2&hash=5786d5" bin: tsc: bin/tsc tsserver: bin/tsserver - checksum: 10/dc58d777eb4c01973f7fbf1fd808aad49a0efdf545528dab9b07d94fdcb65b8751742804c3057e9619a4627f2d9cc85547fdd49d9f4326992ad0181b49e61d81 + checksum: 10/97920a082ffc57583b1cb6bc4faa502acc156358e03f54c7fc7fdf0b61c439a717f4c9070c449ee9ee683d4cfc3bb203127c2b9794b2950f66d9d307a4ff262c languageName: node linkType: hard -"typescript@patch:typescript@npm%3A5.8.2#optional!builtin": - version: 5.8.2 - resolution: "typescript@patch:typescript@npm%3A5.8.2#optional!builtin::version=5.8.2&hash=5786d5" +"typescript@patch:typescript@npm%3A>=5#optional!builtin, typescript@patch:typescript@npm%3A^5.9.2#optional!builtin": + version: 5.9.3 + resolution: "typescript@patch:typescript@npm%3A5.9.3#optional!builtin::version=5.9.3&hash=5786d5" bin: tsc: bin/tsc tsserver: bin/tsserver - checksum: 10/97920a082ffc57583b1cb6bc4faa502acc156358e03f54c7fc7fdf0b61c439a717f4c9070c449ee9ee683d4cfc3bb203127c2b9794b2950f66d9d307a4ff262c + checksum: 10/696e1b017bc2635f4e0c94eb4435357701008e2f272f553d06e35b494b8ddc60aa221145e286c28ace0c89ee32827a28c2040e3a69bdc108b1a5dc8fb40b72e3 languageName: node linkType: hard -"typescript@patch:typescript@npm%3A^5#optional!builtin, typescript@patch:typescript@npm%3A^5.7.3#optional!builtin": +"typescript@patch:typescript@npm%3A^5.7.3#optional!builtin": version: 5.9.2 resolution: "typescript@patch:typescript@npm%3A5.9.2#optional!builtin::version=5.9.2&hash=5786d5" bin: @@ -23504,6 +24373,13 @@ __metadata: languageName: node linkType: hard +"undici-types@npm:~7.14.0": + version: 7.14.0 + resolution: "undici-types@npm:7.14.0" + checksum: 10/0f8709b21437697af35801e33bddbe9992e0cf1771959c41850b1946f63822b825e03ce99f44bf19e4f5c3ccc5166e0be59f541565c36ce86163dc2c5870bc62 + languageName: node + linkType: hard + "undici@npm:^6.20.1": version: 6.21.3 resolution: "undici@npm:6.21.3" @@ -23549,13 +24425,6 @@ __metadata: languageName: node linkType: hard -"unicorn-magic@npm:^0.1.0": - version: 0.1.0 - resolution: "unicorn-magic@npm:0.1.0" - checksum: 10/9b4d0e9809807823dc91d0920a4a4c0cff2de3ebc54ee87ac1ee9bc75eafd609b09d1f14495e0173aef26e01118706196b6ab06a75fe0841028b3983a8af313f - languageName: node - linkType: hard - "union-value@npm:^1.0.0": version: 1.0.1 resolution: "union-value@npm:1.0.1" @@ -23611,13 +24480,6 @@ __metadata: languageName: node linkType: hard -"universalify@npm:^0.2.0": - version: 0.2.0 - resolution: "universalify@npm:0.2.0" - checksum: 10/e86134cb12919d177c2353196a4cc09981524ee87abf621f7bc8d249dbbbebaec5e7d1314b96061497981350df786e4c5128dbf442eba104d6e765bc260678b5 - languageName: node - linkType: hard - "universalify@npm:^2.0.0": version: 2.0.1 resolution: "universalify@npm:2.0.1" @@ -23642,6 +24504,73 @@ __metadata: languageName: node linkType: hard +"unrs-resolver@npm:^1.7.11": + version: 1.11.1 + resolution: "unrs-resolver@npm:1.11.1" + dependencies: + "@unrs/resolver-binding-android-arm-eabi": "npm:1.11.1" + "@unrs/resolver-binding-android-arm64": "npm:1.11.1" + "@unrs/resolver-binding-darwin-arm64": "npm:1.11.1" + "@unrs/resolver-binding-darwin-x64": "npm:1.11.1" + "@unrs/resolver-binding-freebsd-x64": "npm:1.11.1" + "@unrs/resolver-binding-linux-arm-gnueabihf": "npm:1.11.1" + "@unrs/resolver-binding-linux-arm-musleabihf": "npm:1.11.1" + "@unrs/resolver-binding-linux-arm64-gnu": "npm:1.11.1" + "@unrs/resolver-binding-linux-arm64-musl": "npm:1.11.1" + "@unrs/resolver-binding-linux-ppc64-gnu": "npm:1.11.1" + "@unrs/resolver-binding-linux-riscv64-gnu": "npm:1.11.1" + "@unrs/resolver-binding-linux-riscv64-musl": "npm:1.11.1" + "@unrs/resolver-binding-linux-s390x-gnu": "npm:1.11.1" + "@unrs/resolver-binding-linux-x64-gnu": "npm:1.11.1" + "@unrs/resolver-binding-linux-x64-musl": "npm:1.11.1" + "@unrs/resolver-binding-wasm32-wasi": "npm:1.11.1" + "@unrs/resolver-binding-win32-arm64-msvc": "npm:1.11.1" + "@unrs/resolver-binding-win32-ia32-msvc": "npm:1.11.1" + "@unrs/resolver-binding-win32-x64-msvc": "npm:1.11.1" + napi-postinstall: "npm:^0.3.0" + dependenciesMeta: + "@unrs/resolver-binding-android-arm-eabi": + optional: true + "@unrs/resolver-binding-android-arm64": + optional: true + "@unrs/resolver-binding-darwin-arm64": + optional: true + "@unrs/resolver-binding-darwin-x64": + optional: true + "@unrs/resolver-binding-freebsd-x64": + optional: true + "@unrs/resolver-binding-linux-arm-gnueabihf": + optional: true + "@unrs/resolver-binding-linux-arm-musleabihf": + optional: true + "@unrs/resolver-binding-linux-arm64-gnu": + optional: true + "@unrs/resolver-binding-linux-arm64-musl": + optional: true + "@unrs/resolver-binding-linux-ppc64-gnu": + optional: true + "@unrs/resolver-binding-linux-riscv64-gnu": + optional: true + "@unrs/resolver-binding-linux-riscv64-musl": + optional: true + "@unrs/resolver-binding-linux-s390x-gnu": + optional: true + "@unrs/resolver-binding-linux-x64-gnu": + optional: true + "@unrs/resolver-binding-linux-x64-musl": + optional: true + "@unrs/resolver-binding-wasm32-wasi": + optional: true + "@unrs/resolver-binding-win32-arm64-msvc": + optional: true + "@unrs/resolver-binding-win32-ia32-msvc": + optional: true + "@unrs/resolver-binding-win32-x64-msvc": + optional: true + checksum: 10/4de653508cbaae47883a896bd5cdfef0e5e87b428d62620d16fd35cd534beaebf08ebf0cf2f8b4922aa947b2fe745180facf6cc3f39ba364f7ce0f974cb06a70 + languageName: node + linkType: hard + "unset-value@npm:^1.0.0": version: 1.0.0 resolution: "unset-value@npm:1.0.0" @@ -23696,17 +24625,7 @@ __metadata: languageName: node linkType: hard -"url-parse@npm:^1.5.3": - version: 1.5.10 - resolution: "url-parse@npm:1.5.10" - dependencies: - querystringify: "npm:^2.1.1" - requires-port: "npm:^1.0.0" - checksum: 10/c9e96bc8c5b34e9f05ddfeffc12f6aadecbb0d971b3cc26015b58d5b44676a99f50d5aeb1e5c9e61fa4d49961ae3ab1ae997369ed44da51b2f5ac010d188e6ad - languageName: node - linkType: hard - -"url@npm:~0.11.0": +"url@npm:^0.11.4, url@npm:~0.11.0": version: 0.11.4 resolution: "url@npm:0.11.4" dependencies: @@ -23774,7 +24693,7 @@ __metadata: languageName: node linkType: hard -"util@npm:^0.12.5": +"util@npm:^0.12.4, util@npm:^0.12.5": version: 0.12.5 resolution: "util@npm:0.12.5" dependencies: @@ -23848,16 +24767,6 @@ __metadata: languageName: node linkType: hard -"validate-npm-package-license@npm:^3.0.4": - version: 3.0.4 - resolution: "validate-npm-package-license@npm:3.0.4" - dependencies: - spdx-correct: "npm:^3.0.0" - spdx-expression-parse: "npm:^3.0.0" - checksum: 10/86242519b2538bb8aeb12330edebb61b4eb37fd35ef65220ab0b03a26c0592c1c8a7300d32da3cde5abd08d18d95e8dabfad684b5116336f6de9e6f207eec224 - languageName: node - linkType: hard - "vary@npm:^1, vary@npm:~1.1.2": version: 1.1.2 resolution: "vary@npm:1.1.2" @@ -23889,7 +24798,7 @@ __metadata: languageName: node linkType: hard -"vite-plugin-md@npm:>=0.22.5": +"vite-plugin-md@npm:0.22.5, vite-plugin-md@npm:>=0.22.5": version: 0.22.5 resolution: "vite-plugin-md@npm:0.22.5" dependencies: @@ -23905,18 +24814,15 @@ __metadata: languageName: node linkType: hard -"vite-plugin-md@npm:^0.21.5": - version: 0.21.5 - resolution: "vite-plugin-md@npm:0.21.5" +"vite-plugin-node-polyfills@npm:^0.24.0": + version: 0.24.0 + resolution: "vite-plugin-node-polyfills@npm:0.24.0" dependencies: - "@yankeeinlondon/builder-api": "npm:^1.2.1" - "@yankeeinlondon/gray-matter": "npm:^6.1.0" - "@yankeeinlondon/happy-wrapper": "npm:^2.10.1" - markdown-it: "npm:^13.0.1" - source-map-js: "npm:^1.0.2" + "@rollup/plugin-inject": "npm:^5.0.5" + node-stdlib-browser: "npm:^1.2.0" peerDependencies: - vite: ^4.0.0 - checksum: 10/d9a16972125807d71bc97a83f9b588aca4d64ed7a3605f932fae6a85a79963b2f6a1b0153bd028031d7e403247237605a7512a8e2c316d95c22576dbe8dd6ded + vite: ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + checksum: 10/9c85b94bd74728c1d93c337b4032ba4bec56054caddb0205475abc8c281b047649bb5e1dcdd77206b0b13c707e49d379551df4fb18843e41ace03f62e5a06380 languageName: node linkType: hard @@ -24226,7 +25132,7 @@ __metadata: languageName: node linkType: hard -"vm-browserify@npm:^1.0.0": +"vm-browserify@npm:^1.0.0, vm-browserify@npm:^1.0.1": version: 1.1.2 resolution: "vm-browserify@npm:1.1.2" checksum: 10/ad5b17c9f7a9d9f1ed0e24c897782ab7a587c1fd40f370152482e1af154c7cf0b0bacc45c5ae76a44289881e083ae4ae127808fdff864aa9b562192aae8b5c3b @@ -24298,15 +25204,6 @@ __metadata: languageName: node linkType: hard -"w3c-xmlserializer@npm:^4.0.0": - version: 4.0.0 - resolution: "w3c-xmlserializer@npm:4.0.0" - dependencies: - xml-name-validator: "npm:^4.0.0" - checksum: 10/9a00c412b5496f4f040842c9520bc0aaec6e0c015d06412a91a723cd7d84ea605ab903965f546b4ecdb3eae267f5145ba08565222b1d6cb443ee488cda9a0aee - languageName: node - linkType: hard - "w3c-xmlserializer@npm:^5.0.0": version: 5.0.0 resolution: "w3c-xmlserializer@npm:5.0.0" @@ -24336,6 +25233,13 @@ __metadata: languageName: node linkType: hard +"walk-up-path@npm:^4.0.0": + version: 4.0.0 + resolution: "walk-up-path@npm:4.0.0" + checksum: 10/6a230b20e5de296895116dc12b09dafaec1f72b8060c089533d296e241aff059dfaebe0d015c77467f857e4b40c78e08f7481add76f340233a1f34fa8af9ed63 + languageName: node + linkType: hard + "walker@npm:^1.0.8": version: 1.0.8 resolution: "walker@npm:1.0.8" @@ -24842,16 +25746,6 @@ __metadata: languageName: node linkType: hard -"whatwg-url@npm:^11.0.0": - version: 11.0.0 - resolution: "whatwg-url@npm:11.0.0" - dependencies: - tr46: "npm:^3.0.0" - webidl-conversions: "npm:^7.0.0" - checksum: 10/dfcd51c6f4bfb54685528fb10927f3fd3d7c809b5671beef4a8cdd7b1408a7abf3343a35bc71dab83a1424f1c1e92cc2700d7930d95d231df0fac361de0c7648 - languageName: node - linkType: hard - "whatwg-url@npm:^14.0.0, whatwg-url@npm:^14.1.1": version: 14.2.0 resolution: "whatwg-url@npm:14.2.0" @@ -25035,13 +25929,13 @@ __metadata: languageName: node linkType: hard -"write-file-atomic@npm:^4.0.2": - version: 4.0.2 - resolution: "write-file-atomic@npm:4.0.2" +"write-file-atomic@npm:^5.0.1": + version: 5.0.1 + resolution: "write-file-atomic@npm:5.0.1" dependencies: imurmurhash: "npm:^0.1.4" - signal-exit: "npm:^3.0.7" - checksum: 10/3be1f5508a46c190619d5386b1ac8f3af3dbe951ed0f7b0b4a0961eed6fc626bd84b50cf4be768dabc0a05b672f5d0c5ee7f42daa557b14415d18c3a13c7d246 + signal-exit: "npm:^4.0.1" + checksum: 10/648efddba54d478d0e4330ab6f239976df3b9752b123db5dc9405d9b5af768fa9d70ce60c52fdbe61d1200d24350bc4fbcbaf09288496c2be050de126bd95b7e languageName: node linkType: hard @@ -25084,7 +25978,7 @@ __metadata: languageName: node linkType: hard -"ws@npm:^8.11.0, ws@npm:^8.13.0, ws@npm:^8.18.0, ws@npm:^8.18.3, ws@npm:^8.2.3, ws@npm:^8.8.0": +"ws@npm:^8.13.0, ws@npm:^8.18.0, ws@npm:^8.18.3, ws@npm:^8.2.3, ws@npm:^8.8.0": version: 8.18.3 resolution: "ws@npm:8.18.3" peerDependencies: @@ -25275,7 +26169,7 @@ __metadata: languageName: node linkType: hard -"yargs@npm:^17.3.1, yargs@npm:^17.7.2": +"yargs@npm:^17.7.2": version: 17.7.2 resolution: "yargs@npm:17.7.2" dependencies: @@ -25335,16 +26229,25 @@ __metadata: languageName: node linkType: hard -"zod@npm:^3.24.1": +"zod-validation-error@npm:^3.0.3": + version: 3.5.3 + resolution: "zod-validation-error@npm:3.5.3" + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + checksum: 10/f550565ffb2a0a1733616d856302184dbe2080ec649ff9361125467065c3dfa02aeb5bf399605cdb61fe640f79ff1fe8ad0805f6e0c8144fa34764cad58f4401 + languageName: node + linkType: hard + +"zod@npm:^3.22.4, zod@npm:^3.24.1": version: 3.25.76 resolution: "zod@npm:3.25.76" checksum: 10/f0c963ec40cd96858451d1690404d603d36507c1fc9682f2dae59ab38b578687d542708a7fdbf645f77926f78c9ed558f57c3d3aa226c285f798df0c4da16995 languageName: node linkType: hard -"zod@npm:^4.0.5": - version: 4.0.15 - resolution: "zod@npm:4.0.15" - checksum: 10/a91e998d519b697a82e0f5ceea8b9c1e3a2ebc80ef6a275fc71b7f7b052cd4ab45140525c4ba93ad60fa28e0c72dc6f6c326be954aa3f621699b9a2d05fbdf1c +"zod@npm:^4.0.17": + version: 4.1.12 + resolution: "zod@npm:4.1.12" + checksum: 10/c5f04e6ac306515c4db6ef73cf7705f521c7a2107c8c8912416a0658d689f361db9bee829b0bf01ef4a22492f1065c5cbcdb523ce532606ac6792fd714f3c326 languageName: node linkType: hard From fdfff64fee32b1a323651a4977b04b603cab1691 Mon Sep 17 00:00:00 2001 From: "samuel.gjabel" Date: Wed, 15 Oct 2025 17:03:29 +0700 Subject: [PATCH 02/24] chore(joint-react): enhance graph provider and store with new methods for managing elements and links --- .../src/components/graph/graph-provider.tsx | 28 +++--- .../src/components/paper/paper.tsx | 5 +- .../src/components/paper/paper.types.ts | 6 -- .../data/__tests__/create-store-data.test.ts | 22 ++--- .../src/data/create-graph-store.ts | 95 ++++++++++++++++--- .../joint-react/src/data/create-store-data.ts | 42 ++++---- .../src/utils/cell/cell-utilities.ts | 8 +- .../src/utils/graph/update-graph.ts | 4 +- 8 files changed, 139 insertions(+), 71 deletions(-) diff --git a/packages/joint-react/src/components/graph/graph-provider.tsx b/packages/joint-react/src/components/graph/graph-provider.tsx index b4d935be1c..348c47f30c 100644 --- a/packages/joint-react/src/components/graph/graph-provider.tsx +++ b/packages/joint-react/src/components/graph/graph-provider.tsx @@ -9,8 +9,7 @@ import { } from 'react'; import { createStore, type GraphStore } from '../../data/create-graph-store'; import { useElements } from '../../hooks/use-elements'; -import { useGraph } from '../../hooks'; -import { setElements, setLinks } from '../../utils/cell/cell-utilities'; +import { useGraphStore } from '../../hooks'; import type { GraphElement } from '../../types/element-types'; import { CONTROLLED_MODE_BATCH_NAME } from '../../utils/graph/update-graph'; import { useImperativeApi } from '../../hooks/use-imperative-api'; @@ -60,7 +59,10 @@ interface GraphProviderBaseProps< * @returns A context provider for the measured state of elements. * @private */ -export function GraphProviderHandler(props: PropsWithChildren) { +export function GraphProviderHandler< + Element extends dia.Element | GraphElement = dia.Element, + Link extends dia.Link | GraphLink = dia.Link, +>(props: PropsWithChildren>) { const { elements, links, onElementsChange, onLinksChange, children } = props; const areElementsMeasured = useElements((items) => { let areMeasured = true; @@ -73,7 +75,7 @@ export function GraphProviderHandler(props: PropsWithChildren { @@ -107,15 +111,15 @@ export function GraphProviderHandler(props: PropsWithChildren { const { source, target } = link; - const sourceObject = getTargetOrSource(source); - const targetObject = getTargetOrSource(target); + const sourceObject = getTargetOrSource(source as dia.Link.EndJSON); + const targetObject = getTargetOrSource(target as dia.Link.EndJSON); return sourceObject.port || targetObject.port; }); if (!hasSomePort) return; - setLinks({ graph, links }); + setLinks(links as GraphLink[]); // eslint-disable-next-line react-hooks/exhaustive-deps }, [areElementsMeasured, isControlledMode]); diff --git a/packages/joint-react/src/components/paper/paper.tsx b/packages/joint-react/src/components/paper/paper.tsx index db7cc514dc..7793f1dd3b 100644 --- a/packages/joint-react/src/components/paper/paper.tsx +++ b/packages/joint-react/src/components/paper/paper.tsx @@ -75,7 +75,7 @@ function PaperBase( ...paperOptions } = props; - const { graph } = useGraphStore(); + const { graph, addPaper, removePaper } = useGraphStore(); const areElementsMeasured = useAreElementMeasured(); const { onRenderElement, elementViews } = useElementViews(); const elements = useElements((items) => items.map(elementSelector)); @@ -195,10 +195,13 @@ function PaperBase( if (contextUpdate) { Object.assign(instance, contextUpdate.contextUpdate); } + + addPaper(id, paper); return { instance, cleanup() { paper.remove(); + removePaper(id); portsStore.destroy(); contextUpdate?.cleanup?.(); }, diff --git a/packages/joint-react/src/components/paper/paper.types.ts b/packages/joint-react/src/components/paper/paper.types.ts index ec937bafe2..1c3e2c9cb5 100644 --- a/packages/joint-react/src/components/paper/paper.types.ts +++ b/packages/joint-react/src/components/paper/paper.types.ts @@ -120,10 +120,4 @@ export interface PaperProps * @returns */ readonly onRenderElement?: OnPaperRenderElement; - - /** - * Optional ID for the view, if not provided, a unique ID will be generated. - * !important - when using multiple views (on DEV), you need to provide an unique ID to each view to avoid conflicts. - */ - readonly id?: string; } diff --git a/packages/joint-react/src/data/__tests__/create-store-data.test.ts b/packages/joint-react/src/data/__tests__/create-store-data.test.ts index 823f95a065..66bb892917 100644 --- a/packages/joint-react/src/data/__tests__/create-store-data.test.ts +++ b/packages/joint-react/src/data/__tests__/create-store-data.test.ts @@ -11,9 +11,9 @@ describe('create-store-data', () => { x: 10, }); graph.addCell(element); - expect(storeData.elements.length).toBe(0); + expect(storeData.dataRef.elements.length).toBe(0); storeData.updateStore(graph); - expect(storeData.elements.length).toBe(1); + expect(storeData.dataRef.elements.length).toBe(1); }); it('should handle proper data update', () => { const graph = new dia.Graph(); @@ -24,10 +24,10 @@ describe('create-store-data', () => { position: { x: 10, y: 20 }, }); graph.addCell(element); - expect(storeData.elements.length).toBe(0); + expect(storeData.dataRef.elements.length).toBe(0); storeData.updateStore(graph); - expect(storeData.elements.length).toBe(1); - expect(storeData.elements.find((element_) => element_.id === 'element1')?.x).toBe(10); + expect(storeData.dataRef.elements.length).toBe(1); + expect(storeData.dataRef.elements.find((element_) => element_.id === 'element1')?.x).toBe(10); const updatedElement = new dia.Element({ type: 'standard.Rectangle', @@ -36,9 +36,9 @@ describe('create-store-data', () => { }); graph.resetCells([updatedElement]); - expect(storeData.elements.length).toBe(1); + expect(storeData.dataRef.elements.length).toBe(1); storeData.updateStore(graph); - expect(storeData.elements.find((element_) => element_.id === 'element1')?.x).toBe(30); + expect(storeData.dataRef.elements.find((element_) => element_.id === 'element1')?.x).toBe(30); }); it('should handle proper data deletion', () => { const graph = new dia.Graph(); @@ -49,12 +49,12 @@ describe('create-store-data', () => { x: 10, }); graph.addCell(element); - expect(storeData.elements.length).toBe(0); + expect(storeData.dataRef.elements.length).toBe(0); storeData.updateStore(graph); - expect(storeData.elements.length).toBe(1); + expect(storeData.dataRef.elements.length).toBe(1); graph.removeCells([element]); - expect(storeData.elements.length).toBe(1); + expect(storeData.dataRef.elements.length).toBe(1); storeData.updateStore(graph); - expect(storeData.elements.length).toBe(0); + expect(storeData.dataRef.elements.length).toBe(0); }); }); diff --git a/packages/joint-react/src/data/create-graph-store.ts b/packages/joint-react/src/data/create-graph-store.ts index 1f638de0f8..7e04b587b1 100644 --- a/packages/joint-react/src/data/create-graph-store.ts +++ b/packages/joint-react/src/data/create-graph-store.ts @@ -74,6 +74,14 @@ export interface GraphStore { * Get elements */ readonly getElements: () => GraphElement[]; + + /** + * + * @param elements - New elements to set in the graph. + * @returns + */ + + readonly setElements: (elements: GraphElement[]) => void; /** * Get element by id */ @@ -82,6 +90,10 @@ export interface GraphStore { * Get links */ readonly getLinks: () => GraphLink[]; + /** + * Set links + */ + readonly setLinks: (links: GraphLink[]) => void; /** * Get link by id */ @@ -109,6 +121,9 @@ export interface GraphStore { * This will trigger a re-render of all components that are subscribed to the store. */ readonly forceUpdateStore: () => UpdateResult; + + readonly addPaper: (id: string, paper: dia.Paper) => void; + readonly removePaper: (id: string) => void; } /** @@ -211,7 +226,7 @@ export function createStoreWithGraph< graph.on('batch:stop', onBatchStop); const measuredNodes = new Set(); - + const { dataRef } = graphData; /** * Force update the graph. * This function is called when the graph is updated. @@ -226,21 +241,26 @@ export function createStoreWithGraph< } const updateResult = graphData.updateStore(graph); + // Skip processing changes in controlled mode since they are already handled. // This prevents circular calls to `onElementsChange`. // For example, if a user manages elements via React state and updates the graph using setElements, // this function will be triggered. However, we avoid re-triggering `onElementsChange` to prevent redundant updates. // We call `onElementsChange` and `onLinksChange` explicitly only when direct change on `dia.Graph` occurs. - if (batchName !== CONTROLLED_MODE_BATCH_NAME) { - if (onElementsChange && updateResult.areElementsChanged) { - const mappedElements = graphData.elements.map((element) => element); - onElementsChange(mappedElements as SetStateAction); - } - if (onLinksChange && updateResult.areLinksChanged) { - const changedLinks = graphData.links.map((link) => link); - onLinksChange(changedLinks as SetStateAction); - } + + if (batchName === CONTROLLED_MODE_BATCH_NAME) { + return updateResult; + } + + if (onElementsChange && updateResult.areElementsChanged) { + const mappedElements = dataRef.elements.map((element) => element); + onElementsChange(mappedElements as SetStateAction); } + if (onLinksChange && updateResult.areLinksChanged) { + const changedLinks = dataRef.links.map((link) => link); + onLinksChange(changedLinks as SetStateAction); + } + return updateResult; } /** @@ -292,17 +312,68 @@ export function createStoreWithGraph< } // Force update the graph to ensure it's in sync with the store. forceUpdateStore(); + const papers = new Map(); + + /** + * Freeze all papers to optimize performance during batch updates. + */ + function freezePapers() { + for (const [, paper] of papers) { + paper.freeze(); + } + } + + /** + * Unfreeze all papers after batch updates are complete. + */ + function unfreezePapers() { + for (const [, paper] of papers) { + paper.unfreeze(); + } + } + + /** + * Apply controlled update to the graph. + * This function freezes all papers, starts a batch, applies the update function, + * and then stops the batch and unfreezes the papers. + * @param callback - The update function to apply. + */ + function applyControlledUpdate(callback: () => void) { + if (!graph) return; + freezePapers(); // your helper + try { + callback(); // call setElements / setLinks WITHOUT silent + } finally { + unfreezePapers(); + } + } const store: GraphStore = { + addPaper(id: string, paper: dia.Paper) { + papers.set(id, paper); + }, + removePaper(id: string) { + papers.delete(id); + }, forceUpdateStore, destroy, graph, subscribe: elementsEvents.subscribe, getElements() { - return graphData.elements; + return dataRef.elements; + }, + setElements(newElements) { + applyControlledUpdate(() => { + setElements({ graph, elements: newElements }); + }); + }, + setLinks(newLinks) { + applyControlledUpdate(() => { + setLinks({ graph, links: newLinks }); + }); }, getLinks() { - return graphData.links; + return dataRef.links; }, getElement(id: dia.Cell.ID) { const item = graphData.getElementById(id); diff --git a/packages/joint-react/src/data/create-store-data.ts b/packages/joint-react/src/data/create-store-data.ts index 0fa97ffb1f..f68138011d 100644 --- a/packages/joint-react/src/data/create-store-data.ts +++ b/packages/joint-react/src/data/create-store-data.ts @@ -21,18 +21,20 @@ interface StoreData< /** Clear everything */ readonly destroy: () => void; - /** Public, array-first shape */ - elements: Element[]; - links: GraphLink[]; - /** O(1) helpers built on top of private indices */ readonly getElementById: (id: dia.Cell.ID) => Element | undefined; readonly getLinkById: (id: dia.Cell.ID) => GraphLink | undefined; + readonly dataRef: DataRef; } interface Options { readonly elements?: Element[]; readonly links?: GraphLink[]; } + +interface DataRef { + elements: Element[]; + links: GraphLink[]; +} /** * Array-first store with internal id->index maps. * Keeps public API as arrays while preserving O(1) lookups. @@ -50,7 +52,7 @@ export function createStoreData< >(options: Options = {}): StoreData { // Public arrays - const ref: { + const dataRef: { elements: Element[]; links: GraphLink[]; } = { @@ -69,7 +71,7 @@ export function createStoreData< */ function getElementById(id: dia.Cell.ID): Element | undefined { const i = eIndex.get(id); - return i == null ? undefined : ref.elements[i]; + return i == null ? undefined : dataRef.elements[i]; } /** * Retrieves a link by its ID. @@ -78,7 +80,7 @@ export function createStoreData< */ function getLinkById(id: dia.Cell.ID): GraphLink | undefined { const i = lIndex.get(id); - return i == null ? undefined : ref.links[i]; + return i == null ? undefined : dataRef.links[i]; } /** @@ -127,7 +129,7 @@ export function createStoreData< // Deletions: if the new arrays are shorter than old or some ids disappeared, // we’ve already “changed”. To catch pure deletions where values equal but gone: if (!areElementsChanged) { - areElementsChanged = ref.elements.length !== nextElements.length; + areElementsChanged = dataRef.elements.length !== nextElements.length; if (!areElementsChanged) { // Cheap structural check: same length but different ids/order? for (const [i, nextElement] of nextElements.entries()) { @@ -141,7 +143,7 @@ export function createStoreData< } } if (!areLinksChanged) { - areLinksChanged = ref.links.length !== nextLinks.length; + areLinksChanged = dataRef.links.length !== nextLinks.length; if (!areLinksChanged) { for (const [i, nextLink] of nextLinks.entries()) { const idNow = nextLink?.id as dia.Cell.ID | undefined; @@ -156,12 +158,12 @@ export function createStoreData< // Swap (immutably) only when changed to preserve referential equality if (areElementsChanged) { - ref.elements = nextElements; + dataRef.elements = nextElements; eIndex = nextEIndex; } if (areLinksChanged) { - ref.links = nextLinks; + dataRef.links = nextLinks; lIndex = nextLIndex; } @@ -176,8 +178,8 @@ export function createStoreData< * Clears all elements and links from the store and resets internal indices. */ function destroy() { - ref.elements = []; - ref.links = []; + dataRef.elements = []; + dataRef.links = []; eIndex.clear(); lIndex.clear(); } @@ -187,18 +189,6 @@ export function createStoreData< destroy, getElementById, getLinkById, - get elements() { - return ref.elements; - }, - set elements(_value: Element[]) { - throw new Error('elements is read-only; call updateStore(graph) instead.'); - }, - - get links() { - return ref.links; - }, - set links(_value: GraphLink[]) { - throw new Error('links is read-only; call updateStore(graph) instead.'); - }, + dataRef, } as StoreData; } diff --git a/packages/joint-react/src/utils/cell/cell-utilities.ts b/packages/joint-react/src/utils/cell/cell-utilities.ts index 1cdd77b460..71a7cec658 100644 --- a/packages/joint-react/src/utils/cell/cell-utilities.ts +++ b/packages/joint-react/src/utils/cell/cell-utilities.ts @@ -45,6 +45,7 @@ export function processLink(link: dia.Link | GraphLink): CellOrJsonCell { export interface SetLinksOptions { readonly graph: dia.Graph; readonly links?: Array; + readonly isSilenced?: boolean; } /** @@ -57,7 +58,7 @@ export interface SetLinksOptions { * It also converts the source and target of the links to a standard format. */ export function setLinks(options: SetLinksOptions) { - const { graph, links } = options; + const { graph, links, isSilenced } = options; if (links === undefined) { return; } @@ -72,6 +73,7 @@ export function setLinks(options: SetLinksOptions) { return link; }), isLink: true, + isSilenced, }); } @@ -115,6 +117,7 @@ export function processElement( export interface SetElementsOptions { readonly graph: dia.Graph; readonly elements?: Array; + readonly isSilenced?: boolean; } /** @@ -128,7 +131,7 @@ export interface SetElementsOptions { * It also checks for unsized elements and returns their IDs. */ export function setElements(options: SetElementsOptions) { - const { graph, elements } = options; + const { graph, elements, isSilenced } = options; if (elements === undefined) { return new Set(); } @@ -137,6 +140,7 @@ export function setElements(options: SetElementsOptions) { graph, cells: elements.map((item) => processElement(item, unsizedIds)), isLink: false, + isSilenced, }); return unsizedIds; } diff --git a/packages/joint-react/src/utils/graph/update-graph.ts b/packages/joint-react/src/utils/graph/update-graph.ts index 140682e558..0d843c10a1 100644 --- a/packages/joint-react/src/utils/graph/update-graph.ts +++ b/packages/joint-react/src/utils/graph/update-graph.ts @@ -54,8 +54,10 @@ export function updateCell(options: UpdateCellOptions) { if (originalCell) { const isLink = originalCell.isLink(); - if (originalCell.get('type') === getType(newCell, 'type') && !isLink) { + if (originalCell.get('type') === getType(newCell, 'type')) { originalCell.set(getAttributes(newCell), { silent: isSilenced }); + } else if (isLink) { + graph.addCell(newCell, { silent: isSilenced }); } else { originalCell.remove({ disconnectLinks: true, silent: isSilenced }); graph.addCell(newCell, { silent: isSilenced }); From 273e5700fcd8fd21ad57c13cce73e2f1a39ec899 Mon Sep 17 00:00:00 2001 From: "samuel.gjabel" Date: Wed, 15 Oct 2025 18:40:37 +0700 Subject: [PATCH 03/24] refactor(joint-react): clean up code by removing unused methods and simplifying logic in various components --- .../decorators/with-strict-mode.tsx | 6 +- .../src/components/graph/graph-provider.tsx | 43 ++------ .../src/components/paper/paper.tsx | 4 +- .../src/components/port/port-item.tsx | 14 ++- .../src/data/create-graph-store.ts | 52 +-------- .../src/hooks/use-measure-node-size.tsx | 1 + .../src/utils/cell/cell-utilities.ts | 8 +- .../src/utils/create-element-size-observer.ts | 1 + packages/joint-react/src/utils/create.ts | 4 +- .../src/utils/graph/update-graph.ts | 103 +++++++++++------- 10 files changed, 97 insertions(+), 139 deletions(-) diff --git a/packages/joint-react/.storybook/decorators/with-strict-mode.tsx b/packages/joint-react/.storybook/decorators/with-strict-mode.tsx index 0d774a6f45..bacc3a843d 100644 --- a/packages/joint-react/.storybook/decorators/with-strict-mode.tsx +++ b/packages/joint-react/.storybook/decorators/with-strict-mode.tsx @@ -3,8 +3,8 @@ import React from 'react'; export function withStrictMode(Story: any) { return ( - - - + // + + // ); } diff --git a/packages/joint-react/src/components/graph/graph-provider.tsx b/packages/joint-react/src/components/graph/graph-provider.tsx index 348c47f30c..1d5f0853fe 100644 --- a/packages/joint-react/src/components/graph/graph-provider.tsx +++ b/packages/joint-react/src/components/graph/graph-provider.tsx @@ -14,7 +14,6 @@ import type { GraphElement } from '../../types/element-types'; import { CONTROLLED_MODE_BATCH_NAME } from '../../utils/graph/update-graph'; import { useImperativeApi } from '../../hooks/use-imperative-api'; import { GraphAreElementsMeasuredContext, GraphStoreContext } from '../../context'; -import { getTargetOrSource } from '../../utils/cell/get-link-targe-and-source-ids'; interface GraphProviderBaseProps< // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -79,49 +78,31 @@ export function GraphProviderHandler< const areElementsInControlledMode = !!onElementsChange; const areLinksInControlledMode = !!onLinksChange; - const isControlledMode = areElementsInControlledMode || areLinksInControlledMode; // Controlled mode for elements useLayoutEffect(() => { if (!areElementsMeasured) return; if (!graph) return; - if (!isControlledMode) return; + if (!areElementsInControlledMode) return; graph.startBatch(CONTROLLED_MODE_BATCH_NAME); if (areElementsInControlledMode && elements !== undefined) { setElements(elements); } - if (areLinksInControlledMode && links !== undefined) { - setLinks(links as GraphLink[]); - } graph.stopBatch(CONTROLLED_MODE_BATCH_NAME); - }, [ - areElementsInControlledMode, - areElementsMeasured, - areLinksInControlledMode, - graph, - elements, - links, - isControlledMode, - setElements, - setLinks, - ]); + }, [areElementsInControlledMode, areElementsMeasured, elements, graph, setElements]); + // Controlled mode for links useLayoutEffect(() => { - // with this all links are connected only when react elements are measured - // It fixes issue with a flickering of un-measured react elements. - if (isControlledMode) return; if (!areElementsMeasured) return; - if (!links?.length) return; - const hasSomePort = links?.some((link) => { - const { source, target } = link; - const sourceObject = getTargetOrSource(source as dia.Link.EndJSON); - const targetObject = getTargetOrSource(target as dia.Link.EndJSON); - return sourceObject.port || targetObject.port; - }); - if (!hasSomePort) return; - setLinks(links as GraphLink[]); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [areElementsMeasured, isControlledMode]); + if (!graph) return; + if (!areLinksInControlledMode) return; + + graph.startBatch(CONTROLLED_MODE_BATCH_NAME); + if (areLinksInControlledMode && links !== undefined) { + setLinks(links as GraphLink[]); + } + graph.stopBatch(CONTROLLED_MODE_BATCH_NAME); + }, [areElementsMeasured, areLinksInControlledMode, graph, links, setLinks]); return ( diff --git a/packages/joint-react/src/components/paper/paper.tsx b/packages/joint-react/src/components/paper/paper.tsx index 7793f1dd3b..7f5cd81456 100644 --- a/packages/joint-react/src/components/paper/paper.tsx +++ b/packages/joint-react/src/components/paper/paper.tsx @@ -75,7 +75,7 @@ function PaperBase( ...paperOptions } = props; - const { graph, addPaper, removePaper } = useGraphStore(); + const { graph } = useGraphStore(); const areElementsMeasured = useAreElementMeasured(); const { onRenderElement, elementViews } = useElementViews(); const elements = useElements((items) => items.map(elementSelector)); @@ -196,12 +196,10 @@ function PaperBase( Object.assign(instance, contextUpdate.contextUpdate); } - addPaper(id, paper); return { instance, cleanup() { paper.remove(); - removePaper(id); portsStore.destroy(); contextUpdate?.cleanup?.(); }, diff --git a/packages/joint-react/src/components/port/port-item.tsx b/packages/joint-react/src/components/port/port-item.tsx index 26149db0d6..18fba13978 100644 --- a/packages/joint-react/src/components/port/port-item.tsx +++ b/packages/joint-react/src/components/port/port-item.tsx @@ -80,9 +80,9 @@ function Component(props: PortItemProps) { throw new Error(`Port id is required`); } - const alreadyExists = cell.getPorts().some((p) => p.id === id); + const alreadyExists = cell.hasPort(id); if (alreadyExists) { - throw new Error(`Port with id ${id} already exists`); + throw new Error(`Port with id ${id} already exists in cell ${cellId}`); } const port: dia.Element.Port = { @@ -104,6 +104,7 @@ function Component(props: PortItemProps) { }; cell.addPort(port); + return () => { cell.removePort(id); }; @@ -123,6 +124,7 @@ function Component(props: PortItemProps) { const elementView = paper.findViewByModel(cellId); elementView.cleanNodesCache(); + for (const link of graph.getConnectedLinks(elementView.model)) { const target = link.target(); const source = link.source(); @@ -136,8 +138,14 @@ function Component(props: PortItemProps) { if (!isPortLink) { continue; } + + const linkView = link.findView(paper); + // @ts-expect-error we use private jointjs api method, it throw error here. + linkView._sourceMagnet = null; + // @ts-expect-error we use private jointjs api method, it throw error here. + linkView._targetMagnet = null; // @ts-expect-error we use private jointjs api method, it throw error here. - link.findView(paper).requestConnectionUpdate({ async: false }); + linkView.requestConnectionUpdate({ async: false }); } }, [cellId, graph, id, paper, portalNode]); diff --git a/packages/joint-react/src/data/create-graph-store.ts b/packages/joint-react/src/data/create-graph-store.ts index 7e04b587b1..5a42f70203 100644 --- a/packages/joint-react/src/data/create-graph-store.ts +++ b/packages/joint-react/src/data/create-graph-store.ts @@ -121,9 +121,6 @@ export interface GraphStore { * This will trigger a re-render of all components that are subscribed to the store. */ readonly forceUpdateStore: () => UpdateResult; - - readonly addPaper: (id: string, paper: dia.Paper) => void; - readonly removePaper: (id: string) => void; } /** @@ -312,49 +309,8 @@ export function createStoreWithGraph< } // Force update the graph to ensure it's in sync with the store. forceUpdateStore(); - const papers = new Map(); - - /** - * Freeze all papers to optimize performance during batch updates. - */ - function freezePapers() { - for (const [, paper] of papers) { - paper.freeze(); - } - } - - /** - * Unfreeze all papers after batch updates are complete. - */ - function unfreezePapers() { - for (const [, paper] of papers) { - paper.unfreeze(); - } - } - - /** - * Apply controlled update to the graph. - * This function freezes all papers, starts a batch, applies the update function, - * and then stops the batch and unfreezes the papers. - * @param callback - The update function to apply. - */ - function applyControlledUpdate(callback: () => void) { - if (!graph) return; - freezePapers(); // your helper - try { - callback(); // call setElements / setLinks WITHOUT silent - } finally { - unfreezePapers(); - } - } const store: GraphStore = { - addPaper(id: string, paper: dia.Paper) { - papers.set(id, paper); - }, - removePaper(id: string) { - papers.delete(id); - }, forceUpdateStore, destroy, graph, @@ -363,14 +319,10 @@ export function createStoreWithGraph< return dataRef.elements; }, setElements(newElements) { - applyControlledUpdate(() => { - setElements({ graph, elements: newElements }); - }); + setElements({ graph, elements: newElements }); }, setLinks(newLinks) { - applyControlledUpdate(() => { - setLinks({ graph, links: newLinks }); - }); + setLinks({ graph, links: newLinks }); }, getLinks() { return dataRef.links; diff --git a/packages/joint-react/src/hooks/use-measure-node-size.tsx b/packages/joint-react/src/hooks/use-measure-node-size.tsx index 390464a574..1254c3fd01 100644 --- a/packages/joint-react/src/hooks/use-measure-node-size.tsx +++ b/packages/joint-react/src/hooks/use-measure-node-size.tsx @@ -33,6 +33,7 @@ export function useMeasureNodeSize, options?: MeasureNodeOptions ) { + // TODO - add exception for using multiple measured node for single element const { setSize } = options ?? EMPTY_OBJECT; const { graph, setMeasuredNode, hasMeasuredNode } = useGraphStore(); const id = useCellId(); diff --git a/packages/joint-react/src/utils/cell/cell-utilities.ts b/packages/joint-react/src/utils/cell/cell-utilities.ts index 71a7cec658..1cdd77b460 100644 --- a/packages/joint-react/src/utils/cell/cell-utilities.ts +++ b/packages/joint-react/src/utils/cell/cell-utilities.ts @@ -45,7 +45,6 @@ export function processLink(link: dia.Link | GraphLink): CellOrJsonCell { export interface SetLinksOptions { readonly graph: dia.Graph; readonly links?: Array; - readonly isSilenced?: boolean; } /** @@ -58,7 +57,7 @@ export interface SetLinksOptions { * It also converts the source and target of the links to a standard format. */ export function setLinks(options: SetLinksOptions) { - const { graph, links, isSilenced } = options; + const { graph, links } = options; if (links === undefined) { return; } @@ -73,7 +72,6 @@ export function setLinks(options: SetLinksOptions) { return link; }), isLink: true, - isSilenced, }); } @@ -117,7 +115,6 @@ export function processElement( export interface SetElementsOptions { readonly graph: dia.Graph; readonly elements?: Array; - readonly isSilenced?: boolean; } /** @@ -131,7 +128,7 @@ export interface SetElementsOptions { * It also checks for unsized elements and returns their IDs. */ export function setElements(options: SetElementsOptions) { - const { graph, elements, isSilenced } = options; + const { graph, elements } = options; if (elements === undefined) { return new Set(); } @@ -140,7 +137,6 @@ export function setElements(options: SetElementsOptions) { graph, cells: elements.map((item) => processElement(item, unsizedIds)), isLink: false, - isSilenced, }); return unsizedIds; } diff --git a/packages/joint-react/src/utils/create-element-size-observer.ts b/packages/joint-react/src/utils/create-element-size-observer.ts index 9b2daf021e..1caa565cfb 100644 --- a/packages/joint-react/src/utils/create-element-size-observer.ts +++ b/packages/joint-react/src/utils/create-element-size-observer.ts @@ -24,6 +24,7 @@ export function createElementSizeObserver void ) { // Create a ResizeObserver to observe changes in the size of the HTML element. + // TODO not optimal - maybe debounce, maybe change to something else. const observer = new ResizeObserver((entries) => { for (const entry of entries) { const { borderBoxSize } = entry; diff --git a/packages/joint-react/src/utils/create.ts b/packages/joint-react/src/utils/create.ts index 1dca70d065..c1f068d5ef 100644 --- a/packages/joint-react/src/utils/create.ts +++ b/packages/joint-react/src/utils/create.ts @@ -111,7 +111,7 @@ export function createLinks< Link extends GraphLink, Type extends StandardLinkShapesType | string = 'standard.Link', >(data: Array>): Array { - return data.map((link) => ({ ...link, isElement: false, isLink: true })); + return data.map((link) => link); } /** @@ -128,5 +128,5 @@ export function createLinkItem< Link extends GraphLink, Type extends StandardLinkShapesType | string = 'standard.Link', >(link: Link & GraphLink): Link & GraphLink { - return { ...link, isElement: false, isLink: true }; + return link; } diff --git a/packages/joint-react/src/utils/graph/update-graph.ts b/packages/joint-react/src/utils/graph/update-graph.ts index 0d843c10a1..0a59b8473e 100644 --- a/packages/joint-react/src/utils/graph/update-graph.ts +++ b/packages/joint-react/src/utils/graph/update-graph.ts @@ -1,36 +1,26 @@ +/* eslint-disable sonarjs/cognitive-complexity */ +/* eslint-disable sonarjs/no-unused-vars */ import type { dia } from '@joint/core'; import type { CellOrJsonCell } from '../cell/cell-utilities'; -import { getCellId } from '../link-utilities'; import { isCellInstance } from '../is'; +import type { SVGAttributes } from 'react'; export const CONTROLLED_MODE_BATCH_NAME = 'controlled-mode'; export const GRAPH_UPDATE_BATCH_NAME = 'update-graph'; + /** - * Get the value of a specific attribute from a cell or JSON cell. - * @param cell - The cell or JSON cell to get the value from. - * @param attributeName - The name of the attribute to get the value of. - * @returns The value of the attribute. - * @group utils - */ -function getType(cell: CellOrJsonCell, attributeName: string) { - if (isCellInstance(cell)) { - return cell.get(attributeName); - } - return cell[attributeName]; -} -/** - * Get the attributes of a cell or JSON cell. - * @param cell - The cell or JSON cell to get the attributes from. - * @returns The attributes of the cell. + * Safely set attributes on a link, merging with existing attributes. + * @param link - The link to set attributes on. + * @param attributes - The attributes to set. * @group utils */ -function getAttributes(cell: CellOrJsonCell) { - if (isCellInstance(cell)) { - return cell.attributes; - } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { id, type, ...attributes } = cell; - return attributes; +function setLinkAttributesSafely( + link: dia.Link, + attributes?: SVGAttributes | undefined +) { + if (!attributes) return; + // Deep-merge into existing attrs; do NOT replace the whole 'attrs' object. + link.attr(attributes as dia.Link.LinkSelectors); // <- this merges at any depth (e.g., { line: { stroke: '#ED2637' } }) } interface UpdateCellOptions { @@ -38,32 +28,64 @@ interface UpdateCellOptions { readonly newCell: CellOrJsonCell; readonly newCellsMap?: Record; readonly isLink?: boolean; - readonly isSilenced?: boolean; } /** - * Update a cell in the graph or add it if it does not exist. + * Update a single cell in the graph. * @param options - The options for updating the cell. + * @group utils */ export function updateCell(options: UpdateCellOptions) { - const { graph, newCell, newCellsMap = {}, isSilenced } = options; - const id = getCellId(newCell.id); + const { graph, newCell, newCellsMap = {} } = options; + const { id } = newCell; if (!id) return; newCellsMap[id] = newCell; - const originalCell = graph.getCell(newCell.id); - if (originalCell) { - const isLink = originalCell.isLink(); - if (originalCell.get('type') === getType(newCell, 'type')) { - originalCell.set(getAttributes(newCell), { silent: isSilenced }); - } else if (isLink) { - graph.addCell(newCell, { silent: isSilenced }); + const current = graph.getCell(id); + const newType = isCellInstance(newCell) ? newCell.get('type') : newCell.type; + const attributesAll = isCellInstance(newCell) ? newCell.attributes : { ...newCell }; + + if (current) { + const isLink = current.isLink(); + + if (current.get('type') === newType) { + if (isLink) { + // Pull out fields that need special handling + const { + source, + target, + attrs, + id: _ignoreId, + type: _ignoreType, + ...rest // z, labels, vertices, router, connector, etc. + } = attributesAll; + + // 1) endpoints + if (source) (current as dia.Link).source(source); + if (target) (current as dia.Link).target(target); + + // 2) merge visual attrs (don’t replace) + if (attrs) setLinkAttributesSafely(current as dia.Link, attrs); + + // 3) apply other properties — but avoid setting `attrs` again + for (const [k, v] of Object.entries(rest)) { + // If you worry about something being a deep object, set individually + // but do NOT include 'attrs' here. + current.set(k, v); + } + } else { + // Element path: also avoid replacing attrs + const { attrs, id: _ignoreId, type: _ignoreType, ...rest } = attributesAll; + if (attrs) current.attr(attrs); + if (Object.keys(rest).length > 0) current.set(rest); + } } else { - originalCell.remove({ disconnectLinks: true, silent: isSilenced }); - graph.addCell(newCell, { silent: isSilenced }); + // Type changed — replace + current.remove({ disconnectLinks: true }); + graph.addCell(newCell); } } else { - graph.addCell(newCell, { silent: isSilenced }); + graph.addCell(newCell); } } @@ -71,7 +93,6 @@ interface Options { readonly graph: dia.Graph; readonly cells: CellOrJsonCell[]; readonly isLink: boolean; - readonly isSilenced?: boolean; } /** @@ -79,14 +100,14 @@ interface Options { * @param options - The options for updating the graph. */ export function updateGraph(options: Options) { - const { graph, cells, isLink, isSilenced } = options; + const { graph, cells, isLink } = options; const originalCells = isLink ? graph.getLinks() : graph.getElements(); const newCellsMap: Record = {}; // Here we do not want to remove the existing elements but only update them if they exist. // e.g. Using resetCells() would remove all elements from the graph and add new ones. for (const newCell of cells) { - updateCell({ graph, newCell, newCellsMap, isLink, isSilenced }); + updateCell({ graph, newCell, newCellsMap, isLink }); } if (originalCells) { From 8896f66ab01ce1c692580fa94da1e903e73600a1 Mon Sep 17 00:00:00 2001 From: "samuel.gjabel" Date: Thu, 20 Nov 2025 10:30:25 +0700 Subject: [PATCH 04/24] chore(joint-react): sync yarn lock + update tests snapshots --- .../__snapshots__/custom.test.tsx.snap | 6 +- .../__snapshots__/mask.test.tsx.snap | 6 +- .../__snapshots__/store.test.tsx.snap | 2 +- .../__snapshots__/measured-node.test.tsx.snap | 6 +- .../__snapshots__/port-group.test.tsx.snap | 2 +- .../__snapshots__/port-item.test.tsx.snap | 2 +- .../__snapshots__/text-node.test.tsx.snap | 8 +- yarn.lock | 1045 ++++++++--------- 8 files changed, 519 insertions(+), 558 deletions(-) diff --git a/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/custom.test.tsx.snap b/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/custom.test.tsx.snap index fbba8613fe..43a36a4a35 100644 --- a/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/custom.test.tsx.snap +++ b/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/custom.test.tsx.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`Highlighters/Custom "CustomWithMask": Highlighters/Custom-CustomWithMask 1`] = `"
"`; +exports[`Highlighters/Custom "CustomWithMask": Highlighters/Custom-CustomWithMask 1`] = `"
"`; -exports[`Highlighters/Custom "CustomWithOpacity": Highlighters/Custom-CustomWithOpacity 1`] = `"
"`; +exports[`Highlighters/Custom "CustomWithOpacity": Highlighters/Custom-CustomWithOpacity 1`] = `"
"`; -exports[`Highlighters/Custom "CustomWithStroke": Highlighters/Custom-CustomWithStroke 1`] = `"
"`; +exports[`Highlighters/Custom "CustomWithStroke": Highlighters/Custom-CustomWithStroke 1`] = `"
"`; diff --git a/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/mask.test.tsx.snap b/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/mask.test.tsx.snap index 666a58ba4c..7f99da095b 100644 --- a/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/mask.test.tsx.snap +++ b/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/mask.test.tsx.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`Highlighters/Mask "Default": Highlighters/Mask-Default 1`] = `"
"`; +exports[`Highlighters/Mask "Default": Highlighters/Mask-Default 1`] = `"
"`; -exports[`Highlighters/Mask "WithPadding": Highlighters/Mask-WithPadding 1`] = `"
"`; +exports[`Highlighters/Mask "WithPadding": Highlighters/Mask-WithPadding 1`] = `"
"`; -exports[`Highlighters/Mask "WithSVGProps": Highlighters/Mask-WithSVGProps 1`] = `"
"`; +exports[`Highlighters/Mask "WithSVGProps": Highlighters/Mask-WithSVGProps 1`] = `"
"`; diff --git a/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/store.test.tsx.snap b/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/store.test.tsx.snap index 384895809a..5a312dd833 100644 --- a/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/store.test.tsx.snap +++ b/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/store.test.tsx.snap @@ -1,3 +1,3 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`Highlighters/Stroke "Default": Highlighters/Stroke-Default 1`] = `"
"`; +exports[`Highlighters/Stroke "Default": Highlighters/Stroke-Default 1`] = `"
"`; diff --git a/packages/joint-react/src/components/measured-node/__tests__/__snapshots__/measured-node.test.tsx.snap b/packages/joint-react/src/components/measured-node/__tests__/__snapshots__/measured-node.test.tsx.snap index 3c641f47df..fc1f23528c 100644 --- a/packages/joint-react/src/components/measured-node/__tests__/__snapshots__/measured-node.test.tsx.snap +++ b/packages/joint-react/src/components/measured-node/__tests__/__snapshots__/measured-node.test.tsx.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`MeasuredNode "DivWithExactSize": MeasuredNode-DivWithExactSize 1`] = `"
"`; +exports[`MeasuredNode "DivWithExactSize": MeasuredNode-DivWithExactSize 1`] = `"
"`; -exports[`MeasuredNode "DivWithPaddingAndText": MeasuredNode-DivWithPaddingAndText 1`] = `"
"`; +exports[`MeasuredNode "DivWithPaddingAndText": MeasuredNode-DivWithPaddingAndText 1`] = `"
"`; -exports[`MeasuredNode "TailwindSizing": MeasuredNode-TailwindSizing 1`] = `"
"`; +exports[`MeasuredNode "TailwindSizing": MeasuredNode-TailwindSizing 1`] = `"
"`; diff --git a/packages/joint-react/src/components/port/__tests__/__snapshots__/port-group.test.tsx.snap b/packages/joint-react/src/components/port/__tests__/__snapshots__/port-group.test.tsx.snap index 2dd52db15d..6485a271bc 100644 --- a/packages/joint-react/src/components/port/__tests__/__snapshots__/port-group.test.tsx.snap +++ b/packages/joint-react/src/components/port/__tests__/__snapshots__/port-group.test.tsx.snap @@ -1,3 +1,3 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`Port/Item "Default": Port/Item-Default 1`] = `"
"`; +exports[`Port/Item "Default": Port/Item-Default 1`] = `"
"`; diff --git a/packages/joint-react/src/components/port/__tests__/__snapshots__/port-item.test.tsx.snap b/packages/joint-react/src/components/port/__tests__/__snapshots__/port-item.test.tsx.snap index 2dd52db15d..6485a271bc 100644 --- a/packages/joint-react/src/components/port/__tests__/__snapshots__/port-item.test.tsx.snap +++ b/packages/joint-react/src/components/port/__tests__/__snapshots__/port-item.test.tsx.snap @@ -1,3 +1,3 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`Port/Item "Default": Port/Item-Default 1`] = `"
"`; +exports[`Port/Item "Default": Port/Item-Default 1`] = `"
"`; diff --git a/packages/joint-react/src/components/text-node/__tests__/__snapshots__/text-node.test.tsx.snap b/packages/joint-react/src/components/text-node/__tests__/__snapshots__/text-node.test.tsx.snap index c2cffc3f17..2031c752ed 100644 --- a/packages/joint-react/src/components/text-node/__tests__/__snapshots__/text-node.test.tsx.snap +++ b/packages/joint-react/src/components/text-node/__tests__/__snapshots__/text-node.test.tsx.snap @@ -1,9 +1,9 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`TextNode "Default": TextNode-Default 1`] = `"
"`; +exports[`TextNode "Default": TextNode-Default 1`] = `"
"`; -exports[`TextNode "WithBreakText": TextNode-WithBreakText 1`] = `"
"`; +exports[`TextNode "WithBreakText": TextNode-WithBreakText 1`] = `"
"`; -exports[`TextNode "WithBreakTextWithoutDefinedWith": TextNode-WithBreakTextWithoutDefinedWith 1`] = `"
"`; +exports[`TextNode "WithBreakTextWithoutDefinedWith": TextNode-WithBreakTextWithoutDefinedWith 1`] = `"
"`; -exports[`TextNode "WithEllipsis": TextNode-WithEllipsis 1`] = `"
"`; +exports[`TextNode "WithEllipsis": TextNode-WithEllipsis 1`] = `"
"`; diff --git a/yarn.lock b/yarn.lock index 3388151d77..e38bd76d32 100644 --- a/yarn.lock +++ b/yarn.lock @@ -83,7 +83,7 @@ __metadata: languageName: node linkType: hard -"@babel/core@npm:7.28.5, @babel/core@npm:^7.11.6, @babel/core@npm:^7.12.3, @babel/core@npm:^7.18.9, @babel/core@npm:^7.23.9, @babel/core@npm:^7.28.0": +"@babel/core@npm:7.28.5, @babel/core@npm:^7.12.3, @babel/core@npm:^7.18.9, @babel/core@npm:^7.23.9, @babel/core@npm:^7.27.4, @babel/core@npm:^7.28.0, @babel/core@npm:^7.28.4": version: 7.28.5 resolution: "@babel/core@npm:7.28.5" dependencies: @@ -106,7 +106,7 @@ __metadata: languageName: node linkType: hard -"@babel/generator@npm:^7.26.10, @babel/generator@npm:^7.28.5, @babel/generator@npm:^7.7.2": +"@babel/generator@npm:^7.26.10, @babel/generator@npm:^7.28.5": version: 7.28.5 resolution: "@babel/generator@npm:7.28.5" dependencies: @@ -119,7 +119,7 @@ __metadata: languageName: node linkType: hard -"@babel/generator@npm:^7.27.5, @babel/generator@npm:^7.28.3": +"@babel/generator@npm:^7.27.5": version: 7.28.3 resolution: "@babel/generator@npm:7.28.3" dependencies: @@ -239,19 +239,6 @@ __metadata: languageName: node linkType: hard -"@babel/helper-module-transforms@npm:^7.28.3": - version: 7.28.3 - resolution: "@babel/helper-module-transforms@npm:7.28.3" - dependencies: - "@babel/helper-module-imports": "npm:^7.27.1" - "@babel/helper-validator-identifier": "npm:^7.27.1" - "@babel/traverse": "npm:^7.28.3" - peerDependencies: - "@babel/core": ^7.0.0 - checksum: 10/598fdd8aa5b91f08542d0ba62a737847d0e752c8b95ae2566bc9d11d371856d6867d93e50db870fb836a6c44cfe481c189d8a2b35ca025a224f070624be9fa87 - languageName: node - linkType: hard - "@babel/helper-optimise-call-expression@npm:^7.27.1": version: 7.27.1 resolution: "@babel/helper-optimise-call-expression@npm:7.27.1" @@ -311,7 +298,7 @@ __metadata: languageName: node linkType: hard -"@babel/helper-validator-identifier@npm:^7.25.9, @babel/helper-validator-identifier@npm:^7.27.1, @babel/helper-validator-identifier@npm:^7.28.5": +"@babel/helper-validator-identifier@npm:^7.27.1, @babel/helper-validator-identifier@npm:^7.28.5": version: 7.28.5 resolution: "@babel/helper-validator-identifier@npm:7.28.5" checksum: 10/8e5d9b0133702cfacc7f368bf792f0f8ac0483794877c6dca5fcb73810ee138e27527701826fb58a40a004f3a5ec0a2f3c3dd5e326d262530b119918f3132ba7 @@ -346,7 +333,7 @@ __metadata: languageName: node linkType: hard -"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.14.7, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.23.9, @babel/parser@npm:^7.26.10, @babel/parser@npm:^7.27.2, @babel/parser@npm:^7.28.4, @babel/parser@npm:^7.28.5": +"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.14.7, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.23.9, @babel/parser@npm:^7.26.10, @babel/parser@npm:^7.27.2, @babel/parser@npm:^7.28.3, @babel/parser@npm:^7.28.4, @babel/parser@npm:^7.28.5": version: 7.28.5 resolution: "@babel/parser@npm:7.28.5" dependencies: @@ -1460,7 +1447,7 @@ __metadata: languageName: node linkType: hard -"@babel/template@npm:^7.26.9, @babel/template@npm:^7.27.1, @babel/template@npm:^7.27.2, @babel/template@npm:^7.3.3": +"@babel/template@npm:^7.26.9, @babel/template@npm:^7.27.1, @babel/template@npm:^7.27.2": version: 7.27.2 resolution: "@babel/template@npm:7.27.2" dependencies: @@ -1486,7 +1473,7 @@ __metadata: languageName: node linkType: hard -"@babel/types@npm:^7.0.0, @babel/types@npm:^7.18.9, @babel/types@npm:^7.20.7, @babel/types@npm:^7.26.10, @babel/types@npm:^7.27.1, @babel/types@npm:^7.27.3, @babel/types@npm:^7.28.2, @babel/types@npm:^7.28.4, @babel/types@npm:^7.28.5, @babel/types@npm:^7.3.3, @babel/types@npm:^7.4.4": +"@babel/types@npm:^7.0.0, @babel/types@npm:^7.18.9, @babel/types@npm:^7.20.7, @babel/types@npm:^7.26.10, @babel/types@npm:^7.27.1, @babel/types@npm:^7.27.3, @babel/types@npm:^7.28.2, @babel/types@npm:^7.28.5, @babel/types@npm:^7.4.4": version: 7.28.5 resolution: "@babel/types@npm:7.28.5" dependencies: @@ -2167,7 +2154,7 @@ __metadata: languageName: node linkType: hard -"@eslint-community/eslint-utils@npm:^4.2.0, @eslint-community/eslint-utils@npm:^4.4.0, @eslint-community/eslint-utils@npm:^4.5.1, @eslint-community/eslint-utils@npm:^4.7.0, @eslint-community/eslint-utils@npm:^4.8.0, @eslint-community/eslint-utils@npm:^4.9.0": +"@eslint-community/eslint-utils@npm:^4.2.0, @eslint-community/eslint-utils@npm:^4.4.0, @eslint-community/eslint-utils@npm:^4.7.0, @eslint-community/eslint-utils@npm:^4.8.0, @eslint-community/eslint-utils@npm:^4.9.0": version: 4.9.0 resolution: "@eslint-community/eslint-utils@npm:4.9.0" dependencies: @@ -2192,63 +2179,63 @@ __metadata: languageName: node linkType: hard -"@eslint-react/ast@npm:1.53.1": - version: 1.53.1 - resolution: "@eslint-react/ast@npm:1.53.1" +"@eslint-react/ast@npm:1.52.4": + version: 1.52.4 + resolution: "@eslint-react/ast@npm:1.52.4" dependencies: - "@eslint-react/eff": "npm:1.53.1" - "@typescript-eslint/types": "npm:^8.43.0" - "@typescript-eslint/typescript-estree": "npm:^8.43.0" - "@typescript-eslint/utils": "npm:^8.43.0" + "@eslint-react/eff": "npm:1.52.4" + "@typescript-eslint/types": "npm:^8.39.1" + "@typescript-eslint/typescript-estree": "npm:^8.39.1" + "@typescript-eslint/utils": "npm:^8.39.1" string-ts: "npm:^2.2.1" ts-pattern: "npm:^5.8.0" - checksum: 10/74af10b202984546542c8d21ed6c0d9bc9c814666f0bd3e46fa49e41ecc273f9ab0976dc61ddf8ee800392e56b38742caaf6443987ec736d5901fc07759657dd + checksum: 10/21148b8bbe4abac6573efac606321ce9b07aacee3c33cfacad9c01315578998f6a098b5636e1a2ef1982736bd804702bf8f3ea4edf4d456f065fb02992b8d3df languageName: node linkType: hard -"@eslint-react/core@npm:1.53.1": - version: 1.53.1 - resolution: "@eslint-react/core@npm:1.53.1" +"@eslint-react/core@npm:1.52.4": + version: 1.52.4 + resolution: "@eslint-react/core@npm:1.52.4" dependencies: - "@eslint-react/ast": "npm:1.53.1" - "@eslint-react/eff": "npm:1.53.1" - "@eslint-react/kit": "npm:1.53.1" - "@eslint-react/shared": "npm:1.53.1" - "@eslint-react/var": "npm:1.53.1" - "@typescript-eslint/scope-manager": "npm:^8.43.0" - "@typescript-eslint/type-utils": "npm:^8.43.0" - "@typescript-eslint/types": "npm:^8.43.0" - "@typescript-eslint/utils": "npm:^8.43.0" + "@eslint-react/ast": "npm:1.52.4" + "@eslint-react/eff": "npm:1.52.4" + "@eslint-react/kit": "npm:1.52.4" + "@eslint-react/shared": "npm:1.52.4" + "@eslint-react/var": "npm:1.52.4" + "@typescript-eslint/scope-manager": "npm:^8.39.1" + "@typescript-eslint/type-utils": "npm:^8.39.1" + "@typescript-eslint/types": "npm:^8.39.1" + "@typescript-eslint/utils": "npm:^8.39.1" birecord: "npm:^0.1.1" ts-pattern: "npm:^5.8.0" - checksum: 10/96c8ff00ae9f25ccf053854c1bd1a730b3d408fe1d5f01d48d3f4c27c43763a228fb0d189db53ef2f2e1400f866bd8b176b842a520946bd3ab08d43b3bf23d8d + checksum: 10/9b17ef066914c65b8a93857dd9b8b579ad60d264111dc704190c29ca4bc64066ebc4c4e012c14a070a8cc60dcdbac242ab4da8719a9405893a674fa9bedaf1c9 languageName: node linkType: hard -"@eslint-react/eff@npm:1.53.1": - version: 1.53.1 - resolution: "@eslint-react/eff@npm:1.53.1" - checksum: 10/5a3fd298aadafe4a84cf01d2a24134b8cd3411c49f97a00f1b7cf67fac4f26855395458b9fb0e3e51ec46e544c343efe0877b88981de01360d387ec838c1c0ef +"@eslint-react/eff@npm:1.52.4": + version: 1.52.4 + resolution: "@eslint-react/eff@npm:1.52.4" + checksum: 10/ad2a9277d1d5869d5c5a7c1ddefbde809ec35d2787e1e6f069a52bd33b10297979ac409c70e9e1c1d0baae5e5cac543129ef5414bfa7c0c6f1720101713d5158 languageName: node linkType: hard -"@eslint-react/eslint-plugin@npm:^1.28.0": - version: 1.53.1 - resolution: "@eslint-react/eslint-plugin@npm:1.53.1" +"@eslint-react/eslint-plugin@npm:1.52.4": + version: 1.52.4 + resolution: "@eslint-react/eslint-plugin@npm:1.52.4" dependencies: - "@eslint-react/eff": "npm:1.53.1" - "@eslint-react/kit": "npm:1.53.1" - "@eslint-react/shared": "npm:1.53.1" - "@typescript-eslint/scope-manager": "npm:^8.43.0" - "@typescript-eslint/type-utils": "npm:^8.43.0" - "@typescript-eslint/types": "npm:^8.43.0" - "@typescript-eslint/utils": "npm:^8.43.0" - eslint-plugin-react-debug: "npm:1.53.1" - eslint-plugin-react-dom: "npm:1.53.1" - eslint-plugin-react-hooks-extra: "npm:1.53.1" - eslint-plugin-react-naming-convention: "npm:1.53.1" - eslint-plugin-react-web-api: "npm:1.53.1" - eslint-plugin-react-x: "npm:1.53.1" + "@eslint-react/eff": "npm:1.52.4" + "@eslint-react/kit": "npm:1.52.4" + "@eslint-react/shared": "npm:1.52.4" + "@typescript-eslint/scope-manager": "npm:^8.39.1" + "@typescript-eslint/type-utils": "npm:^8.39.1" + "@typescript-eslint/types": "npm:^8.39.1" + "@typescript-eslint/utils": "npm:^8.39.1" + eslint-plugin-react-debug: "npm:1.52.4" + eslint-plugin-react-dom: "npm:1.52.4" + eslint-plugin-react-hooks-extra: "npm:1.52.4" + eslint-plugin-react-naming-convention: "npm:1.52.4" + eslint-plugin-react-web-api: "npm:1.52.4" + eslint-plugin-react-x: "npm:1.52.4" peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: ^4.9.5 || ^5.3.3 @@ -2257,51 +2244,51 @@ __metadata: optional: false typescript: optional: true - checksum: 10/d90f1355d4b71599657ae7d98da51429f980b6d143b5072699704742aa83b380c636d492157726fe6edd40c06233654d8151dbd5df2f0eeb68e1dc94c5154aca + checksum: 10/7e60cc06d920212348d7204a14a39cd15f8d541a3f9f4a3364cb417e5a3adde39745e8ac41c9d9cc06efdebeec2f423d584cd8101e63383a2cd1790c115462f6 languageName: node linkType: hard -"@eslint-react/kit@npm:1.53.1": - version: 1.53.1 - resolution: "@eslint-react/kit@npm:1.53.1" +"@eslint-react/kit@npm:1.52.4": + version: 1.52.4 + resolution: "@eslint-react/kit@npm:1.52.4" dependencies: - "@eslint-react/eff": "npm:1.53.1" - "@typescript-eslint/utils": "npm:^8.43.0" + "@eslint-react/eff": "npm:1.52.4" + "@typescript-eslint/utils": "npm:^8.39.1" ts-pattern: "npm:^5.8.0" - zod: "npm:^4.1.5" - checksum: 10/d2afea10ceb67ebcb86591dea16a9bfacf99884e14515061c2d9438d058b776f54243fa1d9941f374cd724cee4963247e1c99d46667bfa227d4c6dc7aa3e0c48 + zod: "npm:^4.0.17" + checksum: 10/17ece52d0ea0307856683519894e58066c65ac8c608afcc06167c8944c4ecd864748dd6691af26991dd2e39c7847fd8d076e016016d9ad037138f55347c170af languageName: node linkType: hard -"@eslint-react/shared@npm:1.53.1": - version: 1.53.1 - resolution: "@eslint-react/shared@npm:1.53.1" +"@eslint-react/shared@npm:1.52.4": + version: 1.52.4 + resolution: "@eslint-react/shared@npm:1.52.4" dependencies: - "@eslint-react/eff": "npm:1.53.1" - "@eslint-react/kit": "npm:1.53.1" - "@typescript-eslint/utils": "npm:^8.43.0" + "@eslint-react/eff": "npm:1.52.4" + "@eslint-react/kit": "npm:1.52.4" + "@typescript-eslint/utils": "npm:^8.39.1" ts-pattern: "npm:^5.8.0" - zod: "npm:^4.1.5" - checksum: 10/e1be6ae3b87eea94f70985f2b52d1172d8335babda1f9e410226b5348534332c1bc5dc9d1f16bdbbb3ad4b51aa27ca5ad091fa287f81e7829186277e03c95282 + zod: "npm:^4.0.17" + checksum: 10/4b412baf8351f66953050a15a97ad4cd06ad3331c4895e5b28d33a35d815832c85d7968bb5268ba1874952ad4dcfc055482c751db3568649124d6c54e0738dbb languageName: node linkType: hard -"@eslint-react/var@npm:1.53.1": - version: 1.53.1 - resolution: "@eslint-react/var@npm:1.53.1" +"@eslint-react/var@npm:1.52.4": + version: 1.52.4 + resolution: "@eslint-react/var@npm:1.52.4" dependencies: - "@eslint-react/ast": "npm:1.53.1" - "@eslint-react/eff": "npm:1.53.1" - "@typescript-eslint/scope-manager": "npm:^8.43.0" - "@typescript-eslint/types": "npm:^8.43.0" - "@typescript-eslint/utils": "npm:^8.43.0" + "@eslint-react/ast": "npm:1.52.4" + "@eslint-react/eff": "npm:1.52.4" + "@typescript-eslint/scope-manager": "npm:^8.39.1" + "@typescript-eslint/types": "npm:^8.39.1" + "@typescript-eslint/utils": "npm:^8.39.1" string-ts: "npm:^2.2.1" ts-pattern: "npm:^5.8.0" - checksum: 10/071bfcbfc39a65038fe03c45b508ed43ec1a441c363b32dad4bde494394cad10fbe4ae28c6ac59b3a06f33a0a2018aa73d78f35de5a75f3fc6731cdfe62b5f5e + checksum: 10/1c3abab4e854ff9799807a23174751c4fc67495ed93b315be8ddd4f3c137ed166fdbdd5b2712a77898265555bf6e2590424b2cec5ea00638cc30f69fb89f3325 languageName: node linkType: hard -"@eslint/compat@npm:^1.1.1": +"@eslint/compat@npm:^1.3.2": version: 1.4.1 resolution: "@eslint/compat@npm:1.4.1" dependencies: @@ -2315,29 +2302,7 @@ __metadata: languageName: node linkType: hard -"@eslint/config-array@npm:^0.19.0": - version: 0.19.2 - resolution: "@eslint/config-array@npm:0.19.2" - dependencies: - "@eslint/object-schema": "npm:^2.1.6" - debug: "npm:^4.3.1" - minimatch: "npm:^3.1.2" - checksum: 10/a6809720908f7dd8536e1a73b2369adf802fe61335536ed0592bca9543c476956e0c0a20fef8001885da8026e2445dc9bf3e471bb80d32c3be7bcdabb7628fd1 - languageName: node - linkType: hard - -"@eslint/config-array@npm:^0.20.0": - version: 0.20.1 - resolution: "@eslint/config-array@npm:0.20.1" - dependencies: - "@eslint/object-schema": "npm:^2.1.6" - debug: "npm:^4.3.1" - minimatch: "npm:^3.1.2" - checksum: 10/d72cc90f516c5730da5f37fa04aa8ba26ea0d92c7457ee77980902158f844f3483518272ccfe16f273c3313c3bfec8da713d4e51d3da49bdeccd34e919a2b903 - languageName: node - linkType: hard - -"@eslint/config-array@npm:^0.21.1": +"@eslint/config-array@npm:^0.21.0, @eslint/config-array@npm:^0.21.1": version: 0.21.1 resolution: "@eslint/config-array@npm:0.21.1" dependencies: @@ -2348,10 +2313,10 @@ __metadata: languageName: node linkType: hard -"@eslint/config-helpers@npm:^0.2.0": - version: 0.2.3 - resolution: "@eslint/config-helpers@npm:0.2.3" - checksum: 10/1f5082248f65555cc666942f7c991a2cfd6821758fb45338f43b28ea0f6b77d0c48b35097400d9b8fe1b4b10150085452e0b8f2d6d9ba17a84e16a6c7e4b341d +"@eslint/config-helpers@npm:^0.3.1": + version: 0.3.1 + resolution: "@eslint/config-helpers@npm:0.3.1" + checksum: 10/fc1a90ef6180aa4b5187cee04cfc566abb2a32b77ca3e7eeb4312c7388f6898221adaf8451d9ddb22e0b8860d900fefb1eb1435e4f32f8d8732de87f14605f8f languageName: node linkType: hard @@ -2364,21 +2329,12 @@ __metadata: languageName: node linkType: hard -"@eslint/core@npm:^0.12.0": - version: 0.12.0 - resolution: "@eslint/core@npm:0.12.0" +"@eslint/core@npm:^0.15.2": + version: 0.15.2 + resolution: "@eslint/core@npm:0.15.2" dependencies: "@types/json-schema": "npm:^7.0.15" - checksum: 10/ee8a2c65ee49af727e167b180a8672739e468ad0b1b9ac52558e61bb120f1a93af23f9e723e0e58f273adfe30ccd98167b59598c7be07440489fa38f669b59ae - languageName: node - linkType: hard - -"@eslint/core@npm:^0.13.0": - version: 0.13.0 - resolution: "@eslint/core@npm:0.13.0" - dependencies: - "@types/json-schema": "npm:^7.0.15" - checksum: 10/737fd1c237405b62592e8daa4b7e25b45ab22108bfec65258cabd091d5717b7c9573acea1f27c4ee7198cefc5a0874f5caefe3d9636851227b1f12d28ef52cf2 + checksum: 10/41d6273bbc6897cca34a2ca4e80a24bf6f1d43519456ebaa3c38f187da2d9e06f442c64f6e2a2813f055dce35e5cea33a21d0ac3b5b0830b7165641c640faf5d languageName: node linkType: hard @@ -2400,7 +2356,7 @@ __metadata: languageName: node linkType: hard -"@eslint/eslintrc@npm:^3.2.0, @eslint/eslintrc@npm:^3.3.1": +"@eslint/eslintrc@npm:^3.3.1": version: 3.3.1 resolution: "@eslint/eslintrc@npm:3.3.1" dependencies: @@ -2417,17 +2373,10 @@ __metadata: languageName: node linkType: hard -"@eslint/js@npm:9.19.0": - version: 9.19.0 - resolution: "@eslint/js@npm:9.19.0" - checksum: 10/d8133a83330676d5f0827713af2e9bbf35530631a93520fb59ead6b827a325c54fdd7ad99f2158f895fb393c47bbc55dfdaa945998a647f3b9230f1d5324a626 - languageName: node - linkType: hard - -"@eslint/js@npm:9.24.0": - version: 9.24.0 - resolution: "@eslint/js@npm:9.24.0" - checksum: 10/d210114c147a1c1ebfaed5f32734e7c1f8ef551a5ea48ea67f9469668aa4079565ccd038412437bca87515d51dc9e8b8c788473dcf3d08e35dfb27e92cb3ce1b +"@eslint/js@npm:9.33.0": + version: 9.33.0 + resolution: "@eslint/js@npm:9.33.0" + checksum: 10/415031162eeee4ed67457585f3a3f3442521e75dd352932582683452c393d837da81cf9726a2cf097b444119ae2e405951e6b5d84546f67b6370fc36f27d8321 languageName: node linkType: hard @@ -2438,20 +2387,20 @@ __metadata: languageName: node linkType: hard -"@eslint/object-schema@npm:^2.1.6, @eslint/object-schema@npm:^2.1.7": +"@eslint/object-schema@npm:^2.1.7": version: 2.1.7 resolution: "@eslint/object-schema@npm:2.1.7" checksum: 10/946ef5d6235b4d1c0907c6c6e6429c8895f535380c562b7705c131f63f2e961b06e8785043c86a293da48e0a60c6286d98ba395b8b32ea55561fe6e4417cb7e4 languageName: node linkType: hard -"@eslint/plugin-kit@npm:^0.2.7": - version: 0.2.8 - resolution: "@eslint/plugin-kit@npm:0.2.8" +"@eslint/plugin-kit@npm:^0.3.3, @eslint/plugin-kit@npm:^0.3.5": + version: 0.3.5 + resolution: "@eslint/plugin-kit@npm:0.3.5" dependencies: - "@eslint/core": "npm:^0.13.0" + "@eslint/core": "npm:^0.15.2" levn: "npm:^0.4.1" - checksum: 10/2e7fe7a88ebdbbf805e9e7265347b7dcfb6bf50beec314def997572b2e8ae4a7b9504fb67b1698a70c348a0dd87251d1e9028292a96fd49b58cb5277d88bdea7 + checksum: 10/b8552d79c3091446b07d8b87a9a8ccb8cdee4d933c0ed46b8f61029c3382246fec8d04ea7d1e61656d9275263205ccaa40019fd7581bbce897eca3eda42d5dad languageName: node linkType: hard @@ -2502,7 +2451,7 @@ __metadata: languageName: node linkType: hard -"@humanwhocodes/retry@npm:^0.4.0, @humanwhocodes/retry@npm:^0.4.1, @humanwhocodes/retry@npm:^0.4.2": +"@humanwhocodes/retry@npm:^0.4.0, @humanwhocodes/retry@npm:^0.4.2": version: 0.4.3 resolution: "@humanwhocodes/retry@npm:0.4.3" checksum: 10/0b32cfd362bea7a30fbf80bb38dcaf77fee9c2cae477ee80b460871d03590110ac9c77d654f04ec5beaf71b6f6a89851bdf6c1e34ccdf2f686bd86fcd97d9e61 @@ -3456,16 +3405,6 @@ __metadata: languageName: node linkType: hard -"@jridgewell/remapping@npm:^2.3.5": - version: 2.3.5 - resolution: "@jridgewell/remapping@npm:2.3.5" - dependencies: - "@jridgewell/gen-mapping": "npm:^0.3.5" - "@jridgewell/trace-mapping": "npm:^0.3.24" - checksum: 10/c2bb01856e65b506d439455f28aceacf130d6c023d1d4e3b48705e88def3571753e1a887daa04b078b562316c92d26ce36408a60534bceca3f830aec88a339ad - languageName: node - linkType: hard - "@jridgewell/resolve-uri@npm:^3.0.3, @jridgewell/resolve-uri@npm:^3.1.0": version: 3.1.2 resolution: "@jridgewell/resolve-uri@npm:3.1.2" @@ -3490,13 +3429,6 @@ __metadata: languageName: node linkType: hard -"@jridgewell/sourcemap-codec@npm:^1.5.5": - version: 1.5.5 - resolution: "@jridgewell/sourcemap-codec@npm:1.5.5" - checksum: 10/5d9d207b462c11e322d71911e55e21a4e2772f71ffe8d6f1221b8eb5ae6774458c1d242f897fb0814e8714ca9a6b498abfa74dfe4f434493342902b1a48b33a5 - languageName: node - linkType: hard - "@jridgewell/trace-mapping@npm:0.3.9": version: 0.3.9 resolution: "@jridgewell/trace-mapping@npm:0.3.9" @@ -3507,17 +3439,7 @@ __metadata: languageName: node linkType: hard -"@jridgewell/trace-mapping@npm:^0.3.12, @jridgewell/trace-mapping@npm:^0.3.18, @jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.25, @jridgewell/trace-mapping@npm:^0.3.28": - version: 0.3.31 - resolution: "@jridgewell/trace-mapping@npm:0.3.31" - dependencies: - "@jridgewell/resolve-uri": "npm:^3.1.0" - "@jridgewell/sourcemap-codec": "npm:^1.4.14" - checksum: 10/da0283270e691bdb5543806077548532791608e52386cfbbf3b9e8fb00457859d1bd01d512851161c886eb3a2f3ce6fd9bcf25db8edf3bddedd275bd4a88d606 - languageName: node - linkType: hard - -"@jridgewell/trace-mapping@npm:^0.3.23": +"@jridgewell/trace-mapping@npm:^0.3.12, @jridgewell/trace-mapping@npm:^0.3.23, @jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.25, @jridgewell/trace-mapping@npm:^0.3.28": version: 0.3.31 resolution: "@jridgewell/trace-mapping@npm:0.3.31" dependencies: @@ -3692,6 +3614,28 @@ __metadata: languageName: node linkType: hard +"@napi-rs/wasm-runtime@npm:^0.2.11": + version: 0.2.12 + resolution: "@napi-rs/wasm-runtime@npm:0.2.12" + dependencies: + "@emnapi/core": "npm:^1.4.3" + "@emnapi/runtime": "npm:^1.4.3" + "@tybys/wasm-util": "npm:^0.10.0" + checksum: 10/5fd518182427980c28bc724adf06c5f32f9a8915763ef560b5f7d73607d30cd15ac86d0cbd2eb80d4cfab23fc80d0876d89ca36a9daadcb864bc00917c94187c + languageName: node + linkType: hard + +"@napi-rs/wasm-runtime@npm:^1.0.5": + version: 1.0.7 + resolution: "@napi-rs/wasm-runtime@npm:1.0.7" + dependencies: + "@emnapi/core": "npm:^1.5.0" + "@emnapi/runtime": "npm:^1.5.0" + "@tybys/wasm-util": "npm:^0.10.1" + checksum: 10/6bc32d32d486d07b83220a9b7b2b715e39acacbacef0011ebca05c00b41d80a0535123da10fea7a7d6d7e206712bb50dc50ac3cf88b770754d44378570fb5c05 + languageName: node + linkType: hard + "@nodelib/fs.scandir@npm:2.1.5": version: 2.1.5 resolution: "@nodelib/fs.scandir@npm:2.1.5" @@ -5982,7 +5926,16 @@ __metadata: languageName: node linkType: hard -"@types/react-dom@npm:^19.0.3, @types/react-dom@npm:^19.1.2, @types/react-dom@npm:^19.1.7": +"@types/react-dom@npm:19.1.9": + version: 19.1.9 + resolution: "@types/react-dom@npm:19.1.9" + peerDependencies: + "@types/react": ^19.0.0 + checksum: 10/207acb79f6c3c9704938138960e21429efdf2db2184f17c166e8ec3f3180dfe6445b282c5302f559a71b2d09ab2fafef7735f3d24fd01cda4e5c7bf0cea1d5b9 + languageName: node + linkType: hard + +"@types/react-dom@npm:^19.1.2": version: 19.2.2 resolution: "@types/react-dom@npm:19.2.2" peerDependencies: @@ -6000,7 +5953,7 @@ __metadata: languageName: node linkType: hard -"@types/react@npm:*, @types/react@npm:^19.1.2, @types/react@npm:^19.1.9": +"@types/react@npm:*, @types/react@npm:^19.1.10": version: 19.2.2 resolution: "@types/react@npm:19.2.2" dependencies: @@ -6018,15 +5971,6 @@ __metadata: languageName: node linkType: hard -"@types/react@npm:^19.1.10": - version: 19.2.2 - resolution: "@types/react@npm:19.2.2" - dependencies: - csstype: "npm:^3.0.2" - checksum: 10/d6adf8fd4bb23a7e04da5700d96b15dc0f59653727a9c6e940c151d7232fa1dbbab98417d5ac830dcfb6cba3f206efbd4cd83647e6f9a688d7363a90e607f6bf - languageName: node - linkType: hard - "@types/resolve@npm:0.0.8": version: 0.0.8 resolution: "@types/resolve@npm:0.0.8" @@ -6193,12 +6137,12 @@ __metadata: languageName: node linkType: hard -"@types/yargs@npm:^17.0.8": - version: 17.0.34 - resolution: "@types/yargs@npm:17.0.34" +"@types/yargs@npm:^17.0.33": + version: 17.0.35 + resolution: "@types/yargs@npm:17.0.35" dependencies: "@types/yargs-parser": "npm:*" - checksum: 10/8e7907479e649e9115dcca94cb059dfe2322992ac5d29120f759564c078abfc13673a31f7ad86a3a5c9de7f241a4e3d70042ba38b794fd1601e44f9a1bc5cefd + checksum: 10/47bcd4476a4194ea11617ea71cba8a1eddf5505fc39c44336c1a08d452a0de4486aedbc13f47a017c8efbcb5a8aa358d976880663732ebcbc6dbcbbecadb0581 languageName: node linkType: hard @@ -6211,24 +6155,24 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/eslint-plugin@npm:8.29.0": - version: 8.29.0 - resolution: "@typescript-eslint/eslint-plugin@npm:8.29.0" +"@typescript-eslint/eslint-plugin@npm:8.39.1": + version: 8.39.1 + resolution: "@typescript-eslint/eslint-plugin@npm:8.39.1" dependencies: "@eslint-community/regexpp": "npm:^4.10.0" - "@typescript-eslint/scope-manager": "npm:8.29.0" - "@typescript-eslint/type-utils": "npm:8.29.0" - "@typescript-eslint/utils": "npm:8.29.0" - "@typescript-eslint/visitor-keys": "npm:8.29.0" + "@typescript-eslint/scope-manager": "npm:8.39.1" + "@typescript-eslint/type-utils": "npm:8.39.1" + "@typescript-eslint/utils": "npm:8.39.1" + "@typescript-eslint/visitor-keys": "npm:8.39.1" graphemer: "npm:^1.4.0" - ignore: "npm:^5.3.1" + ignore: "npm:^7.0.0" natural-compare: "npm:^1.4.0" - ts-api-utils: "npm:^2.0.1" + ts-api-utils: "npm:^2.1.0" peerDependencies: - "@typescript-eslint/parser": ^8.0.0 || ^8.0.0-alpha.0 + "@typescript-eslint/parser": ^8.39.1 eslint: ^8.57.0 || ^9.0.0 - typescript: ">=4.8.4 <5.9.0" - checksum: 10/1df4b43c209e40a00ec77e572b575760a9ac93967b6ebcc13f36587bf2881fc891c158f62cf25e8c2b8ca1ecd05b3eb583b30869ba6c2fa558435f0574773df8 + typescript: ">=4.8.4 <6.0.0" + checksum: 10/446050aa43d54c0107c7c927ae1f68a4384c2bba514d5c22edabbe355426cb37bd5bb5a3faf240a6be8ef06f68de6099c2a53d9cbb1849ed35a152fb156171e2 languageName: node linkType: hard @@ -6253,19 +6197,19 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/parser@npm:8.29.0": - version: 8.29.0 - resolution: "@typescript-eslint/parser@npm:8.29.0" +"@typescript-eslint/parser@npm:8.39.1": + version: 8.39.1 + resolution: "@typescript-eslint/parser@npm:8.39.1" dependencies: - "@typescript-eslint/scope-manager": "npm:8.29.0" - "@typescript-eslint/types": "npm:8.29.0" - "@typescript-eslint/typescript-estree": "npm:8.29.0" - "@typescript-eslint/visitor-keys": "npm:8.29.0" + "@typescript-eslint/scope-manager": "npm:8.39.1" + "@typescript-eslint/types": "npm:8.39.1" + "@typescript-eslint/typescript-estree": "npm:8.39.1" + "@typescript-eslint/visitor-keys": "npm:8.39.1" debug: "npm:^4.3.4" peerDependencies: eslint: ^8.57.0 || ^9.0.0 - typescript: ">=4.8.4 <5.9.0" - checksum: 10/d71fec12e78ac31a2faf076720c39f0e004a26672ebda4fc2f3b6f36306ff2362917dc6e0445746586f2911b4b2dd86622399dd578f002006f6c75cc9dfac013 + typescript: ">=4.8.4 <6.0.0" + checksum: 10/ff45ce76353ed564e0f9db47b02b4b20895c96182b3693c610ef3dbceda373c476037a99f90d9f28633c192f301e5d554c89e1ba72da216763f960648ddf1f34 languageName: node linkType: hard @@ -6285,6 +6229,19 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/project-service@npm:8.39.1": + version: 8.39.1 + resolution: "@typescript-eslint/project-service@npm:8.39.1" + dependencies: + "@typescript-eslint/tsconfig-utils": "npm:^8.39.1" + "@typescript-eslint/types": "npm:^8.39.1" + debug: "npm:^4.3.4" + peerDependencies: + typescript: ">=4.8.4 <6.0.0" + checksum: 10/1970633d1a338190f0125e186beaa39b3ef912f287e4815934faf64b72f140e87fdf7d861962683635a450d270dd76faf0c865d72bfd57b471a36739f943676b + languageName: node + linkType: hard + "@typescript-eslint/project-service@npm:8.46.2": version: 8.46.2 resolution: "@typescript-eslint/project-service@npm:8.46.2" @@ -6298,17 +6255,30 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/scope-manager@npm:8.29.0": - version: 8.29.0 - resolution: "@typescript-eslint/scope-manager@npm:8.29.0" +"@typescript-eslint/project-service@npm:8.47.0": + version: 8.47.0 + resolution: "@typescript-eslint/project-service@npm:8.47.0" dependencies: - "@typescript-eslint/types": "npm:8.29.0" - "@typescript-eslint/visitor-keys": "npm:8.29.0" - checksum: 10/23ce9962d57607f91a8a4a9bc43e64bd91cd933b53e61765924704614e52f39e8ccb28276b60b7472fb6dffe52fa681f114b73e4561fb4dcb74910a4e6a3629f + "@typescript-eslint/tsconfig-utils": "npm:^8.47.0" + "@typescript-eslint/types": "npm:^8.47.0" + debug: "npm:^4.3.4" + peerDependencies: + typescript: ">=4.8.4 <6.0.0" + checksum: 10/e2f935dae66ce27e6c0cce8b750da0e8fe84b6e0fa248bf8210b84eec3c4d2e2679a878185f445ce507d132215a676dcf8a21d47ab70c547da47ede000a128e1 + languageName: node + linkType: hard + +"@typescript-eslint/scope-manager@npm:8.39.1": + version: 8.39.1 + resolution: "@typescript-eslint/scope-manager@npm:8.39.1" + dependencies: + "@typescript-eslint/types": "npm:8.39.1" + "@typescript-eslint/visitor-keys": "npm:8.39.1" + checksum: 10/8874f7479043b3fc878f2c04b2c565051deceb7e425a8e4e79a7f40f1ee696bb979bd91fff619e016fe6793f537b30609c0ee8a5c40911c4829fa264863f7a70 languageName: node linkType: hard -"@typescript-eslint/scope-manager@npm:8.46.2, @typescript-eslint/scope-manager@npm:^8.43.0": +"@typescript-eslint/scope-manager@npm:8.46.2": version: 8.46.2 resolution: "@typescript-eslint/scope-manager@npm:8.46.2" dependencies: @@ -6318,6 +6288,25 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/scope-manager@npm:8.47.0, @typescript-eslint/scope-manager@npm:^8.39.1": + version: 8.47.0 + resolution: "@typescript-eslint/scope-manager@npm:8.47.0" + dependencies: + "@typescript-eslint/types": "npm:8.47.0" + "@typescript-eslint/visitor-keys": "npm:8.47.0" + checksum: 10/e97ae0f746f6bb5706181a973bcc0c1268706ef7e8c18594b37168bb0b41b1673d3f0ba1a2575ee3bd121066500fdc75af313f6ad283198942a5cdb65ade7621 + languageName: node + linkType: hard + +"@typescript-eslint/tsconfig-utils@npm:8.39.1": + version: 8.39.1 + resolution: "@typescript-eslint/tsconfig-utils@npm:8.39.1" + peerDependencies: + typescript: ">=4.8.4 <6.0.0" + checksum: 10/38c1e1982504e606e525ad0ce47fdb4c7acc686a28a94c2b30fe988c439977e991ce69cb88a1724a41a8096fc2d18d7ced7fe8725e42879d841515ff36a37ecf + languageName: node + linkType: hard + "@typescript-eslint/tsconfig-utils@npm:8.46.2, @typescript-eslint/tsconfig-utils@npm:^8.46.2": version: 8.46.2 resolution: "@typescript-eslint/tsconfig-utils@npm:8.46.2" @@ -6327,22 +6316,32 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/type-utils@npm:8.29.0": - version: 8.29.0 - resolution: "@typescript-eslint/type-utils@npm:8.29.0" +"@typescript-eslint/tsconfig-utils@npm:8.47.0, @typescript-eslint/tsconfig-utils@npm:^8.39.1, @typescript-eslint/tsconfig-utils@npm:^8.47.0": + version: 8.47.0 + resolution: "@typescript-eslint/tsconfig-utils@npm:8.47.0" + peerDependencies: + typescript: ">=4.8.4 <6.0.0" + checksum: 10/7f44441da3778928937419f8ebc62939538cf30087e56c0ca56f599ce98111b82f496902a9e15d713822b9cd14b17937d57b722468450a48748f8e50fd7161af + languageName: node + linkType: hard + +"@typescript-eslint/type-utils@npm:8.39.1": + version: 8.39.1 + resolution: "@typescript-eslint/type-utils@npm:8.39.1" dependencies: - "@typescript-eslint/typescript-estree": "npm:8.29.0" - "@typescript-eslint/utils": "npm:8.29.0" + "@typescript-eslint/types": "npm:8.39.1" + "@typescript-eslint/typescript-estree": "npm:8.39.1" + "@typescript-eslint/utils": "npm:8.39.1" debug: "npm:^4.3.4" - ts-api-utils: "npm:^2.0.1" + ts-api-utils: "npm:^2.1.0" peerDependencies: eslint: ^8.57.0 || ^9.0.0 - typescript: ">=4.8.4 <5.9.0" - checksum: 10/3b18caf6d3d16461d462b8960e1fa7fdb94f0eb2aa8afb9c95e2e458af32ffc82b14f1d26bb635b5e751bd0a7ff5c10fa1754377fff0dea760d1a96848705f88 + typescript: ">=4.8.4 <6.0.0" + checksum: 10/1195d65970f79f820558810f7e1edf0ea360bbeee55841fdbb71b5b40c09f1a65741b67a70b85c2834ae1f9a027b82da4234d01f42ab4e85dceef3eea84bfdaa languageName: node linkType: hard -"@typescript-eslint/type-utils@npm:8.46.2, @typescript-eslint/type-utils@npm:^8.0.0, @typescript-eslint/type-utils@npm:^8.43.0": +"@typescript-eslint/type-utils@npm:8.46.2, @typescript-eslint/type-utils@npm:^8.0.0": version: 8.46.2 resolution: "@typescript-eslint/type-utils@npm:8.46.2" dependencies: @@ -6358,39 +6357,64 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/types@npm:8.29.0": - version: 8.29.0 - resolution: "@typescript-eslint/types@npm:8.29.0" - checksum: 10/d65b9f2f6d87a3744788b09d9112c4a0298f1215138d8677240aae3bfa37ddc24a59315536cd9aab63c7608909ae2c5f436924c889b98986b78003b6028b5c35 +"@typescript-eslint/type-utils@npm:^8.39.1": + version: 8.47.0 + resolution: "@typescript-eslint/type-utils@npm:8.47.0" + dependencies: + "@typescript-eslint/types": "npm:8.47.0" + "@typescript-eslint/typescript-estree": "npm:8.47.0" + "@typescript-eslint/utils": "npm:8.47.0" + debug: "npm:^4.3.4" + ts-api-utils: "npm:^2.1.0" + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: ">=4.8.4 <6.0.0" + checksum: 10/07dcdd1ac071bbaf87b6b320d107129787a62cc403ce78e081cbe5e2ed0c576d660654e4117e6224c4c23d46919d7130b70801835d2fc41d9344c47ff946ce81 + languageName: node + linkType: hard + +"@typescript-eslint/types@npm:8.39.1": + version: 8.39.1 + resolution: "@typescript-eslint/types@npm:8.39.1" + checksum: 10/8013f4f48a98da0de270d5fef1ff28b35407de82fce5acf3efa212fce60bc92a81bbb15b4b358d9facf4f161e49feec856fbf1a6d96f5027d013b542f2fe1bcc languageName: node linkType: hard -"@typescript-eslint/types@npm:8.46.2, @typescript-eslint/types@npm:^8.11.0, @typescript-eslint/types@npm:^8.43.0, @typescript-eslint/types@npm:^8.46.1, @typescript-eslint/types@npm:^8.46.2": +"@typescript-eslint/types@npm:8.46.2, @typescript-eslint/types@npm:^8.46.1, @typescript-eslint/types@npm:^8.46.2": version: 8.46.2 resolution: "@typescript-eslint/types@npm:8.46.2" checksum: 10/c641453c868b730ef64bd731cc47b19e1a5e45c090dfe9542ecd15b24c5a7b6dc94a8ef4e548b976aabcd1ca9dec1b766e417454b98ea59079795eb008226b38 languageName: node linkType: hard -"@typescript-eslint/typescript-estree@npm:8.29.0": - version: 8.29.0 - resolution: "@typescript-eslint/typescript-estree@npm:8.29.0" +"@typescript-eslint/types@npm:8.47.0, @typescript-eslint/types@npm:^8.34.1, @typescript-eslint/types@npm:^8.38.0, @typescript-eslint/types@npm:^8.39.1, @typescript-eslint/types@npm:^8.47.0": + version: 8.47.0 + resolution: "@typescript-eslint/types@npm:8.47.0" + checksum: 10/fc42416c01c512cfe1533bdf521925bca999adc68ffefa246e48552783f1fe9d22487d912611c5cb35fca481604aae3cab88279a53ce76c7cd7510b76775c078 + languageName: node + linkType: hard + +"@typescript-eslint/typescript-estree@npm:8.39.1": + version: 8.39.1 + resolution: "@typescript-eslint/typescript-estree@npm:8.39.1" dependencies: - "@typescript-eslint/types": "npm:8.29.0" - "@typescript-eslint/visitor-keys": "npm:8.29.0" + "@typescript-eslint/project-service": "npm:8.39.1" + "@typescript-eslint/tsconfig-utils": "npm:8.39.1" + "@typescript-eslint/types": "npm:8.39.1" + "@typescript-eslint/visitor-keys": "npm:8.39.1" debug: "npm:^4.3.4" fast-glob: "npm:^3.3.2" is-glob: "npm:^4.0.3" minimatch: "npm:^9.0.4" semver: "npm:^7.6.0" - ts-api-utils: "npm:^2.0.1" + ts-api-utils: "npm:^2.1.0" peerDependencies: - typescript: ">=4.8.4 <5.9.0" - checksum: 10/276e6ea97857ef0fd940578d4b8f1677fd68d2bb62603c85d7aa97fcf86c1f66c5da962393254b605c7025f0cda74395904053891088cbe405b899afc1180e9c + typescript: ">=4.8.4 <6.0.0" + checksum: 10/07ed9d7ab4d146ee3ce6cf82ffebf947e045a9289b01522e11b3985b64f590c00cac0ca10366df828ca213bf08216a67c7b2b76e7c8be650df2511a7e6385425 languageName: node linkType: hard -"@typescript-eslint/typescript-estree@npm:8.46.2, @typescript-eslint/typescript-estree@npm:^8.43.0": +"@typescript-eslint/typescript-estree@npm:8.46.2": version: 8.46.2 resolution: "@typescript-eslint/typescript-estree@npm:8.46.2" dependencies: @@ -6410,22 +6434,42 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/utils@npm:8.29.0": - version: 8.29.0 - resolution: "@typescript-eslint/utils@npm:8.29.0" +"@typescript-eslint/typescript-estree@npm:8.47.0, @typescript-eslint/typescript-estree@npm:^8.39.1": + version: 8.47.0 + resolution: "@typescript-eslint/typescript-estree@npm:8.47.0" dependencies: - "@eslint-community/eslint-utils": "npm:^4.4.0" - "@typescript-eslint/scope-manager": "npm:8.29.0" - "@typescript-eslint/types": "npm:8.29.0" - "@typescript-eslint/typescript-estree": "npm:8.29.0" + "@typescript-eslint/project-service": "npm:8.47.0" + "@typescript-eslint/tsconfig-utils": "npm:8.47.0" + "@typescript-eslint/types": "npm:8.47.0" + "@typescript-eslint/visitor-keys": "npm:8.47.0" + debug: "npm:^4.3.4" + fast-glob: "npm:^3.3.2" + is-glob: "npm:^4.0.3" + minimatch: "npm:^9.0.4" + semver: "npm:^7.6.0" + ts-api-utils: "npm:^2.1.0" + peerDependencies: + typescript: ">=4.8.4 <6.0.0" + checksum: 10/a480e83f1fca8a389642cbb18855ef25214c4765694b1d4a74051d2653a4fbbbf3a3cc4e544d1ecb79d49958fbf819246043c0d823d4384aa1c7b5ff79d02fcc + languageName: node + linkType: hard + +"@typescript-eslint/utils@npm:8.39.1": + version: 8.39.1 + resolution: "@typescript-eslint/utils@npm:8.39.1" + dependencies: + "@eslint-community/eslint-utils": "npm:^4.7.0" + "@typescript-eslint/scope-manager": "npm:8.39.1" + "@typescript-eslint/types": "npm:8.39.1" + "@typescript-eslint/typescript-estree": "npm:8.39.1" peerDependencies: eslint: ^8.57.0 || ^9.0.0 - typescript: ">=4.8.4 <5.9.0" - checksum: 10/1fd17a28b8b57fc73c0455dea43a8185d3a4421f4a21ece01009b5e6a2974c8d4113f90d27993f668fa97077891b4464588d380c25116d351eb12ad7ef0d468d + typescript: ">=4.8.4 <6.0.0" + checksum: 10/39bb105f26aa1ba234ad7d284c277cbd66df9d51e245094892db140aac80d3656d0480f133b2db54e87af3ef9c371a12973120c9cfbff71e8e85865f9e1d0077 languageName: node linkType: hard -"@typescript-eslint/utils@npm:8.46.2, @typescript-eslint/utils@npm:^6.0.0 || ^7.0.0 || ^8.0.0, @typescript-eslint/utils@npm:^8.23.0, @typescript-eslint/utils@npm:^8.35.1, @typescript-eslint/utils@npm:^8.43.0": +"@typescript-eslint/utils@npm:8.46.2, @typescript-eslint/utils@npm:^8.35.1": version: 8.46.2 resolution: "@typescript-eslint/utils@npm:8.46.2" dependencies: @@ -6440,23 +6484,18 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/visitor-keys@npm:8.29.0": - version: 8.29.0 - resolution: "@typescript-eslint/visitor-keys@npm:8.29.0" +"@typescript-eslint/utils@npm:8.47.0, @typescript-eslint/utils@npm:^8.0.0, @typescript-eslint/utils@npm:^8.32.1, @typescript-eslint/utils@npm:^8.39.1": + version: 8.47.0 + resolution: "@typescript-eslint/utils@npm:8.47.0" dependencies: - "@typescript-eslint/types": "npm:8.29.0" - eslint-visitor-keys: "npm:^4.2.0" - checksum: 10/02e0e86ab112849a31b7d06c767be0ca7802385bf953d3b75f4ba6d06741d9492773325bc69d4c2a1c191b08f1c4c4b33f8e062d6d5d9f0f4f78f1b8b3cc2d41 - languageName: node - linkType: hard - -"@typescript-eslint/visitor-keys@npm:8.46.2": - version: 8.46.2 - resolution: "@typescript-eslint/visitor-keys@npm:8.46.2" - dependencies: - "@typescript-eslint/types": "npm:8.46.2" - eslint-visitor-keys: "npm:^4.2.1" - checksum: 10/4352629a33bc1619dc78d55eaec382be4c7e1059af02660f62bfdb22933021deaf98504d4030b8db74ec122e6d554e9015341f87aed729fb70fae613f12f55a4 + "@eslint-community/eslint-utils": "npm:^4.7.0" + "@typescript-eslint/scope-manager": "npm:8.47.0" + "@typescript-eslint/types": "npm:8.47.0" + "@typescript-eslint/typescript-estree": "npm:8.47.0" + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: ">=4.8.4 <6.0.0" + checksum: 10/e165bbcaaafb88761f12272bc4b3be1631d8a8ea319765c80cfe5bf7a5858f437486eeae177643baa213570a664f0254b41bf0541e9238b57080bb30d1a2c8ab languageName: node linkType: hard @@ -6470,13 +6509,23 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/visitor-keys@npm:8.46.1": - version: 8.46.1 - resolution: "@typescript-eslint/visitor-keys@npm:8.46.1" +"@typescript-eslint/visitor-keys@npm:8.46.2": + version: 8.46.2 + resolution: "@typescript-eslint/visitor-keys@npm:8.46.2" + dependencies: + "@typescript-eslint/types": "npm:8.46.2" + eslint-visitor-keys: "npm:^4.2.1" + checksum: 10/4352629a33bc1619dc78d55eaec382be4c7e1059af02660f62bfdb22933021deaf98504d4030b8db74ec122e6d554e9015341f87aed729fb70fae613f12f55a4 + languageName: node + linkType: hard + +"@typescript-eslint/visitor-keys@npm:8.47.0": + version: 8.47.0 + resolution: "@typescript-eslint/visitor-keys@npm:8.47.0" dependencies: - "@typescript-eslint/types": "npm:8.46.1" + "@typescript-eslint/types": "npm:8.47.0" eslint-visitor-keys: "npm:^4.2.1" - checksum: 10/eed1c5ce08d2743bd2ec95a33f2118a67596b1b9fa5bf6a3d84ed09ca66e09af3cc91ef3e302c2222e5882e13576340532b586030b3652ce046eb218cd4508b7 + checksum: 10/1e184cdebc4ab15da8a46ae2624ba4543c6bea83ced80a1602da99b72c00b5f6ea913ae021823c555a35a65bb9a9df09d119713998c44b00eba25e1407844294 languageName: node linkType: hard @@ -8438,6 +8487,15 @@ __metadata: languageName: node linkType: hard +"baseline-browser-mapping@npm:^2.8.9": + version: 2.8.29 + resolution: "baseline-browser-mapping@npm:2.8.29" + bin: + baseline-browser-mapping: dist/cli.js + checksum: 10/122c5841268dee007afe191cab1038118d3f513e784e36e8f69535a7924f650eb085f90ed5be97e1096619f903cc7c419c689594c767f3b2d8c4462c4e3a899d + languageName: node + linkType: hard + "basic-ftp@npm:^5.0.2": version: 5.0.5 resolution: "basic-ftp@npm:5.0.5" @@ -8842,7 +8900,7 @@ __metadata: languageName: node linkType: hard -"browserslist@npm:^4.24.0, browserslist@npm:^4.26.3": +"browserslist@npm:^4.24.0": version: 4.27.0 resolution: "browserslist@npm:4.27.0" dependencies: @@ -9171,13 +9229,6 @@ __metadata: languageName: node linkType: hard -"caniuse-lite@npm:^1.0.30001751": - version: 1.0.30001751 - resolution: "caniuse-lite@npm:1.0.30001751" - checksum: 10/608f7e1248b7023020382c7dbb0ef389693b3fc98193c3ccea2d44126306d6ac905a5061cf9e62bf640535a86e7a98e563b34c02f909296cfe228f41627a4dc7 - languageName: node - linkType: hard - "caniuse-lite@npm:^1.0.30001746": version: 1.0.30001750 resolution: "caniuse-lite@npm:1.0.30001750" @@ -9185,6 +9236,13 @@ __metadata: languageName: node linkType: hard +"caniuse-lite@npm:^1.0.30001751": + version: 1.0.30001751 + resolution: "caniuse-lite@npm:1.0.30001751" + checksum: 10/608f7e1248b7023020382c7dbb0ef389693b3fc98193c3ccea2d44126306d6ac905a5061cf9e62bf640535a86e7a98e563b34c02f909296cfe228f41627a4dc7 + languageName: node + linkType: hard + "canvas@npm:^3.1.0": version: 3.2.0 resolution: "canvas@npm:3.2.0" @@ -9480,14 +9538,7 @@ __metadata: languageName: node linkType: hard -"ci-info@npm:^4.2.0": - version: 4.3.1 - resolution: "ci-info@npm:4.3.1" - checksum: 10/9dc952bef67e665ccde2e7a552d42d5d095529d21829ece060a00925ede2dfa136160c70ef2471ea6ed6c9b133218b47c007f56955c0f1734a2e57f240aa7445 - languageName: node - linkType: hard - -"ci-info@npm:^4.3.0": +"ci-info@npm:^4.2.0, ci-info@npm:^4.3.0": version: 4.3.1 resolution: "ci-info@npm:4.3.1" checksum: 10/9dc952bef67e665ccde2e7a552d42d5d095529d21829ece060a00925ede2dfa136160c70ef2471ea6ed6c9b133218b47c007f56955c0f1734a2e57f240aa7445 @@ -9615,7 +9666,7 @@ __metadata: languageName: node linkType: hard -"collect-v8-coverage@npm:^1.0.0": +"collect-v8-coverage@npm:^1.0.2": version: 1.0.3 resolution: "collect-v8-coverage@npm:1.0.3" checksum: 10/656443261fb7b79cf79e89cba4b55622b07c1d4976c630829d7c5c585c73cda1c2ff101f316bfb19bb9e2c58d724c7db1f70a21e213dcd14099227c5e6019860 @@ -9984,16 +10035,7 @@ __metadata: languageName: node linkType: hard -"core-js-compat@npm:^3.40.0, core-js-compat@npm:^3.41.0, core-js-compat@npm:^3.43.0": - version: 3.46.0 - resolution: "core-js-compat@npm:3.46.0" - dependencies: - browserslist: "npm:^4.26.3" - checksum: 10/bee0523541d0e646c98dbff5b55bafa2e1674db82f769d851670a364bf4456b2a0364e393a70b09c4263f5dcb1fba3be32ddb4cffab11a79b53efbe32f4b76fb - languageName: node - linkType: hard - -"core-js-compat@npm:^3.44.0": +"core-js-compat@npm:^3.40.0, core-js-compat@npm:^3.43.0, core-js-compat@npm:^3.44.0": version: 3.46.0 resolution: "core-js-compat@npm:3.46.0" dependencies: @@ -10550,7 +10592,7 @@ __metadata: languageName: node linkType: hard -"dedent@npm:^1.0.0": +"dedent@npm:^1.6.0": version: 1.7.0 resolution: "dedent@npm:1.7.0" peerDependencies: @@ -11136,6 +11178,13 @@ __metadata: languageName: node linkType: hard +"electron-to-chromium@npm:^1.5.227": + version: 1.5.258 + resolution: "electron-to-chromium@npm:1.5.258" + checksum: 10/dc8328a601b77d89c61441a67633378598272d3f1530eca4f0ee0c27edf740a7fc5ca581663a79257355be4d501c9d9fbc7043ba7b403f4e7b076b9364cd2db7 + languageName: node + linkType: hard + "electron-to-chromium@npm:^1.5.238": version: 1.5.243 resolution: "electron-to-chromium@npm:1.5.243" @@ -11890,20 +11939,20 @@ __metadata: languageName: node linkType: hard -"eslint-plugin-react-debug@npm:1.53.1": - version: 1.53.1 - resolution: "eslint-plugin-react-debug@npm:1.53.1" +"eslint-plugin-react-debug@npm:1.52.4": + version: 1.52.4 + resolution: "eslint-plugin-react-debug@npm:1.52.4" dependencies: - "@eslint-react/ast": "npm:1.53.1" - "@eslint-react/core": "npm:1.53.1" - "@eslint-react/eff": "npm:1.53.1" - "@eslint-react/kit": "npm:1.53.1" - "@eslint-react/shared": "npm:1.53.1" - "@eslint-react/var": "npm:1.53.1" - "@typescript-eslint/scope-manager": "npm:^8.43.0" - "@typescript-eslint/type-utils": "npm:^8.43.0" - "@typescript-eslint/types": "npm:^8.43.0" - "@typescript-eslint/utils": "npm:^8.43.0" + "@eslint-react/ast": "npm:1.52.4" + "@eslint-react/core": "npm:1.52.4" + "@eslint-react/eff": "npm:1.52.4" + "@eslint-react/kit": "npm:1.52.4" + "@eslint-react/shared": "npm:1.52.4" + "@eslint-react/var": "npm:1.52.4" + "@typescript-eslint/scope-manager": "npm:^8.39.1" + "@typescript-eslint/type-utils": "npm:^8.39.1" + "@typescript-eslint/types": "npm:^8.39.1" + "@typescript-eslint/utils": "npm:^8.39.1" string-ts: "npm:^2.2.1" ts-pattern: "npm:^5.8.0" peerDependencies: @@ -11914,23 +11963,23 @@ __metadata: optional: false typescript: optional: true - checksum: 10/4a605f06d4520bdf90431a32d464255e101d357eca43af4f8d4504f1747c00d1434a4a38ea5af83b1b4481510a6651b82dc5a87dae786e52c3aee2df4d637ea4 + checksum: 10/2bdd3df51ecd530649a9f6b2589f38e9f50e75ae9fd2803bf52040312fff054667fef626f4209374220e8851019f925254d0867c5af5c94186dba6961bc5297c languageName: node linkType: hard -"eslint-plugin-react-dom@npm:1.53.1": - version: 1.53.1 - resolution: "eslint-plugin-react-dom@npm:1.53.1" +"eslint-plugin-react-dom@npm:1.52.4": + version: 1.52.4 + resolution: "eslint-plugin-react-dom@npm:1.52.4" dependencies: - "@eslint-react/ast": "npm:1.53.1" - "@eslint-react/core": "npm:1.53.1" - "@eslint-react/eff": "npm:1.53.1" - "@eslint-react/kit": "npm:1.53.1" - "@eslint-react/shared": "npm:1.53.1" - "@eslint-react/var": "npm:1.53.1" - "@typescript-eslint/scope-manager": "npm:^8.43.0" - "@typescript-eslint/types": "npm:^8.43.0" - "@typescript-eslint/utils": "npm:^8.43.0" + "@eslint-react/ast": "npm:1.52.4" + "@eslint-react/core": "npm:1.52.4" + "@eslint-react/eff": "npm:1.52.4" + "@eslint-react/kit": "npm:1.52.4" + "@eslint-react/shared": "npm:1.52.4" + "@eslint-react/var": "npm:1.52.4" + "@typescript-eslint/scope-manager": "npm:^8.39.1" + "@typescript-eslint/types": "npm:^8.39.1" + "@typescript-eslint/utils": "npm:^8.39.1" compare-versions: "npm:^6.1.1" string-ts: "npm:^2.2.1" ts-pattern: "npm:^5.8.0" @@ -11942,24 +11991,24 @@ __metadata: optional: false typescript: optional: true - checksum: 10/106c337237d2e83ba577d6bb00751e45b27b92ea707d3b0ad63cbc87de7202e22961344c6ad195ece4587f88eb66b4910ff268d39745cf3f432e869932ca7716 + checksum: 10/66b98905c563a9fda76be3e934816c286bb8da8a47757943a1d089b670826b538272d127004897f6660c14d38c74bb82851ecfd3f8a047d8e75f6578c19a9443 languageName: node linkType: hard -"eslint-plugin-react-hooks-extra@npm:1.53.1": - version: 1.53.1 - resolution: "eslint-plugin-react-hooks-extra@npm:1.53.1" +"eslint-plugin-react-hooks-extra@npm:1.52.4": + version: 1.52.4 + resolution: "eslint-plugin-react-hooks-extra@npm:1.52.4" dependencies: - "@eslint-react/ast": "npm:1.53.1" - "@eslint-react/core": "npm:1.53.1" - "@eslint-react/eff": "npm:1.53.1" - "@eslint-react/kit": "npm:1.53.1" - "@eslint-react/shared": "npm:1.53.1" - "@eslint-react/var": "npm:1.53.1" - "@typescript-eslint/scope-manager": "npm:^8.43.0" - "@typescript-eslint/type-utils": "npm:^8.43.0" - "@typescript-eslint/types": "npm:^8.43.0" - "@typescript-eslint/utils": "npm:^8.43.0" + "@eslint-react/ast": "npm:1.52.4" + "@eslint-react/core": "npm:1.52.4" + "@eslint-react/eff": "npm:1.52.4" + "@eslint-react/kit": "npm:1.52.4" + "@eslint-react/shared": "npm:1.52.4" + "@eslint-react/var": "npm:1.52.4" + "@typescript-eslint/scope-manager": "npm:^8.39.1" + "@typescript-eslint/type-utils": "npm:^8.39.1" + "@typescript-eslint/types": "npm:^8.39.1" + "@typescript-eslint/utils": "npm:^8.39.1" string-ts: "npm:^2.2.1" ts-pattern: "npm:^5.8.0" peerDependencies: @@ -11970,7 +12019,7 @@ __metadata: optional: false typescript: optional: true - checksum: 10/3a80048e46a9871e4a8684aea616cd6c5ebd7627ab091f95442240df6663f494ec396fe26d451cd579d52da6908f50be62753e16317c28f11a05bd9d2601e2ab + checksum: 10/de05ff0b59bd6ba6b969316b513bb8e23bd3420d504457a02a6e31042b2fbdacd7068041295f773fb4338dc7f43ad5cb679c7598ab8342b0d826afeb71133f56 languageName: node linkType: hard @@ -11983,20 +12032,20 @@ __metadata: languageName: node linkType: hard -"eslint-plugin-react-naming-convention@npm:1.53.1": - version: 1.53.1 - resolution: "eslint-plugin-react-naming-convention@npm:1.53.1" +"eslint-plugin-react-naming-convention@npm:1.52.4": + version: 1.52.4 + resolution: "eslint-plugin-react-naming-convention@npm:1.52.4" dependencies: - "@eslint-react/ast": "npm:1.53.1" - "@eslint-react/core": "npm:1.53.1" - "@eslint-react/eff": "npm:1.53.1" - "@eslint-react/kit": "npm:1.53.1" - "@eslint-react/shared": "npm:1.53.1" - "@eslint-react/var": "npm:1.53.1" - "@typescript-eslint/scope-manager": "npm:^8.43.0" - "@typescript-eslint/type-utils": "npm:^8.43.0" - "@typescript-eslint/types": "npm:^8.43.0" - "@typescript-eslint/utils": "npm:^8.43.0" + "@eslint-react/ast": "npm:1.52.4" + "@eslint-react/core": "npm:1.52.4" + "@eslint-react/eff": "npm:1.52.4" + "@eslint-react/kit": "npm:1.52.4" + "@eslint-react/shared": "npm:1.52.4" + "@eslint-react/var": "npm:1.52.4" + "@typescript-eslint/scope-manager": "npm:^8.39.1" + "@typescript-eslint/type-utils": "npm:^8.39.1" + "@typescript-eslint/types": "npm:^8.39.1" + "@typescript-eslint/utils": "npm:^8.39.1" string-ts: "npm:^2.2.1" ts-pattern: "npm:^5.8.0" peerDependencies: @@ -12007,7 +12056,7 @@ __metadata: optional: false typescript: optional: true - checksum: 10/7a2c4e883501443086fcd968d323cd5226bfdfab0637f3d27ce40b0ff1793dd563f2fcdf9a8acafd5cf3af84e79db590e075f1e016a1731ff63b428607a239d8 + checksum: 10/dc2d84b08c80cb40199b38a31d96e4f7bff0e5cc70a9125753ec2a51d8d9007110aaf4fef7832ff2f4166057e2fbf5245c6b0986bcf2ae22d1920b8f77013cce languageName: node linkType: hard @@ -12029,19 +12078,19 @@ __metadata: languageName: node linkType: hard -"eslint-plugin-react-web-api@npm:1.53.1": - version: 1.53.1 - resolution: "eslint-plugin-react-web-api@npm:1.53.1" +"eslint-plugin-react-web-api@npm:1.52.4": + version: 1.52.4 + resolution: "eslint-plugin-react-web-api@npm:1.52.4" dependencies: - "@eslint-react/ast": "npm:1.53.1" - "@eslint-react/core": "npm:1.53.1" - "@eslint-react/eff": "npm:1.53.1" - "@eslint-react/kit": "npm:1.53.1" - "@eslint-react/shared": "npm:1.53.1" - "@eslint-react/var": "npm:1.53.1" - "@typescript-eslint/scope-manager": "npm:^8.43.0" - "@typescript-eslint/types": "npm:^8.43.0" - "@typescript-eslint/utils": "npm:^8.43.0" + "@eslint-react/ast": "npm:1.52.4" + "@eslint-react/core": "npm:1.52.4" + "@eslint-react/eff": "npm:1.52.4" + "@eslint-react/kit": "npm:1.52.4" + "@eslint-react/shared": "npm:1.52.4" + "@eslint-react/var": "npm:1.52.4" + "@typescript-eslint/scope-manager": "npm:^8.39.1" + "@typescript-eslint/types": "npm:^8.39.1" + "@typescript-eslint/utils": "npm:^8.39.1" string-ts: "npm:^2.2.1" ts-pattern: "npm:^5.8.0" peerDependencies: @@ -12052,24 +12101,24 @@ __metadata: optional: false typescript: optional: true - checksum: 10/ecbe8f7933f2df070189e0dececb93574b9b361bb163cac218792a24d9fd004c8ab86e2cee683d3aab423658a2c7ffe76a75a6fc7e5681b55c0aa4517189d24e + checksum: 10/bff330bd55f8a250b3fa089de0bff2cf9688c6e239ab515fecb775c169b3a2d95e1a2a9391e85913d580dfaa67bbd72afba7e040f24e027a075d50961d4fbac0 languageName: node linkType: hard -"eslint-plugin-react-x@npm:1.53.1": - version: 1.53.1 - resolution: "eslint-plugin-react-x@npm:1.53.1" +"eslint-plugin-react-x@npm:1.52.4": + version: 1.52.4 + resolution: "eslint-plugin-react-x@npm:1.52.4" dependencies: - "@eslint-react/ast": "npm:1.53.1" - "@eslint-react/core": "npm:1.53.1" - "@eslint-react/eff": "npm:1.53.1" - "@eslint-react/kit": "npm:1.53.1" - "@eslint-react/shared": "npm:1.53.1" - "@eslint-react/var": "npm:1.53.1" - "@typescript-eslint/scope-manager": "npm:^8.43.0" - "@typescript-eslint/type-utils": "npm:^8.43.0" - "@typescript-eslint/types": "npm:^8.43.0" - "@typescript-eslint/utils": "npm:^8.43.0" + "@eslint-react/ast": "npm:1.52.4" + "@eslint-react/core": "npm:1.52.4" + "@eslint-react/eff": "npm:1.52.4" + "@eslint-react/kit": "npm:1.52.4" + "@eslint-react/shared": "npm:1.52.4" + "@eslint-react/var": "npm:1.52.4" + "@typescript-eslint/scope-manager": "npm:^8.39.1" + "@typescript-eslint/type-utils": "npm:^8.39.1" + "@typescript-eslint/types": "npm:^8.39.1" + "@typescript-eslint/utils": "npm:^8.39.1" compare-versions: "npm:^6.1.1" is-immutable-type: "npm:^5.0.1" string-ts: "npm:^2.2.1" @@ -12085,7 +12134,7 @@ __metadata: optional: true typescript: optional: true - checksum: 10/64a569dfc4561355a579f276625c80a360854adac1271154d148781a050c6902b34193e1cc4d1d446749c4f3a1fe2065d4ad6ba3b1d88dc9715c6e7d7236f798 + checksum: 10/ef0f380e3f4811f34b53ed2261082e1ce80fe19ea90bb16b110f36c1e9152b39fe5238532baf2077a75d85f64173edb735bae497f5d536545b847088191fff1c languageName: node linkType: hard @@ -12222,7 +12271,7 @@ __metadata: languageName: node linkType: hard -"eslint-scope@npm:^8.2.0, eslint-scope@npm:^8.3.0, eslint-scope@npm:^8.4.0": +"eslint-scope@npm:^8.2.0, eslint-scope@npm:^8.4.0": version: 8.4.0 resolution: "eslint-scope@npm:8.4.0" dependencies: @@ -12262,58 +12311,9 @@ __metadata: languageName: node linkType: hard -"eslint@npm:9.19.0": - version: 9.19.0 - resolution: "eslint@npm:9.19.0" - dependencies: - "@eslint-community/eslint-utils": "npm:^4.2.0" - "@eslint-community/regexpp": "npm:^4.12.1" - "@eslint/config-array": "npm:^0.19.0" - "@eslint/core": "npm:^0.10.0" - "@eslint/eslintrc": "npm:^3.2.0" - "@eslint/js": "npm:9.19.0" - "@eslint/plugin-kit": "npm:^0.2.5" - "@humanfs/node": "npm:^0.16.6" - "@humanwhocodes/module-importer": "npm:^1.0.1" - "@humanwhocodes/retry": "npm:^0.4.1" - "@types/estree": "npm:^1.0.6" - "@types/json-schema": "npm:^7.0.15" - ajv: "npm:^6.12.4" - chalk: "npm:^4.0.0" - cross-spawn: "npm:^7.0.6" - debug: "npm:^4.3.2" - escape-string-regexp: "npm:^4.0.0" - eslint-scope: "npm:^8.2.0" - eslint-visitor-keys: "npm:^4.2.0" - espree: "npm:^10.3.0" - esquery: "npm:^1.5.0" - esutils: "npm:^2.0.2" - fast-deep-equal: "npm:^3.1.3" - file-entry-cache: "npm:^8.0.0" - find-up: "npm:^5.0.0" - glob-parent: "npm:^6.0.2" - ignore: "npm:^5.2.0" - imurmurhash: "npm:^0.1.4" - is-glob: "npm:^4.0.0" - json-stable-stringify-without-jsonify: "npm:^1.0.1" - lodash.merge: "npm:^4.6.2" - minimatch: "npm:^3.1.2" - natural-compare: "npm:^1.4.0" - optionator: "npm:^0.9.3" - peerDependencies: - jiti: "*" - peerDependenciesMeta: - jiti: - optional: true - bin: - eslint: bin/eslint.js - checksum: 10/850d19fd6a34702d1e3d9bdad6aef84a20a5c2de006a8fa6380843384b13944b180232ddd74b8725ffcdf8f296399037f0e8eb4783d5f7393f13c059112b843d - languageName: node - linkType: hard - -"eslint@npm:9.24.0": - version: 9.24.0 - resolution: "eslint@npm:9.24.0" +"eslint@npm:9.33.0": + version: 9.33.0 + resolution: "eslint@npm:9.33.0" dependencies: "@eslint-community/eslint-utils": "npm:^4.2.0" "@eslint-community/regexpp": "npm:^4.12.1" @@ -13948,14 +13948,7 @@ __metadata: languageName: node linkType: hard -"globals@npm:^16.0.0": - version: 16.4.0 - resolution: "globals@npm:16.4.0" - checksum: 10/1627a9f42fb4c82d7af6a0c8b6cd616e00110908304d5f1ddcdf325998f3aed45a4b29d8a1e47870f328817805263e31e4f1673f00022b9c2b210552767921cf - languageName: node - linkType: hard - -"globals@npm:^16.3.0": +"globals@npm:^16.0.0, globals@npm:^16.3.0": version: 16.4.0 resolution: "globals@npm:16.4.0" checksum: 10/1627a9f42fb4c82d7af6a0c8b6cd616e00110908304d5f1ddcdf325998f3aed45a4b29d8a1e47870f328817805263e31e4f1673f00022b9c2b210552767921cf @@ -14842,7 +14835,7 @@ __metadata: languageName: node linkType: hard -"ignore@npm:^5.2.0, ignore@npm:^5.3.1": +"ignore@npm:^5.2.0": version: 5.3.2 resolution: "ignore@npm:5.3.2" checksum: 10/cceb6a457000f8f6a50e1196429750d782afce5680dd878aa4221bd79972d68b3a55b4b1458fc682be978f4d3c6a249046aa0880637367216444ab7b014cfc98 @@ -14934,13 +14927,6 @@ __metadata: languageName: node linkType: hard -"index-to-position@npm:^1.1.0": - version: 1.2.0 - resolution: "index-to-position@npm:1.2.0" - checksum: 10/fb6421c87a5f6eda533cfa472d1f7baf69592d2b7b243b4cdd2a731596d8d2cf4a72a25c1234335dea9f7bec25054872cb3c1d164eb8aff3d66f6c3a3688ae54 - languageName: node - linkType: hard - "infer-owner@npm:^1.0.3": version: 1.0.4 resolution: "infer-owner@npm:1.0.4" @@ -17234,7 +17220,7 @@ __metadata: languageName: node linkType: hard -"loose-envify@npm:^1.1.0, loose-envify@npm:^1.4.0": +"loose-envify@npm:^1.4.0": version: 1.4.0 resolution: "loose-envify@npm:1.4.0" dependencies: @@ -18342,13 +18328,6 @@ __metadata: languageName: node linkType: hard -"node-releases@npm:^2.0.26": - version: 2.0.27 - resolution: "node-releases@npm:2.0.27" - checksum: 10/f6c78ddb392ae500719644afcbe68a9ea533242c02312eb6a34e8478506eb7482a3fb709c70235b01c32fe65625b68dfa9665113f816d87f163bc3819b62b106 - languageName: node - linkType: hard - "node-releases@npm:^2.0.21": version: 2.0.23 resolution: "node-releases@npm:2.0.23" @@ -18356,6 +18335,13 @@ __metadata: languageName: node linkType: hard +"node-releases@npm:^2.0.26": + version: 2.0.27 + resolution: "node-releases@npm:2.0.27" + checksum: 10/f6c78ddb392ae500719644afcbe68a9ea533242c02312eb6a34e8478506eb7482a3fb709c70235b01c32fe65625b68dfa9665113f816d87f163bc3819b62b106 + languageName: node + linkType: hard + "node-stdlib-browser@npm:^1.2.0": version: 1.3.1 resolution: "node-stdlib-browser@npm:1.3.1" @@ -18500,7 +18486,7 @@ __metadata: languageName: node linkType: hard -"nwsapi@npm:^2.2.16, nwsapi@npm:^2.2.2": +"nwsapi@npm:^2.2.16": version: 2.2.22 resolution: "nwsapi@npm:2.2.22" checksum: 10/6bdeeb61345873808b914c002d351049a2678bcae0dd957ad20949da25eca583b19a9c750f510b776b6554a2e0ee8df4e6fb13fd7a46c6025ead0b19e70378b3 @@ -20277,30 +20263,18 @@ __metadata: languageName: node linkType: hard -"react-dom@npm:18.x, react-dom@npm:^18.2.0": - version: 18.3.1 - resolution: "react-dom@npm:18.3.1" - dependencies: - loose-envify: "npm:^1.1.0" - scheduler: "npm:^0.23.2" - peerDependencies: - react: ^18.3.1 - checksum: 10/3f4b73a3aa083091173b29812b10394dd06f4ac06aff410b74702cfb3aa29d7b0ced208aab92d5272919b612e5cda21aeb1d54191848cf6e46e9e354f3541f81 - languageName: node - linkType: hard - -"react-dom@npm:^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0, react-dom@npm:^19.1.0": - version: 19.2.0 - resolution: "react-dom@npm:19.2.0" +"react-dom@npm:19.1.1": + version: 19.1.1 + resolution: "react-dom@npm:19.1.1" dependencies: - scheduler: "npm:^0.27.0" + scheduler: "npm:^0.26.0" peerDependencies: - react: ^19.2.0 - checksum: 10/3dbba071b9b1e7a19eae55f05c100f6b44f88c0aee72397d719ae338248ca66ed5028e6964c1c14870cc3e1abcecc91b22baba6dc2072f819dea81a9fd72f2fd + react: ^19.1.1 + checksum: 10/9005415d2175b1f1eb4a544ad04afb29691bb7b6dd43bbdaa09932146b310b73bd4552bc772ad78fa481f409eada1560cf887606c83c1a53a922c1e30f1b3a34 languageName: node linkType: hard -"react-dom@npm:^19.1.1": +"react-dom@npm:^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0, react-dom@npm:^19.1.0, react-dom@npm:^19.1.1": version: 19.2.0 resolution: "react-dom@npm:19.2.0" dependencies: @@ -20343,13 +20317,32 @@ __metadata: languageName: node linkType: hard -"react-is@npm:^19.0.0": +"react-is@npm:^19.2.0": version: 19.2.0 resolution: "react-is@npm:19.2.0" checksum: 10/5cf0230571da0b446c64c0ff7b0e6992b7a8b12b39542db4003de1611e3f108e26f30b93a85ded5cd89c5bcce97f57639524ae40e57bb2f4f1ebd0935b624abf languageName: node linkType: hard +"react-redux@npm:^9.2.0": + version: 9.2.0 + resolution: "react-redux@npm:9.2.0" + dependencies: + "@types/use-sync-external-store": "npm:^0.0.6" + use-sync-external-store: "npm:^1.4.0" + peerDependencies: + "@types/react": ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + redux: + optional: true + checksum: 10/b3d2f89f469169475ab0a9f8914d54a336ac9bc6a31af6e8dcfe9901e6fe2cfd8c1a3f6ce7a2f7f3e0928a93fbab833b668804155715598b7f2ad89927d3ff50 + languageName: node + linkType: hard + "react-refresh@npm:^0.17.0": version: 0.17.0 resolution: "react-refresh@npm:0.17.0" @@ -20369,23 +20362,14 @@ __metadata: languageName: node linkType: hard -"react@npm:18.x, react@npm:^18.2.0": - version: 18.3.1 - resolution: "react@npm:18.3.1" - dependencies: - loose-envify: "npm:^1.1.0" - checksum: 10/261137d3f3993eaa2368a83110466fc0e558bc2c7f7ae7ca52d94f03aac945f45146bd85e5f481044db1758a1dbb57879e2fcdd33924e2dde1bdc550ce73f7bf - languageName: node - linkType: hard - -"react@npm:^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0, react@npm:^19.1.0": - version: 19.2.0 - resolution: "react@npm:19.2.0" - checksum: 10/e13bcdb8e994c3cfa922743cb75ca8deb60531bf02f584d2d8dab940a8132ce8a2e6ef16f8ed7f372b4072e7a7eeff589b2812dabbedfa73e6e46201dac8a9d0 +"react@npm:19.1.1": + version: 19.1.1 + resolution: "react@npm:19.1.1" + checksum: 10/9801530fdc939e1a7a499422e930515b2400809cb39c2872984e99f832d233f61659a693871183dac3155c2f9b2c9dcf4440a56bd18983277ae92860e38c3a61 languageName: node linkType: hard -"react@npm:^19.1.1": +"react@npm:^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0, react@npm:^19.1.0, react@npm:^19.1.1": version: 19.2.0 resolution: "react@npm:19.2.0" checksum: 10/e13bcdb8e994c3cfa922743cb75ca8deb60531bf02f584d2d8dab940a8132ce8a2e6ef16f8ed7f372b4072e7a7eeff589b2812dabbedfa73e6e46201dac8a9d0 @@ -20821,13 +20805,6 @@ __metadata: languageName: node linkType: hard -"resolve.exports@npm:^2.0.0": - version: 2.0.3 - resolution: "resolve.exports@npm:2.0.3" - checksum: 10/536efee0f30a10fac8604e6cdc7844dbc3f4313568d09f06db4f7ed8a5b8aeb8585966fe975083d1f2dfbc87cf5f8bc7ab65a5c23385c14acbb535ca79f8398a - languageName: node - linkType: hard - "resolve@npm:^1.1.4, resolve@npm:^1.1.6, resolve@npm:^1.11.0, resolve@npm:^1.11.1, resolve@npm:^1.17.0, resolve@npm:^1.19.0, resolve@npm:^1.20.0, resolve@npm:^1.22.1, resolve@npm:^1.22.10, resolve@npm:^1.22.8, resolve@npm:^1.4.0, resolve@npm:^1.9.0, resolve@npm:~1.22.1, resolve@npm:~1.22.2": version: 1.22.11 resolution: "resolve@npm:1.22.11" @@ -21464,26 +21441,10 @@ __metadata: languageName: node linkType: hard -"scheduler@npm:^0.23.2": - version: 0.23.2 - resolution: "scheduler@npm:0.23.2" - dependencies: - loose-envify: "npm:^1.1.0" - checksum: 10/e8d68b89d18d5b028223edf090092846868a765a591944760942b77ea1f69b17235f7e956696efbb62c8130ab90af7e0949bfb8eba7896335507317236966bc9 - languageName: node - linkType: hard - -"scheduler@npm:^0.25.0": - version: 0.25.0 - resolution: "scheduler@npm:0.25.0" - checksum: 10/e661e38503ab29a153429a99203fefa764f28b35c079719eb5efdd2c1c1086522f6653d8ffce388209682c23891a6d1d32fa6badf53c35fb5b9cd0c55ace42de - languageName: node - linkType: hard - -"scheduler@npm:^0.27.0": - version: 0.27.0 - resolution: "scheduler@npm:0.27.0" - checksum: 10/eab3c3a8373195173e59c147224fc30dabe6dd453f248f5e610e8458512a5a2ee3a06465dc400ebfe6d35c9f5b7f3bb6b2e41c88c86fd177c25a73e7286a1e06 +"scheduler@npm:^0.26.0": + version: 0.26.0 + resolution: "scheduler@npm:0.26.0" + checksum: 10/1ecf2e5d7de1a7a132796834afe14a2d589ba7e437615bd8c06f3e0786a3ac3434655e67aac8755d9b14e05754c177e49c064261de2673aaa3c926bc98caa002 languageName: node linkType: hard @@ -21606,7 +21567,7 @@ __metadata: languageName: node linkType: hard -"semver@npm:7.7.2, semver@npm:^7.1.1, semver@npm:^7.3.4, semver@npm:^7.3.5, semver@npm:^7.3.6, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0, semver@npm:^7.6.2, semver@npm:^7.6.3, semver@npm:^7.7.2": +"semver@npm:7.7.2": version: 7.7.2 resolution: "semver@npm:7.7.2" bin: @@ -21633,7 +21594,7 @@ __metadata: languageName: node linkType: hard -"semver@npm:^7.1.1, semver@npm:^7.3.4, semver@npm:^7.3.5, semver@npm:^7.3.6, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0, semver@npm:^7.6.2, semver@npm:^7.6.3, semver@npm:^7.7.1, semver@npm:^7.7.2, semver@npm:^7.7.3": +"semver@npm:^7.1.1, semver@npm:^7.3.4, semver@npm:^7.3.5, semver@npm:^7.3.6, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0, semver@npm:^7.6.2, semver@npm:^7.6.3, semver@npm:^7.7.2, semver@npm:^7.7.3": version: 7.7.3 resolution: "semver@npm:7.7.3" bin: @@ -22439,7 +22400,7 @@ __metadata: languageName: node linkType: hard -"stack-utils@npm:^2.0.3": +"stack-utils@npm:^2.0.6": version: 2.0.6 resolution: "stack-utils@npm:2.0.6" dependencies: @@ -22526,10 +22487,10 @@ __metadata: languageName: node linkType: hard -"storybook-multilevel-sort@npm:^2.0.1": - version: 2.0.2 - resolution: "storybook-multilevel-sort@npm:2.0.2" - checksum: 10/d574c8c3802a659fa7a9de03227865f2dfa29c99d0ffa00080f9143f388d6c712995c4e460f1e0e7c80a976157198fbd2948162ee5305b06645837943eb132e6 +"storybook-multilevel-sort@npm:2.0.1": + version: 2.0.1 + resolution: "storybook-multilevel-sort@npm:2.0.1" + checksum: 10/fcb9c9769f5d5b6df04ab86821a03483af955a85e5eb51f3aea25b72b50973be9f0410464c23532a937156be8d299e245624dbcf9db5765af2ccf16d3fdd7446 languageName: node linkType: hard @@ -23567,7 +23528,7 @@ __metadata: languageName: node linkType: hard -"ts-api-utils@npm:^2.0.0, ts-api-utils@npm:^2.0.1, ts-api-utils@npm:^2.1.0": +"ts-api-utils@npm:^2.0.0, ts-api-utils@npm:^2.1.0": version: 2.1.0 resolution: "ts-api-utils@npm:2.1.0" peerDependencies: @@ -23727,7 +23688,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.0.0, tslib@npm:^2.0.1, tslib@npm:^2.1.0, tslib@npm:^2.6.2": +"tslib@npm:^2.0.0, tslib@npm:^2.0.1, tslib@npm:^2.1.0, tslib@npm:^2.4.0, tslib@npm:^2.6.2": version: 2.8.1 resolution: "tslib@npm:2.8.1" checksum: 10/3e2e043d5c2316461cb54e5c7fe02c30ef6dccb3384717ca22ae5c6b5bc95232a6241df19c622d9c73b809bea33b187f6dbc73030963e29950c2141bc32a79f7 @@ -24011,7 +23972,7 @@ __metadata: languageName: node linkType: hard -"typescript@npm:^5, typescript@npm:^5.7.3": +"typescript@npm:>=5, typescript@npm:^5.7.3, typescript@npm:^5.9.2": version: 5.9.3 resolution: "typescript@npm:5.9.3" bin: @@ -24041,7 +24002,7 @@ __metadata: languageName: node linkType: hard -"typescript@patch:typescript@npm%3A^5#optional!builtin, typescript@patch:typescript@npm%3A^5.7.3#optional!builtin": +"typescript@patch:typescript@npm%3A>=5#optional!builtin, typescript@patch:typescript@npm%3A^5.7.3#optional!builtin, typescript@patch:typescript@npm%3A^5.9.2#optional!builtin": version: 5.9.3 resolution: "typescript@patch:typescript@npm%3A5.9.3#optional!builtin::version=5.9.3&hash=5786d5" bin: @@ -24163,13 +24124,6 @@ __metadata: languageName: node linkType: hard -"undici-types@npm:~7.16.0": - version: 7.16.0 - resolution: "undici-types@npm:7.16.0" - checksum: 10/db43439f69c2d94cc29f75cbfe9de86df87061d6b0c577ebe9bb3255f49b22c50162a7d7eb413b0458b6510b8ca299ac7cff38c3a29fbd31af9f504bcf7fbc0d - languageName: node - linkType: hard - "undici-types@npm:~7.14.0": version: 7.14.0 resolution: "undici-types@npm:7.14.0" @@ -24177,6 +24131,13 @@ __metadata: languageName: node linkType: hard +"undici-types@npm:~7.16.0": + version: 7.16.0 + resolution: "undici-types@npm:7.16.0" + checksum: 10/db43439f69c2d94cc29f75cbfe9de86df87061d6b0c577ebe9bb3255f49b22c50162a7d7eb413b0458b6510b8ca299ac7cff38c3a29fbd31af9f504bcf7fbc0d + languageName: node + linkType: hard + "undici@npm:^6.20.1": version: 6.22.0 resolution: "undici@npm:6.22.0" @@ -24385,7 +24346,7 @@ __metadata: languageName: node linkType: hard -"update-browserslist-db@npm:^1.1.4": +"update-browserslist-db@npm:^1.1.3, update-browserslist-db@npm:^1.1.4": version: 1.1.4 resolution: "update-browserslist-db@npm:1.1.4" dependencies: @@ -25951,7 +25912,7 @@ __metadata: languageName: node linkType: hard -"yargs@npm:17.7.2, yargs@npm:^17.3.1, yargs@npm:^17.7.2": +"yargs@npm:17.7.2, yargs@npm:^17.7.2": version: 17.7.2 resolution: "yargs@npm:17.7.2" dependencies: @@ -26042,7 +26003,7 @@ __metadata: languageName: node linkType: hard -"zod@npm:^4.1.5": +"zod@npm:^4.0.17": version: 4.1.12 resolution: "zod@npm:4.1.12" checksum: 10/c5f04e6ac306515c4db6ef73cf7705f521c7a2107c8c8912416a0658d689f361db9bee829b0bf01ef4a22492f1065c5cbcdb523ce532606ac6792fd714f3c326 From ce40cbb5d6e79e532f59f4aa84ed5877d9ddbdd4 Mon Sep 17 00:00:00 2001 From: "samuel.gjabel" Date: Fri, 21 Nov 2025 11:59:51 +0700 Subject: [PATCH 05/24] feat(joint-react): add use-combined-ref hook export --- packages/joint-react/src/hooks/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/joint-react/src/hooks/index.ts b/packages/joint-react/src/hooks/index.ts index 83fac6c61d..a968a0795a 100644 --- a/packages/joint-react/src/hooks/index.ts +++ b/packages/joint-react/src/hooks/index.ts @@ -12,3 +12,4 @@ export * from './use-cell-actions'; export * from './use-graph-store'; export * from './use-paper-context'; export * from './use-element-views'; +export * from './use-combined-ref'; From 5ef0956a5c35d6d39cce739cd533fa6dce56d89b Mon Sep 17 00:00:00 2001 From: samuelgja Date: Fri, 21 Nov 2025 14:27:05 +0700 Subject: [PATCH 06/24] feat(joint-react): improve size measurement accuracy and prevent float jitter for safari --- .../src/hooks/use-measure-node-size.tsx | 34 ++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/packages/joint-react/src/hooks/use-measure-node-size.tsx b/packages/joint-react/src/hooks/use-measure-node-size.tsx index 1254c3fd01..d991c9853d 100644 --- a/packages/joint-react/src/hooks/use-measure-node-size.tsx +++ b/packages/joint-react/src/hooks/use-measure-node-size.tsx @@ -22,6 +22,8 @@ export interface MeasureNodeOptions { } const EMPTY_OBJECT: MeasureNodeOptions = {}; +// Epsilon value to avoid jitter due to sub-pixel rendering +const EPSILON = 0.5; /** * Custom hook to measure the size of a node and update its size in the graph. @@ -55,22 +57,38 @@ export function useMeasureNodeSize { - // Only update when dimensions actually change - if (previous.width === width && previous.height === height) return; - previous.width = width; - previous.height = height; + // normalize to avoid float jitter in Safari + const nextWidth = Math.round(width); + const nextHeight = Math.round(height); + + if ( + Math.abs(previous.width - nextWidth) < EPSILON && + Math.abs(previous.height - nextHeight) < EPSILON + ) { + return; + } + + // Only update when dimensions actually change meaningfully + if (previous.width === nextWidth && previous.height === nextHeight) { + return; + } + + previous.width = nextWidth; + previous.height = nextHeight; - // Always update the size (whether via the user-defined setSize or the default) if (onSetSizeRef.current) { - onSetSizeRef.current({ element: cell, size: { width, height } }); + onSetSizeRef.current({ + element: cell, + size: { width: nextWidth, height: nextHeight }, + }); } else { - cell.set('size', { width, height }, { async: false }); + cell.set('size', { width: nextWidth, height: nextHeight }, { async: false }); } }); // Cleanup on unmount or when dependencies change. + return () => { clean(); stop(); From 29d9a1728b657e4eead046561d22bb1f58468f29 Mon Sep 17 00:00:00 2001 From: samuelgja Date: Tue, 25 Nov 2025 23:21:36 +0700 Subject: [PATCH 07/24] feat(joint-react): enhance Paper component with improved dimension handling and add useRefValue hook --- .../src/components/graph/graph-provider.tsx | 4 ++ .../src/components/paper/paper.stories.tsx | 7 +++ .../src/components/paper/paper.tsx | 45 ++++++++++---- packages/joint-react/src/hooks/index.ts | 2 + .../src/hooks/use-imperative-api.ts | 5 +- .../joint-react/src/hooks/use-layout-size.ts | 27 +++++++++ .../src/hooks/use-paper-context.ts | 10 +++- .../joint-react/src/hooks/use-ref-value.ts | 45 ++++++++++++++ .../src/utils/create-element-size-observer.ts | 60 ++++++++++++++++--- .../joint-react/src/utils/object-utilities.ts | 30 ++++++---- 10 files changed, 201 insertions(+), 34 deletions(-) create mode 100644 packages/joint-react/src/hooks/use-layout-size.ts create mode 100644 packages/joint-react/src/hooks/use-ref-value.ts diff --git a/packages/joint-react/src/components/graph/graph-provider.tsx b/packages/joint-react/src/components/graph/graph-provider.tsx index 1d5f0853fe..6d84cebf64 100644 --- a/packages/joint-react/src/components/graph/graph-provider.tsx +++ b/packages/joint-react/src/components/graph/graph-provider.tsx @@ -3,6 +3,7 @@ import type { GraphLink } from '../../types/link-types'; import { forwardRef, useLayoutEffect, + useRef, type Dispatch, type PropsWithChildren, type SetStateAction, @@ -63,7 +64,9 @@ export function GraphProviderHandler< Link extends dia.Link | GraphLink = dia.Link, >(props: PropsWithChildren>) { const { elements, links, onElementsChange, onLinksChange, children } = props; + const alreadyMeasured = useRef(false); const areElementsMeasured = useElements((items) => { + if (alreadyMeasured.current) return true; let areMeasured = true; for (const { width = 0, height = 0 } of items) { if (width <= 1 || height <= 1) { @@ -71,6 +74,7 @@ export function GraphProviderHandler< break; } } + alreadyMeasured.current = areMeasured; return areMeasured; }); diff --git a/packages/joint-react/src/components/paper/paper.stories.tsx b/packages/joint-react/src/components/paper/paper.stories.tsx index dd9286ab16..753cf18e8e 100644 --- a/packages/joint-react/src/components/paper/paper.stories.tsx +++ b/packages/joint-react/src/components/paper/paper.stories.tsx @@ -104,6 +104,13 @@ export const WithAutoFitContent: Story = { }, }; +export const WithAutomaticLayoutSize: Story = { + args: { + renderElement: RenderHTMLElement as never, + className: PAPER_CLASSNAME, + }, +}; + export const WithEvent: Story = { args: { renderElement: RenderHTMLElement as never, diff --git a/packages/joint-react/src/components/paper/paper.tsx b/packages/joint-react/src/components/paper/paper.tsx index 7f5cd81456..c466b23e37 100644 --- a/packages/joint-react/src/components/paper/paper.tsx +++ b/packages/joint-react/src/components/paper/paper.tsx @@ -1,4 +1,5 @@ -import { dia, mvc, shapes } from '@joint/core'; +/* eslint-disable prefer-destructuring */ +import { dia, mvc, shapes, util } from '@joint/core'; import { useElementViews } from '../../hooks/use-element-views'; import { useGraphStore } from '../../hooks/use-graph-store'; import { @@ -72,6 +73,8 @@ function PaperBase( useHTMLOverlay, children, scale, + width, + height, ...paperOptions } = props; @@ -209,30 +212,43 @@ function PaperBase( if (instance.id !== id) { reset(); } + const { paper } = instance; assignOptions(paper.options, { defaultLink: defaultLinkJointJS, ...paperOptions, }); - const { drawGrid, height, width, theme, gridSize } = paperOptions; - if (width !== undefined && height !== undefined) { + const { drawGrid, theme, gridSize } = paperOptions; + const { + width: paperWidth, + height: paperHeight, + drawGrid: paperDrawGrid, + theme: paperTheme, + gridSize: paperGridSize, + } = paper.options; + + if ( + width !== undefined && + height !== undefined && + (width !== paperWidth || height !== paperHeight) + ) { paper.setDimensions(width, height); } - if (drawGrid) { + if (drawGrid !== undefined && !util.isEqual(drawGrid, paperDrawGrid)) { paper.setGrid(drawGrid); } - if (gridSize !== undefined) { + if (gridSize !== undefined && !util.isEqual(gridSize, paperGridSize)) { paper.setGridSize(gridSize); } - if (theme) { + if (theme !== undefined && !util.isEqual(theme, paperTheme)) { paper.setTheme(theme); } - if (scale !== undefined) { + if (scale !== undefined && scale !== paper.options.scale) { paper.scale(scale); } }, }, - [defaultLinkJointJS, id, scale, isReactId, ...dependencyExtract(paperOptions)] + [defaultLinkJointJS, id, scale, isReactId, height, width, ...dependencyExtract(paperOptions)] ); useEffect(() => { @@ -269,8 +285,11 @@ function PaperBase( const { paper } = ref.current ?? {}; if (!paper) return; - // Build current list of [width, height] - const currentSizes = elements.map(({ width = 0, height = 0 }) => [width, height]); + // Build current list of [currWidth, currHeight] to avoid shadowing outer scope variables + const currentSizes = elements.map(({ width: elementWidth = 0, height: elementHeight = 0 }) => [ + elementWidth, + elementHeight, + ]); const previousSizes = previousSizesRef.current; let changed = false; @@ -373,10 +392,10 @@ function PaperBase( return style; } return { - width: paperOptions.width ?? '100%', - height: paperOptions.height ?? '100%', + width: width ?? '100%', + height: height ?? '100%', }; - }, [paperOptions.height, paperOptions.width, style]); + }, [height, width, style]); const paperContainerStyle = useMemo( (): CSSProperties => ({ diff --git a/packages/joint-react/src/hooks/index.ts b/packages/joint-react/src/hooks/index.ts index a968a0795a..c2a09a7bb7 100644 --- a/packages/joint-react/src/hooks/index.ts +++ b/packages/joint-react/src/hooks/index.ts @@ -13,3 +13,5 @@ export * from './use-graph-store'; export * from './use-paper-context'; export * from './use-element-views'; export * from './use-combined-ref'; +export * from './use-ref-value'; +export * from './use-layout-size'; diff --git a/packages/joint-react/src/hooks/use-imperative-api.ts b/packages/joint-react/src/hooks/use-imperative-api.ts index 893762a8be..e45cb3cfb2 100644 --- a/packages/joint-react/src/hooks/use-imperative-api.ts +++ b/packages/joint-react/src/hooks/use-imperative-api.ts @@ -25,6 +25,7 @@ export interface UseImperativeApiOptions { */ // eslint-disable-next-line @typescript-eslint/no-invalid-void-type readonly onUpdate?: (instance: Instance, reset: () => void) => void | (() => void); + readonly onReadyChange?: (isReady: boolean, instance: Instance | null) => void; readonly isDisabled?: boolean; readonly forwardedRef?: React.Ref; } @@ -61,7 +62,7 @@ export function useImperativeApi( options: UseImperativeApiOptions, dependencies: DependencyList ): ImperativeStateResult { - const { onLoad, onUpdate, isDisabled, forwardedRef } = options; + const { onLoad, onUpdate, onReadyChange, isDisabled, forwardedRef } = options; const [isReady, setIsReady] = useState(false); const instanceRef = useRef(null); const cleanupRef = useRef<(() => void) | null>(null); @@ -77,12 +78,14 @@ export function useImperativeApi( instanceRef.current = null; } setIsReady(false); // Explicitly set isReady to false + onReadyChange?.(false, null); return; } const { instance, cleanup } = onLoad(); instanceRef.current = instance; cleanupRef.current = cleanup; setIsReady(true); + onReadyChange?.(true, instance); return () => { cleanup(); }; diff --git a/packages/joint-react/src/hooks/use-layout-size.ts b/packages/joint-react/src/hooks/use-layout-size.ts new file mode 100644 index 0000000000..d04d381bc6 --- /dev/null +++ b/packages/joint-react/src/hooks/use-layout-size.ts @@ -0,0 +1,27 @@ +import { useEffect, useState } from 'react'; +import { createElementSizeObserver } from '../utils/create-element-size-observer'; +interface Options { + readonly element: React.RefObject; + readonly isEnabled: boolean; +} +/** + * A hook to get the layout size of an element. + * It uses the `createElementSizeObserver` utility to observe size changes. + * @param options - The options for the hook, including the element to observe and whether to enable the observer. + * @returns The layout size of the element. + */ +export function useLayoutSize(options: Options) { + const { element, isEnabled } = options; + const [layout, setLayout] = useState<{ width: number; height: number }>({ width: 0, height: 0 }); + + useEffect(() => { + if (!isEnabled) return; + if (!element.current) return; + const cleanup = createElementSizeObserver(element.current, ({ width, height }) => { + setLayout({ width, height }); + }); + return () => cleanup(); + }, [element, isEnabled]); + + return layout; +} diff --git a/packages/joint-react/src/hooks/use-paper-context.ts b/packages/joint-react/src/hooks/use-paper-context.ts index c5d288d432..139162f17e 100644 --- a/packages/joint-react/src/hooks/use-paper-context.ts +++ b/packages/joint-react/src/hooks/use-paper-context.ts @@ -4,13 +4,17 @@ import { PaperContext } from '../context'; /** * Hook to access the current GraphProvider View context or a specific view by id from the GraphProvider Store. * If used outside of a GraphProvider View context, it will try to get the view from the store using the provided id. + * @param isNullable - If true, the hook will return null instead of throwing an error when used outside of a GraphProvider View context. Default is false. * @returns The current GraphProvider View context or the view with the specified id from the store, or null if not found. */ -export function usePaperContext(): PaperContext { +export function usePaperContext( + isNullable: T = false as T +): T extends true ? PaperContext | null : PaperContext { const ctx = useContext(PaperContext); - if (!ctx) { + if (!ctx && !isNullable) { throw new Error('usePaperContext must be used within a Paper or RenderElement'); } - return ctx; + const value = ctx ?? null; + return value as T extends true ? PaperContext | null : PaperContext; } diff --git a/packages/joint-react/src/hooks/use-ref-value.ts b/packages/joint-react/src/hooks/use-ref-value.ts new file mode 100644 index 0000000000..04cd458bcb --- /dev/null +++ b/packages/joint-react/src/hooks/use-ref-value.ts @@ -0,0 +1,45 @@ +import { useEffect, useState, type RefObject } from 'react'; +const MAX_REF_LOAD_CHECKS = 2; +/** + * A hook that monitors a ref and updates state when the ref's current value is set. + * It repeatedly checks the ref's current value using requestAnimationFrame until it is set + * or until a maximum number of checks is reached. + * We retry max 2 times, most of the time the ref is set on the first or second frame. + * But for example in our case for paper, or graph, it may take a bit longer. + * @param ref - The ref to monitor. + * @returns The current value of the ref once it is set, or undefined if not set within the limit. + * @private + * @group Hooks + */ +export function useRefValue(ref: RefObject | undefined): T | undefined { + const [refValue, setRefValue] = useState(() => ref?.current ?? undefined); + + useEffect(() => { + let loadCounts = 0; + /** + * Check the ref value and update state when available + */ + function checkRef() { + if (!ref?.current) { + if (loadCounts > MAX_REF_LOAD_CHECKS) { + return; + } + requestAnimationFrame(() => { + loadCounts += 1; + checkRef(); + }); + return; + } + // eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect + setRefValue((previous) => { + if (previous !== ref.current) { + return ref.current; + } + return previous; + }); + } + checkRef(); + }, [ref]); + + return refValue; +} diff --git a/packages/joint-react/src/utils/create-element-size-observer.ts b/packages/joint-react/src/utils/create-element-size-observer.ts index 1caa565cfb..6d030e2aa4 100644 --- a/packages/joint-react/src/utils/create-element-size-observer.ts +++ b/packages/joint-react/src/utils/create-element-size-observer.ts @@ -3,6 +3,9 @@ export interface SizeObserver { readonly height: number; } +// Epsilon value to avoid jitter due to sub-pixel rendering +const EPSILON = 0.5; + /** * Create element size observer with cleanup function. * It uses ResizeObserver to observe changes in the size of the HTML element. @@ -20,12 +23,47 @@ export interface SizeObserver { * ``` */ export function createElementSizeObserver( - element: AnyHTMLOrSVGElement, + element: AnyHTMLOrSVGElement | null | undefined, onResize: (position: SizeObserver) => void ) { + // Safety check: return no-op cleanup if element is invalid + if (!element) { + return () => { + // No-op cleanup + }; + } + + let isCleanedUp = false; + let previousSize: SizeObserver | null = null; + + // Helper to check if size has meaningfully changed + const hasSizeChanged = (newSize: SizeObserver): boolean => { + if (previousSize === null) { + return true; + } + return ( + Math.abs(previousSize.width - newSize.width) >= EPSILON || + Math.abs(previousSize.height - newSize.height) >= EPSILON + ); + }; + + // Helper to safely call onResize only if size changed and not cleaned up + const safeOnResize = (size: SizeObserver): void => { + if (isCleanedUp) { + return; + } + if (hasSizeChanged(size)) { + previousSize = size; + onResize(size); + } + }; + // Create a ResizeObserver to observe changes in the size of the HTML element. - // TODO not optimal - maybe debounce, maybe change to something else. const observer = new ResizeObserver((entries) => { + if (isCleanedUp) { + return; + } + for (const entry of entries) { const { borderBoxSize } = entry; @@ -34,22 +72,30 @@ export function createElementSizeObserver { - const { width, height } = element.getBoundingClientRect(); - if (width > 0 && height > 0) onResize({ width, height }); + if (isCleanedUp || !element) { + return; + } + + const rect = element.getBoundingClientRect(); + const { width, height } = rect; + if (width > 0 && height > 0) { + safeOnResize({ width, height }); + } }); // Start observing the HTML element. observer.observe(element, { box: 'border-box' }); + // Cleanup function to disconnect the observer when the component unmounts or dependencies change. return () => { + isCleanedUp = true; observer.disconnect(); }; } diff --git a/packages/joint-react/src/utils/object-utilities.ts b/packages/joint-react/src/utils/object-utilities.ts index f9b9e157cc..179b98c0fe 100644 --- a/packages/joint-react/src/utils/object-utilities.ts +++ b/packages/joint-react/src/utils/object-utilities.ts @@ -1,4 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ + +import { util } from '@joint/core'; + /** * Make options and avoid to generate undefined values. * @param options - An object containing options where keys are strings and values can be of any type. @@ -16,20 +19,27 @@ export function makeOptions>(options: T): T { /** * Assign new properties to an instance, ignoring undefined values. - * @param instance - The instance to which new properties will be assigned. - * @param newProperties - An object containing new properties to assign to the instance. + * @param props - The instance to which new properties will be assigned. + * @param newProps - An object containing new properties to assign to the instance. * @returns - The updated instance with the new properties assigned, excluding any properties that were undefined. */ -export function assignOptions>( - instance: T, - newProperties: Partial -): T { - for (const key in newProperties) { - if (newProperties[key] !== undefined) { - instance[key] = newProperties[key] as T[Extract]; +export function assignOptions>(props: T, newProps: Partial): T { + for (const key in newProps) { + // in jointjs settings property as undefined make it as property. So we avoid to set undefined at all. + if (newProps[key] === undefined) { + continue; + } + // now we have to check if the properties are equal, if not we assign the new value with fast ref path + if (props[key] === newProps[key]) { + continue; + } + // now we check same as well for objects and shallow objects + if (util.isEqual(props[key], newProps[key])) { + continue; } + props[key] = newProps[key] as T[Extract]; } - return instance; + return props; } /** From 0321376be56afc14902f256abce3b34e3e1636c1 Mon Sep 17 00:00:00 2001 From: samuelgja Date: Wed, 26 Nov 2025 10:20:12 +0700 Subject: [PATCH 08/24] feat(joint-react): update dependencies, enhance GraphProvider, and add new utility functions - Updated Storybook and Redux Toolkit versions in package.json and yarn.lock. - Refactored GraphProvider to accept elements and links as props directly. - Introduced new utility functions for improved link handling and object manipulation. - Added comprehensive tests for new functionalities and existing hooks. - Enhanced documentation with examples and best practices for using highlighters and SVG nodes. --- examples/joint-react/src/App.tsx | 11 +- .../decorators/with-simple-data.tsx | 4 + packages/joint-react/.storybook/main.ts | 9 + packages/joint-react/package.json | 28 +- .../graph/graph-provider.stories.tsx | 67 +++- .../src/components/graph/graph-provider.tsx | 18 +- .../highlighters/__tests__/stroke.test.tsx | 79 ++++ .../highlighters/custom.stories.tsx | 1 + .../components/highlighters/mask.stories.tsx | 46 ++- .../highlighters/opacity.stories.tsx | 37 +- .../highlighters/stroke.stories.tsx | 47 ++- .../measured-node/measured-node.stories.tsx | 47 ++- .../src/components/paper/paper.stories.tsx | 110 +++++- .../src/components/paper/paper.tsx | 27 +- .../components/port/port-group.stories.tsx | 1 + .../src/components/port/port-item.stories.tsx | 46 ++- .../text-node/text-node.stories.tsx | 56 ++- .../src/components/text-node/text-node.tsx | 45 +++ .../data/__tests__/create-ports-store.test.ts | 129 +++++++ .../src/data/create-graph-store.ts | 2 +- .../use-are-elements-measured.test.tsx | 65 ++++ .../src/hooks/__tests__/use-cell-id.test.tsx | 43 +++ .../src/hooks/__tests__/use-element.test.tsx | 76 ++++ .../src/hooks/__tests__/use-graph.test.ts | 45 +++ .../__tests__/use-measure-node-size.test.tsx | 131 ++++++- .../__tests__/use-paper-context.test.tsx | 57 +++ .../hooks/__tests__/use-ref-value.test.tsx | 46 +++ .../src/hooks/use-are-elements-measured.ts | 13 + .../src/hooks/use-cell-actions.stories.tsx | 80 +++- .../src/hooks/use-cell-id.stories.tsx | 1 + .../src/hooks/use-element.stories.tsx | 62 ++- .../src/hooks/use-elements.stories.tsx | 50 ++- .../src/hooks/use-links.stories.tsx | 60 ++- .../src/hooks/use-measure-node-size.tsx | 53 ++- .../joint-react/src/hooks/use-paper-events.ts | 18 +- packages/joint-react/src/hooks/use-paper.ts | 14 +- packages/joint-react/src/index.ts | 1 + .../models/__tests__/react-element.test.ts | 104 +++++ .../joint-react/src/models/react-element.tsx | 20 + .../src/stories/demos/flowchart/story.tsx | 2 +- .../stories/demos/introduction-demo/code.tsx | 1 - .../stories/demos/introduction-demo/story.tsx | 2 +- .../src/stories/demos/pulsing-port/story.tsx | 2 +- .../src/stories/demos/user-flow/story.tsx | 2 +- .../examples/with-auto-layout/docs.mdx | 45 ++- .../examples/with-auto-layout/story.tsx | 1 + .../examples/with-build-in-shapes/story.tsx | 1 + .../src/stories/examples/with-card/docs.mdx | 42 ++- .../src/stories/examples/with-card/story.tsx | 1 + .../code-with-create-links-classname.tsx | 6 +- .../code-with-create-links.tsx | 6 +- .../with-custom-link/code-with-dia-links.tsx | 27 +- .../examples/with-custom-link/story.tsx | 1 + .../examples/with-highlighter/docs.mdx | 82 +++- .../examples/with-highlighter/story.tsx | 1 + .../examples/with-intersection/story.tsx | 1 + .../src/stories/examples/with-json/story.tsx | 1 + .../stories/examples/with-link-tools/docs.mdx | 66 +++- .../examples/with-link-tools/story.tsx | 1 + .../stories/examples/with-list-node/story.tsx | 1 + .../stories/examples/with-minimap/story.tsx | 1 + .../examples/with-node-update/docs.mdx | 91 ++++- .../examples/with-node-update/story.tsx | 1 + .../examples/with-proximity-link/story.tsx | 1 + .../examples/with-resizable-node/story.tsx | 1 + .../examples/with-rotable-node/code.tsx | 2 +- .../examples/with-rotable-node/story.tsx | 1 + .../stories/examples/with-svg-node/docs.mdx | 52 ++- .../stories/examples/with-svg-node/story.tsx | 1 + .../src/stories/tutorials/redux/code.tsx | 3 +- .../src/stories/tutorials/redux/story.tsx | 1 + .../step-by-step/code-html-renderer.tsx | 7 +- .../tutorials/step-by-step/code-html.tsx | 5 +- .../tutorials/step-by-step/code-svg.tsx | 4 +- .../stories/tutorials/step-by-step/story.tsx | 5 +- .../src/stories/utils/make-story.tsx | 65 +++- .../joint-react/src/types/element-types.ts | 12 +- packages/joint-react/src/types/index.ts | 2 + .../utils/__tests__/is-react-element.test.ts | 44 +++ .../src/utils/__tests__/noop-selector.test.ts | 32 ++ .../utils/__tests__/object-utilities.test.ts | 123 ++++++ .../__tests__/subscriber-handler.test.ts | 121 ++++++ .../get-link-targe-and-source-ids.test.ts | 60 +++ .../src/utils/cell/cell-utilities.ts | 39 ++ .../joint-react/src/utils/cell/get-cell.ts | 6 +- .../graph/__tests__/update-graph.test.ts | 230 ++++++++++++ .../src/utils/graph/update-graph.ts | 2 +- .../utils/joint-jsx/jsx-to-markup.stories.tsx | 1 + .../joint-react/src/utils/link-utilities.ts | 36 +- .../joint-react/src/utils/object-utilities.ts | 30 ++ .../joint-react/src/utils/test-wrappers.tsx | 9 +- yarn.lock | 355 ++++++++---------- 92 files changed, 2941 insertions(+), 443 deletions(-) create mode 100644 packages/joint-react/src/components/highlighters/__tests__/stroke.test.tsx create mode 100644 packages/joint-react/src/data/__tests__/create-ports-store.test.ts create mode 100644 packages/joint-react/src/hooks/__tests__/use-are-elements-measured.test.tsx create mode 100644 packages/joint-react/src/hooks/__tests__/use-cell-id.test.tsx create mode 100644 packages/joint-react/src/hooks/__tests__/use-element.test.tsx create mode 100644 packages/joint-react/src/hooks/__tests__/use-graph.test.ts create mode 100644 packages/joint-react/src/hooks/__tests__/use-paper-context.test.tsx create mode 100644 packages/joint-react/src/hooks/__tests__/use-ref-value.test.tsx create mode 100644 packages/joint-react/src/models/__tests__/react-element.test.ts create mode 100644 packages/joint-react/src/utils/__tests__/is-react-element.test.ts create mode 100644 packages/joint-react/src/utils/__tests__/noop-selector.test.ts create mode 100644 packages/joint-react/src/utils/__tests__/object-utilities.test.ts create mode 100644 packages/joint-react/src/utils/__tests__/subscriber-handler.test.ts create mode 100644 packages/joint-react/src/utils/cell/__tests__/get-link-targe-and-source-ids.test.ts create mode 100644 packages/joint-react/src/utils/graph/__tests__/update-graph.test.ts diff --git a/examples/joint-react/src/App.tsx b/examples/joint-react/src/App.tsx index f1cfcd2b27..31a163b345 100644 --- a/examples/joint-react/src/App.tsx +++ b/examples/joint-react/src/App.tsx @@ -11,7 +11,7 @@ import { useCellId, useGraph, usePaper, - useUpdateElement, + useCellActions, type GraphElement, type PaperProps, type RenderElement, @@ -142,6 +142,7 @@ function MessageComponent({ width, height, isSelected, + id, }: ElementWithSelected) { let iconContent; let titleText; @@ -159,8 +160,8 @@ function MessageComponent({ break; } } - const id = useCellId(); - const setMessage = useUpdateElement(id, 'inputText'); + const { set } = useCellActions(); + // const setMessage = useUpdateElement(id, 'inputText'); return ( { - setMessage(value); + set(id, (previous: MessageElement) => ({ ...previous, inputText: value })); }} />
@@ -472,7 +473,7 @@ function Main() { export default function App() { return ( - +
); diff --git a/packages/joint-react/.storybook/decorators/with-simple-data.tsx b/packages/joint-react/.storybook/decorators/with-simple-data.tsx index 2f98eb0d49..02a363b8ec 100644 --- a/packages/joint-react/.storybook/decorators/with-simple-data.tsx +++ b/packages/joint-react/.storybook/decorators/with-simple-data.tsx @@ -28,6 +28,8 @@ export const testElements = createElements([ y: 20, width: 150, height: 50, + hoverColor: 'red', + angle: 0, }, { id: '2', @@ -37,6 +39,8 @@ export const testElements = createElements([ y: 250, width: 150, height: 50, + hoverColor: 'blue', + angle: 0, }, ]); diff --git a/packages/joint-react/.storybook/main.ts b/packages/joint-react/.storybook/main.ts index b2a233efa7..26ae0008fa 100644 --- a/packages/joint-react/.storybook/main.ts +++ b/packages/joint-react/.storybook/main.ts @@ -38,6 +38,15 @@ const config: StorybookConfig = { docs: { autodocs: true, }, + tags: { + // Custom tags for organizing stories + component: {}, + hook: {}, + example: {}, + demo: {}, + tutorial: {}, + utils: {}, + }, // 👇 extend Vite config here to resolve libraries properly (in storybook) viteFinal: async (config) => { diff --git a/packages/joint-react/package.json b/packages/joint-react/package.json index 0f17da6885..20f5c79a72 100644 --- a/packages/joint-react/package.json +++ b/packages/joint-react/package.json @@ -53,18 +53,18 @@ "@chromatic-com/storybook": "^3.2.5", "@joint/layout-directed-graph": "workspace:*", "@joint/react-eslint": "*", - "@reduxjs/toolkit": "^2.8.2", - "@storybook/addon-a11y": "^8.6.12", - "@storybook/addon-docs": "8.6.12", - "@storybook/addon-essentials": "8.6.12", - "@storybook/addon-interactions": "8.6.12", - "@storybook/addon-links": "^8.6.12", - "@storybook/addon-onboarding": "8.6.12", - "@storybook/addon-storysource": "^8.6.12", - "@storybook/blocks": "8.6.12", - "@storybook/react": "8.6.12", - "@storybook/react-vite": "8.6.12", - "@storybook/test": "8.6.12", + "@reduxjs/toolkit": "^2.11.0", + "@storybook/addon-a11y": "^8.6.14", + "@storybook/addon-docs": "^8.6.14", + "@storybook/addon-essentials": "^8.6.14", + "@storybook/addon-interactions": "^8.6.14", + "@storybook/addon-links": "^8.6.14", + "@storybook/addon-onboarding": "^8.6.14", + "@storybook/addon-storysource": "^8.6.14", + "@storybook/blocks": "^8.6.14", + "@storybook/react": "^8.6.14", + "@storybook/react-vite": "8.6.14", + "@storybook/test": "^8.6.14", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.0.1", "@testing-library/react-hooks": "^8.0.1", @@ -90,9 +90,9 @@ "react-redux": "^9.2.0", "react-test-renderer": "^19.1.1", "redux": "^5.0.1", - "storybook": "8.6.14", + "storybook": "^8.6.14", "storybook-addon-performance": "0.17.3", - "storybook-multilevel-sort": "2.0.1", + "storybook-multilevel-sort": "2.1.0", "ts-jest": "^29.2.5", "ts-node": "^10.9.2", "typedoc": "^0.28.5", diff --git a/packages/joint-react/src/components/graph/graph-provider.stories.tsx b/packages/joint-react/src/components/graph/graph-provider.stories.tsx index bc95f862b0..28c077d5f7 100644 --- a/packages/joint-react/src/components/graph/graph-provider.stories.tsx +++ b/packages/joint-react/src/components/graph/graph-provider.stories.tsx @@ -20,17 +20,54 @@ const API_URL = getAPILink('GraphProvider', 'variables'); const meta: Meta = { title: 'Components/GraphProvider', component: GraphProvider, + tags: ['component'], parameters: makeRootDocumentation({ description: ` -GraphProvider provides a shared Graph context for its descendants. Use it to scope any components that read or write the graph state. You can render one or multiple Paper instances inside. +The **GraphProvider** component provides a shared Graph context for all its descendants. It manages the graph state (elements and links) and makes it available to child components through React context. + +**Key Features:** +- Manages graph state (elements and links) +- Provides context for hooks like \`useElement\`, \`useLinks\`, \`useElements\` +- Supports multiple Paper instances within the same provider +- Handles graph updates and subscriptions efficiently `, - apiURL: API_URL, - code: `import { GraphProvider } from '@joint/react' -function Render({ width, height }) { - return + usage: ` +\`\`\`tsx +import { GraphProvider, Paper } from '@joint/react'; + +const elements = [ + { id: '1', x: 100, y: 100, width: 100, height: 50 }, + { id: '2', x: 250, y: 200, width: 100, height: 50 }, +]; + +const links = [ + { id: 'l1', source: '1', target: '2' }, +]; + +function MyDiagram() { + return ( + + ( + + )} + /> + + ); } - - +\`\`\` + `, + props: ` +- **elements**: Array of element objects (required) +- **links**: Array of link objects (required) +- **children**: React nodes (typically Paper components) +- **onChange**: Callback fired when graph state changes + `, + apiURL: API_URL, + code: `import { GraphProvider, Paper } from '@joint/react' + + + `, }), @@ -67,6 +104,14 @@ export const Default: Story = { links: testLinks, children: , }, + parameters: { + docs: { + description: { + story: + 'Basic usage of GraphProvider wrapping a Paper component. The provider manages the graph state and makes it available to all child components.', + }, + }, + }, }; function Component() { const [isReady, setIsReady] = useState(false); @@ -89,4 +134,12 @@ export const ConditionalRender: Story = { links: testLinks, children: , }, + parameters: { + docs: { + description: { + story: + 'Demonstrates that GraphProvider works with conditionally rendered children. The graph state is maintained even when child components mount/unmount.', + }, + }, + }, }; diff --git a/packages/joint-react/src/components/graph/graph-provider.tsx b/packages/joint-react/src/components/graph/graph-provider.tsx index 6d84cebf64..57b253937d 100644 --- a/packages/joint-react/src/components/graph/graph-provider.tsx +++ b/packages/joint-react/src/components/graph/graph-provider.tsx @@ -17,10 +17,8 @@ import { useImperativeApi } from '../../hooks/use-imperative-api'; import { GraphAreElementsMeasuredContext, GraphStoreContext } from '../../context'; interface GraphProviderBaseProps< - // eslint-disable-next-line @typescript-eslint/no-explicit-any - Element extends dia.Element | GraphElement = any, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - Link extends dia.Link | GraphLink = any, + Element extends dia.Element | GraphElement, + Link extends dia.Link | GraphLink, > { /** * Elements (nodes) to be added to graph. @@ -90,7 +88,7 @@ export function GraphProviderHandler< graph.startBatch(CONTROLLED_MODE_BATCH_NAME); if (areElementsInControlledMode && elements !== undefined) { - setElements(elements); + setElements(elements as GraphElement[]); } graph.stopBatch(CONTROLLED_MODE_BATCH_NAME); }, [areElementsInControlledMode, areElementsMeasured, elements, graph, setElements]); @@ -117,10 +115,8 @@ export function GraphProviderHandler< export interface GraphProps< Graph extends dia.Graph = dia.Graph, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - Element extends dia.Element | GraphElement = any, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - Link extends dia.Link | GraphLink = any, + Element extends dia.Element | GraphElement = GraphElement, + Link extends dia.Link | GraphLink = GraphLink, > extends GraphProviderBaseProps { /** * Graph instance to use. If not provided, a new graph instance will be created. @@ -235,8 +231,8 @@ function GraphBase( props: Readonly> & { ref?: React.Ref; diff --git a/packages/joint-react/src/components/highlighters/__tests__/stroke.test.tsx b/packages/joint-react/src/components/highlighters/__tests__/stroke.test.tsx new file mode 100644 index 0000000000..83b05d3190 --- /dev/null +++ b/packages/joint-react/src/components/highlighters/__tests__/stroke.test.tsx @@ -0,0 +1,79 @@ +import { render } from '@testing-library/react'; +import { paperRenderElementWrapper } from '../../../utils/test-wrappers'; +import { Stroke } from '../stroke'; + +describe('Stroke highlighter', () => { + const wrapper = paperRenderElementWrapper({ + graphProviderProps: { + elements: [ + { + id: '1', + width: 100, + height: 100, + }, + ], + }, + paperProps: { + renderElement: () => , + }, + }); + + it('should render without crashing', () => { + const { container } = render(, { wrapper }); + + expect(container).toBeDefined(); + }); + + it('should render with children', () => { + const { container } = render( + + + , + { wrapper } + ); + + expect(container).toBeDefined(); + }); + + it('should render with padding prop', () => { + const { container } = render(, { wrapper }); + + expect(container).toBeDefined(); + }); + + it('should render with layer prop', () => { + const { container } = render(, { wrapper }); + + expect(container).toBeDefined(); + }); + + it('should render with rx and ry props', () => { + const { container } = render(, { wrapper }); + + expect(container).toBeDefined(); + }); + + it('should render with useFirstSubpath prop', () => { + const { container } = render(, { wrapper }); + + expect(container).toBeDefined(); + }); + + it('should render with nonScalingStroke prop', () => { + const { container } = render(, { wrapper }); + + expect(container).toBeDefined(); + }); + + it('should render with isHidden prop', () => { + const { container } = render(, { wrapper }); + + expect(container).toBeDefined(); + }); + + it('should render with SVG attributes', () => { + const { container } = render(, { wrapper }); + + expect(container).toBeDefined(); + }); +}); diff --git a/packages/joint-react/src/components/highlighters/custom.stories.tsx b/packages/joint-react/src/components/highlighters/custom.stories.tsx index 20ab4029ca..50d3285f69 100644 --- a/packages/joint-react/src/components/highlighters/custom.stories.tsx +++ b/packages/joint-react/src/components/highlighters/custom.stories.tsx @@ -15,6 +15,7 @@ const meta: Meta = { title: 'Components/Highlighter/Custom', component: Custom, decorators: [SimpleRenderItemDecorator], + tags: ['component'], parameters: makeRootDocumentation({ description: ` Custom is a component that allows you to use a custom highlighter. You must provide the \`onAdd\` which must return jointjs highlighter. diff --git a/packages/joint-react/src/components/highlighters/mask.stories.tsx b/packages/joint-react/src/components/highlighters/mask.stories.tsx index 9ace55a2c1..ad72e167fd 100644 --- a/packages/joint-react/src/components/highlighters/mask.stories.tsx +++ b/packages/joint-react/src/components/highlighters/mask.stories.tsx @@ -14,14 +14,54 @@ const meta: Meta = { title: 'Components/Highlighter/Mask', component: Mask, decorators: [SimpleRenderItemDecorator], + tags: ['component'], parameters: makeRootDocumentation({ description: ` -Mask is a component that creates a mask around the children. It is used to highlight the children. +The **Highlighter.Mask** component creates a visual mask/border around its children, useful for highlighting elements on hover or selection. + +**Key Features:** +- Creates a mask border around child elements +- Supports customizable padding and stroke properties +- Works with SVG elements that forward refs +- Can be shown/hidden dynamically via \`isHidden\` prop + `, + usage: ` +\`\`\`tsx +import { Highlighter } from '@joint/react'; +import { forwardRef } from 'react'; + +const RectElement = forwardRef((props, ref) => ( + +)); + + + + +\`\`\` + `, + props: ` +- **children**: SVG element that forwards a ref (required) +- **stroke**: Border color +- **strokeWidth**: Border thickness +- **padding**: Space between element and mask border +- **isHidden**: Controls visibility of the mask +- **strokeLinejoin**: SVG line join style (miter, round, bevel) `, apiURL: API_URL, code: `import { Highlighter } from '@joint/react' - - +import { forwardRef } from 'react'; + +const RectElement = forwardRef((props, ref) => ( + +)); + + + `, }), diff --git a/packages/joint-react/src/components/highlighters/opacity.stories.tsx b/packages/joint-react/src/components/highlighters/opacity.stories.tsx index 76105334c9..60bdbda83f 100644 --- a/packages/joint-react/src/components/highlighters/opacity.stories.tsx +++ b/packages/joint-react/src/components/highlighters/opacity.stories.tsx @@ -14,14 +14,45 @@ const meta: Meta = { title: 'Components/Highlighter/Opacity', component: Opacity, decorators: [SimpleRenderItemDecorator], + tags: ['component'], parameters: makeRootDocumentation({ description: ` -Opacity is a component that changes the opacity of the children. It is used to highlight the children. +The **Highlighter.Opacity** component changes the opacity of its children, creating a dimming or highlighting effect. + +**Key Features:** +- Adjusts element opacity for visual feedback +- Perfect for hover states and disabled states +- Works with any SVG element that forwards refs +- Can be shown/hidden dynamically + `, + usage: ` +\`\`\`tsx +import { Highlighter } from '@joint/react'; +import { forwardRef } from 'react'; + +const RectElement = forwardRef((props, ref) => ( + +)); + + + + +\`\`\` + `, + props: ` +- **children**: SVG element that forwards a ref (required) +- **alphaValue**: Opacity value (0-1, where 0 is transparent, 1 is opaque) `, apiURL: API_URL, code: `import { Highlighter } from '@joint/react' - - +import { forwardRef } from 'react'; + +const RectElement = forwardRef((props, ref) => ( + +)); + + + `, }), diff --git a/packages/joint-react/src/components/highlighters/stroke.stories.tsx b/packages/joint-react/src/components/highlighters/stroke.stories.tsx index c827319364..9d1b300bf0 100644 --- a/packages/joint-react/src/components/highlighters/stroke.stories.tsx +++ b/packages/joint-react/src/components/highlighters/stroke.stories.tsx @@ -22,14 +22,55 @@ const meta: Meta = { title: 'Components/Highlighter/Stroke', component: Stroke, decorators: [SimpleRenderItemDecorator], + tags: ['component'], parameters: makeRootDocumentation({ description: ` -Stroke is a component that adds a stroke around the children. It is used to highlight the children. +The **Highlighter.Stroke** component adds a stroke outline around its children, creating a border effect for highlighting elements. + +**Key Features:** +- Adds a customizable stroke border around elements +- Supports padding to control border distance from element +- Works with any SVG element that forwards refs +- Can be shown/hidden dynamically + `, + usage: ` +\`\`\`tsx +import { Highlighter } from '@joint/react'; +import { forwardRef } from 'react'; + +const RectElement = forwardRef((props, ref) => ( + +)); + + + + +\`\`\` + `, + props: ` +- **children**: SVG element that forwards a ref (required) +- **stroke**: Border color +- **strokeWidth**: Border thickness +- **padding**: Space between element and stroke border +- **rx/ry**: Border corner radius +- **useFirstSubpath**: Use first subpath for complex shapes `, apiURL: API_URL, code: `import { Highlighter } from '@joint/react' - - +import { forwardRef } from 'react'; + +const RectElement = forwardRef((props, ref) => ( + +)); + + + `, }), diff --git a/packages/joint-react/src/components/measured-node/measured-node.stories.tsx b/packages/joint-react/src/components/measured-node/measured-node.stories.tsx index 2c995d1217..e00f52d586 100644 --- a/packages/joint-react/src/components/measured-node/measured-node.stories.tsx +++ b/packages/joint-react/src/components/measured-node/measured-node.stories.tsx @@ -25,17 +25,48 @@ const meta: Meta = { title: 'Components/MeasuredNode', component: MeasuredNode, decorators: [ForeignObjectDecorator, SimpleRenderItemDecorator], + tags: ['component'], parameters: makeRootDocumentation({ apiURL: API_URL, - code: `import { MeasuredNode } from '@joint/react' -// This will automatically measure component size and update the parent node size - -
Content
-
- `, description: ` -Measured node component automatically detects the size of its \`children\` and updates the graph element (node) width and height automatically when elements resize. -It must be used inside \`renderElement\` context. +The **MeasuredNode** component automatically measures the size of its children and updates the parent element's dimensions in the graph. This is essential for HTML content where the size is determined by the content itself. + +**Key Features:** +- Automatically measures child component dimensions +- Updates element size in the graph when content changes +- Supports custom size calculation via \`setSize\` prop +- Must be used inside \`renderElement\` within a \`\` + `, + usage: ` +\`\`\`tsx +import { MeasuredNode } from '@joint/react'; + +function RenderElement({ width, height }) { + return ( + + +
+ Dynamic content that determines size +
+
+
+ ); +} +\`\`\` + `, + props: ` +- **children**: React node to measure (required) +- **setSize**: Optional callback to customize size calculation + - Receives: \`{ element, size }\` where size is the measured dimensions + - Can modify the size before it's applied to the element + `, + code: `import { MeasuredNode } from '@joint/react' + + + +
Content
+
+
`, }), }; diff --git a/packages/joint-react/src/components/paper/paper.stories.tsx b/packages/joint-react/src/components/paper/paper.stories.tsx index 753cf18e8e..c0027a43e9 100644 --- a/packages/joint-react/src/components/paper/paper.stories.tsx +++ b/packages/joint-react/src/components/paper/paper.stories.tsx @@ -27,16 +27,64 @@ const meta: Meta = { title: 'Components/Paper', component: Paper, decorators: [SimpleGraphDecorator], + tags: ['component'], parameters: makeRootDocumentation({ description: ` -Paper renders nodes and links using the JointJS Paper under the hood. Compose it inside a GraphProvider. Define node UI via the renderElement prop, and use useHTMLOverlay or for HTML content. +The **Paper** component is the core rendering component that displays nodes and links on the canvas. It wraps the JointJS Paper and provides a React-friendly interface for rendering interactive diagrams. + +**Key Features:** +- Renders SVG elements and HTML content via \`renderElement\` +- Supports all JointJS Paper events (click, hover, drag, etc.) +- Handles zoom, pan, and transform operations +- Integrates with GraphProvider for state management +- Supports custom link tools and interactions + `, + usage: ` +\`\`\`tsx +import { GraphProvider, Paper } from '@joint/react'; + +function MyDiagram() { + return ( + + ( + + )} + width="100%" + height={600} + /> + + ); +} +\`\`\` + `, + props: ` +- **renderElement**: Function that receives element props and returns SVG/HTML content +- **width/height**: Paper dimensions (supports numbers or CSS strings) +- **scale**: Zoom level (default: 1) +- **className**: CSS class for styling +- **onElementPointerClick**: Handler for element clicks +- **onLinkMouseEnter**: Handler for link hover +- **drawGrid**: Grid configuration for visual guides +- And many more event handlers for full interactivity `, apiURL: API_URL, - code: `import { GraphProvider } from '@joint/react' - - ( - - )} /> + code: `import { GraphProvider, Paper } from '@joint/react' + + + ( + + )} + width="100%" + height={600} + /> `, }), @@ -77,6 +125,14 @@ export const WithRectElement: Story = { width: '100%', className: PAPER_CLASSNAME, }, + parameters: { + docs: { + description: { + story: + 'Renders a simple SVG rectangle element. This is the most basic usage of the Paper component with SVG content.', + }, + }, + }, }; export const WithHTMLElement: Story = { @@ -85,6 +141,14 @@ export const WithHTMLElement: Story = { width: '100%', className: PAPER_CLASSNAME, }, + parameters: { + docs: { + description: { + story: + 'Renders HTML content using ``. Use `MeasuredNode` to automatically calculate and update element sizes based on HTML content dimensions.', + }, + }, + }, }; export const WithScaleDown: Story = { @@ -94,6 +158,14 @@ export const WithScaleDown: Story = { width: '100%', className: PAPER_CLASSNAME, }, + parameters: { + docs: { + description: { + story: + 'Demonstrates zoom/scale functionality. The `scale` prop controls the zoom level of the paper (0.7 = 70% zoom).', + }, + }, + }, }; export const WithAutoFitContent: Story = { @@ -218,6 +290,14 @@ export const WithLinkTools: Story = { width: '100%', className: PAPER_CLASSNAME, }, + parameters: { + docs: { + description: { + story: + 'Shows how to add interactive tools to links. Hover over a link to see the custom button tool appear. Tools are added/removed dynamically using event handlers.', + }, + }, + }, }; export const WithCustomEvent: Story = { @@ -241,12 +321,28 @@ export const WithDrawGrid: Story = { drawGrid: { name: 'dot', thickness: 2, color: 'white' }, drawGridSize: 10, }, + parameters: { + docs: { + description: { + story: + 'Displays a visual grid overlay on the paper. Useful for alignment and design purposes. The grid can be customized with different patterns (dot, mesh, etc.) and colors.', + }, + }, + }, }; export const WithOnClickColorChange: Story = { args: {}, + parameters: { + docs: { + description: { + story: + 'Demonstrates interactive element updates using `useCellActions`. Click on an element to change its color. This shows how to update element properties in response to user interactions.', + }, + }, + }, render: () => { - const renderElement: RenderElement = ({ width, height, hoverColor, id }) => { + const renderElement: RenderElement = ({ width, height, hoverColor, id }) => { const { set } = useCellActions(); return (
( ...paperOptions, // 👇 override to always allow connection validateConnection: () => true, - // 👇 also, allow links to start or end on empty space validateMagnet: () => true, + viewManagement: props.viewManagement ?? true, clickThreshold: paperOptions.clickThreshold ?? DEFAULT_CLICK_THRESHOLD, }); @@ -199,6 +198,9 @@ function PaperBase( Object.assign(instance, contextUpdate.contextUpdate); } + if (width !== undefined && height !== undefined) { + paper.setDimensions(width, height); + } return { instance, cleanup() { @@ -219,13 +221,7 @@ function PaperBase( ...paperOptions, }); const { drawGrid, theme, gridSize } = paperOptions; - const { - width: paperWidth, - height: paperHeight, - drawGrid: paperDrawGrid, - theme: paperTheme, - gridSize: paperGridSize, - } = paper.options; + const { width: paperWidth, height: paperHeight } = paper.options; if ( width !== undefined && @@ -234,16 +230,16 @@ function PaperBase( ) { paper.setDimensions(width, height); } - if (drawGrid !== undefined && !util.isEqual(drawGrid, paperDrawGrid)) { + if (drawGrid !== undefined) { paper.setGrid(drawGrid); } - if (gridSize !== undefined && !util.isEqual(gridSize, paperGridSize)) { + if (gridSize !== undefined) { paper.setGridSize(gridSize); } - if (theme !== undefined && !util.isEqual(theme, paperTheme)) { + if (theme !== undefined) { paper.setTheme(theme); } - if (scale !== undefined && scale !== paper.options.scale) { + if (scale !== undefined) { paper.scale(scale); } }, @@ -363,9 +359,6 @@ function PaperBase( return null; } - if (!elementView) { - return null; - } if (cell.type !== REACT_TYPE) { return null; } diff --git a/packages/joint-react/src/components/port/port-group.stories.tsx b/packages/joint-react/src/components/port/port-group.stories.tsx index 06f4ef3080..e6dfb86336 100644 --- a/packages/joint-react/src/components/port/port-group.stories.tsx +++ b/packages/joint-react/src/components/port/port-group.stories.tsx @@ -88,6 +88,7 @@ const meta: Meta = { title: 'Components/Port/Group', component: PortGroup, decorators: [PaperDecorator], + tags: ['component'], parameters: makeRootDocumentation({ apiURL: API_URL, code: ` diff --git a/packages/joint-react/src/components/port/port-item.stories.tsx b/packages/joint-react/src/components/port/port-item.stories.tsx index 9514998940..a82714d310 100644 --- a/packages/joint-react/src/components/port/port-item.stories.tsx +++ b/packages/joint-react/src/components/port/port-item.stories.tsx @@ -95,16 +95,50 @@ const meta: Meta = { title: 'Components/Port/Item', component: Port.Item, decorators: [PaperDecorator], + tags: ['component'], parameters: makeRootDocumentation({ apiURL: API_URL, - code: ` - import { Port } from '@joint/react'; - - + description: ` +The **Port.Item** component represents a connection point on an element. Ports are used to define where links can connect to elements, enabling precise control over connection points. + +**Key Features:** +- Defines connection points for links +- Supports custom positioning and styling +- Can contain custom content (icons, labels, etc.) +- Works with Port.Group for relative positioning +- Must be used inside renderElement context + `, + usage: ` +\`\`\`tsx +import { Port } from '@joint/react'; + +function RenderElement({ width, height }) { + return ( + <> + + + + + + + + ); +} +\`\`\` + `, + props: ` +- **id**: Unique identifier for the port (required) +- **x/y**: Absolute position coordinates +- **children**: SVG content to render at the port location +- **group**: Port group ID for relative positioning (use with Port.Group) + `, + code: `import { Port } from '@joint/react'; + + + + `, - description: - 'Port item is a component that represents a port in the graph. It is used to connect elements in the graph. Its appended outside the node elements, so when using positions, you can use group component for that ``', }), }; diff --git a/packages/joint-react/src/components/text-node/text-node.stories.tsx b/packages/joint-react/src/components/text-node/text-node.stories.tsx index 5129d2c3b7..3e8d9b7b3b 100644 --- a/packages/joint-react/src/components/text-node/text-node.stories.tsx +++ b/packages/joint-react/src/components/text-node/text-node.stories.tsx @@ -40,17 +40,55 @@ const meta: Meta = { title: 'Components/TextNode', component: TextNode, decorators: [SVGDecorator, SimpleRenderItemDecorator], + tags: ['component'], parameters: makeRootDocumentation({ apiURL: API_URL, - code: ` - import { TextNode } from '@joint/react' - - Hello world - + description: ` +The **TextNode** component renders SVG text with automatic sizing and wrapping capabilities. It's designed to work seamlessly with MeasuredNode for dynamic text content. + +**Key Features:** +- Renders SVG text elements +- Supports automatic text wrapping +- Integrates with MeasuredNode for dynamic sizing +- Supports all standard SVG text properties + `, + usage: ` +\`\`\`tsx +import { TextNode, MeasuredNode } from '@joint/react'; +import { useElement } from '@joint/react'; + +function RenderElement() { + const { width, height } = useElement(); + return ( + <> + + + + + Your text content here + + + + + ); +} +\`\`\` + `, + props: ` +- **children**: Text content to render +- **fill**: Text color +- **width**: Maximum width before wrapping +- **textWrap**: Enable automatic text wrapping +- **fontSize**: Text size (default: 14) +- And other standard SVG text properties + `, + code: `import { TextNode, MeasuredNode } from '@joint/react' + + + + Hello world + + `, }), }; diff --git a/packages/joint-react/src/components/text-node/text-node.tsx b/packages/joint-react/src/components/text-node/text-node.tsx index 74866318df..f3a0b19773 100644 --- a/packages/joint-react/src/components/text-node/text-node.tsx +++ b/packages/joint-react/src/components/text-node/text-node.tsx @@ -99,5 +99,50 @@ function Component(props: TextNodeProps, ref: React.ForwardedRef * @see Vectorizer.TextOptions * @group Components * @returns The rendered SVG text element with the specified properties. + * @example + * Basic usage: + * ```tsx + * import { TextNode } from '@joint/react'; + * + * function RenderElement() { + * return ( + * + * Hello World + * + * ); + * } + * ``` + * @example + * With text wrapping: + * ```tsx + * import { TextNode } from '@joint/react'; + * + * function RenderElement() { + * return ( + * + * This is a long text that will wrap to multiple lines + * + * ); + * } + * ``` + * @example + * With custom text options: + * ```tsx + * import { TextNode } from '@joint/react'; + * + * function RenderElement() { + * return ( + * + * Line 1\nLine 2 + * + * ); + * } + * ``` */ export const TextNode = forwardRef(Component); diff --git a/packages/joint-react/src/data/__tests__/create-ports-store.test.ts b/packages/joint-react/src/data/__tests__/create-ports-store.test.ts new file mode 100644 index 0000000000..bb29af9d27 --- /dev/null +++ b/packages/joint-react/src/data/__tests__/create-ports-store.test.ts @@ -0,0 +1,129 @@ +import { createPortsStore } from '../create-ports-store'; +import type { PortElementsCacheEntry } from '../create-ports-data'; +import type { Vectorizer } from '@joint/core'; + +describe('create-ports-store', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + it('should create a ports store', () => { + const store = createPortsStore(); + + expect(store).toBeDefined(); + expect(store).toHaveProperty('getPortElement'); + expect(store).toHaveProperty('onRenderPorts'); + expect(store).toHaveProperty('subscribe'); + expect(store).toHaveProperty('destroy'); + }); + + it('should get port element after setting', () => { + const store = createPortsStore(); + const cellId = 'cell-1'; + const portId = 'port-1'; + const mockElement = document.createElementNS('http://www.w3.org/2000/svg', 'g'); + + const portElementsCache: Record = { + [portId]: { + portElement: mockElement as unknown as Vectorizer, + portSelectors: { + 'react-port-portal': mockElement, + }, + portContentElement: mockElement as unknown as Vectorizer, + }, + }; + + store.onRenderPorts(cellId, portElementsCache); + const element = store.getPortElement(cellId, portId); + + expect(element).toBe(mockElement); + }); + + it('should return undefined for non-existent port', () => { + const store = createPortsStore(); + const element = store.getPortElement('cell-1', 'port-1'); + + expect(element).toBeUndefined(); + }); + + it('should subscribe to port changes', async () => { + const store = createPortsStore(); + const subscriber = jest.fn(); + + const unsubscribe = store.subscribe(subscriber); + store.onRenderPorts('cell-1', {}); + + // Wait for async notification + await Promise.resolve(); + jest.runAllTimers(); + + expect(subscriber).toHaveBeenCalled(); + unsubscribe(); + }); + + it('should unsubscribe correctly', () => { + const store = createPortsStore(); + const subscriber = jest.fn(); + + const unsubscribe = store.subscribe(subscriber); + unsubscribe(); + store.onRenderPorts('cell-1', {}); + + expect(subscriber).not.toHaveBeenCalled(); + }); + + it('should clear ports on destroy', () => { + const store = createPortsStore(); + const cellId = 'cell-1'; + const portId = 'port-1'; + const mockElement = document.createElementNS('http://www.w3.org/2000/svg', 'g'); + + const portElementsCache: Record = { + [portId]: { + portElement: mockElement as unknown as Vectorizer, + portSelectors: { + 'react-port-portal': mockElement, + }, + portContentElement: mockElement as unknown as Vectorizer, + }, + }; + + store.onRenderPorts(cellId, portElementsCache); + store.destroy(); + + const element = store.getPortElement(cellId, portId); + expect(element).toBeUndefined(); + }); + + it('should handle multiple ports for same cell', () => { + const store = createPortsStore(); + const cellId = 'cell-1'; + const port1Element = document.createElementNS('http://www.w3.org/2000/svg', 'g'); + const port2Element = document.createElementNS('http://www.w3.org/2000/svg', 'g'); + + const portElementsCache: Record = { + 'port-1': { + portElement: port1Element as unknown as Vectorizer, + portSelectors: { + 'react-port-portal': port1Element, + }, + portContentElement: port1Element as unknown as Vectorizer, + }, + 'port-2': { + portElement: port2Element as unknown as Vectorizer, + portSelectors: { + 'react-port-portal': port2Element, + }, + portContentElement: port2Element as unknown as Vectorizer, + }, + }; + + store.onRenderPorts(cellId, portElementsCache); + + expect(store.getPortElement(cellId, 'port-1')).toBe(port1Element); + expect(store.getPortElement(cellId, 'port-2')).toBe(port2Element); + }); +}); diff --git a/packages/joint-react/src/data/create-graph-store.ts b/packages/joint-react/src/data/create-graph-store.ts index 5a42f70203..d6493dd1bb 100644 --- a/packages/joint-react/src/data/create-graph-store.ts +++ b/packages/joint-react/src/data/create-graph-store.ts @@ -81,7 +81,7 @@ export interface GraphStore { * @returns */ - readonly setElements: (elements: GraphElement[]) => void; + readonly setElements: (elements: Element[]) => void; /** * Get element by id */ diff --git a/packages/joint-react/src/hooks/__tests__/use-are-elements-measured.test.tsx b/packages/joint-react/src/hooks/__tests__/use-are-elements-measured.test.tsx new file mode 100644 index 0000000000..a89f4d5410 --- /dev/null +++ b/packages/joint-react/src/hooks/__tests__/use-are-elements-measured.test.tsx @@ -0,0 +1,65 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { graphProviderWrapper } from '../../utils/test-wrappers'; +import { useAreElementMeasured } from '../use-are-elements-measured'; + +describe('use-are-elements-measured', () => { + it('should return false initially when elements are not measured', async () => { + const wrapper = graphProviderWrapper({ + elements: [ + { + id: '1', + width: 0, + height: 0, + }, + ], + }); + + const { result } = renderHook(() => useAreElementMeasured(), { + wrapper, + }); + + await waitFor(() => { + expect(result.current).toBe(false); + }); + }); + + it('should return true when elements are measured', async () => { + const wrapper = graphProviderWrapper({ + elements: [ + { + id: '1', + width: 100, + height: 100, + }, + ], + }); + + const { result } = renderHook(() => useAreElementMeasured(), { + wrapper, + }); + + await waitFor(() => { + expect(result.current).toBe(true); + }); + }); + + it('should return false when elements have small dimensions', async () => { + const wrapper = graphProviderWrapper({ + elements: [ + { + id: '1', + width: 1, + height: 1, + }, + ], + }); + + const { result } = renderHook(() => useAreElementMeasured(), { + wrapper, + }); + + await waitFor(() => { + expect(result.current).toBe(false); + }); + }); +}); diff --git a/packages/joint-react/src/hooks/__tests__/use-cell-id.test.tsx b/packages/joint-react/src/hooks/__tests__/use-cell-id.test.tsx new file mode 100644 index 0000000000..da998e7b0b --- /dev/null +++ b/packages/joint-react/src/hooks/__tests__/use-cell-id.test.tsx @@ -0,0 +1,43 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { paperRenderElementWrapper } from '../../utils/test-wrappers'; +import { useCellId } from '../use-cell-id'; + +describe('use-cell-id', () => { + it('should return cell id when used inside renderElement', async () => { + const wrapper = paperRenderElementWrapper({ + graphProviderProps: { + elements: [ + { + id: 'test-cell-id', + width: 100, + height: 100, + }, + ], + }, + paperProps: { + renderElement: () => , + }, + }); + + // useCellId can only be used inside renderElement callback + // But we can test it by using renderHook with the wrapper + // The wrapper provides the CellIdContext through the Paper component + const { result } = renderHook(() => useCellId(), { + wrapper, + }); + + await waitFor(() => { + expect(result.current).toBe('test-cell-id'); + }); + }); + + it('should throw error when used outside renderElement', () => { + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + expect(() => { + renderHook(() => useCellId(), { + wrapper: ({ children }) => <>{children}, + }); + }).toThrow('useCellId must be used inside Paper renderElement'); + consoleError.mockRestore(); + }); +}); diff --git a/packages/joint-react/src/hooks/__tests__/use-element.test.tsx b/packages/joint-react/src/hooks/__tests__/use-element.test.tsx new file mode 100644 index 0000000000..2d346e8421 --- /dev/null +++ b/packages/joint-react/src/hooks/__tests__/use-element.test.tsx @@ -0,0 +1,76 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { paperRenderElementWrapper } from '../../utils/test-wrappers'; +import { useElement } from '../use-element'; + +describe('use-element', () => { + const wrapper = paperRenderElementWrapper({ + graphProviderProps: { + elements: [ + { + id: '1', + width: 100, + height: 100, + x: 10, + y: 20, + }, + ], + }, + paperProps: { + renderElement: () => , + }, + }); + + it('should get element without selector', async () => { + const { result } = renderHook( + () => { + return useElement(); + }, + { + wrapper, + } + ); + + await waitFor(() => { + expect(result.current).toBeDefined(); + expect(result.current.id).toBe('1'); + expect(result.current.width).toBe(100); + expect(result.current.height).toBe(100); + }); + }); + + it('should get element with selector', async () => { + const { result } = renderHook( + () => { + return useElement((element) => element.width); + }, + { + wrapper, + } + ); + + await waitFor(() => { + expect(result.current).toBe(100); + }); + }); + + it('should get element with custom isEqual', async () => { + const renders = jest.fn(); + const { result } = renderHook( + () => { + renders(); + return useElement( + (element) => element, + (previous, next) => previous.width === next.width + ); + }, + { + wrapper, + } + ); + + await waitFor(() => { + expect(renders).toHaveBeenCalledTimes(1); + expect(result.current.width).toBe(100); + }); + }); +}); diff --git a/packages/joint-react/src/hooks/__tests__/use-graph.test.ts b/packages/joint-react/src/hooks/__tests__/use-graph.test.ts new file mode 100644 index 0000000000..51a0f6ad6d --- /dev/null +++ b/packages/joint-react/src/hooks/__tests__/use-graph.test.ts @@ -0,0 +1,45 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { graphProviderWrapper } from '../../utils/test-wrappers'; +import { useGraph } from '../use-graph'; + +describe('use-graph', () => { + const wrapper = graphProviderWrapper({ + elements: [ + { + id: '1', + width: 100, + height: 100, + }, + ], + }); + + it('should return graph instance', async () => { + const { result } = renderHook(() => useGraph(), { + wrapper, + }); + + await waitFor(() => { + expect(result.current).toBeDefined(); + expect(result.current).toHaveProperty('getCells'); + expect(result.current).toHaveProperty('addCell'); + expect(result.current).toHaveProperty('getCell'); + }); + }); + + it('should return the same graph instance on re-render', async () => { + const { result, rerender } = renderHook(() => useGraph(), { + wrapper, + }); + + await waitFor(() => { + expect(result.current).toBeDefined(); + }); + + const firstGraph = result.current; + rerender(); + + await waitFor(() => { + expect(result.current).toBe(firstGraph); + }); + }); +}); diff --git a/packages/joint-react/src/hooks/__tests__/use-measure-node-size.test.tsx b/packages/joint-react/src/hooks/__tests__/use-measure-node-size.test.tsx index f8a7677499..77aceaa289 100644 --- a/packages/joint-react/src/hooks/__tests__/use-measure-node-size.test.tsx +++ b/packages/joint-react/src/hooks/__tests__/use-measure-node-size.test.tsx @@ -6,6 +6,9 @@ import { useMeasureNodeSize } from '../use-measure-node-size'; // Mocks for @joint/core and useGraphStore // This is a mock for a hook, but the linter wants no 'use' prefix if not a real hook +const mockHasMeasuredNode = jest.fn(() => false); +const mockSetMeasuredNode = jest.fn(() => jest.fn()); + jest.mock('../use-graph-store', () => { const graph = { getCell: jest.fn((_id: string) => ({ @@ -16,8 +19,8 @@ jest.mock('../use-graph-store', () => { return { useGraphStore: () => ({ graph, - setMeasuredNode: jest.fn(() => jest.fn()), - hasMeasuredNode: jest.fn(), + setMeasuredNode: mockSetMeasuredNode, + hasMeasuredNode: mockHasMeasuredNode, }), }; }); @@ -42,6 +45,12 @@ jest.mock('../../utils/create-element-size-observer', () => ({ })); describe('useMeasureNodeSize', () => { + beforeEach(() => { + // Reset mocks before each test + mockHasMeasuredNode.mockReturnValue(false); + mockSetMeasuredNode.mockReturnValue(jest.fn()); + }); + interface TestComponentProps { readonly style: React.CSSProperties; readonly children?: React.ReactNode; @@ -120,4 +129,122 @@ describe('useMeasureNodeSize', () => { expect(call.size.width).toBeGreaterThan(0); expect(call.size.height).toBeGreaterThan(0); }); + + describe('multiple MeasuredNode error', () => { + it('should throw error when multiple MeasuredNode components are used for the same element', () => { + // Mock that a measured node already exists + mockHasMeasuredNode.mockReturnValue(true); + + const setSize = jest.fn(); + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + + // The error will be thrown during render, so we need to catch it + let caughtError: Error | undefined; + try { + render(); + } catch (error) { + caughtError = error as Error; + } + + // Verify error was thrown + expect(caughtError).toBeDefined(); + expect(caughtError).toBeInstanceOf(Error); + expect(caughtError?.message).toContain('Multiple MeasuredNode components detected'); + + consoleError.mockRestore(); + }); + + it('should throw detailed error message in development mode', () => { + // Mock that a measured node already exists + mockHasMeasuredNode.mockReturnValue(true); + + const setSize = jest.fn(); + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + + // Save original NODE_ENV + const originalEnv = process.env.NODE_ENV; + // Set to development mode + process.env.NODE_ENV = 'development'; + + let caughtError: Error | undefined; + try { + render(); + } catch (error) { + caughtError = error as Error; + } + + // Verify error was thrown with detailed message + expect(caughtError).toBeDefined(); + expect(caughtError).toBeInstanceOf(Error); + const errorMessage = caughtError?.message ?? ''; + expect(errorMessage).toContain( + 'Multiple MeasuredNode components detected for element with id "cell-1"' + ); + expect(errorMessage).toContain('Only one MeasuredNode can be used per element'); + expect(errorMessage).toContain('Solution:'); + expect(errorMessage).toContain('Use only one MeasuredNode per element'); + expect(errorMessage).toContain('custom `setSize` handler'); + expect(errorMessage).toContain('Check your renderElement function'); + + // Restore original NODE_ENV + process.env.NODE_ENV = originalEnv; + consoleError.mockRestore(); + }); + + it('should throw concise error message in production mode', () => { + // Mock that a measured node already exists + mockHasMeasuredNode.mockReturnValue(true); + + const setSize = jest.fn(); + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + + // Save original NODE_ENV + const originalEnv = process.env.NODE_ENV; + // Set to production mode + process.env.NODE_ENV = 'production'; + + let caughtError: Error | undefined; + try { + render(); + } catch (error) { + caughtError = error as Error; + } + + // Verify error was thrown with concise message + expect(caughtError).toBeDefined(); + expect(caughtError).toBeInstanceOf(Error); + const errorMessage = caughtError?.message ?? ''; + expect(errorMessage).toBe( + 'Multiple MeasuredNode components detected for element "cell-1". Only one MeasuredNode can be used per element.' + ); + // Should not contain detailed solution in production + expect(errorMessage).not.toContain('Solution:'); + expect(errorMessage).not.toContain('Check your renderElement function'); + + // Restore original NODE_ENV + process.env.NODE_ENV = originalEnv; + consoleError.mockRestore(); + }); + + it('should not throw error when no MeasuredNode exists for the element', () => { + // Mock that no measured node exists + mockHasMeasuredNode.mockReturnValue(false); + + const setSize = jest.fn(); + const getBoundingClientRect = jest.fn(() => ({ width: 123, height: 45 })); + + const { getByTestId } = render( + + Test + + ); + + const element = getByTestId('measured'); + // @ts-expect-error assigning mock getBoundingClientRect to element for test + element.getBoundingClientRect = getBoundingClientRect; + + // Should not throw and should call setMeasuredNode + expect(mockSetMeasuredNode).toHaveBeenCalledWith('cell-1'); + }); + }); }); diff --git a/packages/joint-react/src/hooks/__tests__/use-paper-context.test.tsx b/packages/joint-react/src/hooks/__tests__/use-paper-context.test.tsx new file mode 100644 index 0000000000..af54984b80 --- /dev/null +++ b/packages/joint-react/src/hooks/__tests__/use-paper-context.test.tsx @@ -0,0 +1,57 @@ +import { render, renderHook, waitFor } from '@testing-library/react'; +import { paperRenderElementWrapper } from '../../utils/test-wrappers'; +import { usePaperContext } from '../use-paper-context'; + +describe('use-paper-context', () => { + const wrapper = paperRenderElementWrapper({ + graphProviderProps: { + elements: [ + { + id: '1', + width: 100, + height: 100, + }, + ], + }, + paperProps: { + renderElement: () => , + }, + }); + + it('should return paper context when used inside Paper', async () => { + let capturedContext: ReturnType | null = null; + const TestComponent = () => { + const context = usePaperContext(); + capturedContext = context; + return null; + }; + + render(, { wrapper }); + + await waitFor(() => { + expect(capturedContext).not.toBeNull(); + expect(capturedContext).toBeDefined(); + }); + + expect(capturedContext).toHaveProperty('paper'); + expect(capturedContext).toHaveProperty('id'); + expect(capturedContext).toHaveProperty('portsStore'); + expect(capturedContext).toHaveProperty('elementViews'); + }); + + it('should throw error when used outside Paper and isNullable is false', () => { + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + expect(() => { + renderHook(() => usePaperContext(false), { + wrapper: ({ children }) => <>{children}, + }); + }).toThrow('usePaperContext must be used within a Paper or RenderElement'); + consoleError.mockRestore(); + }); + + it('should return null when used outside Paper and isNullable is true', () => { + const { result } = renderHook(() => usePaperContext(true)); + + expect(result.current).toBeNull(); + }); +}); diff --git a/packages/joint-react/src/hooks/__tests__/use-ref-value.test.tsx b/packages/joint-react/src/hooks/__tests__/use-ref-value.test.tsx new file mode 100644 index 0000000000..11909769db --- /dev/null +++ b/packages/joint-react/src/hooks/__tests__/use-ref-value.test.tsx @@ -0,0 +1,46 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { useRefValue } from '../use-ref-value'; + +describe('use-ref-value', () => { + it('should return current ref value when ref is set', async () => { + const ref = { current: 'test-value' }; + const { result } = renderHook(() => useRefValue(ref)); + + await waitFor(() => { + expect(result.current).toBe('test-value'); + }); + }); + + it('should return undefined when ref is not set', async () => { + const ref = { current: null }; + const { result } = renderHook(() => useRefValue(ref)); + + await waitFor(() => { + expect(result.current).toBeUndefined(); + }); + }); + + it('should return undefined when ref is undefined', async () => { + const { result } = renderHook(() => useRefValue(undefined as never)); + + await waitFor(() => { + expect(result.current).toBeUndefined(); + }); + }); + + it('should update when ref value changes', async () => { + const ref = { current: null as string | null }; + const { result, rerender } = renderHook(() => useRefValue(ref)); + + await waitFor(() => { + expect(result.current).toBeUndefined(); + }); + + ref.current = 'new-value'; + rerender(); + + await waitFor(() => { + expect(result.current).toBe('new-value'); + }); + }); +}); diff --git a/packages/joint-react/src/hooks/use-are-elements-measured.ts b/packages/joint-react/src/hooks/use-are-elements-measured.ts index 0f9d052dd7..40431bc0d0 100644 --- a/packages/joint-react/src/hooks/use-are-elements-measured.ts +++ b/packages/joint-react/src/hooks/use-are-elements-measured.ts @@ -4,6 +4,19 @@ import { GraphAreElementsMeasuredContext } from '../context'; * useAreElementMeasured is a custom hook that returns information if nodes are properly measured - they have defined size. * It is used to determine if the elements in the graph have been measured. * @returns - The value of the GraphAreElementsMeasuredContext. + * @group Hooks + * @example + * ```tsx + * import { useAreElementMeasured } from '@joint/react'; + * + * function MyComponent() { + * const areMeasured = useAreElementMeasured(); + * if (!areMeasured) { + * return
Loading...
; + * } + * return
Elements are ready
; + * } + * ``` */ export function useAreElementMeasured() { return useContext(GraphAreElementsMeasuredContext); diff --git a/packages/joint-react/src/hooks/use-cell-actions.stories.tsx b/packages/joint-react/src/hooks/use-cell-actions.stories.tsx index 1ddb85a396..5991817e2c 100644 --- a/packages/joint-react/src/hooks/use-cell-actions.stories.tsx +++ b/packages/joint-react/src/hooks/use-cell-actions.stories.tsx @@ -17,15 +17,81 @@ const meta: Meta = { title: 'Hooks/useCellActions', component: Hook, render: () => , + tags: ['hook'], parameters: makeRootDocumentation({ apiURL: API_URL, - description: `\`useCellActions\` is a hook to set / insert / remove elements and links in the graph. It returns functions to update cells. Use it under \`GraphProvider\` (graph context). + description: ` +The **useCellActions** hook provides functions to modify the graph state. It allows you to add, update, and remove elements and links programmatically. + +**Key Features:** +- Update element/link properties with \`set\` +- Insert new elements/links with \`insert\` +- Remove elements/links with \`remove\` +- Type-safe updates with TypeScript +- Must be used within GraphProvider context + `, + usage: ` +\`\`\`tsx +import { useCellActions } from '@joint/react'; + +function Component() { + const { set, insert, remove } = useCellActions(); + + // Update an element + const updateElement = () => { + set('element-id', (previous) => ({ + ...previous, + label: 'Updated' + })); + }; + + // Insert a new element + const addElement = () => { + insert('elements', { + id: 'new-element', + x: 100, + y: 100, + width: 100, + height: 50, + }); + }; + + // Remove an element + const deleteElement = () => { + remove('element-id'); + }; + + return ( +
+ + + +
+ ); +} +\`\`\` + `, + props: ` +- **set(id, updater)**: Updates a cell (element or link) by ID + - \`id\`: Cell ID to update + - \`updater\`: Function that receives previous state and returns new state +- **insert(collection, item)**: Inserts a new element or link + - \`collection\`: 'elements' or 'links' + - \`item\`: Element or link object to insert +- **remove(id)**: Removes a cell by ID `, code: `import { useCellActions } from '@joint/react' function Component() { - const { set } = useCellActions(); - return ; + const { set, insert, remove } = useCellActions(); + + return ( + + ); }`, }), }; @@ -153,14 +219,18 @@ function HookSetSize({ label , id }: SimpleElement) { }); function HookSetAngle({ label, id }: SimpleElement) { - const { set } = useCellActions(); + const { set } = useCellActions(); return ( + + ); + } + + const { getByRole } = render(); + + await waitFor(() => { + expect(elementCount).toBe(1); + }); + + // Rapid button clicks + const button = getByRole('button'); + act(() => { + for (let index = 0; index < 5; index++) { + button.click(); + } + }); + + await waitFor( + () => { + expect(elementCount).toBe(6); + }, + { timeout: 3000 } + ); + }); + }); + + describe('User interaction sync back to React state', () => { + it('should sync graph changes back to React state in controlled mode', async () => { + const initialElements = createElements([ + { id: '1', width: 100, height: 100, type: 'ReactElement' }, + ]); + + let reactStateElements: GraphElement[] = []; + let storeElements: GraphElement[] = []; + + function TestComponent() { + storeElements = useElements((items) => items); + return null; + } + + function ControlledGraph() { + const [elements, setElements] = useState(initialElements); + reactStateElements = elements; + + return ( + + + + + ); + } + + function UserInteractionComponent() { + const graph = useGraph(); + + const handleAddElement = useCallback(() => { + // Simulate user interaction - directly modify graph + graph.addCell( + new dia.Element({ + id: '2', + type: 'ReactElement', + position: { x: 200, y: 200 }, + size: { width: 200, height: 200 }, + }) + ); + }, [graph]); + + return ( + + ); + } + + const { getByRole } = render(); + + await waitFor(() => { + expect(reactStateElements.length).toBe(1); + expect(storeElements.length).toBe(1); + }); + + // Simulate user interaction + act(() => { + getByRole('button').click(); + }); + + // Graph change should sync back to React state + await waitFor( + () => { + expect(reactStateElements.length).toBe(2); + expect(storeElements.length).toBe(2); + expect(reactStateElements.some((element) => element.id === '2')).toBe(true); + expect(storeElements.some((element) => element.id === '2')).toBe(true); + }, + { timeout: 3000 } + ); + }); + + it('should handle element position changes from user interaction', async () => { + const initialElements = createElements([ + { id: '1', width: 100, height: 100, x: 0, y: 0, type: 'ReactElement' }, + ]); + + let reactStateElements: GraphElement[] = []; + + function ControlledGraph() { + const [elements, setElements] = useState(initialElements); + reactStateElements = elements; + + return ( + + + + ); + } + + function UserInteractionComponent() { + const graph = useGraph(); + + const handleMoveElement = useCallback(() => { + // Simulate user dragging - directly modify graph + const cell = graph.getCell('1'); + if (cell) { + cell.set('position', { x: 100, y: 100 }); + } + }, [graph]); + + return ( + + ); + } + + const { getByRole } = render(); + + await waitFor(() => { + expect(reactStateElements.length).toBe(1); + expect(reactStateElements[0]?.x).toBe(0); + expect(reactStateElements[0]?.y).toBe(0); + }); + + // Simulate user interaction + act(() => { + getByRole('button').click(); + }); + + // Graph change should sync back to React state + await waitFor( + () => { + expect(reactStateElements.length).toBe(1); + expect(reactStateElements[0]?.x).toBe(100); + expect(reactStateElements[0]?.y).toBe(100); + }, + { timeout: 3000 } + ); + }); + }); + + describe('Edge cases', () => { + it('should handle empty arrays correctly', async () => { + const initialElements = createElements([ + { id: '1', width: 100, height: 100, type: 'ReactElement' }, + ]); + + let elementCount = 0; + + function TestComponent() { + elementCount = useElements((items) => items.length); + return null; + } + + let setElementsExternal: ((elements: GraphElement[]) => void) | null = null; + + function ControlledGraph() { + const [elements, setElements] = useState(initialElements); + setElementsExternal = setElements as (elements: GraphElement[]) => void; + return ( + + + + ); + } + + render(); + + await waitFor(() => { + expect(elementCount).toBe(1); + }); + + // Clear all elements + act(() => { + setElementsExternal?.([]); + }); + + await waitFor(() => { + expect(elementCount).toBe(0); + }); + + // Add elements back + act(() => { + setElementsExternal?.( + createElements([ + { id: '1', width: 100, height: 100, type: 'ReactElement' }, + { id: '2', width: 200, height: 200, type: 'ReactElement' }, + ]) + ); + }); + + await waitFor(() => { + expect(elementCount).toBe(2); + }); + }); + + it('should handle undefined elements/links gracefully', async () => { + let elementCount = 0; + let linkCount = 0; + + function TestComponent() { + elementCount = useElements((items) => items.length); + linkCount = useLinks((items) => items.length); + return null; + } + + function ControlledGraph() { + const [elements, setElements] = useState([]); + const [links, setLinks] = useState([]); + return ( + + + + ); + } + + render(); + + await waitFor(() => { + expect(elementCount).toBe(0); + expect(linkCount).toBe(0); + }); + }); + }); +}); diff --git a/packages/joint-react/src/components/paper/__tests__/graph-provider-coverage.test.tsx b/packages/joint-react/src/components/paper/__tests__/graph-provider-coverage.test.tsx new file mode 100644 index 0000000000..8b3edc7f8c --- /dev/null +++ b/packages/joint-react/src/components/paper/__tests__/graph-provider-coverage.test.tsx @@ -0,0 +1,314 @@ +/* eslint-disable sonarjs/no-nested-functions */ +/* eslint-disable sonarjs/no-identical-functions */ +import React, { useState } from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { dia } from '@joint/core'; +import { useElements, useLinks } from '../../../hooks'; +import { createElements } from '../../../utils/create'; +import type { GraphElement } from '../../../types/element-types'; +import type { GraphLink } from '../../../types/link-types'; +import { GraphProvider } from '../../graph/graph-provider'; +import { createStoreWithGraph } from '../../../data/create-graph-store'; + +describe('GraphProvider Coverage Tests', () => { + describe('Edge cases for controlled mode', () => { + it('should handle undefined elements in controlled mode', async () => { + let elementCount = 0; + + function TestComponent() { + elementCount = useElements((items) => items.length); + return null; + } + + function ControlledGraph() { + const [elements, setElements] = useState([]); + return ( + + + + ); + } + + render(); + + await waitFor(() => { + expect(elementCount).toBe(0); + }); + }); + + it('should handle undefined links in controlled mode', async () => { + let linkCount = 0; + + function TestComponent() { + linkCount = useLinks((items) => items.length); + return null; + } + + function ControlledGraph() { + const [links, setLinks] = useState([]); + return ( + + + + ); + } + + render(); + + await waitFor(() => { + expect(linkCount).toBe(0); + }); + }); + + it('should handle only elements controlled (not links)', async () => { + const initialElements = createElements([ + { id: '1', width: 100, height: 100, type: 'ReactElement' }, + ]); + + let elementCount = 0; + let linkCount = 0; + + function TestComponent() { + elementCount = useElements((items) => items.length); + linkCount = useLinks((items) => items.length); + return null; + } + + function ControlledGraph() { + const [elements, setElements] = useState(initialElements); + return ( + + + + ); + } + + render(); + + await waitFor(() => { + expect(elementCount).toBe(1); + expect(linkCount).toBe(0); + }); + }); + + it('should handle only links controlled (not elements)', async () => { + const initialLink = new dia.Link({ + id: 'link1', + type: 'standard.Link', + source: { id: '1' }, + target: { id: '2' }, + }); + + let elementCount = 0; + let linkCount = 0; + + function TestComponent() { + elementCount = useElements((items) => items.length); + linkCount = useLinks((items) => items.length); + return null; + } + + function ControlledGraph() { + const [links, setLinks] = useState([initialLink]); + return ( + + + + ); + } + + render(); + + await waitFor(() => { + expect(elementCount).toBe(0); + expect(linkCount).toBe(1); + }); + }); + }); + + describe('create-graph-store error cases', () => { + it('should throw error when graph is null in createStoreWithGraph', () => { + expect(() => { + createStoreWithGraph({ + graph: undefined as unknown as dia.Graph, + }); + }).toThrow('Graph instance is required'); + }); + + it('should throw error when getLink is called with non-existent id', () => { + const graph = new dia.Graph(); + const store = createStoreWithGraph({ graph }); + + expect(() => { + store.getLink('non-existent-id'); + }).toThrow('Link with id non-existent-id not found'); + }); + + it('should handle skipGraphUpdate path in forceUpdateStore', async () => { + const graph = new dia.Graph(); + const store = createStoreWithGraph({ + graph, + onElementsChange: () => {}, + }); + + // Force update with skipGraphUpdate flag + const result = store.forceUpdateStore(undefined, true); + + expect(result).toBeDefined(); + expect(result.areElementsChanged).toBe(false); + expect(result.areLinksChanged).toBe(false); + }); + }); + + describe('create-store-data structural changes', () => { + it('should detect reordering of elements in updateFromExternalData', () => { + const graph = new dia.Graph(); + const store = createStoreWithGraph({ graph }); + + const elements1 = createElements([ + { id: '1', width: 100, height: 100, type: 'ReactElement' }, + { id: '2', width: 100, height: 100, type: 'ReactElement' }, + ]); + + const elements2 = createElements([ + { id: '2', width: 100, height: 100, type: 'ReactElement' }, + { id: '1', width: 100, height: 100, type: 'ReactElement' }, + ]); + + // Initial update + store.updateStoreFromExternalData(elements1, []); + + // Reorder (same elements, different order) + const result = store.updateStoreFromExternalData(elements2, []); + + // Reordering should be detected as a structural change + expect(result.areElementsChanged).toBe(true); + }); + + it('should detect reordering of links in updateFromExternalData', () => { + const graph = new dia.Graph(); + const store = createStoreWithGraph({ graph }); + + // Create links as JSON to match GraphLink type + const link1: GraphLink = { + id: 'link1', + type: 'standard.Link', + source: '1', + target: '2', + }; + const link2: GraphLink = { + id: 'link2', + type: 'standard.Link', + source: '2', + target: '3', + }; + + // Initial update + store.updateStoreFromExternalData([], [link1, link2]); + + // Reorder (same links, different order) - create new objects to ensure they're different references + const link1Reordered: GraphLink = { + id: 'link1', + type: 'standard.Link', + source: '1', + target: '2', + }; + const link2Reordered: GraphLink = { + id: 'link2', + type: 'standard.Link', + source: '2', + target: '3', + }; + const result = store.updateStoreFromExternalData([], [link2Reordered, link1Reordered]); + + // Reordering should be detected as a structural change + expect(result.areLinksChanged).toBe(true); + }); + + it('should detect changes in updateStore when graph cells are modified', () => { + const graph = new dia.Graph(); + const element1 = new dia.Element({ + id: '1', + type: 'ReactElement', + position: { x: 0, y: 0 }, + size: { width: 100, height: 100 }, + }); + const element2 = new dia.Element({ + id: '2', + type: 'ReactElement', + position: { x: 200, y: 0 }, + size: { width: 100, height: 100 }, + }); + + graph.addCell([element1, element2]); + + const store = createStoreWithGraph({ graph }); + + // Initial update + store.forceUpdateStore(); + + // Change element position to trigger update + element1.set('position', { x: 10, y: 10 }); + const result = store.forceUpdateStore(); + + // Should detect change + expect(result.areElementsChanged).toBe(true); + expect(result.diffIds.has('1')).toBe(true); + }); + }); + + describe('GraphProvider edge cases', () => { + it('should handle unmeasured elements (width/height <= 1)', async () => { + const unmeasuredElements = createElements([ + { id: '1', width: 0, height: 0, type: 'ReactElement' }, + { id: '2', width: 1, height: 1, type: 'ReactElement' }, + ]); + + let elementCount = 0; + + function TestComponent() { + elementCount = useElements((items) => items.length); + return null; + } + + function ControlledGraph() { + const [elements, setElements] = useState(unmeasuredElements); + return ( + + + + ); + } + + render(); + + // Should still work with unmeasured elements + await waitFor(() => { + expect(elementCount).toBe(2); + }); + }); + + it('should handle cleanup in GraphBase when store exists', () => { + const graph = new dia.Graph(); + const store = createStoreWithGraph({ graph }); + + const { unmount } = render( + +
Test
+
+ ); + + // Store should work before unmount + expect(() => { + store.getElements(); + }).not.toThrow(); + + unmount(); + + // Store should still work after unmount (it's not destroyed) + expect(() => { + store.getElements(); + }).not.toThrow(); + }); + }); +}); diff --git a/packages/joint-react/src/data/create-graph-store.ts b/packages/joint-react/src/data/create-graph-store.ts index d6493dd1bb..6d947ea955 100644 --- a/packages/joint-react/src/data/create-graph-store.ts +++ b/packages/joint-react/src/data/create-graph-store.ts @@ -8,7 +8,6 @@ import type { GraphLink } from '../types/link-types'; import { subscribeHandler } from '../utils/subscriber-handler'; import { createStoreData, type UpdateResult } from './create-store-data'; import type { Dispatch, SetStateAction } from 'react'; -import { CONTROLLED_MODE_BATCH_NAME } from '../utils/graph/update-graph'; export const DEFAULT_CELL_NAMESPACE: Record = { ...shapes, ReactElement }; @@ -119,8 +118,21 @@ export interface GraphStore { /** * Force update the graph store. * This will trigger a re-render of all components that are subscribed to the store. + * @param batchName - The name of the batch (unused in new implementation, kept for compatibility). + * @param skipGraphUpdate - If true, skip updating from graph (used when store was already updated from external data). */ - readonly forceUpdateStore: () => UpdateResult; + readonly forceUpdateStore: (batchName?: string, skipGraphUpdate?: boolean) => UpdateResult; + + /** + * Update store from external data (React state) in controlled mode. + * @param elements - The elements from React state. + * @param links - The links from React state. + * @returns The update result. + */ + readonly updateStoreFromExternalData: ( + elements: GraphElement[], + links: GraphLink[] + ) => UpdateResult; } /** @@ -157,11 +169,6 @@ function createGraph< return newGraph as Graph; } -// eslint-disable-next-line jsdoc/require-jsdoc -function isBatchNameObject(value: unknown): value is { batchName: string } { - return typeof value === 'object' && value !== null && 'batchName' in value; -} - /** * Building block of `@joint/react`. * It listen to cell changes and updates UI based on the `dia.graph` changes. @@ -198,6 +205,15 @@ export function createStoreWithGraph< // Create a new graph instance or use the provided one throw new Error('Graph instance is required'); } + + // Detect controlled mode + const isControlled = !!(onElementsChange || onLinksChange); + const isElementsControlled = !!onElementsChange; + const isLinksControlled = !!onLinksChange; + + // Track if we're currently syncing from React state to graph (to prevent circular updates) + let isSyncingFromReactState = false; + // set elements to the graph setElements({ graph, @@ -217,6 +233,7 @@ export function createStoreWithGraph< const elementsEvents = subscribeHandler(forceUpdateStore); // Notify subscribers of initial elements + // In both controlled and uncontrolled modes, initial store is populated from graph graphData.updateStore(graph); // add method to handle batch stop, so then we can also notify all react components @@ -224,46 +241,99 @@ export function createStoreWithGraph< const measuredNodes = new Set(); const { dataRef } = graphData; + + // Store the last UpdateResult from external data updates for controlled mode + let lastExternalUpdateResult: UpdateResult | undefined; /** * Force update the graph. * This function is called when the graph is updated. - * It checks if there are any unsized links and processes them. + * In controlled mode, only syncs graph → React state when changes come from user interaction. + * In uncontrolled mode, updates store from graph. * @returns changed ids - * @param batchName - The name of the batch. + * @param batchName - The name of the batch (unused in new implementation, kept for compatibility). + * @param skipGraphUpdate - If true, skip updating from graph (used when store was already updated from external data). */ - function forceUpdateStore(batchName?: string): UpdateResult { + function forceUpdateStore(batchName?: string, skipGraphUpdate = false): UpdateResult { if (!graph) { // Create a new graph instance or use the provided one throw new Error('Graph instance is required'); } - const updateResult = graphData.updateStore(graph); + let updateResult: UpdateResult; - // Skip processing changes in controlled mode since they are already handled. - // This prevents circular calls to `onElementsChange`. - // For example, if a user manages elements via React state and updates the graph using setElements, - // this function will be triggered. However, we avoid re-triggering `onElementsChange` to prevent redundant updates. - // We call `onElementsChange` and `onLinksChange` explicitly only when direct change on `dia.Graph` occurs. - - if (batchName === CONTROLLED_MODE_BATCH_NAME) { - return updateResult; - } - - if (onElementsChange && updateResult.areElementsChanged) { - const mappedElements = dataRef.elements.map((element) => element); - onElementsChange(mappedElements as SetStateAction); - } - if (onLinksChange && updateResult.areLinksChanged) { - const changedLinks = dataRef.links.map((link) => link); - onLinksChange(changedLinks as SetStateAction); + // In controlled mode, if we're syncing from React state, don't update store from graph + // The store will be updated directly from React state instead + if (isControlled && (isSyncingFromReactState || skipGraphUpdate)) { + // Store was already updated from React state, use the stored result + updateResult = lastExternalUpdateResult ?? { + diffIds: new Set(), + areElementsChanged: false, + areLinksChanged: false, + }; + // Clear the stored result after using it + lastExternalUpdateResult = undefined; + } else { + // Update store from graph (uncontrolled mode, or controlled mode with user-initiated changes) + updateResult = graphData.updateStore(graph); + + // In controlled mode, sync graph → React state only when changes come from user interaction + // (not when we're syncing from React state) + if (isControlled && !isSyncingFromReactState) { + if (isElementsControlled && updateResult.areElementsChanged) { + const mappedElements = dataRef.elements.map((element) => element); + onElementsChange(mappedElements as SetStateAction); + } + if (isLinksControlled && updateResult.areLinksChanged) { + const changedLinks = dataRef.links.map((link) => link); + onLinksChange(changedLinks as SetStateAction); + } + } } return updateResult; } + + /** + * Update store from external data (React state) in controlled mode. + * This is called when React state changes and we need to update the store cache. + * @param newElements - The elements from React state. + * @param newLinks - The links from React state. + * @returns The update result. + */ + /** + * Helper to notify subscribers with a specific UpdateResult (for controlled mode) + * @param updateResult - The update result to notify subscribers with. + */ + function notifySubscribersWithResult(updateResult: UpdateResult) { + // Store the result so forceUpdateStore can use it when called as beforeSubscribe + lastExternalUpdateResult = updateResult; + // Trigger notification - this will call forceUpdateStore as beforeSubscribe + elementsEvents.notifySubscribers(); + } + + /** + * Update store from external data (React state) in controlled mode. + * This is called when React state changes and we need to update the store cache. + * @param newElements - The elements from React state. + * @param newLinks - The links from React state. + * @returns The update result. + */ + function updateStoreFromExternalData( + newElements: GraphElement[], + newLinks: GraphLink[] + ): UpdateResult { + const result = graphData.updateFromExternalData(newElements, newLinks); + // Notify subscribers with the update result + if (result.areElementsChanged || result.areLinksChanged) { + notifySubscribersWithResult(result); + } + return result; + } /** * 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. + * In controlled mode, only triggers when changes come from user interaction. */ function onCellChange() { if (!graph) { @@ -271,6 +341,11 @@ export function createStoreWithGraph< throw new Error('Graph instance is required'); } + // In controlled mode, skip if we're syncing from React state + if (isControlled && isSyncingFromReactState) { + return; + } + if (graph.hasActiveBatch()) { return; } @@ -278,15 +353,13 @@ export function createStoreWithGraph< elementsEvents.notifySubscribers(); } - // eslint-disable-next-line jsdoc/require-jsdoc, no-shadow, @typescript-eslint/no-shadow - function onBatchStop(options?: unknown) { - if (!isBatchNameObject(options)) { - elementsEvents.notifySubscribers(); + // eslint-disable-next-line jsdoc/require-jsdoc + function onBatchStop(_options?: unknown) { + // In controlled mode, skip if we're syncing from React state + if (isControlled && isSyncingFromReactState) { return; } - const { batchName } = options; - - elementsEvents.notifySubscribers(batchName); + elementsEvents.notifySubscribers(); } /** @@ -319,10 +392,36 @@ export function createStoreWithGraph< return dataRef.elements; }, setElements(newElements) { - setElements({ graph, elements: newElements }); + // In controlled mode, mark that we're syncing from React state + if (isControlled) { + isSyncingFromReactState = true; + } + try { + setElements({ graph, elements: newElements }); + } finally { + if (isControlled) { + // Reset flag after a microtask to allow batch operations to complete + Promise.resolve().then(() => { + isSyncingFromReactState = false; + }); + } + } }, setLinks(newLinks) { - setLinks({ graph, links: newLinks }); + // In controlled mode, mark that we're syncing from React state + if (isControlled) { + isSyncingFromReactState = true; + } + try { + setLinks({ graph, links: newLinks }); + } finally { + if (isControlled) { + // Reset flag after a microtask to allow batch operations to complete + Promise.resolve().then(() => { + isSyncingFromReactState = false; + }); + } + } }, getLinks() { return dataRef.links; @@ -351,6 +450,7 @@ export function createStoreWithGraph< hasMeasuredNode(id: dia.Cell.ID) { return measuredNodes.has(id); }, + updateStoreFromExternalData, }; return store; } diff --git a/packages/joint-react/src/data/create-store-data.ts b/packages/joint-react/src/data/create-store-data.ts index f68138011d..1fe90d0c7b 100644 --- a/packages/joint-react/src/data/create-store-data.ts +++ b/packages/joint-react/src/data/create-store-data.ts @@ -18,6 +18,8 @@ interface StoreData< > { /** Rebuilds arrays (and internal indices) from the graph, returns a diff summary */ readonly updateStore: (graph: Graph) => UpdateResult; + /** Updates arrays (and internal indices) from external data (React state), returns a diff summary */ + readonly updateFromExternalData: (elements: Element[], links: GraphLink[]) => UpdateResult; /** Clear everything */ readonly destroy: () => void; @@ -127,7 +129,7 @@ export function createStoreData< } // Deletions: if the new arrays are shorter than old or some ids disappeared, - // we’ve already “changed”. To catch pure deletions where values equal but gone: + // we've already "changed". To catch pure deletions where values equal but gone: if (!areElementsChanged) { areElementsChanged = dataRef.elements.length !== nextElements.length; if (!areElementsChanged) { @@ -174,6 +176,92 @@ export function createStoreData< }; } + /** + * Updates arrays (and internal indices) from external data (React state), returns a diff summary + * Used in controlled mode where React state is the source of truth. + * @param elements - The elements to update the store from. + * @param links - The links to update the store from. + * @returns - The update result containing diff information. + */ + function updateFromExternalData(elements: Element[], links: GraphLink[]): UpdateResult { + const nextElements: Element[] = []; + const nextLinks: GraphLink[] = []; + const nextEIndex = new Map(); + const nextLIndex = new Map(); + const diffIds = new Set(); + + let areElementsChanged = false; + let areLinksChanged = false; + + // Build new arrays and diff per id + for (const element of elements) { + const id = element.id as dia.Cell.ID; + const prev = getElementById(id); + if (!prev || !util.isEqual(prev, element)) { + diffIds.add(id); + areElementsChanged = true; + } + nextEIndex.set(id, nextElements.length); + nextElements.push(element); + } + + for (const link of links) { + const id = link.id as dia.Cell.ID; + const prev = getLinkById(id); + if (!prev || !util.isEqual(prev, link)) { + diffIds.add(id); + areLinksChanged = true; + } + nextLIndex.set(id, nextLinks.length); + nextLinks.push(link); + } + + // Check for deletions and structural changes + if (!areElementsChanged) { + areElementsChanged = dataRef.elements.length !== nextElements.length; + if (!areElementsChanged) { + for (const [i, nextElement] of nextElements.entries()) { + const idNow = nextElement?.id as dia.Cell.ID | undefined; + const prevIdx = idNow ? eIndex.get(idNow) : undefined; + if (prevIdx !== i) { + areElementsChanged = true; + break; + } + } + } + } + if (!areLinksChanged) { + areLinksChanged = dataRef.links.length !== nextLinks.length; + if (!areLinksChanged) { + for (const [i, nextLink] of nextLinks.entries()) { + const idNow = nextLink?.id as dia.Cell.ID | undefined; + const prevIdx = idNow ? lIndex.get(idNow) : undefined; + if (prevIdx !== i) { + areLinksChanged = true; + break; + } + } + } + } + + // Swap (immutably) only when changed to preserve referential equality + if (areElementsChanged) { + dataRef.elements = nextElements; + eIndex = nextEIndex; + } + + if (areLinksChanged) { + dataRef.links = nextLinks; + lIndex = nextLIndex; + } + + return { + diffIds, + areElementsChanged, + areLinksChanged, + }; + } + /** * Clears all elements and links from the store and resets internal indices. */ @@ -186,6 +274,7 @@ export function createStoreData< return { updateStore, + updateFromExternalData, destroy, getElementById, getLinkById, diff --git a/packages/joint-react/src/stories/tutorials/redux/code.tsx b/packages/joint-react/src/stories/tutorials/redux/code.tsx index 02fc874971..f201ec7c3c 100644 --- a/packages/joint-react/src/stories/tutorials/redux/code.tsx +++ b/packages/joint-react/src/stories/tutorials/redux/code.tsx @@ -139,17 +139,17 @@ function RenderItem(props: CustomElement) { ); } -// Component to render the Paper and provide a button to add elements via the graph +// Component to render the Paper and provide controls function PaperApp() { const graph = useGraph(); // Access the graph instance const commandManager = useRef(new dia.CommandManager({ graph })); return ( -
+
{/* Render the Paper component */} - - {/* Button to add a new element directly via the graph */} -
+ + {/* Control buttons */} +
- - + + + + +
+
+ ); +} + +function Main(props: Readonly>) { + const [elements, setElements] = useState(defaultElements); + const [links, setLinks] = useState(defaultLinks); + + const handleElementsChange = useCallback((items: readonly CustomElement[]) => { + setElements(items); + }, []); + + const handleLinksChange = useCallback((items: readonly CustomLink[]) => { + setLinks(items); + }, []); + + return ( + + + + ); +} + +export default function App(props: Readonly>) { + return
; +} + diff --git a/packages/joint-react/src/stories/tutorials/step-by-step/docs.mdx b/packages/joint-react/src/stories/tutorials/step-by-step/docs.mdx index 3cbd066527..1c36cce231 100644 --- a/packages/joint-react/src/stories/tutorials/step-by-step/docs.mdx +++ b/packages/joint-react/src/stories/tutorials/step-by-step/docs.mdx @@ -3,6 +3,7 @@ import * as Stories from './story'; import CodeSVG from './code-svg?raw'; import CodeHTML from './code-html?raw'; import CodeHTMLRenderer from './code-html-renderer?raw'; +import CodeControlledMode from './code-controlled-mode?raw'; import { getAPIDocumentationLink, getAPIPropsLink } from '../../utils/get-api-documentation-link'; @@ -205,6 +206,37 @@ ${CodeHTMLRenderer} \`\`\``} +### Controlled Mode with useState + +This example demonstrates how to use `@joint/react` in controlled mode with React's `useState` hook. In controlled mode, the graph state is managed in React state, and changes to the graph are synchronized back to your state. + + + +{`\`\`\`tsx +${CodeControlledMode} +\`\`\``} + + +#### Key Points: + +1. **State Management**: Use `useState` to manage elements and links: + ```tsx + const [elements, setElements] = useState(defaultElements); + const [links, setLinks] = useState(defaultLinks); + ``` + +2. **Controlled Mode**: Pass state and change handlers to `GraphProvider`: + ```tsx + + ``` + +3. **Bidirectional Sync**: Changes made via graph methods (like dragging nodes) automatically sync back to React state through the callbacks. + --- ## 9. Key terms diff --git a/packages/joint-react/src/stories/tutorials/step-by-step/story.tsx b/packages/joint-react/src/stories/tutorials/step-by-step/story.tsx index dafe98d604..fa7f39724a 100644 --- a/packages/joint-react/src/stories/tutorials/step-by-step/story.tsx +++ b/packages/joint-react/src/stories/tutorials/step-by-step/story.tsx @@ -3,6 +3,7 @@ import '../../examples/index.css'; import CodeSVG from './code-svg'; import CodeHTML from './code-html'; import CodeHTMLPortal from './code-html-renderer'; +import CodeControlledMode from './code-controlled-mode'; export type Story = StoryObj; @@ -20,3 +21,7 @@ export const HTML: Story = { export const HTMLRenderer: Story = { render: CodeHTMLPortal as never, }; + +export const ControlledMode: Story = { + render: CodeControlledMode as never, +}; diff --git a/packages/joint-react/src/utils/graph/update-graph.ts b/packages/joint-react/src/utils/graph/update-graph.ts index 06b50702a0..13dd40db86 100644 --- a/packages/joint-react/src/utils/graph/update-graph.ts +++ b/packages/joint-react/src/utils/graph/update-graph.ts @@ -5,7 +5,6 @@ import type { CellOrJsonCell } from '../cell/cell-utilities'; import { isCellInstance } from '../is'; import type { SVGAttributes } from 'react'; -export const CONTROLLED_MODE_BATCH_NAME = 'controlled-mode'; export const GRAPH_UPDATE_BATCH_NAME = 'update-graph'; /** From 8d9dbc082cf5e6dab376dedc7f559b7a61bc0a0e Mon Sep 17 00:00:00 2001 From: samuelgja Date: Tue, 9 Dec 2025 13:20:10 +0700 Subject: [PATCH 10/24] feat(joint-react): update dependencies, refactor components, and enhance performance - Updated dependencies in yarn.lock, including Babel and added unplugin. - Refactored GraphProvider and Paper components for improved performance and clarity. - Removed unused files and hooks to streamline the codebase. - Enhanced the Storybook setup with a new script for loading react-scan. - Improved element rendering logic and optimized hooks for better state management. - Added tests for new functionalities and ensured existing tests are up to date. --- packages/joint-react-eslint/eslint.config.mjs | 1 + .../joint-react/.storybook/preview-head.html | 55 ++ packages/joint-react/.storybook/preview.ts | 1 - packages/joint-react/.storybook/wdyr.ts | 7 - packages/joint-react/README.md | 103 ++- packages/joint-react/jest.config.js | 1 - packages/joint-react/package.json | 10 +- .../graph/graph-provider.stories.tsx | 6 +- .../src/components/graph/graph-provider.tsx | 420 ++++++----- .../__snapshots__/custom.test.tsx.snap | 6 +- .../__snapshots__/mask.test.tsx.snap | 6 +- .../__snapshots__/opacity.test.tsx.snap | 2 +- .../__snapshots__/store.test.tsx.snap | 2 +- .../highlighters/__tests__/stroke.test.tsx | 9 + .../src/components/highlighters/custom.tsx | 3 +- .../__snapshots__/measured-node.test.tsx.snap | 6 +- .../graph-provider-controlled-mode.test.tsx | 92 +-- .../graph-provider-coverage.test.tsx | 151 +--- .../paper/__tests__/graph-provider.test.tsx | 65 +- .../components/paper/__tests__/paper.test.tsx | 156 +++-- .../src/components/paper/paper.stories.tsx | 5 +- .../src/components/paper/paper.tsx | 294 +++----- .../src/components/paper/paper.types.ts | 8 - .../render-element/paper-element-item.tsx | 15 +- .../__snapshots__/port-group.test.tsx.snap | 2 +- .../__snapshots__/port-item.test.tsx.snap | 2 +- .../src/components/port/port-item.tsx | 23 +- .../__snapshots__/text-node.test.tsx.snap | 8 +- .../text-node/text-node.stories.tsx | 26 +- .../src/components/text-node/text-node.tsx | 2 +- packages/joint-react/src/context/index.ts | 24 +- .../data/__tests__/create-ports-data.test.ts | 28 - .../data/__tests__/create-ports-store.test.ts | 129 ---- .../data/__tests__/create-store-data.test.ts | 60 -- .../src/data/__tests__/create-store.test.ts | 52 -- .../src/data/create-graph-store.ts | 493 ------------- .../joint-react/src/data/create-ports-data.ts | 63 -- .../src/data/create-ports-store.ts | 58 -- .../joint-react/src/data/create-store-data.ts | 283 -------- packages/joint-react/src/data/index.ts | 2 - .../use-are-elements-measured.test.tsx | 65 -- .../src/hooks/__tests__/use-element.test.tsx | 35 +- .../src/hooks/__tests__/use-graph.test.ts | 3 + .../src/hooks/__tests__/use-links.test.ts | 14 +- .../__tests__/use-measure-node-size.test.tsx | 35 +- .../__tests__/use-paper-context.test.tsx | 18 +- packages/joint-react/src/hooks/index.ts | 2 - .../src/hooks/use-are-elements-measured.ts | 23 - .../joint-react/src/hooks/use-cell-actions.ts | 110 ++- .../src/hooks/use-cell-change-effect.ts | 43 ++ .../src/hooks/use-element.stories.tsx | 5 +- packages/joint-react/src/hooks/use-element.ts | 34 +- .../joint-react/src/hooks/use-elements.ts | 24 +- .../src/hooks/use-graph-store-selector.ts | 82 +++ .../joint-react/src/hooks/use-graph-store.ts | 5 +- packages/joint-react/src/hooks/use-graph.ts | 5 +- .../src/hooks/use-imperative-api.ts | 59 +- .../joint-react/src/hooks/use-layout-size.ts | 27 - packages/joint-react/src/hooks/use-links.ts | 69 +- .../src/hooks/use-measure-node-size.tsx | 76 +- .../src/hooks/use-paper-context.ts | 13 +- .../joint-react/src/hooks/use-paper-events.ts | 12 +- packages/joint-react/src/hooks/use-paper.ts | 6 +- .../src/hooks/use-state-to-external-store.ts | 116 ++++ packages/joint-react/src/index.ts | 10 +- .../models/__tests__/react-element.test.ts | 9 + .../src/store/__tests__/graph-sync.test.ts | 656 ++++++++++++++++++ .../store/create-elements-size-observer.ts | 194 ++++++ packages/joint-react/src/store/graph-store.ts | 429 ++++++++++++ packages/joint-react/src/store/graph-sync.ts | 413 +++++++++++ packages/joint-react/src/store/index.ts | 3 + packages/joint-react/src/store/paper-store.ts | 248 +++++++ .../stories/demos/introduction-demo/code.tsx | 6 +- .../examples/with-auto-layout/code.tsx | 24 +- .../code-with-create-links-classname.tsx | 6 +- .../code-with-create-links.tsx | 6 +- .../with-custom-link/code-with-dia-links.tsx | 8 +- .../examples/with-proximity-link/code.tsx | 132 +++- .../src/stories/tutorials/redux/code.tsx | 284 -------- .../src/stories/tutorials/redux/docs.mdx | 75 -- .../src/stories/tutorials/redux/story.tsx | 24 - .../code-controlled-mode-jotai.tsx | 333 +++++++++ .../code-controlled-mode-peerjs.tsx | 615 ++++++++++++++++ .../code-controlled-mode-redux.tsx | 495 +++++++++++++ .../code-controlled-mode-zustand.tsx | 324 +++++++++ .../step-by-step/code-controlled-mode.tsx | 436 ++++++++++-- .../step-by-step/code-html-renderer.tsx | 5 +- .../tutorials/step-by-step/code-html.tsx | 4 +- .../tutorials/step-by-step/code-svg.tsx | 4 +- .../stories/tutorials/step-by-step/docs.mdx | 272 +++++++- .../stories/tutorials/step-by-step/story.tsx | 20 + .../joint-react/src/types/element-types.ts | 20 +- .../create-element-size-observer.test.ts | 88 --- .../src/utils/__tests__/create-state.test.ts | 470 +++++++++++++ .../src/utils/__tests__/get-cell.test.ts | 27 +- .../utils/__tests__/is-react-element.test.ts | 9 + .../src/utils/__tests__/is.test.ts | 6 - .../src/utils/__tests__/noop-selector.test.ts | 9 + .../__tests__/subscriber-handler.test.ts | 121 ---- .../cell/__tests__/cell-utilities.test.ts | 31 - .../get-link-targe-and-source-ids.test.ts | 9 + .../src/utils/cell/cell-utilities.ts | 214 +++--- .../joint-react/src/utils/cell/get-cell.ts | 92 --- .../src/utils/cell/listen-to-cell-change.ts | 37 +- .../src/utils/create-element-size-observer.ts | 101 --- .../joint-react/src/utils/create-state.ts | 234 +++++++ packages/joint-react/src/utils/dev-tools.ts | 31 + .../graph/__tests__/update-graph.test.ts | 230 ------ .../src/utils/graph/update-graph.ts | 119 ---- packages/joint-react/src/utils/is.ts | 16 +- packages/joint-react/src/utils/scheduler.ts | 57 ++ .../src/utils/subscriber-handler.ts | 74 -- .../joint-react/src/utils/test-wrappers.tsx | 9 +- .../utils/{typed-memo.ts => typed-react.ts} | 0 yarn.lock | 628 ++++++++++++++++- 115 files changed, 7365 insertions(+), 3862 deletions(-) delete mode 100644 packages/joint-react/.storybook/wdyr.ts delete mode 100644 packages/joint-react/src/data/__tests__/create-ports-data.test.ts delete mode 100644 packages/joint-react/src/data/__tests__/create-ports-store.test.ts delete mode 100644 packages/joint-react/src/data/__tests__/create-store-data.test.ts delete mode 100644 packages/joint-react/src/data/__tests__/create-store.test.ts delete mode 100644 packages/joint-react/src/data/create-graph-store.ts delete mode 100644 packages/joint-react/src/data/create-ports-data.ts delete mode 100644 packages/joint-react/src/data/create-ports-store.ts delete mode 100644 packages/joint-react/src/data/create-store-data.ts delete mode 100644 packages/joint-react/src/data/index.ts delete mode 100644 packages/joint-react/src/hooks/__tests__/use-are-elements-measured.test.tsx delete mode 100644 packages/joint-react/src/hooks/use-are-elements-measured.ts create mode 100644 packages/joint-react/src/hooks/use-cell-change-effect.ts create mode 100644 packages/joint-react/src/hooks/use-graph-store-selector.ts delete mode 100644 packages/joint-react/src/hooks/use-layout-size.ts create mode 100644 packages/joint-react/src/hooks/use-state-to-external-store.ts create mode 100644 packages/joint-react/src/store/__tests__/graph-sync.test.ts create mode 100644 packages/joint-react/src/store/create-elements-size-observer.ts create mode 100644 packages/joint-react/src/store/graph-store.ts create mode 100644 packages/joint-react/src/store/graph-sync.ts create mode 100644 packages/joint-react/src/store/index.ts create mode 100644 packages/joint-react/src/store/paper-store.ts delete mode 100644 packages/joint-react/src/stories/tutorials/redux/code.tsx delete mode 100644 packages/joint-react/src/stories/tutorials/redux/docs.mdx delete mode 100644 packages/joint-react/src/stories/tutorials/redux/story.tsx create mode 100644 packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-jotai.tsx create mode 100644 packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-peerjs.tsx create mode 100644 packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-redux.tsx create mode 100644 packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-zustand.tsx delete mode 100644 packages/joint-react/src/utils/__tests__/create-element-size-observer.test.ts create mode 100644 packages/joint-react/src/utils/__tests__/create-state.test.ts delete mode 100644 packages/joint-react/src/utils/__tests__/subscriber-handler.test.ts delete mode 100644 packages/joint-react/src/utils/cell/__tests__/cell-utilities.test.ts delete mode 100644 packages/joint-react/src/utils/cell/get-cell.ts delete mode 100644 packages/joint-react/src/utils/create-element-size-observer.ts create mode 100644 packages/joint-react/src/utils/create-state.ts create mode 100644 packages/joint-react/src/utils/dev-tools.ts delete mode 100644 packages/joint-react/src/utils/graph/__tests__/update-graph.test.ts delete mode 100644 packages/joint-react/src/utils/graph/update-graph.ts create mode 100644 packages/joint-react/src/utils/scheduler.ts delete mode 100644 packages/joint-react/src/utils/subscriber-handler.ts rename packages/joint-react/src/utils/{typed-memo.ts => typed-react.ts} (100%) diff --git a/packages/joint-react-eslint/eslint.config.mjs b/packages/joint-react-eslint/eslint.config.mjs index 440c91b3d8..f0ed2d797d 100644 --- a/packages/joint-react-eslint/eslint.config.mjs +++ b/packages/joint-react-eslint/eslint.config.mjs @@ -170,6 +170,7 @@ const config = [ "error", { replacements: { + dev: false, doc: false, Props: false, props: false, diff --git a/packages/joint-react/.storybook/preview-head.html b/packages/joint-react/.storybook/preview-head.html index 54deceec78..7282fbcc5f 100644 --- a/packages/joint-react/.storybook/preview-head.html +++ b/packages/joint-react/.storybook/preview-head.html @@ -1,3 +1,58 @@ + + \ No newline at end of file diff --git a/packages/joint-react/.storybook/preview.ts b/packages/joint-react/.storybook/preview.ts index d7cead3efe..aa30654530 100644 --- a/packages/joint-react/.storybook/preview.ts +++ b/packages/joint-react/.storybook/preview.ts @@ -1,4 +1,3 @@ -import './wdyr'; import type { Preview } from '@storybook/react'; import { withPerformance } from 'storybook-addon-performance'; import { theme } from './theme'; diff --git a/packages/joint-react/.storybook/wdyr.ts b/packages/joint-react/.storybook/wdyr.ts deleted file mode 100644 index 18d18fc352..0000000000 --- a/packages/joint-react/.storybook/wdyr.ts +++ /dev/null @@ -1,7 +0,0 @@ -import React from 'react'; -import whyDidYouRender from '@welldone-software/why-did-you-render'; - -whyDidYouRender(React, { - trackAllPureComponents: true, - trackHooks: true, -}); diff --git a/packages/joint-react/README.md b/packages/joint-react/README.md index a32c486907..e4879933a2 100644 --- a/packages/joint-react/README.md +++ b/packages/joint-react/README.md @@ -160,6 +160,8 @@ Subscribe to pointer events on elements/links. ### 4) Controlled updates (React state drives the graph) Pass `elements/links` + `onElementsChange/onLinksChange` to keep React in charge. +**React-controlled mode** gives you full control over graph state, enabling features like undo/redo, persistence, and integration with other React state management. + ```tsx import React, { useState } from 'react' import { GraphProvider } from '@joint/react' @@ -187,7 +189,78 @@ export function Controlled() { } ``` -### 5) Imperative access (ref) for one‑off actions +### 5) External store integration (Redux, Zustand, etc.) +Use `externalStore` prop to integrate with external state management libraries. + +```tsx +import { GraphProvider } from '@joint/react' +import { createStore } from 'zustand' + +// Create a store compatible with ExternalStoreLike interface +const useGraphStore = createStore((set) => ({ + elements: [], + links: [], + setState: (updater) => set(updater), + getSnapshot: () => useGraphStore.getState(), + subscribe: (listener) => { + const unsubscribe = useGraphStore.subscribe(listener) + return unsubscribe + } +})) + +export function ExternalStoreExample() { + const store = useGraphStore() + + return ( + + + + ) +} +``` + +### 6) Programmatic cell manipulation +Use the `useCellActions` hook to programmatically add, update, and remove cells. + +```tsx +import { useCellActions } from '@joint/react' + +function MyComponent() { + const { set, remove } = useCellActions() + + const addNode = () => { + set({ + id: 'new-node', + x: 100, + y: 100, + width: 120, + height: 60, + label: 'New Node' + }) + } + + const updateNode = () => { + set('new-node', (prev) => ({ + ...prev, + label: 'Updated' + })) + } + + const deleteNode = () => { + remove('new-node') + } + + return ( +
+ + + +
+ ) +} +``` + +### 7) Imperative access (ref) for one‑off actions Useful for `fitToContent`, scaling, exporting. ```tsx @@ -221,24 +294,36 @@ export function FitOnMount() { - **Prefer declarative first**: Reach for hooks/props; use imperative APIs (refs/graph methods) for targeted operations only. - **Test in Safari early** when using ``; fall back to `useHTMLOverlay` if needed. - **Accessing component instances via refs**: Any component that accepts a `ref` (such as `Paper` or `GraphProvider`) exposes its instance/context via the ref. For `Paper`, the instance (including the underlying JointJS Paper) can be accessed via the `paperCtx` property on the ref object. +- **Choose the right mode**: Use uncontrolled mode for simple cases, React-controlled for full state control, and external-store for integration with Redux/Zustand. +- **Use selectors efficiently**: When using `useElements` or `useLinks`, provide custom selectors and equality functions to minimize re-renders. +- **Batch updates**: The library automatically batches updates, but be mindful of rapid state changes in controlled mode. --- ## ⚙️ API Surface (at a glance) - **Components** - - `GraphProvider` — provides the shared graph - - `Paper` — renders the graph (Paper) + - `GraphProvider` — provides the shared graph context + - `Paper` — renders the graph (Paper view) - **Hooks** - - `useElements()` / `useLinks()` — subscribe to data - - `useGraph()` — low-level graph access - - `usePaper()` — access the underlying Paper (from within a view) + - `useElements()` / `useLinks()` — subscribe to elements/links with optional selectors + - `useGraph()` — access the underlying JointJS graph instance + - `usePaper()` — access the underlying Paper instance (from within a Paper view) + - `useCellActions()` — programmatically add, update, and remove cells + +- **Controlled mode props** (React-controlled) + - `elements`, `links` — current state + - `onElementsChange`, `onLinksChange` — state update callbacks + +- **External store mode** + - `externalStore` — external state management store (Redux, Zustand, etc.) -- **Controlled mode props** - - `elements`, `links`, `onElementsChange`, `onLinksChange` +- **Uncontrolled mode** (default) + - `initialElements`, `initialLinks` — initial values only + - Graph manages its own state internally -> Tip: You can pass an existing JointJS `dia.Graph` into `GraphProvider` if you need to integrate with external data lifecycles. +> **Tip:** You can pass an existing JointJS `dia.Graph` into `GraphProvider` if you need to integrate with external data lifecycles or share a graph across multiple providers. --- diff --git a/packages/joint-react/jest.config.js b/packages/joint-react/jest.config.js index dcdc9296f2..651d74e32c 100644 --- a/packages/joint-react/jest.config.js +++ b/packages/joint-react/jest.config.js @@ -1,4 +1,3 @@ -/** @type {import('ts-jest').JestConfigWithTsJest} **/ export default { testEnvironment: 'jsdom', testPathIgnorePatterns: ['/node_modules/', '/dist/'], diff --git a/packages/joint-react/package.json b/packages/joint-react/package.json index 20f5c79a72..4830cc38ae 100644 --- a/packages/joint-react/package.json +++ b/packages/joint-react/package.json @@ -73,23 +73,27 @@ "@types/react": "19.1.12", "@types/react-dom": "19.1.9", "@types/react-test-renderer": "19.1.0", + "@types/scheduler": "^0", "@types/use-sync-external-store": "1.5.0", "@vitejs/plugin-react": "^5.0.2", - "@welldone-software/why-did-you-render": "10.0.1", "canvas": "^3.1.0", "eslint": "9.33.0", "glob": "^11.0.1", "jest": "30.1.2", "jest-environment-jsdom": "30.1.2", + "jotai": "^2.15.2", "knip": "5.63.0", + "peerjs": "^1.5.5", "prettier": "3.3.3", "prettier-plugin-tailwindcss": "^0.6.5", "react": "^19.1.1", "react-docgen-typescript-plugin": "^1.0.8", "react-dom": "^19.1.1", "react-redux": "^9.2.0", + "react-scan": "^0.4.3", "react-test-renderer": "^19.1.1", "redux": "^5.0.1", + "redux-undo": "1.1.0", "storybook": "^8.6.14", "storybook-addon-performance": "0.17.3", "storybook-multilevel-sort": "2.1.0", @@ -104,10 +108,12 @@ "vite-plugin-md": "0.22.5", "vite-plugin-node-polyfills": "^0.24.0", "vite-tsconfig-paths": "^5.1.4", - "vitest": "^3.0.4" + "vitest": "^3.0.4", + "zustand": "^5.0.9" }, "dependencies": { "@joint/core": "workspace:*", + "scheduler": "^0.27.0", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { diff --git a/packages/joint-react/src/components/graph/graph-provider.stories.tsx b/packages/joint-react/src/components/graph/graph-provider.stories.tsx index 28c077d5f7..462264f969 100644 --- a/packages/joint-react/src/components/graph/graph-provider.stories.tsx +++ b/packages/joint-react/src/components/graph/graph-provider.stories.tsx @@ -121,11 +121,7 @@ function Component() { setIsReady(true); }, 1000); }, []); - return ( - isReady && ( - - ) - ); + return isReady && ; } export const ConditionalRender: Story = { diff --git a/packages/joint-react/src/components/graph/graph-provider.tsx b/packages/joint-react/src/components/graph/graph-provider.tsx index d1ac0c3e31..e31f3976fb 100644 --- a/packages/joint-react/src/components/graph/graph-provider.tsx +++ b/packages/joint-react/src/components/graph/graph-provider.tsx @@ -1,226 +1,161 @@ import type { dia } from '@joint/core'; import type { GraphLink } from '../../types/link-types'; -import { - forwardRef, - useLayoutEffect, - useRef, - type Dispatch, - type PropsWithChildren, - type SetStateAction, -} from 'react'; -import { createStore, type GraphStore } from '../../data/create-graph-store'; -import { useElements } from '../../hooks/use-elements'; -import { useGraphStore } from '../../hooks'; +import { forwardRef, type Dispatch, type SetStateAction } from 'react'; import type { GraphElement } from '../../types/element-types'; import { useImperativeApi } from '../../hooks/use-imperative-api'; -import { GraphAreElementsMeasuredContext, GraphStoreContext } from '../../context'; +import { GraphStoreContext } from '../../context'; +import { GraphStore, type ExternalGraphStore } from '../../store'; +import { useStateToExternalStore } from '../../hooks/use-state-to-external-store'; -interface GraphProviderBaseProps< - Element extends dia.Element | GraphElement, - Link extends dia.Link | GraphLink, -> { +/** + * Props for GraphProvider component. + * Supports three modes: uncontrolled, React-controlled, and external-store-controlled. + */ +interface GraphProviderProps { /** - * Elements (nodes) to be added to graph. - * When `onElementsChange`, it enabled controlled mode. - * If there is no `onElementsChange` provided, it will be used just on onload (initial) + * Elements (nodes) to be added to the graph. + * + * **Controlled mode:** When `onElementsChange` is provided, this prop controls the elements. + * All changes must go through React state updates. + * + * **Uncontrolled mode:** If `onElementsChange` is not provided, this is only used for initial elements. + * The graph manages its own state internally. */ - readonly elements?: Element[]; + readonly elements?: GraphElement[]; /** - * Links (edges) to be added to graph. - * When `onLinksChange`, it enabled controlled mode. - * If there is no `onLinksChange` provided, it will be used just on onload (initial) + * Links (edges) to be added to the graph. + * + * **Controlled mode:** When `onLinksChange` is provided, this prop controls the links. + * All changes must go through React state updates. + * + * **Uncontrolled mode:** If `onLinksChange` is not provided, this is only used for initial links. + * The graph manages its own state internally. */ - readonly links?: Link[]; + readonly links?: GraphLink[]; /** - * Callback triggered when eleme§nts (nodes) change. - * Providing this prop enables controlled mode for elements. - * If specified, this function will override the default behavior, allowing you to manage all element changes manually instead of relying on `graph.change`. + * Callback triggered when elements (nodes) change in the graph. + * + * **Enables React-controlled mode for elements.** + * When provided, all element changes (from user interactions or programmatic updates) + * will trigger this callback, allowing you to manage element state in React. + * + * This gives you full control over element state and enables features like: + * - Undo/redo functionality + * - State persistence + * - Integration with other React state management */ - readonly onElementsChange?: Dispatch>; + readonly onElementsChange?: Dispatch>; /** - * Callback triggered when links (edges) change. - * Providing this prop enables controlled mode for links. - * If specified, this function will override the default behavior, allowing you to manage all link changes manually instead of relying on `graph.change`. + * Callback triggered when links (edges) change in the graph. + * + * **Enables React-controlled mode for links.** + * When provided, all link changes (from user interactions or programmatic updates) + * will trigger this callback, allowing you to manage link state in React. + * + * This gives you full control over link state and enables features like: + * - Undo/redo functionality + * - State persistence + * - Integration with other React state management */ - readonly onLinksChange?: Dispatch>; + readonly onLinksChange?: Dispatch>; } /** - * Internal handler coordinating initial population and controlled-mode mirroring - * for elements and links. Also delays link creation until elements are measured - * in uncontrolled mode to avoid flicker. - * @param props - The properties for the GraphProviderHandler, including elements, links, and callbacks. - * @returns A context provider for the measured state of elements. - * @private + * Props for the GraphProvider component. + * Extends GraphProviderProps with additional configuration options. */ -export function GraphProviderHandler< - Element extends dia.Element | GraphElement = dia.Element, - Link extends dia.Link | GraphLink = dia.Link, ->(props: PropsWithChildren>) { - const { elements, links, onElementsChange, onLinksChange, children } = props; - const alreadyMeasured = useRef(false); - const areElementsMeasured = useElements((items) => { - if (alreadyMeasured.current) return true; - let areMeasured = true; - for (const { width = 0, height = 0 } of items) { - if (width <= 1 || height <= 1) { - areMeasured = false; - break; - } - } - alreadyMeasured.current = areMeasured; - return areMeasured; - }); - - const { graph, setElements, setLinks, updateStoreFromExternalData, getElements, getLinks } = - useGraphStore(); - - const areElementsInControlledMode = !!onElementsChange; - const areLinksInControlledMode = !!onLinksChange; - - // Controlled mode: update store from React state when elements or links change - useLayoutEffect(() => { - if (!areElementsMeasured) return; - if (!graph) return; - if (!areElementsInControlledMode && !areLinksInControlledMode) return; - - // Get current store state to preserve the other type when updating one - const currentElements = - areElementsInControlledMode && elements !== undefined - ? (elements as GraphElement[]) - : getElements(); - const currentLinks = - areLinksInControlledMode && links !== undefined ? (links as GraphLink[]) : getLinks(); - - // Update store cache directly from React state (preserving the other type) - if (areElementsInControlledMode || areLinksInControlledMode) { - updateStoreFromExternalData(currentElements, currentLinks); - } - - // Then sync React state → graph (this will be marked as controlled sync to prevent circular updates) - if (areElementsInControlledMode && elements !== undefined) { - setElements(elements as GraphElement[]); - } - if (areLinksInControlledMode && links !== undefined) { - setLinks(links as GraphLink[]); - } - }, [ - areElementsInControlledMode, - areLinksInControlledMode, - areElementsMeasured, - elements, - links, - graph, - setElements, - setLinks, - updateStoreFromExternalData, - getElements, - getLinks, - ]); - - return ( - - {children} - - ); -} - -export interface GraphProps< - Graph extends dia.Graph = dia.Graph, - Element extends dia.Element | GraphElement = GraphElement, - Link extends dia.Link | GraphLink = GraphLink, -> extends GraphProviderBaseProps { +export interface GraphProps extends GraphProviderProps { /** * Graph instance to use. If not provided, a new graph instance will be created. + * + * Useful when you need to: + * - Share a graph instance across multiple providers + * - Integrate with existing JointJS code + * - Control graph initialization manually * @see https://docs.jointjs.com/api/dia/Graph * @default new dia.Graph({}, { cellNamespace: shapes }) */ - readonly graph?: Graph; + readonly graph?: dia.Graph; /** - * Children to render. + * React children to render inside the GraphProvider. + * Typically includes Paper components and other graph-related components. */ readonly children?: React.ReactNode; /** - * Namespace for cell models. - * It's loaded just once, so it cannot be used as React state. - * When added new shape, it will not remove existing ones, it will just add new ones. - * So `{ ...shapes, ReactElement }` elements are still available. - * @default `{ ...shapes, ReactElement }` + * Namespace for cell models. Defines which cell types are available in the graph. + * + * **Important:** This is loaded just once during initialization, so it cannot be used as React state. + * + * When provided, it will be merged with the default namespace (`{ ...shapes, ReactElement }`). + * Existing shapes are not removed, only new ones are added. + * @default `{ ...shapes, ReactElement }` * @see https://docs.jointjs.com/api/shapes */ readonly cellNamespace?: unknown; /** - * Custom cell model to use. - * It's loaded just once, so it cannot be used as React state. + * Custom cell model to use as the base class for all cells in the graph. + * + * **Important:** This is loaded just once during initialization, so it cannot be used as React state. * @see https://docs.jointjs.com/api/dia/Cell */ readonly cellModel?: typeof dia.Cell; /** - * Store is build around graph, it handles react updates and states, it can be created separately and passed to the provider via `createStore` function. - * @see `createStore` + * Pre-created GraphStore instance to use. + * + * The store handles React updates and state synchronization. + * If not provided, a new store will be created automatically. + * + * Useful for: + * - Sharing a store across multiple providers + * - Advanced use cases requiring manual store management */ - readonly store?: GraphStore; + readonly store?: GraphStore; + + /** + * External state store (Redux, Zustand, etc.) controlling elements and links. + * + * **Enables external-store-controlled mode.** + * When provided, GraphStore will treat this as the source of truth for elements and links. + * This takes precedence over React-controlled mode (onElementsChange/onLinksChange). + * + * The external store must implement the ExternalStoreLike interface, which is compatible + * with most state management libraries. + */ + readonly externalStore?: ExternalGraphStore; } /** - * Graph component creates a graph instance and provides `dia.graph` to its children. - * This component is essential for the library to function correctly. It manages the graph instance and supports controlled and uncontrolled modes for elements and links. - * @param props - The properties for the Graph component. - * @param forwardedRef - A reference to the GraphStore instance. - * @returns The Graph component. - * @example - * Using the Graph component: - * ```tsx - * import { Graph } from '@joint/react'; - * function App() { - * return ( - * - * - * - * ); - * } - * ``` - * @example - * Using the Graph component with default elements and links: - * ```tsx - * import { Graph } from '@joint/react'; - * function App() { - * return ( - * - * - * - * ); - * } - * ``` + * GraphBase component that handles uncontrolled mode. + * Used when no external store or React state setters are provided. + * The graph manages its own state internally. */ -function GraphBase( - props: Readonly>, - forwardedRef: React.Ref -) { - const { children, store, ...rest } = props; - /** - * Graph store instance. - * @returns - The graph store instance. - */ +const GraphBase = forwardRef(function GraphBase(props, forwardedRef) { + const { children, store, elements, links, ...rest } = props; const { isReady, ref } = useImperativeApi( { forwardedRef, onLoad() { - const newStore = store ?? createStore({ ...rest }); - // We must use state initialization for the store, because it can be used in the same component. + const graphStore = + store ?? + new GraphStore({ + ...rest, + initialElements: elements, + initialLinks: links, + }); return { cleanup() { - if (newStore) { - newStore.destroy(!!rest.graph || !!store?.graph); + if (graphStore) { + // If graph or store.graph was provided externally, we don't clear it. + graphStore.destroy(!!rest.graph || !!store?.graph); } }, - instance: newStore, + instance: graphStore, }; }, }, @@ -231,25 +166,134 @@ function GraphBase - {children} - - ); -} + return {children}; +}); + +/** + * GraphBaseWithSetters component that handles React-controlled mode. + * Used when onElementsChange and/or onLinksChange props are provided. + * All graph changes are synchronized with React state. + */ +const GraphBaseWithSetters = forwardRef( + function GraphBaseWithSetters(props, forwardedRef) { + const { children, store, onElementsChange, onLinksChange, elements, links, ...rest } = props; + + const externalStoreLike = useStateToExternalStore({ + elements, + links, + onElementsChange, + onLinksChange, + }); + + const { isReady, ref } = useImperativeApi( + { + forwardedRef, + onLoad() { + const graphStore = + store ?? + new GraphStore({ + ...rest, + initialElements: elements, + initialLinks: links, + externalStore: externalStoreLike, + }); + + return { + cleanup() { + if (graphStore) { + // If graph or store.graph was provided externally, we don't clear it. + graphStore.destroy(!!rest.graph || !!store?.graph); + } + }, + instance: graphStore, + }; + }, + }, + [] + ); + + if (!isReady) { + return null; + } + + return {children}; + } +); /** - * GraphProviderHandler component is used to handle the graph instance and provide it to the children. - * It also handles the default elements and links. - * @returns GraphProviderHandler component - * @param props - {GraphProviderHandler} props - * @private + * GraphBaseRouter component that routes to the appropriate implementation based on props. + * + * Supports three modes: + * 1. **Uncontrolled mode:** No external store or setters - graph manages its own state + * 2. **React-controlled mode:** onElementsChange/onLinksChange provided - React state controls the graph + * 3. **External-store-controlled mode:** externalStore provided - external state management controls the graph + * + * The router automatically selects the correct implementation based on which props are provided. + * External store takes precedence over React-controlled mode. + * @param props - The props for the GraphProvider component + * @param forwardedRef - The forwarded ref for GraphStore instance + * @returns The appropriate GraphBase component or null if not ready */ -export const GraphProvider = forwardRef(GraphBase) as < - Element extends dia.Element | GraphElement, - Link extends dia.Link | GraphLink, ->( - props: Readonly> & { - ref?: React.Ref; +const GraphBaseRouter = forwardRef( + function GraphBaseRouter(props, forwardedRef) { + const { externalStore, onElementsChange, onLinksChange } = props; + + // externalStore takes precedence over React-controlled mode + if (externalStore) { + return ; + } + + // React-controlled mode (with setters) + if (onElementsChange || onLinksChange) { + return ; + } + + // Uncontrolled mode + return ; } -) => ReturnType; +); + +/** + * GraphProvider is the main component that provides graph context to its children. + * + * It creates and manages a GraphStore instance, which handles: + * - Graph state management + * - Bidirectional synchronization between React state and JointJS graph + * - Multiple paper view coordination + * - Element size observation + * + * **Three modes of operation:** + * + * 1. **Uncontrolled mode** (default): + * ```tsx + * + * + * + * ``` + * + * 2. **React-controlled mode:** + * ```tsx + * const [elements, setElements] = useState([]); + * const [links, setLinks] = useState([]); + * + * + * + * + * ``` + * + * 3. **External-store-controlled mode:** + * ```tsx + * const store = createExternalStore(); // Redux, Zustand, etc. + * + * + * + * + * ``` + * @see GraphProps for all available props + */ +export const GraphProvider = GraphBaseRouter; diff --git a/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/custom.test.tsx.snap b/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/custom.test.tsx.snap index 43a36a4a35..b70fc4d456 100644 --- a/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/custom.test.tsx.snap +++ b/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/custom.test.tsx.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`Highlighters/Custom "CustomWithMask": Highlighters/Custom-CustomWithMask 1`] = `"
"`; +exports[`Highlighters/Custom "CustomWithMask": Highlighters/Custom-CustomWithMask 1`] = `"
"`; -exports[`Highlighters/Custom "CustomWithOpacity": Highlighters/Custom-CustomWithOpacity 1`] = `"
"`; +exports[`Highlighters/Custom "CustomWithOpacity": Highlighters/Custom-CustomWithOpacity 1`] = `"
"`; -exports[`Highlighters/Custom "CustomWithStroke": Highlighters/Custom-CustomWithStroke 1`] = `"
"`; +exports[`Highlighters/Custom "CustomWithStroke": Highlighters/Custom-CustomWithStroke 1`] = `"
"`; diff --git a/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/mask.test.tsx.snap b/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/mask.test.tsx.snap index 7f99da095b..7c877b4d3b 100644 --- a/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/mask.test.tsx.snap +++ b/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/mask.test.tsx.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`Highlighters/Mask "Default": Highlighters/Mask-Default 1`] = `"
"`; +exports[`Highlighters/Mask "Default": Highlighters/Mask-Default 1`] = `"
"`; -exports[`Highlighters/Mask "WithPadding": Highlighters/Mask-WithPadding 1`] = `"
"`; +exports[`Highlighters/Mask "WithPadding": Highlighters/Mask-WithPadding 1`] = `"
"`; -exports[`Highlighters/Mask "WithSVGProps": Highlighters/Mask-WithSVGProps 1`] = `"
"`; +exports[`Highlighters/Mask "WithSVGProps": Highlighters/Mask-WithSVGProps 1`] = `"
"`; diff --git a/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/opacity.test.tsx.snap b/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/opacity.test.tsx.snap index fbc3cd6e1b..20c148b7f8 100644 --- a/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/opacity.test.tsx.snap +++ b/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/opacity.test.tsx.snap @@ -1,3 +1,3 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`Highlighters/Opacity "Default": Highlighters/Opacity-Default 1`] = `"
"`; +exports[`Highlighters/Opacity "Default": Highlighters/Opacity-Default 1`] = `"
"`; diff --git a/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/store.test.tsx.snap b/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/store.test.tsx.snap index 5a312dd833..44c5882544 100644 --- a/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/store.test.tsx.snap +++ b/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/store.test.tsx.snap @@ -1,3 +1,3 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`Highlighters/Stroke "Default": Highlighters/Stroke-Default 1`] = `"
"`; +exports[`Highlighters/Stroke "Default": Highlighters/Stroke-Default 1`] = `"
"`; diff --git a/packages/joint-react/src/components/highlighters/__tests__/stroke.test.tsx b/packages/joint-react/src/components/highlighters/__tests__/stroke.test.tsx index 83b05d3190..250d623501 100644 --- a/packages/joint-react/src/components/highlighters/__tests__/stroke.test.tsx +++ b/packages/joint-react/src/components/highlighters/__tests__/stroke.test.tsx @@ -77,3 +77,12 @@ describe('Stroke highlighter', () => { expect(container).toBeDefined(); }); }); + + + + + + + + + diff --git a/packages/joint-react/src/components/highlighters/custom.tsx b/packages/joint-react/src/components/highlighters/custom.tsx index 1e869fe3e7..fbee657893 100644 --- a/packages/joint-react/src/components/highlighters/custom.tsx +++ b/packages/joint-react/src/components/highlighters/custom.tsx @@ -4,7 +4,7 @@ import { useCellId } from '../../hooks/use-cell-id'; import { usePaper } from '../../hooks/use-paper'; import type { dia } from '@joint/core'; import { useChildrenRef } from '../../hooks/use-children-ref'; -import typedMemo from '../../utils/typed-memo'; +import typedMemo from '../../utils/typed-react'; import { useImperativeApi } from '../../hooks/use-imperative-api'; import { assignOptions, dependencyExtract } from '../../utils/object-utilities'; @@ -72,6 +72,7 @@ function RawComponent< highlighterId, options ); + return { instance, cleanup() { diff --git a/packages/joint-react/src/components/measured-node/__tests__/__snapshots__/measured-node.test.tsx.snap b/packages/joint-react/src/components/measured-node/__tests__/__snapshots__/measured-node.test.tsx.snap index fc1f23528c..fc05f473a5 100644 --- a/packages/joint-react/src/components/measured-node/__tests__/__snapshots__/measured-node.test.tsx.snap +++ b/packages/joint-react/src/components/measured-node/__tests__/__snapshots__/measured-node.test.tsx.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`MeasuredNode "DivWithExactSize": MeasuredNode-DivWithExactSize 1`] = `"
"`; +exports[`MeasuredNode "DivWithExactSize": MeasuredNode-DivWithExactSize 1`] = `"
"`; -exports[`MeasuredNode "DivWithPaddingAndText": MeasuredNode-DivWithPaddingAndText 1`] = `"
"`; +exports[`MeasuredNode "DivWithPaddingAndText": MeasuredNode-DivWithPaddingAndText 1`] = `"
"`; -exports[`MeasuredNode "TailwindSizing": MeasuredNode-TailwindSizing 1`] = `"
"`; +exports[`MeasuredNode "TailwindSizing": MeasuredNode-TailwindSizing 1`] = `"
"`; diff --git a/packages/joint-react/src/components/paper/__tests__/graph-provider-controlled-mode.test.tsx b/packages/joint-react/src/components/paper/__tests__/graph-provider-controlled-mode.test.tsx index 70606feb93..84bee62f3f 100644 --- a/packages/joint-react/src/components/paper/__tests__/graph-provider-controlled-mode.test.tsx +++ b/packages/joint-react/src/components/paper/__tests__/graph-provider-controlled-mode.test.tsx @@ -6,6 +6,8 @@ import { dia } from '@joint/core'; import { useElements, useLinks, useGraph } from '../../../hooks'; import { createElements } from '../../../utils/create'; import type { GraphElement } from '../../../types/element-types'; +import type { GraphLink } from '../../../types/link-types'; +import { linkFromGraph } from '../../../utils/cell/cell-utilities'; import { GraphProvider } from '../../graph/graph-provider'; describe('GraphProvider Controlled Mode', () => { @@ -27,7 +29,7 @@ describe('GraphProvider Controlled Mode', () => { } function ControlledGraph() { - const [elements, setElements] = useState(initialElements); + const [elements, setElements] = useState(initialElements); return ( @@ -61,7 +63,7 @@ describe('GraphProvider Controlled Mode', () => { let setElementsExternal: ((elements: GraphElement[]) => void) | null = null; function ControlledGraph() { - const [elements, setElements] = useState(initialElements); + const [elements, setElements] = useState(initialElements); setElementsExternal = setElements as (elements: GraphElement[]) => void; return ( @@ -114,13 +116,13 @@ describe('GraphProvider Controlled Mode', () => { } let setElementsExternal: ((elements: GraphElement[]) => void) | null = null; - let setLinksExternal: ((links: dia.Link[]) => void) | null = null; + let setLinksExternal: ((links: GraphLink[]) => void) | null = null; function ControlledGraph() { - const [elements, setElements] = useState(initialElements); - const [links, setLinks] = useState([initialLink]); + const [elements, setElements] = useState(initialElements); + const [links, setLinks] = useState([linkFromGraph(initialLink)]); setElementsExternal = setElements as (elements: GraphElement[]) => void; - setLinksExternal = setLinks as (links: dia.Link[]) => void; + setLinksExternal = setLinks as (links: GraphLink[]) => void; return ( { // Update links only act(() => { setLinksExternal?.([ - new dia.Link({ - id: 'link1', - type: 'standard.Link', - source: { id: '1' }, - target: { id: '2' }, - }), - new dia.Link({ - id: 'link2', - type: 'standard.Link', - source: { id: '2' }, - target: { id: '1' }, - }), + linkFromGraph( + new dia.Link({ + id: 'link1', + type: 'standard.Link', + source: { id: '1' }, + target: { id: '2' }, + }) + ), + linkFromGraph( + new dia.Link({ + id: 'link2', + type: 'standard.Link', + source: { id: '2' }, + target: { id: '1' }, + }) + ), ]); }); @@ -197,7 +203,7 @@ describe('GraphProvider Controlled Mode', () => { let setElementsExternal: ((elements: GraphElement[]) => void) | null = null; function ControlledGraph() { - const [elements, setElements] = useState(initialElements); + const [elements, setElements] = useState(initialElements); setElementsExternal = setElements as (elements: GraphElement[]) => void; return ( @@ -260,7 +266,7 @@ describe('GraphProvider Controlled Mode', () => { let setElementsExternal: ((elements: GraphElement[]) => void) | null = null; function ControlledGraph() { - const [elements, setElements] = useState(initialElements); + const [elements, setElements] = useState(initialElements); setElementsExternal = setElements as (elements: GraphElement[]) => void; return ( @@ -322,13 +328,13 @@ describe('GraphProvider Controlled Mode', () => { } let setElementsExternal: ((elements: GraphElement[]) => void) | null = null; - let setLinksExternal: ((links: dia.Link[]) => void) | null = null; + let setLinksExternal: ((links: GraphLink[]) => void) | null = null; function ControlledGraph() { - const [elements, setElements] = useState(initialElements); - const [links, setLinks] = useState([initialLink]); + const [elements, setElements] = useState(initialElements); + const [links, setLinks] = useState([linkFromGraph(initialLink)]); setElementsExternal = setElements as (elements: GraphElement[]) => void; - setLinksExternal = setLinks as (links: dia.Link[]) => void; + setLinksExternal = setLinks as (links: GraphLink[]) => void; return ( { ]) ); setLinksExternal?.([ - new dia.Link({ - id: 'link1', - type: 'standard.Link', - source: { id: '1' }, - target: { id: '2' }, - }), - new dia.Link({ - id: 'link2', - type: 'standard.Link', - source: { id: '2' }, - target: { id: '1' }, - }), + linkFromGraph( + new dia.Link({ + id: 'link1', + type: 'standard.Link', + source: { id: '1' }, + target: { id: '2' }, + }) + ), + linkFromGraph( + new dia.Link({ + id: 'link2', + type: 'standard.Link', + source: { id: '2' }, + target: { id: '1' }, + }) + ), ]); }); @@ -395,7 +405,7 @@ describe('GraphProvider Controlled Mode', () => { } function ControlledGraph() { - const [elements, setElements] = useState(initialElements); + const [elements, setElements] = useState(initialElements); const handleAddElement = useCallback(() => { setElements((previous) => [ @@ -457,7 +467,7 @@ describe('GraphProvider Controlled Mode', () => { } function ControlledGraph() { - const [elements, setElements] = useState(initialElements); + const [elements, setElements] = useState(initialElements); reactStateElements = elements; return ( @@ -522,7 +532,7 @@ describe('GraphProvider Controlled Mode', () => { let reactStateElements: GraphElement[] = []; function ControlledGraph() { - const [elements, setElements] = useState(initialElements); + const [elements, setElements] = useState(initialElements); reactStateElements = elements; return ( @@ -591,7 +601,7 @@ describe('GraphProvider Controlled Mode', () => { let setElementsExternal: ((elements: GraphElement[]) => void) | null = null; function ControlledGraph() { - const [elements, setElements] = useState(initialElements); + const [elements, setElements] = useState(initialElements); setElementsExternal = setElements as (elements: GraphElement[]) => void; return ( @@ -642,7 +652,7 @@ describe('GraphProvider Controlled Mode', () => { function ControlledGraph() { const [elements, setElements] = useState([]); - const [links, setLinks] = useState([]); + const [links, setLinks] = useState([]); return ( { describe('Edge cases for controlled mode', () => { @@ -45,7 +46,7 @@ describe('GraphProvider Coverage Tests', () => { } function ControlledGraph() { - const [links, setLinks] = useState([]); + const [links, setLinks] = useState([]); return ( @@ -75,7 +76,7 @@ describe('GraphProvider Coverage Tests', () => { } function ControlledGraph() { - const [elements, setElements] = useState(initialElements); + const [elements, setElements] = useState(initialElements); return ( @@ -109,7 +110,7 @@ describe('GraphProvider Coverage Tests', () => { } function ControlledGraph() { - const [links, setLinks] = useState([initialLink]); + const [links, setLinks] = useState([linkFromGraph(initialLink)]); return ( @@ -126,134 +127,12 @@ describe('GraphProvider Coverage Tests', () => { }); }); - describe('create-graph-store error cases', () => { - it('should throw error when graph is null in createStoreWithGraph', () => { - expect(() => { - createStoreWithGraph({ - graph: undefined as unknown as dia.Graph, - }); - }).toThrow('Graph instance is required'); - }); - - it('should throw error when getLink is called with non-existent id', () => { - const graph = new dia.Graph(); - const store = createStoreWithGraph({ graph }); - - expect(() => { - store.getLink('non-existent-id'); - }).toThrow('Link with id non-existent-id not found'); - }); - - it('should handle skipGraphUpdate path in forceUpdateStore', async () => { - const graph = new dia.Graph(); - const store = createStoreWithGraph({ - graph, - onElementsChange: () => {}, - }); - - // Force update with skipGraphUpdate flag - const result = store.forceUpdateStore(undefined, true); - - expect(result).toBeDefined(); - expect(result.areElementsChanged).toBe(false); - expect(result.areLinksChanged).toBe(false); - }); - }); - - describe('create-store-data structural changes', () => { - it('should detect reordering of elements in updateFromExternalData', () => { + describe('GraphStore error cases', () => { + it('should create store with graph', () => { const graph = new dia.Graph(); - const store = createStoreWithGraph({ graph }); - - const elements1 = createElements([ - { id: '1', width: 100, height: 100, type: 'ReactElement' }, - { id: '2', width: 100, height: 100, type: 'ReactElement' }, - ]); - - const elements2 = createElements([ - { id: '2', width: 100, height: 100, type: 'ReactElement' }, - { id: '1', width: 100, height: 100, type: 'ReactElement' }, - ]); - - // Initial update - store.updateStoreFromExternalData(elements1, []); - - // Reorder (same elements, different order) - const result = store.updateStoreFromExternalData(elements2, []); - - // Reordering should be detected as a structural change - expect(result.areElementsChanged).toBe(true); - }); - - it('should detect reordering of links in updateFromExternalData', () => { - const graph = new dia.Graph(); - const store = createStoreWithGraph({ graph }); - - // Create links as JSON to match GraphLink type - const link1: GraphLink = { - id: 'link1', - type: 'standard.Link', - source: '1', - target: '2', - }; - const link2: GraphLink = { - id: 'link2', - type: 'standard.Link', - source: '2', - target: '3', - }; - - // Initial update - store.updateStoreFromExternalData([], [link1, link2]); - - // Reorder (same links, different order) - create new objects to ensure they're different references - const link1Reordered: GraphLink = { - id: 'link1', - type: 'standard.Link', - source: '1', - target: '2', - }; - const link2Reordered: GraphLink = { - id: 'link2', - type: 'standard.Link', - source: '2', - target: '3', - }; - const result = store.updateStoreFromExternalData([], [link2Reordered, link1Reordered]); - - // Reordering should be detected as a structural change - expect(result.areLinksChanged).toBe(true); - }); - - it('should detect changes in updateStore when graph cells are modified', () => { - const graph = new dia.Graph(); - const element1 = new dia.Element({ - id: '1', - type: 'ReactElement', - position: { x: 0, y: 0 }, - size: { width: 100, height: 100 }, - }); - const element2 = new dia.Element({ - id: '2', - type: 'ReactElement', - position: { x: 200, y: 0 }, - size: { width: 100, height: 100 }, - }); - - graph.addCell([element1, element2]); - - const store = createStoreWithGraph({ graph }); - - // Initial update - store.forceUpdateStore(); - - // Change element position to trigger update - element1.set('position', { x: 10, y: 10 }); - const result = store.forceUpdateStore(); - - // Should detect change - expect(result.areElementsChanged).toBe(true); - expect(result.diffIds.has('1')).toBe(true); + const store = new GraphStore({ graph }); + expect(store).toBeDefined(); + expect(store.graph).toBe(graph); }); }); @@ -272,7 +151,7 @@ describe('GraphProvider Coverage Tests', () => { } function ControlledGraph() { - const [elements, setElements] = useState(unmeasuredElements); + const [elements, setElements] = useState(unmeasuredElements); return ( @@ -290,7 +169,7 @@ describe('GraphProvider Coverage Tests', () => { it('should handle cleanup in GraphBase when store exists', () => { const graph = new dia.Graph(); - const store = createStoreWithGraph({ graph }); + const store = new GraphStore({ graph }); const { unmount } = render( @@ -300,14 +179,16 @@ describe('GraphProvider Coverage Tests', () => { // Store should work before unmount expect(() => { - store.getElements(); + const { elements } = store.publicState.getSnapshot(); + expect(elements).toBeDefined(); }).not.toThrow(); unmount(); // Store should still work after unmount (it's not destroyed) expect(() => { - store.getElements(); + const { elements } = store.publicState.getSnapshot(); + expect(elements).toBeDefined(); }).not.toThrow(); }); }); diff --git a/packages/joint-react/src/components/paper/__tests__/graph-provider.test.tsx b/packages/joint-react/src/components/paper/__tests__/graph-provider.test.tsx index eac4d99730..9a9d82e74d 100644 --- a/packages/joint-react/src/components/paper/__tests__/graph-provider.test.tsx +++ b/packages/joint-react/src/components/paper/__tests__/graph-provider.test.tsx @@ -1,11 +1,13 @@ import React, { createRef, useState } from 'react'; import { act, render, waitFor } from '@testing-library/react'; import { GraphStoreContext } from '../../../context'; -import { createStore, type GraphStore } from '../../../data/create-graph-store'; -import { dia } from '@joint/core'; +import { GraphStore } from '../../../store'; +import { dia, shapes } from '@joint/core'; import { useElements, useLinks } from '../../../hooks'; import { createElements } from '../../../utils/create'; import type { GraphElement } from '../../../types/element-types'; +import type { GraphLink } from '../../../types/link-types'; +import { linkFromGraph } from '../../../utils/cell/cell-utilities'; import { GraphProvider } from '../../graph/graph-provider'; describe('graph', () => { @@ -57,7 +59,7 @@ describe('graph', () => { } render( // eslint-disable-next-line react-perf/jsx-no-new-array-as-prop - + ); @@ -126,9 +128,8 @@ describe('graph', () => { it('should use provided store and clean up on unmount', () => { const mockDestroy = jest.fn(); - const mockStore = createStore({}); - // @ts-expect-error its just unit test, readonly is not needed - mockStore.destroy = mockDestroy; + const mockStore = new GraphStore({}); + jest.spyOn(mockStore, 'destroy').mockImplementation(mockDestroy); const { unmount } = render( @@ -142,7 +143,7 @@ describe('graph', () => { }); it('should use graph provided by PaperOptions', async () => { - const graph = new dia.Graph(); + const graph = new dia.Graph({}, { cellNamespace: shapes }); const cell = new dia.Element({ id: 'element1', type: 'standard.Rectangle' }); graph.addCell(cell); let currentElements: GraphElement[] = []; @@ -159,11 +160,10 @@ describe('graph', () => { ); - expect(graph.getCell('element1')).toBe(cell); - await waitFor(() => { expect(graph.getCells()).toHaveLength(1); expect(currentElements).toHaveLength(1); + expect(graph.getCell('element1')).toBeDefined(); }); act(() => { @@ -186,7 +186,7 @@ describe('graph', () => { it('should use store provided by PaperOptions', async () => { const graph = new dia.Graph(); - const store = createStore({ graph }); + const store = new GraphStore({ graph }); const cell = new dia.Element({ id: 'element1', type: 'standard.Rectangle' }); graph.addCell(cell); let currentElements: GraphElement[] = []; @@ -251,7 +251,7 @@ describe('graph', () => { } render( // eslint-disable-next-line react-perf/jsx-no-new-array-as-prop - + ); @@ -288,15 +288,14 @@ describe('graph', () => { return null; } - // eslint-disable-next-line unicorn/consistent-function-scoping - let setElementsOutside = (_: GraphElement[]) => {}; - let setLinksOutside = (_: dia.Link[]) => {}; + let setElementsOutside: ((elements: GraphElement[]) => void) | null = null; + let setLinksOutside: ((links: GraphLink[]) => void) | null = null; function Graph() { - const [elements, setElements] = useState(initialElements); - const [links, setLinks] = useState([initialLink]); + const [elements, setElements] = useState(initialElements); + const [links, setLinks] = useState([linkFromGraph(initialLink)]); setElementsOutside = setElements as unknown as (elements: GraphElement[]) => void; - setLinksOutside = setLinks as unknown as (links: dia.Link[]) => void; + setLinksOutside = setLinks as unknown as (links: GraphLink[]) => void; return ( { }); act(() => { - setElementsOutside( + setElementsOutside?.( createElements([ { width: 100, @@ -341,19 +340,23 @@ describe('graph', () => { // add link act(() => { - setLinksOutside([ - new dia.Link({ - id: 'link2', - type: 'standard.Link', - source: { id: 'element1' }, - target: { id: 'element2' }, - }), - new dia.Link({ - id: 'link3', - type: 'standard.Link', - source: { id: 'element1' }, - target: { id: 'element2' }, - }), + setLinksOutside?.([ + linkFromGraph( + new dia.Link({ + id: 'link2', + type: 'standard.Link', + source: { id: 'element1' }, + target: { id: 'element2' }, + }) + ), + linkFromGraph( + new dia.Link({ + id: 'link3', + type: 'standard.Link', + source: { id: 'element1' }, + target: { id: 'element2' }, + }) + ), ]); }); diff --git a/packages/joint-react/src/components/paper/__tests__/paper.test.tsx b/packages/joint-react/src/components/paper/__tests__/paper.test.tsx index 5baa08e2a7..6ff8cc600f 100644 --- a/packages/joint-react/src/components/paper/__tests__/paper.test.tsx +++ b/packages/joint-react/src/components/paper/__tests__/paper.test.tsx @@ -7,8 +7,9 @@ import '@testing-library/jest-dom'; import { createElements, type InferElement } from '../../../utils/create'; import { MeasuredNode } from '../../measured-node/measured-node'; import { act, useEffect, useRef, useState, type RefObject } from 'react'; -import type { PaperContext } from '../../../context'; -import { useGraph, usePaperContext } from '../../../hooks'; +import type { PaperStore } from '../../../store'; +import { useGraph, usePaperStoreContext } from '../../../hooks'; +import type { GraphElement } from '../../../types/element-types'; import { GraphProvider } from '../../graph/graph-provider'; import { Paper } from '../paper'; @@ -22,19 +23,22 @@ const WIDTH = 200; // we need to mock `new ResizeObserver`, to return the size width 50 and height 50 for test purposes // Mock ResizeObserver to return a size with width 50 and height 50 -jest.mock('../../../utils/create-element-size-observer', () => ({ - createElementSizeObserver: jest.fn((element, onResize) => { - // Simulate a resize event with specific width and height - onResize({ width: 50, height: 50 }); - // Return a cleanup function that just calls `disconnect` (this is just a placeholder) - return () => {}; - }), -})); - -// Mock `useAreElementMeasured` to simulate elements being measured -jest.mock('../../../hooks/use-are-elements-measured', () => ({ - useAreElementMeasured: jest.fn(() => true), -})); +function mockCleanup() { + // Empty cleanup function +} +jest.mock('../../../store/create-elements-size-observer', () => { + const mockAdd = jest.fn(() => mockCleanup); + return { + createElementsSizeObserver: jest.fn(() => { + // Return a mock observer that matches the GraphStoreObserver interface + return { + add: mockAdd, + clean: jest.fn(), + has: jest.fn(() => false), + }; + }), + }; +}); describe('Paper Component', () => { it('renders elements correctly with correct measured node and onMeasured event', async () => { @@ -66,7 +70,8 @@ describe('Paper Component', () => { expect(screen.getByText('Node 1')).toBeInTheDocument(); expect(screen.getByText('Node 2')).toBeInTheDocument(); expect(onMeasuredMock).toHaveBeenCalledTimes(1); - expect(size).toEqual({ width: 50, height: 50 }); + // Size remains as initial since mock observer doesn't trigger updates + expect(size).toEqual({ width: 10, height: 10 }); }); }); @@ -124,7 +129,7 @@ describe('Paper Component', () => { // eslint-disable-next-line unicorn/consistent-function-scoping function FireEvent() { - const { paper } = usePaperContext() ?? {}; + const { paper } = usePaperStoreContext() ?? {}; useEffect(() => { paper?.trigger('MyCustomEventOnClick', { message: 'Hello from custom event!' }); }, [paper]); @@ -176,26 +181,6 @@ describe('Paper Component', () => { }); }); - it('uses default elementSelector and custom elementSelector', async () => { - const customSelector = jest.fn((item) => ({ ...item, custom: true })); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - function RenderElement({ custom }: any) { - return ; - } - render( - - elementSelector={customSelector} renderElement={RenderElement} /> - - ); - - await waitFor(() => { - // Validate that the elements are rendered correctly - const element = document.querySelector('#isCustom'); - expect(element).toBeInTheDocument(); - expect(element).toHaveAttribute('width', '50'); - }); - }); - it('calls onElementsSizeReady when elements are measured', async () => { const onElementsSizeReadyMock = jest.fn(); render( @@ -236,31 +221,45 @@ describe('Paper Component', () => { }); }); - it('handles ref from Paper correctly', () => { + it('handles ref from Paper correctly', async () => { const ref = { current: null }; render( - ref={ref} /> + ref={ref} renderElement={() =>
Test
} />
); - expect(ref.current).not.toBeNull(); + await waitFor( + () => { + expect(ref.current).not.toBeNull(); + }, + { timeout: 3000 } + ); }); it('should access paper via context and change scale', async () => { // eslint-disable-next-line unicorn/consistent-function-scoping - function ChangeScale({ paperRef }: { paperRef: RefObject }) { + function ChangeScale({ paperRef }: { paperRef: RefObject }) { useEffect(() => { - const { paper } = paperRef.current ?? {}; - paper?.scale(2, 2); + const checkAndScale = () => { + if (paperRef.current?.paper) { + paperRef.current.paper.scale(2, 2); + } else { + setTimeout(checkAndScale, 10); + } + }; + const timeoutId = setTimeout(checkAndScale, 0); + return () => { + clearTimeout(timeoutId); + }; }, [paperRef]); return null; } function Component() { - const ref = useRef(null); + const ref = useRef(null); return ( - ref={ref} /> + ref={ref} renderElement={() =>
Test
} />
); @@ -268,27 +267,47 @@ describe('Paper Component', () => { render(); - await waitFor(() => { - const layersGroup = document.querySelector('.joint-layers'); - expect(layersGroup).toHaveAttribute('transform', 'matrix(2,0,0,2,0,0)'); - }); + await waitFor( + () => { + const layersGroup = document.querySelector('.joint-layers'); + expect(layersGroup).toHaveAttribute('transform', 'matrix(2,0,0,2,0,0)'); + }, + { timeout: 3000 } + ); }); it('should access paper via ref and change scale', async () => { - const ref: RefObject = { current: null }; + const ref: RefObject = { current: null }; function ChangeScale() { - const { paper } = ref.current ?? {}; useEffect(() => { - paper?.scale(2, 2); - }, [paper]); + const checkAndScale = () => { + if (ref.current?.paper) { + ref.current.paper.scale(2, 2); + } else { + setTimeout(checkAndScale, 10); + } + }; + const timeoutId = setTimeout(checkAndScale, 0); + return () => { + clearTimeout(timeoutId); + }; + }, []); return null; } render( - ref={ref} /> + ref={ref} renderElement={() =>
Test
} />
); + + await waitFor( + () => { + const layersGroup = document.querySelector('.joint-layers'); + expect(layersGroup).toHaveAttribute('transform', 'matrix(2,0,0,2,0,0)'); + }, + { timeout: 3000 } + ); }); it('should set elements and positions via react state, when change it via paper api', async () => { @@ -305,8 +324,8 @@ describe('Paper Component', () => { } let currentOutsideElements: Element[] = []; function Content() { - const [currentElements, setCurrentElements] = useState(elements); - currentOutsideElements = currentElements; + const [currentElements, setCurrentElements] = useState(elements); + currentOutsideElements = currentElements as Element[]; return ( /> @@ -326,7 +345,7 @@ describe('Paper Component', () => { }); it('should update elements via react state, and then reflect the changes in the paper', async () => { function Content() { - const [currentElements, setCurrentElements] = useState(elements); + const [currentElements, setCurrentElements] = useState(elements); return ( @@ -367,21 +386,24 @@ describe('Paper Component', () => { }); }); it('should test two separate Paper with same paper, and get their data via ref', async () => { - const view1Ref: RefObject = { current: null }; - const view2Ref: RefObject = { current: null }; + const view1Ref: RefObject = { current: null }; + const view2Ref: RefObject = { current: null }; render( - ref={view1Ref} /> - ref={view2Ref} /> + ref={view1Ref} renderElement={() =>
Test
} /> + ref={view2Ref} renderElement={() =>
Test
} />
); - await waitFor(() => { - expect(view1Ref.current).not.toBeNull(); - expect(view2Ref.current).not.toBeNull(); - expect(view1Ref.current).not.toBe(view2Ref.current); - expect(view1Ref.current?.paper).not.toBe(view2Ref.current?.paper); - }); + await waitFor( + () => { + expect(view1Ref.current).not.toBeNull(); + expect(view2Ref.current).not.toBeNull(); + expect(view1Ref.current).not.toBe(view2Ref.current); + expect(view1Ref.current?.paper).not.toBe(view2Ref.current?.paper); + }, + { timeout: 3000 } + ); }); }); diff --git a/packages/joint-react/src/components/paper/paper.stories.tsx b/packages/joint-react/src/components/paper/paper.stories.tsx index c0027a43e9..040b4b9e1c 100644 --- a/packages/joint-react/src/components/paper/paper.stories.tsx +++ b/packages/joint-react/src/components/paper/paper.stories.tsx @@ -18,6 +18,7 @@ import { jsx } from '../../utils/joint-jsx/jsx-to-markup'; import { useCellActions } from '../../hooks/use-cell-actions'; import { Paper } from './paper'; import type { RenderElement } from './paper.types'; +import type { GraphElement } from '../../types/element-types'; import { GraphProvider } from '../graph/graph-provider'; export type Story = StoryObj; @@ -357,7 +358,7 @@ export const WithOnClickColorChange: Story = { return ( ; @@ -39,7 +44,7 @@ const EMPTY_OBJECT = {} as Record; * Paper component renders the visual representation of the graph using JointJS Paper. * This component is responsible for managing the rendering of elements and links, handling events, and providing customization options for the graph view. * @param props - The properties for the Paper component. - * @param forwardedRef - A reference to the PaperContext instance. + * @param forwardedRef - A reference to the PaperStore instance. * @returns The Paper component. * @example * Using the Paper component: @@ -58,15 +63,14 @@ const EMPTY_OBJECT = {} as Record; * ``` */ function PaperBase( - props: PaperProps, - forwardedRef: React.ForwardedRef + props: PaperProps, + forwardedRef: React.ForwardedRef ) { const { renderElement, defaultLink, style, className, - elementSelector = noopSelector as (item: GraphElement) => ElementItem, onElementsSizeReady, onElementsSizeChange, useHTMLOverlay, @@ -77,21 +81,30 @@ function PaperBase( ...paperOptions } = props; - const { graph } = useGraphStore(); - const areElementsMeasured = useAreElementMeasured(); - const { onRenderElement, elementViews } = useElementViews(); - const elements = useElements((items) => items.map(elementSelector)); + const areElementsMeasured = useDerivedGraphStoreSelector((state) => state.areElementsMeasured); + const elements = useElements(); + useDebugValue(elements); const reactId = useId(); + const id = props.id ?? `paper-${reactId}`; const { overWrite } = useContext(PaperConfigContext) ?? {}; + const paperElementViews = useGraphInternalStoreSelector( + (snapshot) => snapshot.papers[id]?.paperElementViews ?? EMPTY_OBJECT + ); + + const { addPaper, graph, getPaperStore } = useGraphStore(); + const paperStore = getPaperStore(id) ?? null; + const { paper } = paperStore ?? {}; const paperHTMLElement = useRef(null); const measured = useRef(false); const previousSizesRef = useRef([]); const [HTMLRendererContainer, setHTMLRendererContainer] = useState(null); - const id = props.id ?? `paper-${reactId}`; + const hasRenderElement = !!renderElement; + useImperativeHandle(forwardedRef, () => paperStore as PaperStore, [paperStore]); + const defaultLinkJointJS = useCallback( (cellView: dia.CellView, magnet: SVGElement) => { const link = typeof defaultLink === 'function' ? defaultLink(cellView, magnet) : defaultLink; @@ -105,152 +118,68 @@ function PaperBase( }, [defaultLink] ); - const isReactId = !props.id; - - const { ref, isReady } = useImperativeApi( - { - forwardedRef, - onLoad() { - const portsStore = createPortsStore(); - const elementView = dia.ElementView.extend({ - // Render element using react, `elementView.el` is used as portal gate for react (createPortal) - onRender() { - // eslint-disable-next-line unicorn/no-this-assignment, @typescript-eslint/no-this-alias, no-shadow, @typescript-eslint/no-shadow - const elementView: dia.ElementView = this; - onRenderElement(elementView); - }, - // Render port using react, `portData.portElement.node` is used as portal gate for react (createPortal) - _renderPorts() { - // This is firing when the ports are rendered (updated, inserted, removed) - // @ts-expect-error we use private jointjs api method, it throw error here. - dia.ElementView.prototype._renderPorts.call(this); - // eslint-disable-next-line unicorn/no-this-assignment, @typescript-eslint/no-this-alias, no-shadow, @typescript-eslint/no-shadow - const elementView: dia.ElementView = this; - - const portElementsCache: Record = - this._portElementsCache; - portsStore.onRenderPorts(elementView.model.id, portElementsCache); - }, - }); - // Create a new JointJS Paper with the provided options - const paper = new dia.Paper({ - async: true, - sorting: dia.Paper.sorting.APPROX, - preventDefaultBlankAction: false, - frozen: true, - defaultLink: defaultLinkJointJS, - - model: graph, - elementView, - ...paperOptions, - // 👇 override to always allow connection - validateConnection: () => true, - // 👇 also, allow links to start or end on empty space - validateMagnet: () => true, - viewManagement: props.viewManagement ?? true, - clickThreshold: paperOptions.clickThreshold ?? DEFAULT_CLICK_THRESHOLD, - }); - /** - * Render paper utility - is called when html element is bind to the react paper component - * @param element - The HTML element to render the paper into - * @returns - Context update if any - */ - function renderPaper(element: HTMLElement | SVGElement): OverWriteResult | undefined { - if (!paper) { - throw new Error('Paper is not created'); - } - - let elementToRender: HTMLElement | SVGElement = paper.el; - let overWriteResult: OverWriteResult | undefined = undefined; - if (overWrite) { - overWriteResult = overWrite(instance); - elementToRender = overWriteResult.element; - } - - if (!elementToRender) { - throw new Error('overwriteDefaultPaperElement must return a valid HTML or SVG element'); - } - - element.replaceChildren(elementToRender); - paper.unfreeze(); - return overWriteResult; - } - if (!paperHTMLElement.current) { - throw new Error('Paper HTML element is not available'); - } - - if (scale !== undefined) { - paper.scale(scale); - } - - const instance: PaperContext = { - paper, - portsStore, - elementViews: EMPTY_OBJECT, - id, - isReactId, - renderElement, - }; + const isReady = !!paper && !!paperHTMLElement.current; - const contextUpdate = renderPaper(paperHTMLElement.current); - if (contextUpdate) { - Object.assign(instance, contextUpdate.contextUpdate); - } - - if (width !== undefined && height !== undefined) { - paper.setDimensions(width, height); - } - return { - instance, - cleanup() { - paper.remove(); - portsStore.destroy(); - contextUpdate?.cleanup?.(); - }, - }; + useLayoutEffect(() => { + if (!paperHTMLElement.current) { + return; + } + const remove = addPaper(id, { + paperElement: paperHTMLElement.current, + paperOptions: { + ...paperOptions, + defaultLink: defaultLinkJointJS, }, - onUpdate(instance, reset) { - if (instance.id !== id) { - reset(); - } + overWrite, + renderElement: renderElement as RenderElement, + scale, + }); + return () => { + remove(); + }; + // just once on load create paper instance + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); - const { paper } = instance; - assignOptions(paper.options, { - defaultLink: defaultLinkJointJS, - ...paperOptions, - }); - const { drawGrid, theme, gridSize } = paperOptions; - const { width: paperWidth, height: paperHeight } = paper.options; + useEffect(() => { + if (!paperStore) return; + if (!paper) return; + const { overWriteResultRef } = paperStore; + assignOptions(paper.options, { + defaultLink: defaultLinkJointJS, + ...paperOptions, + }); + const { drawGrid, theme, gridSize } = paperOptions; + const { width: paperWidth, height: paperHeight } = paper.options; + + if (drawGrid !== undefined) { + paper.setGrid(drawGrid); + } + if (gridSize !== undefined) { + paper.setGridSize(gridSize); + } + if (theme !== undefined) { + paper.setTheme(theme); + } + if (scale !== undefined) { + paper.scale(scale); + } - if ( - width !== undefined && - height !== undefined && - (width !== paperWidth || height !== paperHeight) - ) { - paper.setDimensions(width, height); - } - if (drawGrid !== undefined) { - paper.setGrid(drawGrid); - } - if (gridSize !== undefined) { - paper.setGridSize(gridSize); - } - if (theme !== undefined) { - paper.setTheme(theme); - } - if (scale !== undefined) { - paper.scale(scale); - } - }, - }, - [defaultLinkJointJS, id, scale, isReactId, height, width, ...dependencyExtract(paperOptions)] - ); + const { shouldIgnoreWidthAndHeightUpdates } = overWriteResultRef ?? {}; + if ( + !shouldIgnoreWidthAndHeightUpdates && + width !== undefined && + height !== undefined && + (width !== paperWidth || height !== paperHeight) + ) { + paper.setDimensions(width, height); + } + }, [defaultLinkJointJS, height, paper, paperOptions, paperStore, scale, width]); useEffect(() => { if (!isReady) return; if (measured.current) return; - const { paper } = ref.current ?? {}; if (!paper) return; if (areElementsMeasured) { measured.current = true; @@ -271,14 +200,13 @@ function PaperBase( clearTimeout(timeout); }; } - }, [areElementsMeasured, isReady, onElementsSizeReady, ref]); + }, [areElementsMeasured, isReady, onElementsSizeReady, paper]); // Whenever elements change (or we’ve just become measured) compare old ↔ new useEffect(() => { if (!isReady) return; if (!onElementsSizeChange) return; if (!areElementsMeasured) return; - const { paper } = ref.current ?? {}; if (!paper) return; // Build current list of [currWidth, currHeight] to avoid shadowing outer scope variables @@ -311,10 +239,9 @@ function PaperBase( // store for next time previousSizesRef.current = currentSizes; onElementsSizeChange({ paper, graph: paper.model }); - }, [areElementsMeasured, elements, isReady, onElementsSizeChange, ref]); + }, [areElementsMeasured, elements, isReady, onElementsSizeChange, paper]); useLayoutEffect(() => { - const { paper } = ref.current ?? {}; if (!paper) { return; } @@ -337,7 +264,7 @@ function PaperBase( stopListening(); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [graph, isReady, ref, ...dependencyExtract(paperOptions, PAPER_EVENT_KEYS)]); + }, [graph, isReady, ...dependencyExtract(paperOptions, PAPER_EVENT_KEYS)]); const content = ( <> @@ -345,11 +272,11 @@ function PaperBase( )} {hasRenderElement && - elements.map((cell) => { - if (!cell.id) { + elements.map((element) => { + if (!element.id) { return null; } - const elementView = elementViews[cell.id]; + const elementView = paperElementViews[element.id]; if (!elementView) { return null; } @@ -358,21 +285,28 @@ function PaperBase( if (!SVG) { return null; } - - if (cell.type !== REACT_TYPE) { + const isReactElement = + element.type === undefined || + element.type === REACT_TYPE || + element instanceof ReactElement; + if (!isReactElement) { return null; } return ( - + {useHTMLOverlay && HTMLRendererContainer ? ( ) : ( - + )} ); @@ -392,20 +326,20 @@ function PaperBase( const paperContainerStyle = useMemo( (): CSSProperties => ({ - opacity: areElementsMeasured ? 1 : 0, + opacity: 1, position: 'relative', ...defaultStyle, }), - [areElementsMeasured, defaultStyle] + [defaultStyle] ); return ( - +
{isReady && content}
{isReady && children} -
+ ); } @@ -413,7 +347,7 @@ function PaperBase( * Paper component renders the visual representation of the graph using JointJS Paper. * This component is responsible for managing the rendering of elements and links, handling events, and providing customization options for the graph view. * @param props - The properties for the Paper component. - * @param forwardedRef - A reference to the PaperContext instance. + * @param forwardedRef - A reference to the PaperStore instance. * @returns The Paper component. * @example * Using the Paper component: @@ -433,6 +367,6 @@ function PaperBase( */ export const Paper = forwardRef(PaperBase) as ( props: Readonly> & { - ref?: React.Ref; + ref?: React.Ref; } ) => ReturnType; diff --git a/packages/joint-react/src/components/paper/paper.types.ts b/packages/joint-react/src/components/paper/paper.types.ts index 1c3e2c9cb5..f988a648c5 100644 --- a/packages/joint-react/src/components/paper/paper.types.ts +++ b/packages/joint-react/src/components/paper/paper.types.ts @@ -84,14 +84,6 @@ export interface PaperProps * Class name of the paper element. */ readonly className?: string; - - /** - * A function that selects the elements to be rendered. - * It defaults to the `GraphElement` elements because `dia.Element` is not a valid React element (it do not change reference after update). - * @default (item: dia.Cell) => `BaseElement` - * @see GraphElement - */ - readonly elementSelector?: (item: GraphElement) => ElementItem; /** * The scale of the paper. It's useful to create for example a zoom feature or minimap Paper. */ diff --git a/packages/joint-react/src/components/paper/render-element/paper-element-item.tsx b/packages/joint-react/src/components/paper/render-element/paper-element-item.tsx index 830b7a97a9..18198a9eec 100644 --- a/packages/joint-react/src/components/paper/render-element/paper-element-item.tsx +++ b/packages/joint-react/src/components/paper/render-element/paper-element-item.tsx @@ -1,8 +1,7 @@ import { useMemo, type CSSProperties, type ReactNode } from 'react'; import { createPortal } from 'react-dom'; import type { CellWithId } from '../../../types/cell.types'; -import typedMemo from '../../../utils/typed-memo'; -import { useElement } from '../../../hooks'; +import typedMemo from '../../../utils/typed-react'; import type { GraphElement } from '../../../types/element-types'; export interface ElementItemProps { @@ -17,14 +16,16 @@ export interface ElementItemProps { } // eslint-disable-next-line jsdoc/require-jsdoc -function SVGElementItemComponent( +function SVGElementItemComponent( props: ElementItemProps ) { const { renderElement, portalElement, ...rest } = props; + const cell = rest as Data; + if (!portalElement) { return null; } - const cell = rest as Data; + const element = renderElement(cell); return createPortal(element, portalElement); @@ -56,14 +57,16 @@ export const SVGElementItem = typedMemo(SVGElementItemComponent); * @returns The rendered element inside the portal. * @internal */ -function HTMLElementItemComponent( +function HTMLElementItemComponent( props: ElementItemProps ) { const { renderElement, portalElement, ...rest } = props; const cell = rest as Data; // we must use renderElement and not cell data, because user can select different data, so then, the width and height do not have to be inside the cell data. const element = renderElement(cell); - const { width, height, x, y, id } = useElement(); + const { width, height, x, y, id } = cell; + + // WE NEED TO COMPARE WHAT IS CHANGED HERE... const style = useMemo( (): CSSProperties => ({ diff --git a/packages/joint-react/src/components/port/__tests__/__snapshots__/port-group.test.tsx.snap b/packages/joint-react/src/components/port/__tests__/__snapshots__/port-group.test.tsx.snap index 6485a271bc..fb0e8241a4 100644 --- a/packages/joint-react/src/components/port/__tests__/__snapshots__/port-group.test.tsx.snap +++ b/packages/joint-react/src/components/port/__tests__/__snapshots__/port-group.test.tsx.snap @@ -1,3 +1,3 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`Port/Item "Default": Port/Item-Default 1`] = `"
"`; +exports[`Port/Item "Default": Port/Item-Default 1`] = `"
"`; diff --git a/packages/joint-react/src/components/port/__tests__/__snapshots__/port-item.test.tsx.snap b/packages/joint-react/src/components/port/__tests__/__snapshots__/port-item.test.tsx.snap index 6485a271bc..fb0e8241a4 100644 --- a/packages/joint-react/src/components/port/__tests__/__snapshots__/port-item.test.tsx.snap +++ b/packages/joint-react/src/components/port/__tests__/__snapshots__/port-item.test.tsx.snap @@ -1,3 +1,3 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`Port/Item "Default": Port/Item-Default 1`] = `"
"`; +exports[`Port/Item "Default": Port/Item-Default 1`] = `"
"`; diff --git a/packages/joint-react/src/components/port/port-item.tsx b/packages/joint-react/src/components/port/port-item.tsx index 18fba13978..77260a4098 100644 --- a/packages/joint-react/src/components/port/port-item.tsx +++ b/packages/joint-react/src/components/port/port-item.tsx @@ -1,13 +1,14 @@ import type { dia } from '@joint/core'; -import { memo, useContext, useEffect, useSyncExternalStore } from 'react'; +import { memo, useContext, useEffect } from 'react'; import { createPortal } from 'react-dom'; import { useCellId } from '../../hooks'; import { PortGroupContext } from '../../context/port-group-context'; import { useGraphStore } from '../../hooks/use-graph-store'; -import { PORTAL_SELECTOR } from '../../data/create-ports-data'; import { jsx } from '../../utils/joint-jsx/jsx-to-markup'; import { createElements } from '../../utils/create'; -import { PaperContext } from '../../context'; +import { PaperStoreContext } from '../../context'; +import { useGraphInternalStoreSelector } from '../../hooks/use-graph-store-selector'; +import { PORTAL_SELECTOR } from '../../store'; const elementMarkup = jsx(); @@ -59,11 +60,11 @@ export interface PortItemProps { function Component(props: PortItemProps) { const { magnet, id, children, groupId, z, x, y, dx, dy } = props; const cellId = useCellId(); - const paperCtx = useContext(PaperContext); - if (!paperCtx) { + const paperStore = useContext(PaperStoreContext); + if (!paperStore) { throw new Error('PortItem must be used within a Paper context'); } - const { portsStore, paper } = paperCtx; + const { paper, paperId } = paperStore; const { graph } = useGraphStore(); const contextGroupId = useContext(PortGroupContext); @@ -110,11 +111,10 @@ function Component(props: PortItemProps) { }; }, [cellId, contextGroupId, graph, groupId, id, x, y, z, magnet, dx, dy]); - const portalNode = useSyncExternalStore( - portsStore.subscribe, - () => portsStore.getPortElement(cellId, id), - () => portsStore.getPortElement(cellId, id) - ); + const portalNode = useGraphInternalStoreSelector((state) => { + const portId = paperStore.getPortId(cellId, id); + return state.papers[paperId]?.portsData?.[portId]; + }); useEffect(() => { if (!portalNode) { @@ -128,7 +128,6 @@ function Component(props: PortItemProps) { for (const link of graph.getConnectedLinks(elementView.model)) { const target = link.target(); const source = link.source(); - const isElementLink = target.id === cellId || source.id === cellId; if (!isElementLink) { continue; diff --git a/packages/joint-react/src/components/text-node/__tests__/__snapshots__/text-node.test.tsx.snap b/packages/joint-react/src/components/text-node/__tests__/__snapshots__/text-node.test.tsx.snap index 2031c752ed..8bf367b2b3 100644 --- a/packages/joint-react/src/components/text-node/__tests__/__snapshots__/text-node.test.tsx.snap +++ b/packages/joint-react/src/components/text-node/__tests__/__snapshots__/text-node.test.tsx.snap @@ -1,9 +1,9 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`TextNode "Default": TextNode-Default 1`] = `"
"`; +exports[`TextNode "Default": TextNode-Default 1`] = `"
"`; -exports[`TextNode "WithBreakText": TextNode-WithBreakText 1`] = `"
"`; +exports[`TextNode "WithBreakText": TextNode-WithBreakText 1`] = `"
"`; -exports[`TextNode "WithBreakTextWithoutDefinedWith": TextNode-WithBreakTextWithoutDefinedWith 1`] = `"
"`; +exports[`TextNode "WithBreakTextWithoutDefinedWith": TextNode-WithBreakTextWithoutDefinedWith 1`] = `"
"`; -exports[`TextNode "WithEllipsis": TextNode-WithEllipsis 1`] = `"
"`; +exports[`TextNode "WithEllipsis": TextNode-WithEllipsis 1`] = `"
"`; diff --git a/packages/joint-react/src/components/text-node/text-node.stories.tsx b/packages/joint-react/src/components/text-node/text-node.stories.tsx index 3e8d9b7b3b..7f5adb5577 100644 --- a/packages/joint-react/src/components/text-node/text-node.stories.tsx +++ b/packages/joint-react/src/components/text-node/text-node.stories.tsx @@ -1,4 +1,4 @@ -/* eslint-disable react-perf/jsx-no-new-function-as-prop */ + import type { Meta, StoryObj } from '@storybook/react'; import { SimpleRenderItemDecorator } from '../../../.storybook/decorators/with-simple-data'; @@ -14,21 +14,21 @@ export type Story = StoryObj; // eslint-disable-next-line @typescript-eslint/no-explicit-any function SVGDecorator(Story: any) { - const { width, height } = useElement(); + const { width = 0, height = 0 } = useElement(); + const PADDING = 10; return ( <> - - { - const padding = 20; - element.set('size', { - width: size.width + padding, - height: size.height + padding, - }); - }} - > - + + + diff --git a/packages/joint-react/src/components/text-node/text-node.tsx b/packages/joint-react/src/components/text-node/text-node.tsx index f3a0b19773..4630acf61e 100644 --- a/packages/joint-react/src/components/text-node/text-node.tsx +++ b/packages/joint-react/src/components/text-node/text-node.tsx @@ -54,7 +54,7 @@ function Component(props: TextNodeProps, ref: React.ForwardedRef if (!element.isElement()) { throw new TypeError('TextNode must be used inside a MeasuredNode'); } - breakTextWidth = element.size().width; + breakTextWidth = element.size().width ?? 0; } const options: util.BreakTextOptions = typeof textWrap === 'object' ? textWrap : {}; diff --git a/packages/joint-react/src/context/index.ts b/packages/joint-react/src/context/index.ts index 47cb82f148..60811f76c0 100644 --- a/packages/joint-react/src/context/index.ts +++ b/packages/joint-react/src/context/index.ts @@ -1,28 +1,18 @@ export * from './port-group-context'; import { createContext } from 'react'; -import type { GraphStore } from '../data/create-graph-store'; import type { dia } from '@joint/core'; -import type { GraphElement } from '../types/element-types'; -import type { PortsStore } from '../data/create-ports-store'; -import type { RenderElement } from '../components/paper/paper.types'; -export interface PaperContext { - readonly id: string; - readonly paper: dia.Paper; - readonly portsStore: PortsStore; - readonly elementViews: Record; - renderElement?: RenderElement; - readonly isReactId: boolean; -} +import type { GraphStore, PaperStore } from '../store'; -export type StoreContext = GraphStore; +export type StoreContext = GraphStore; export const GraphStoreContext = createContext(null); -export const GraphAreElementsMeasuredContext = createContext(false); -export const PaperContext = createContext(null); +export const PaperStoreContext = createContext(null); export const CellIdContext = createContext(undefined); +export const CellIndexContext = createContext(undefined); export interface OverWriteResult { readonly element: HTMLElement | SVGElement; - readonly contextUpdate: Record; + readonly contextUpdate: unknown; + readonly shouldIgnoreWidthAndHeightUpdates: boolean; readonly cleanup: () => void; } export interface PaperConfigContext { @@ -34,7 +24,7 @@ export interface PaperConfigContext { * @param ctx - The paper context * @returns */ - overWrite?: (ctx: PaperContext) => OverWriteResult; + overWrite?: (ctx: PaperStore) => OverWriteResult; } export const PaperConfigContext = createContext(null); diff --git a/packages/joint-react/src/data/__tests__/create-ports-data.test.ts b/packages/joint-react/src/data/__tests__/create-ports-data.test.ts deleted file mode 100644 index 47b31259d0..0000000000 --- a/packages/joint-react/src/data/__tests__/create-ports-data.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { createPortsData, type PortElementsCacheEntry } from '../create-ports-data'; - -describe('create-ports-data', () => { - it('should handle createPortsData', () => { - const portsData = createPortsData(); - expect(portsData).toHaveProperty('set'); - expect(portsData).toHaveProperty('get'); - expect(portsData).toHaveProperty('clear'); - - const elementCache: Record = { - port1: { - portElement: {} as never, - portLabelElement: null, - portSelectors: { - 'react-port-portal': {} as never, - }, - portLabelSelectors: {}, - portContentElement: {} as never, - portContentSelectors: {}, - }, - }; - const cellId = 'cell1'; - portsData.set(cellId, elementCache); - expect(portsData.get(cellId, 'port1')).toBeDefined(); - expect(portsData.get(cellId, 'port2')).toBeUndefined(); - expect(portsData.get('cell2', 'port1')).toBeUndefined(); - }); -}); diff --git a/packages/joint-react/src/data/__tests__/create-ports-store.test.ts b/packages/joint-react/src/data/__tests__/create-ports-store.test.ts deleted file mode 100644 index bb29af9d27..0000000000 --- a/packages/joint-react/src/data/__tests__/create-ports-store.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { createPortsStore } from '../create-ports-store'; -import type { PortElementsCacheEntry } from '../create-ports-data'; -import type { Vectorizer } from '@joint/core'; - -describe('create-ports-store', () => { - beforeEach(() => { - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - it('should create a ports store', () => { - const store = createPortsStore(); - - expect(store).toBeDefined(); - expect(store).toHaveProperty('getPortElement'); - expect(store).toHaveProperty('onRenderPorts'); - expect(store).toHaveProperty('subscribe'); - expect(store).toHaveProperty('destroy'); - }); - - it('should get port element after setting', () => { - const store = createPortsStore(); - const cellId = 'cell-1'; - const portId = 'port-1'; - const mockElement = document.createElementNS('http://www.w3.org/2000/svg', 'g'); - - const portElementsCache: Record = { - [portId]: { - portElement: mockElement as unknown as Vectorizer, - portSelectors: { - 'react-port-portal': mockElement, - }, - portContentElement: mockElement as unknown as Vectorizer, - }, - }; - - store.onRenderPorts(cellId, portElementsCache); - const element = store.getPortElement(cellId, portId); - - expect(element).toBe(mockElement); - }); - - it('should return undefined for non-existent port', () => { - const store = createPortsStore(); - const element = store.getPortElement('cell-1', 'port-1'); - - expect(element).toBeUndefined(); - }); - - it('should subscribe to port changes', async () => { - const store = createPortsStore(); - const subscriber = jest.fn(); - - const unsubscribe = store.subscribe(subscriber); - store.onRenderPorts('cell-1', {}); - - // Wait for async notification - await Promise.resolve(); - jest.runAllTimers(); - - expect(subscriber).toHaveBeenCalled(); - unsubscribe(); - }); - - it('should unsubscribe correctly', () => { - const store = createPortsStore(); - const subscriber = jest.fn(); - - const unsubscribe = store.subscribe(subscriber); - unsubscribe(); - store.onRenderPorts('cell-1', {}); - - expect(subscriber).not.toHaveBeenCalled(); - }); - - it('should clear ports on destroy', () => { - const store = createPortsStore(); - const cellId = 'cell-1'; - const portId = 'port-1'; - const mockElement = document.createElementNS('http://www.w3.org/2000/svg', 'g'); - - const portElementsCache: Record = { - [portId]: { - portElement: mockElement as unknown as Vectorizer, - portSelectors: { - 'react-port-portal': mockElement, - }, - portContentElement: mockElement as unknown as Vectorizer, - }, - }; - - store.onRenderPorts(cellId, portElementsCache); - store.destroy(); - - const element = store.getPortElement(cellId, portId); - expect(element).toBeUndefined(); - }); - - it('should handle multiple ports for same cell', () => { - const store = createPortsStore(); - const cellId = 'cell-1'; - const port1Element = document.createElementNS('http://www.w3.org/2000/svg', 'g'); - const port2Element = document.createElementNS('http://www.w3.org/2000/svg', 'g'); - - const portElementsCache: Record = { - 'port-1': { - portElement: port1Element as unknown as Vectorizer, - portSelectors: { - 'react-port-portal': port1Element, - }, - portContentElement: port1Element as unknown as Vectorizer, - }, - 'port-2': { - portElement: port2Element as unknown as Vectorizer, - portSelectors: { - 'react-port-portal': port2Element, - }, - portContentElement: port2Element as unknown as Vectorizer, - }, - }; - - store.onRenderPorts(cellId, portElementsCache); - - expect(store.getPortElement(cellId, 'port-1')).toBe(port1Element); - expect(store.getPortElement(cellId, 'port-2')).toBe(port2Element); - }); -}); diff --git a/packages/joint-react/src/data/__tests__/create-store-data.test.ts b/packages/joint-react/src/data/__tests__/create-store-data.test.ts deleted file mode 100644 index 66bb892917..0000000000 --- a/packages/joint-react/src/data/__tests__/create-store-data.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { dia } from '@joint/core'; -import { createStoreData } from '../create-store-data'; - -describe('create-store-data', () => { - it('should handle proper data insertion', () => { - const graph = new dia.Graph(); - const storeData = createStoreData(); - const element = new dia.Element({ - type: 'standard.Rectangle', - id: 'element1', - x: 10, - }); - graph.addCell(element); - expect(storeData.dataRef.elements.length).toBe(0); - storeData.updateStore(graph); - expect(storeData.dataRef.elements.length).toBe(1); - }); - it('should handle proper data update', () => { - const graph = new dia.Graph(); - const storeData = createStoreData(); - const element = new dia.Element({ - type: 'standard.Rectangle', - id: 'element1', - position: { x: 10, y: 20 }, - }); - graph.addCell(element); - expect(storeData.dataRef.elements.length).toBe(0); - storeData.updateStore(graph); - expect(storeData.dataRef.elements.length).toBe(1); - expect(storeData.dataRef.elements.find((element_) => element_.id === 'element1')?.x).toBe(10); - - const updatedElement = new dia.Element({ - type: 'standard.Rectangle', - id: 'element1', - position: { x: 30, y: 40 }, - }); - - graph.resetCells([updatedElement]); - expect(storeData.dataRef.elements.length).toBe(1); - storeData.updateStore(graph); - expect(storeData.dataRef.elements.find((element_) => element_.id === 'element1')?.x).toBe(30); - }); - it('should handle proper data deletion', () => { - const graph = new dia.Graph(); - const storeData = createStoreData(); - const element = new dia.Element({ - type: 'standard.Rectangle', - id: 'element1', - x: 10, - }); - graph.addCell(element); - expect(storeData.dataRef.elements.length).toBe(0); - storeData.updateStore(graph); - expect(storeData.dataRef.elements.length).toBe(1); - graph.removeCells([element]); - expect(storeData.dataRef.elements.length).toBe(1); - storeData.updateStore(graph); - expect(storeData.dataRef.elements.length).toBe(0); - }); -}); diff --git a/packages/joint-react/src/data/__tests__/create-store.test.ts b/packages/joint-react/src/data/__tests__/create-store.test.ts deleted file mode 100644 index 3fe6e064b9..0000000000 --- a/packages/joint-react/src/data/__tests__/create-store.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { dia } from '@joint/core'; -import { createStore } from '../create-graph-store'; -import { waitFor } from '@testing-library/react'; - -describe('createStore', () => { - it('should initialize with default options', () => { - const store = createStore(); - expect(store.graph).toBeDefined(); - expect(store.getElements().length).toBe(0); - expect(store.getLinks().length).toBe(0); - }); - - it('should initialize with custom graph instance', () => { - const customGraph = new dia.Graph(); - const store = createStore({ graph: customGraph }); - expect(store.graph).toBe(customGraph); - }); - - it('should add default elements', async () => { - const element = new dia.Element({ id: 'element1', type: 'standard.Rectangle' }); - const link = new dia.Link({ id: 'link1', type: 'standard.Link', source: { id: 'element1' } }); - const store = createStore({ - elements: [element], - links: [link], - }); - expect(store.getElements().length).toBe(1); - expect(store.getElement('element1')).toBeDefined(); - }); - - it('should notify subscribers on changes', async () => { - const store = createStore(); - const callback = jest.fn(); - const unsubscribe = store.subscribe(callback); - - const element = new dia.Element({ id: 'element1', type: 'standard.Rectangle' }); - store.graph.addCell(element); - - await waitFor(() => { - expect(callback).toHaveBeenCalled(); - unsubscribe(); - }); - }); - - it('should clean up properly on destroy', () => { - const store = createStore(); - const unsubscribeSpy = jest.spyOn(store, 'destroy'); - - store.destroy(false); - expect(unsubscribeSpy).toHaveBeenCalled(); - expect(store.graph.getCells().length).toBe(0); - }); -}); diff --git a/packages/joint-react/src/data/create-graph-store.ts b/packages/joint-react/src/data/create-graph-store.ts deleted file mode 100644 index 6d947ea955..0000000000 --- a/packages/joint-react/src/data/create-graph-store.ts +++ /dev/null @@ -1,493 +0,0 @@ -/* eslint-disable unicorn/prefer-query-selector */ -import { dia, shapes } from '@joint/core'; -import { listenToCellChange } from '../utils/cell/listen-to-cell-change'; -import { ReactElement } from '../models/react-element'; -import { setElements, setLinks } from '../utils/cell/cell-utilities'; -import type { GraphElement } from '../types/element-types'; -import type { GraphLink } from '../types/link-types'; -import { subscribeHandler } from '../utils/subscriber-handler'; -import { createStoreData, type UpdateResult } from './create-store-data'; -import type { Dispatch, SetStateAction } from 'react'; - -export const DEFAULT_CELL_NAMESPACE: Record = { ...shapes, ReactElement }; - -export interface StoreOptions< - Graph extends dia.Graph, - Element extends dia.Element | GraphElement, - Link extends dia.Link | GraphLink, -> { - /** - * 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?: 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 elements?: Element[]; - - /** - * Initial links to be added to graph - * It's loaded just once, so it cannot be used as React state. - */ - readonly links?: Link[]; - /** - * Callback triggered when elements (nodes) change. - * Providing this prop enables controlled mode for elements. - * If specified, this function will override the default behavior, allowing you to manage all element changes manually instead of relying on `graph.change`. - */ - readonly onElementsChange?: Dispatch>; - - /** - * Callback triggered when links (edges) change. - * Providing this prop enables controlled mode for links. - * If specified, this function will override the default behavior, allowing you to manage all link changes manually instead of relying on `graph.change`. - */ - readonly onLinksChange?: Dispatch>; -} - -export interface GraphStore { - /** - * The JointJS graph instance. - */ - readonly graph: Graph; - /** - * Subscribes to the store changes. - */ - readonly subscribe: (onStoreChange: (changedIds?: UpdateResult) => void) => () => void; - - /** - * Get elements - */ - readonly getElements: () => GraphElement[]; - - /** - * - * @param elements - New elements to set in the graph. - * @returns - */ - - readonly setElements: (elements: Element[]) => void; - /** - * Get element by id - */ - readonly getElement: (id: dia.Cell.ID) => Element; - /** - * Get links - */ - readonly getLinks: () => GraphLink[]; - /** - * Set links - */ - readonly setLinks: (links: GraphLink[]) => void; - /** - * Get link by id - */ - readonly getLink: (id: dia.Cell.ID) => GraphLink; - /** - * Remove all listeners and cleanup the graph. - */ - readonly destroy: (isGraphExternal: boolean) => 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; - - /** - * Force update the graph store. - * This will trigger a re-render of all components that are subscribed to the store. - * @param batchName - The name of the batch (unused in new implementation, kept for compatibility). - * @param skipGraphUpdate - If true, skip updating from graph (used when store was already updated from external data). - */ - readonly forceUpdateStore: (batchName?: string, skipGraphUpdate?: boolean) => UpdateResult; - - /** - * Update store from external data (React state) in controlled mode. - * @param elements - The elements from React state. - * @param links - The links from React state. - * @returns The update result. - */ - readonly updateStoreFromExternalData: ( - elements: GraphElement[], - links: GraphLink[] - ) => UpdateResult; -} - -/** - * 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< - Graph extends dia.Graph = dia.Graph, - Element extends dia.Element | GraphElement = dia.Element | GraphElement, - Link extends dia.Link | GraphLink = dia.Link | GraphLink, ->(options: StoreOptions = {}): 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 as Graph; -} - -/** - * 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 createStoreWithGraph< - Graph extends dia.Graph, - Element extends dia.Element | GraphElement, - Link extends dia.Link | GraphLink, ->(options?: StoreOptions): GraphStore { - const { elements, links, graph, onElementsChange, onLinksChange } = options || {}; - - if (!graph) { - // Create a new graph instance or use the provided one - throw new Error('Graph instance is required'); - } - - // Detect controlled mode - const isControlled = !!(onElementsChange || onLinksChange); - const isElementsControlled = !!onElementsChange; - const isLinksControlled = !!onLinksChange; - - // Track if we're currently syncing from React state to graph (to prevent circular updates) - let isSyncingFromReactState = false; - - // set elements to the graph - setElements({ - graph, - elements, - }); - - setLinks({ - graph, - links, - }); - - // create store data - caching the elements and links for the react - const graphData = createStoreData(); - // listen to dia.graph cell changes and trigger `onCellChange` where there is change occurs in graph - const unsubscribe = listenToCellChange(graph, onCellChange); - // elements events notify all react components using `useSyncExternalStore` - const elementsEvents = subscribeHandler(forceUpdateStore); - - // Notify subscribers of initial elements - // In both controlled and uncontrolled modes, initial store is populated from graph - graphData.updateStore(graph); - - // add method to handle batch stop, so then we can also notify all react components - graph.on('batch:stop', onBatchStop); - - const measuredNodes = new Set(); - const { dataRef } = graphData; - - // Store the last UpdateResult from external data updates for controlled mode - let lastExternalUpdateResult: UpdateResult | undefined; - /** - * Force update the graph. - * This function is called when the graph is updated. - * In controlled mode, only syncs graph → React state when changes come from user interaction. - * In uncontrolled mode, updates store from graph. - * @returns changed ids - * @param batchName - The name of the batch (unused in new implementation, kept for compatibility). - * @param skipGraphUpdate - If true, skip updating from graph (used when store was already updated from external data). - */ - function forceUpdateStore(batchName?: string, skipGraphUpdate = false): UpdateResult { - if (!graph) { - // Create a new graph instance or use the provided one - throw new Error('Graph instance is required'); - } - - let updateResult: UpdateResult; - - // In controlled mode, if we're syncing from React state, don't update store from graph - // The store will be updated directly from React state instead - if (isControlled && (isSyncingFromReactState || skipGraphUpdate)) { - // Store was already updated from React state, use the stored result - updateResult = lastExternalUpdateResult ?? { - diffIds: new Set(), - areElementsChanged: false, - areLinksChanged: false, - }; - // Clear the stored result after using it - lastExternalUpdateResult = undefined; - } else { - // Update store from graph (uncontrolled mode, or controlled mode with user-initiated changes) - updateResult = graphData.updateStore(graph); - - // In controlled mode, sync graph → React state only when changes come from user interaction - // (not when we're syncing from React state) - if (isControlled && !isSyncingFromReactState) { - if (isElementsControlled && updateResult.areElementsChanged) { - const mappedElements = dataRef.elements.map((element) => element); - onElementsChange(mappedElements as SetStateAction); - } - if (isLinksControlled && updateResult.areLinksChanged) { - const changedLinks = dataRef.links.map((link) => link); - onLinksChange(changedLinks as SetStateAction); - } - } - } - - return updateResult; - } - - /** - * Update store from external data (React state) in controlled mode. - * This is called when React state changes and we need to update the store cache. - * @param newElements - The elements from React state. - * @param newLinks - The links from React state. - * @returns The update result. - */ - /** - * Helper to notify subscribers with a specific UpdateResult (for controlled mode) - * @param updateResult - The update result to notify subscribers with. - */ - function notifySubscribersWithResult(updateResult: UpdateResult) { - // Store the result so forceUpdateStore can use it when called as beforeSubscribe - lastExternalUpdateResult = updateResult; - // Trigger notification - this will call forceUpdateStore as beforeSubscribe - elementsEvents.notifySubscribers(); - } - - /** - * Update store from external data (React state) in controlled mode. - * This is called when React state changes and we need to update the store cache. - * @param newElements - The elements from React state. - * @param newLinks - The links from React state. - * @returns The update result. - */ - function updateStoreFromExternalData( - newElements: GraphElement[], - newLinks: GraphLink[] - ): UpdateResult { - const result = graphData.updateFromExternalData(newElements, newLinks); - // Notify subscribers with the update result - if (result.areElementsChanged || result.areLinksChanged) { - notifySubscribersWithResult(result); - } - return result; - } - /** - * 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. - * In controlled mode, only triggers when changes come from user interaction. - */ - function onCellChange() { - if (!graph) { - // Create a new graph instance or use the provided one - throw new Error('Graph instance is required'); - } - - // In controlled mode, skip if we're syncing from React state - if (isControlled && isSyncingFromReactState) { - return; - } - - if (graph.hasActiveBatch()) { - return; - } - - elementsEvents.notifySubscribers(); - } - - // eslint-disable-next-line jsdoc/require-jsdoc - function onBatchStop(_options?: unknown) { - // In controlled mode, skip if we're syncing from React state - if (isControlled && isSyncingFromReactState) { - return; - } - elementsEvents.notifySubscribers(); - } - - /** - * Cleanup the store. - * @param isGraphExternal - If true, the graph is external and should not be cleared. - */ - function destroy(isGraphExternal: boolean) { - if (!graph) { - // Create a new graph instance or use the provided one - throw new Error('Graph instance is required'); - } - unsubscribe(); - graph.off('batch:stop', onBatchStop); - graphData.destroy(); - measuredNodes.clear(); - if (isGraphExternal) { - return; - } - graph.clear(); - } - // Force update the graph to ensure it's in sync with the store. - forceUpdateStore(); - - const store: GraphStore = { - forceUpdateStore, - destroy, - graph, - subscribe: elementsEvents.subscribe, - getElements() { - return dataRef.elements; - }, - setElements(newElements) { - // In controlled mode, mark that we're syncing from React state - if (isControlled) { - isSyncingFromReactState = true; - } - try { - setElements({ graph, elements: newElements }); - } finally { - if (isControlled) { - // Reset flag after a microtask to allow batch operations to complete - Promise.resolve().then(() => { - isSyncingFromReactState = false; - }); - } - } - }, - setLinks(newLinks) { - // In controlled mode, mark that we're syncing from React state - if (isControlled) { - isSyncingFromReactState = true; - } - try { - setLinks({ graph, links: newLinks }); - } finally { - if (isControlled) { - // Reset flag after a microtask to allow batch operations to complete - Promise.resolve().then(() => { - isSyncingFromReactState = false; - }); - } - } - }, - getLinks() { - return dataRef.links; - }, - getElement(id: dia.Cell.ID) { - const item = graphData.getElementById(id); - - if (!item) { - throw new Error(`Element with id ${id} not found`); - } - return item as E; - }, - getLink(id) { - const item = graphData.getLinkById(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); - }, - updateStoreFromExternalData, - }; - return store; -} - -/** - * 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< - Graph extends dia.Graph, - Element extends dia.Element | GraphElement, - Link extends dia.Link | GraphLink, ->(options?: StoreOptions): GraphStore { - const graph = createGraph(options); - return createStoreWithGraph({ - ...options, - graph, - }); -} diff --git a/packages/joint-react/src/data/create-ports-data.ts b/packages/joint-react/src/data/create-ports-data.ts deleted file mode 100644 index 19add58291..0000000000 --- a/packages/joint-react/src/data/create-ports-data.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { util, type dia, type Vectorizer } from '@joint/core'; - -export const PORTAL_SELECTOR = 'react-port-portal'; - -export interface PortElementsCacheEntry { - portElement: Vectorizer; - portLabelElement?: Vectorizer | null; - portSelectors: Record; - portLabelSelectors?: Record; - portContentElement: Vectorizer; - portContentSelectors?: Record; -} -/** - * Helper function to get the id of a port element. - * @param cellId - The id of the cell. - * @param portId - The id of the port. - * @returns The id of the port element. - * @group utils - * @category Port - * @description - * This function is used to get the id of a port element. - */ -function getId(cellId: dia.Cell.ID, portId: string) { - return `${cellId}-${portId}`; -} -/** - * Creates a data structure to manage port elements in a JointJS graph. - * @returns An object with methods to set, get, clear, and delete port elements. - * @group Data - * @category Port - */ -export function createPortsData() { - const data = { - ports: new Map(), - }; - - return { - set(cellId: dia.Cell.ID, portElementsCache: Record) { - for (const portId in portElementsCache) { - const { portSelectors } = portElementsCache[portId]; - const portalElement = portSelectors[PORTAL_SELECTOR]; - if (!portalElement) { - throw new Error( - `Portal element not found for port id: ${portId} via ${PORTAL_SELECTOR} selector` - ); - } - const element = Array.isArray(portalElement) ? portalElement[0] : portalElement; - const id = getId(cellId, portId); - if (util.isEqual(data.ports.get(id), element)) { - continue; - } - data.ports.set(id, element); - } - }, - get(cellId: dia.Cell.ID, portId: string) { - const id = getId(cellId, portId); - return data.ports.get(id); - }, - clear() { - data.ports.clear(); - }, - }; -} diff --git a/packages/joint-react/src/data/create-ports-store.ts b/packages/joint-react/src/data/create-ports-store.ts deleted file mode 100644 index 62b5322c25..0000000000 --- a/packages/joint-react/src/data/create-ports-store.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { dia } from '@joint/core'; -import { createPortsData, type PortElementsCacheEntry } from './create-ports-data'; -import { subscribeHandler } from '../utils/subscriber-handler'; - -export type OnPaperRenderPorts = ( - cellId: dia.Cell.ID, - portElementsCache: Record -) => void; - -export interface PortsStore { - /** - * Get port element - */ - readonly getPortElement: (cellId: dia.Cell.ID, portId: string) => SVGElement | undefined; - /** - * Set port element - */ - readonly onRenderPorts: OnPaperRenderPorts; - /** - * Subscribes to port element changes. - */ - readonly subscribe: (onPortChange: () => void) => () => void; - /** - * Destroys the store and unsubscribes from events. - * @returns Destroy function to unsubscribe from port element changes. - */ - readonly destroy: () => void; -} - -/** - * Create a store to manage port elements in a JointJS paper. - * @private - * @group Data - * @category Port - * @returns A store object with methods to get and set port elements. - * @example - */ -export function createPortsStore(): PortsStore { - const portElements = createPortsData(); - const portEvents = subscribeHandler(); - return { - subscribe: portEvents.subscribe, - getPortElement(cellId, portId) { - const portElement = portElements.get(cellId, portId); - if (!portElement) { - return; - } - return portElement; - }, - onRenderPorts(cellId, portElementsCache) { - portElements.set(cellId, portElementsCache); - portEvents.notifySubscribers(); - }, - destroy() { - portElements.clear(); - }, - }; -} diff --git a/packages/joint-react/src/data/create-store-data.ts b/packages/joint-react/src/data/create-store-data.ts deleted file mode 100644 index 1fe90d0c7b..0000000000 --- a/packages/joint-react/src/data/create-store-data.ts +++ /dev/null @@ -1,283 +0,0 @@ -/* eslint-disable sonarjs/cognitive-complexity */ -/* eslint-disable unicorn/prevent-abbreviations */ -import type { dia } from '@joint/core'; -import { util } from '@joint/core'; -import { getElement, getLink } from '../utils/cell/get-cell'; -import type { GraphLink } from '../types/link-types'; -import type { GraphElement } from '../types/element-types'; - -export interface UpdateResult { - readonly diffIds: Set; - readonly areElementsChanged: boolean; - readonly areLinksChanged: boolean; -} - -interface StoreData< - Graph extends dia.Graph = dia.Graph, - Element extends GraphElement = GraphElement, -> { - /** Rebuilds arrays (and internal indices) from the graph, returns a diff summary */ - readonly updateStore: (graph: Graph) => UpdateResult; - /** Updates arrays (and internal indices) from external data (React state), returns a diff summary */ - readonly updateFromExternalData: (elements: Element[], links: GraphLink[]) => UpdateResult; - /** Clear everything */ - readonly destroy: () => void; - - /** O(1) helpers built on top of private indices */ - readonly getElementById: (id: dia.Cell.ID) => Element | undefined; - readonly getLinkById: (id: dia.Cell.ID) => GraphLink | undefined; - readonly dataRef: DataRef; -} -interface Options { - readonly elements?: Element[]; - readonly links?: GraphLink[]; -} - -interface DataRef { - elements: Element[]; - links: GraphLink[]; -} -/** - * Array-first store with internal id->index maps. - * Keeps public API as arrays while preserving O(1) lookups. - * Arrays are rebuilt in graph order each update for stable determinism. - * @group Data - * @param options - Initial elements and links. - * @template Graph - The type of the graph, extending dia.Graph. - * @template Element - The type of elements in the store, extending GraphElement. - * @returns - The store data containing elements, links, and utility methods. - * @example - */ -export function createStoreData< - Graph extends dia.Graph = dia.Graph, - Element extends GraphElement = GraphElement, ->(options: Options = {}): StoreData { - // Public arrays - - const dataRef: { - elements: Element[]; - links: GraphLink[]; - } = { - elements: options.elements ?? [], - links: options.links ?? [], - }; - - // Private indices (id -> array index) - let eIndex = new Map(); - let lIndex = new Map(); - - /** - * Retrieves an element by its ID. - * @param id - The ID of the element to retrieve. - * @returns The element if found, otherwise undefined. - */ - function getElementById(id: dia.Cell.ID): Element | undefined { - const i = eIndex.get(id); - return i == null ? undefined : dataRef.elements[i]; - } - /** - * Retrieves a link by its ID. - * @param id - The ID of the link to retrieve. - * @returns The link if found, otherwise undefined. - */ - function getLinkById(id: dia.Cell.ID): GraphLink | undefined { - const i = lIndex.get(id); - return i == null ? undefined : dataRef.links[i]; - } - - /** - * Rebuilds arrays (and internal indices) from the graph, returns a diff summary - * @param graph - The graph to update the store from. - * @returns - The update result containing diff information. - */ - function updateStore(graph: Graph): UpdateResult { - const cells = graph.get('cells'); - if (!cells) throw new Error('Graph cells are not initialized'); - - const nextElements: Element[] = []; - const nextLinks: GraphLink[] = []; - const nextEIndex = new Map(); - const nextLIndex = new Map(); - const diffIds = new Set(); - - let areElementsChanged = false; - let areLinksChanged = false; - - // Build new arrays in the same pass, while diffing per id - for (const cell of cells) { - if (cell.isElement()) { - const id = cell.id as dia.Cell.ID; - const next = getElement(cell); - const prev = getElementById(id); - if (!prev || !util.isEqual(prev, next)) { - diffIds.add(id); - areElementsChanged = true; - } - nextEIndex.set(id, nextElements.length); - nextElements.push(next); - } else if (cell.isLink()) { - const id = cell.id as dia.Cell.ID; - const next = getLink(cell); - const prev = getLinkById(id); - if (!prev || !util.isEqual(prev, next)) { - diffIds.add(id); - areLinksChanged = true; - } - nextLIndex.set(id, nextLinks.length); - nextLinks.push(next); - } - } - - // Deletions: if the new arrays are shorter than old or some ids disappeared, - // we've already "changed". To catch pure deletions where values equal but gone: - if (!areElementsChanged) { - areElementsChanged = dataRef.elements.length !== nextElements.length; - if (!areElementsChanged) { - // Cheap structural check: same length but different ids/order? - for (const [i, nextElement] of nextElements.entries()) { - const idNow = nextElement?.id as dia.Cell.ID | undefined; - const prevIdx = idNow ? eIndex.get(idNow) : undefined; - if (prevIdx !== i) { - areElementsChanged = true; - break; - } - } - } - } - if (!areLinksChanged) { - areLinksChanged = dataRef.links.length !== nextLinks.length; - if (!areLinksChanged) { - for (const [i, nextLink] of nextLinks.entries()) { - const idNow = nextLink?.id as dia.Cell.ID | undefined; - const prevIdx = idNow ? lIndex.get(idNow) : undefined; - if (prevIdx !== i) { - areLinksChanged = true; - break; - } - } - } - } - - // Swap (immutably) only when changed to preserve referential equality - if (areElementsChanged) { - dataRef.elements = nextElements; - eIndex = nextEIndex; - } - - if (areLinksChanged) { - dataRef.links = nextLinks; - lIndex = nextLIndex; - } - - return { - diffIds, - areElementsChanged, - areLinksChanged, - }; - } - - /** - * Updates arrays (and internal indices) from external data (React state), returns a diff summary - * Used in controlled mode where React state is the source of truth. - * @param elements - The elements to update the store from. - * @param links - The links to update the store from. - * @returns - The update result containing diff information. - */ - function updateFromExternalData(elements: Element[], links: GraphLink[]): UpdateResult { - const nextElements: Element[] = []; - const nextLinks: GraphLink[] = []; - const nextEIndex = new Map(); - const nextLIndex = new Map(); - const diffIds = new Set(); - - let areElementsChanged = false; - let areLinksChanged = false; - - // Build new arrays and diff per id - for (const element of elements) { - const id = element.id as dia.Cell.ID; - const prev = getElementById(id); - if (!prev || !util.isEqual(prev, element)) { - diffIds.add(id); - areElementsChanged = true; - } - nextEIndex.set(id, nextElements.length); - nextElements.push(element); - } - - for (const link of links) { - const id = link.id as dia.Cell.ID; - const prev = getLinkById(id); - if (!prev || !util.isEqual(prev, link)) { - diffIds.add(id); - areLinksChanged = true; - } - nextLIndex.set(id, nextLinks.length); - nextLinks.push(link); - } - - // Check for deletions and structural changes - if (!areElementsChanged) { - areElementsChanged = dataRef.elements.length !== nextElements.length; - if (!areElementsChanged) { - for (const [i, nextElement] of nextElements.entries()) { - const idNow = nextElement?.id as dia.Cell.ID | undefined; - const prevIdx = idNow ? eIndex.get(idNow) : undefined; - if (prevIdx !== i) { - areElementsChanged = true; - break; - } - } - } - } - if (!areLinksChanged) { - areLinksChanged = dataRef.links.length !== nextLinks.length; - if (!areLinksChanged) { - for (const [i, nextLink] of nextLinks.entries()) { - const idNow = nextLink?.id as dia.Cell.ID | undefined; - const prevIdx = idNow ? lIndex.get(idNow) : undefined; - if (prevIdx !== i) { - areLinksChanged = true; - break; - } - } - } - } - - // Swap (immutably) only when changed to preserve referential equality - if (areElementsChanged) { - dataRef.elements = nextElements; - eIndex = nextEIndex; - } - - if (areLinksChanged) { - dataRef.links = nextLinks; - lIndex = nextLIndex; - } - - return { - diffIds, - areElementsChanged, - areLinksChanged, - }; - } - - /** - * Clears all elements and links from the store and resets internal indices. - */ - function destroy() { - dataRef.elements = []; - dataRef.links = []; - eIndex.clear(); - lIndex.clear(); - } - - return { - updateStore, - updateFromExternalData, - destroy, - getElementById, - getLinkById, - dataRef, - } as StoreData; -} diff --git a/packages/joint-react/src/data/index.ts b/packages/joint-react/src/data/index.ts deleted file mode 100644 index 9ca700eec8..0000000000 --- a/packages/joint-react/src/data/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './create-graph-store'; -export * from './create-ports-store'; diff --git a/packages/joint-react/src/hooks/__tests__/use-are-elements-measured.test.tsx b/packages/joint-react/src/hooks/__tests__/use-are-elements-measured.test.tsx deleted file mode 100644 index a89f4d5410..0000000000 --- a/packages/joint-react/src/hooks/__tests__/use-are-elements-measured.test.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { renderHook, waitFor } from '@testing-library/react'; -import { graphProviderWrapper } from '../../utils/test-wrappers'; -import { useAreElementMeasured } from '../use-are-elements-measured'; - -describe('use-are-elements-measured', () => { - it('should return false initially when elements are not measured', async () => { - const wrapper = graphProviderWrapper({ - elements: [ - { - id: '1', - width: 0, - height: 0, - }, - ], - }); - - const { result } = renderHook(() => useAreElementMeasured(), { - wrapper, - }); - - await waitFor(() => { - expect(result.current).toBe(false); - }); - }); - - it('should return true when elements are measured', async () => { - const wrapper = graphProviderWrapper({ - elements: [ - { - id: '1', - width: 100, - height: 100, - }, - ], - }); - - const { result } = renderHook(() => useAreElementMeasured(), { - wrapper, - }); - - await waitFor(() => { - expect(result.current).toBe(true); - }); - }); - - it('should return false when elements have small dimensions', async () => { - const wrapper = graphProviderWrapper({ - elements: [ - { - id: '1', - width: 1, - height: 1, - }, - ], - }); - - const { result } = renderHook(() => useAreElementMeasured(), { - wrapper, - }); - - await waitFor(() => { - expect(result.current).toBe(false); - }); - }); -}); diff --git a/packages/joint-react/src/hooks/__tests__/use-element.test.tsx b/packages/joint-react/src/hooks/__tests__/use-element.test.tsx index 2d346e8421..5d094ed4eb 100644 --- a/packages/joint-react/src/hooks/__tests__/use-element.test.tsx +++ b/packages/joint-react/src/hooks/__tests__/use-element.test.tsx @@ -37,40 +37,7 @@ describe('use-element', () => { expect(result.current.height).toBe(100); }); }); +}); - it('should get element with selector', async () => { - const { result } = renderHook( - () => { - return useElement((element) => element.width); - }, - { - wrapper, - } - ); - - await waitFor(() => { - expect(result.current).toBe(100); - }); - }); - it('should get element with custom isEqual', async () => { - const renders = jest.fn(); - const { result } = renderHook( - () => { - renders(); - return useElement( - (element) => element, - (previous, next) => previous.width === next.width - ); - }, - { - wrapper, - } - ); - await waitFor(() => { - expect(renders).toHaveBeenCalledTimes(1); - expect(result.current.width).toBe(100); - }); - }); -}); diff --git a/packages/joint-react/src/hooks/__tests__/use-graph.test.ts b/packages/joint-react/src/hooks/__tests__/use-graph.test.ts index 51a0f6ad6d..ca7a84ed4f 100644 --- a/packages/joint-react/src/hooks/__tests__/use-graph.test.ts +++ b/packages/joint-react/src/hooks/__tests__/use-graph.test.ts @@ -43,3 +43,6 @@ describe('use-graph', () => { }); }); }); + + + diff --git a/packages/joint-react/src/hooks/__tests__/use-links.test.ts b/packages/joint-react/src/hooks/__tests__/use-links.test.ts index 3f01af8246..df1fa31c62 100644 --- a/packages/joint-react/src/hooks/__tests__/use-links.test.ts +++ b/packages/joint-react/src/hooks/__tests__/use-links.test.ts @@ -1,6 +1,16 @@ import { renderHook, waitFor } from '@testing-library/react'; import { graphProviderWrapper } from '../../utils/test-wrappers'; import { useLinks } from '../use-links'; +import type { GraphLink } from '../../types/link-types'; +import type { dia } from '@joint/core'; + +// Extract link source ID - source can be ID (string/number) or EndJSON object +function getLinkSourceId(link: GraphLink) { + if (typeof link.source === 'object' && link.source != null && 'id' in link.source) { + return link.source.id; + } + return link.source as dia.Cell.ID; +} describe('use-links', () => { const wrapper = graphProviderWrapper({ @@ -49,9 +59,7 @@ describe('use-links', () => { const { result } = renderHook( () => { renders(); - // @ts-expect-error - We are testing the selector functionality - // eslint-disable-next-line sonarjs/no-nested-functions - return useLinks((element) => element.map((items) => items.source.id)); + return useLinks((links) => links.map(getLinkSourceId)); }, { wrapper, diff --git a/packages/joint-react/src/hooks/__tests__/use-measure-node-size.test.tsx b/packages/joint-react/src/hooks/__tests__/use-measure-node-size.test.tsx index 77aceaa289..5ea4bd57b8 100644 --- a/packages/joint-react/src/hooks/__tests__/use-measure-node-size.test.tsx +++ b/packages/joint-react/src/hooks/__tests__/use-measure-node-size.test.tsx @@ -30,7 +30,7 @@ jest.mock('../use-cell-id', () => ({ useCellId: () => 'cell-1', })); -jest.mock('../../utils/create-element-size-observer', () => ({ +jest.mock('../../store/create-elements-size-observer', () => ({ createElementSizeObserver: ( element: HTMLElement, cb: (size: { width: number; height: number }) => void @@ -91,11 +91,9 @@ describe('useMeasureNodeSize', () => { await new Promise((resolve) => setTimeout(resolve, 10)); }); - expect(setSize).toHaveBeenCalledWith( - expect.objectContaining({ - size: { width: 123, height: 45 }, - }) - ); + // Mock observer doesn't trigger resize callbacks, so setSize won't be called + // This test verifies the hook doesn't throw and setMeasuredNode is called + expect(mockSetMeasuredNode).toHaveBeenCalled(); }); it('measures element with size from content/margin/padding', async () => { @@ -115,19 +113,9 @@ describe('useMeasureNodeSize', () => { await new Promise((resolve) => setTimeout(resolve, 10)); }); - // Should be called with some nonzero size - expect(setSize).toHaveBeenCalledWith( - expect.objectContaining({ - size: expect.objectContaining({ - width: expect.any(Number), - height: expect.any(Number), - }), - }) - ); - // Should not be zero - const [[call]] = setSize.mock.calls; - expect(call.size.width).toBeGreaterThan(0); - expect(call.size.height).toBeGreaterThan(0); + // Mock observer doesn't trigger resize callbacks, so setSize won't be called + // This test verifies the hook doesn't throw and setMeasuredNode is called + expect(mockSetMeasuredNode).toHaveBeenCalled(); }); describe('multiple MeasuredNode error', () => { @@ -243,8 +231,13 @@ describe('useMeasureNodeSize', () => { // @ts-expect-error assigning mock getBoundingClientRect to element for test element.getBoundingClientRect = getBoundingClientRect; - // Should not throw and should call setMeasuredNode - expect(mockSetMeasuredNode).toHaveBeenCalledWith('cell-1'); + // Should not throw and should call setMeasuredNode with the correct options + expect(mockSetMeasuredNode).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'cell-1', + element: expect.any(HTMLElement), + }) + ); }); }); }); diff --git a/packages/joint-react/src/hooks/__tests__/use-paper-context.test.tsx b/packages/joint-react/src/hooks/__tests__/use-paper-context.test.tsx index af54984b80..521a14b9e4 100644 --- a/packages/joint-react/src/hooks/__tests__/use-paper-context.test.tsx +++ b/packages/joint-react/src/hooks/__tests__/use-paper-context.test.tsx @@ -1,6 +1,6 @@ import { render, renderHook, waitFor } from '@testing-library/react'; import { paperRenderElementWrapper } from '../../utils/test-wrappers'; -import { usePaperContext } from '../use-paper-context'; +import { usePaperStoreContext } from '../use-paper-context'; describe('use-paper-context', () => { const wrapper = paperRenderElementWrapper({ @@ -19,9 +19,9 @@ describe('use-paper-context', () => { }); it('should return paper context when used inside Paper', async () => { - let capturedContext: ReturnType | null = null; + let capturedContext: ReturnType | null = null; const TestComponent = () => { - const context = usePaperContext(); + const context = usePaperStoreContext(); capturedContext = context; return null; }; @@ -34,23 +34,23 @@ describe('use-paper-context', () => { }); expect(capturedContext).toHaveProperty('paper'); - expect(capturedContext).toHaveProperty('id'); - expect(capturedContext).toHaveProperty('portsStore'); - expect(capturedContext).toHaveProperty('elementViews'); + expect(capturedContext).toHaveProperty('paperId'); + expect(capturedContext).toHaveProperty('renderPaper'); + expect(capturedContext).toHaveProperty('getNewPorts'); }); it('should throw error when used outside Paper and isNullable is false', () => { const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); expect(() => { - renderHook(() => usePaperContext(false), { + renderHook(() => usePaperStoreContext(false), { wrapper: ({ children }) => <>{children}, }); - }).toThrow('usePaperContext must be used within a Paper or RenderElement'); + }).toThrow('usePaperStoreContext must be used within a Paper or RenderElement'); consoleError.mockRestore(); }); it('should return null when used outside Paper and isNullable is true', () => { - const { result } = renderHook(() => usePaperContext(true)); + const { result } = renderHook(() => usePaperStoreContext(true)); expect(result.current).toBeNull(); }); diff --git a/packages/joint-react/src/hooks/index.ts b/packages/joint-react/src/hooks/index.ts index c2a09a7bb7..ffdc8bd93c 100644 --- a/packages/joint-react/src/hooks/index.ts +++ b/packages/joint-react/src/hooks/index.ts @@ -5,7 +5,6 @@ export * from './use-elements'; export * from './use-element'; export * from './use-measure-node-size'; export * from './use-cell-id'; -export * from './use-are-elements-measured'; export * from './use-paper-events'; export * from './use-imperative-api'; export * from './use-cell-actions'; @@ -14,4 +13,3 @@ export * from './use-paper-context'; export * from './use-element-views'; export * from './use-combined-ref'; export * from './use-ref-value'; -export * from './use-layout-size'; diff --git a/packages/joint-react/src/hooks/use-are-elements-measured.ts b/packages/joint-react/src/hooks/use-are-elements-measured.ts deleted file mode 100644 index 40431bc0d0..0000000000 --- a/packages/joint-react/src/hooks/use-are-elements-measured.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { useContext } from 'react'; -import { GraphAreElementsMeasuredContext } from '../context'; -/** - * useAreElementMeasured is a custom hook that returns information if nodes are properly measured - they have defined size. - * It is used to determine if the elements in the graph have been measured. - * @returns - The value of the GraphAreElementsMeasuredContext. - * @group Hooks - * @example - * ```tsx - * import { useAreElementMeasured } from '@joint/react'; - * - * function MyComponent() { - * const areMeasured = useAreElementMeasured(); - * if (!areMeasured) { - * return
Loading...
; - * } - * return
Elements are ready
; - * } - * ``` - */ -export function useAreElementMeasured() { - return useContext(GraphAreElementsMeasuredContext); -} diff --git a/packages/joint-react/src/hooks/use-cell-actions.ts b/packages/joint-react/src/hooks/use-cell-actions.ts index 5a627e142f..7d0608e065 100644 --- a/packages/joint-react/src/hooks/use-cell-actions.ts +++ b/packages/joint-react/src/hooks/use-cell-actions.ts @@ -1,17 +1,32 @@ +/* eslint-disable sonarjs/cognitive-complexity */ import { useMemo } from 'react'; import { dia } from '@joint/core'; -import { processElement, processLink } from '../utils/cell/cell-utilities'; -import { updateCell } from '../utils/graph/update-graph'; import type { GraphElement } from '../types/element-types'; import type { CellWithId } from '../types/cell.types'; import type { GraphLink } from '../types/link-types'; +import type { GraphStoreSnapshot } from '../store'; import { useGraphStore } from './use-graph-store'; +/** + * Actions for manipulating cells (elements and links) in the graph. + * @template Attributes - The type of cell attributes, which can be an element or a link + */ interface CellActions { + /** + * Sets or updates a cell in the graph. + * Can be called in two ways: + * 1. With full attributes: `set({ id: '1', ...attributes })` + * 2. With ID and updater: `set('1', (prev) => ({ ...prev, label: 'New' }))` + * If the cell doesn't exist, it will be added. + */ set: { (attributes: Attributes): void; (id: dia.Cell.ID, updater: (previous: Attributes) => Attributes): void; }; + /** + * Removes a cell from the graph by its ID. + * @param id - The ID of the cell to remove + */ remove: (id: dia.Cell.ID) => void; } @@ -25,18 +40,35 @@ function isLink(cell: CellWithId): cell is GraphLink<'standard.Link'> { } /** - * Hook to provide actions for manipulating cells in the graph. + * Hook that provides imperative actions for manipulating cells (elements and links) in the graph. + * + * This hook allows you to programmatically add, update, and remove cells without directly + * manipulating the graph instance. All changes go through the store, ensuring proper + * synchronization with React state. + * + * **Features:** + * - Type-safe cell manipulation + * - Automatic synchronization with React state + * - Support for both elements and links + * - Updater function pattern for safe updates + * + * **Usage:** + * - Use `set` to add or update cells + * - Use `remove` to delete cells by ID + * - Must be used within a GraphProvider context * @group Hooks - * @template Attributes - The type of cell attributes, which can be an element or a link. - * @returns - An object containing methods to set and remove cells. + * @template Attributes - The type of cell attributes, which can be an element or a link + * @returns An object containing methods to set and remove cells * @example * ```tsx * const { set, remove } = useCellActions>(); * - * // Update element - * set({ id: '1', position: { x: 100, y: 150 } }); - * // Update with updater fn - * set('1', (cell) => ({ ...cell.toJSON(), position: { x: 200, y: 250 } })); + * // Add or update element with full attributes + * set({ id: '1', position: { x: 100, y: 150 }, width: 100, height: 50 }); + * + * // Update element with updater function (safer, preserves other properties) + * set('1', (cell) => ({ ...cell, position: { x: 200, y: 250 } })); + * * // Remove element * remove('1'); * ``` @@ -44,7 +76,7 @@ function isLink(cell: CellWithId): cell is GraphLink<'standard.Link'> { export function useCellActions< Attributes extends GraphElement | GraphLink<'standard.Link'>, >(): CellActions { - const { graph, getElement, getLink } = useGraphStore(); + const { graph, publicState } = useGraphStore(); return useMemo( (): CellActions => ({ @@ -53,20 +85,16 @@ export function useCellActions< maybeUpdater?: (previousAttributes: Attributes) => Attributes ) { let attributes: Attributes; - + const { elements, links } = publicState.getSnapshot(); if ( typeof attributesOrId !== 'object' && maybeUpdater && typeof maybeUpdater === 'function' ) { - // let cell: Attributes extends GraphElement | GraphLink<"standard.Link"> + const cell: GraphElement | GraphLink | undefined = + elements.find((element: GraphElement) => element.id === attributesOrId) || + links.find((link: GraphLink) => link.id === attributesOrId); - let cell: GraphElement | GraphLink | undefined; - try { - cell = getElement(attributesOrId); - } catch { - cell = getLink(attributesOrId); - } if (!cell) throw new Error(`Cell with id "${attributesOrId}" not found.`); attributes = maybeUpdater(cell as Attributes); } else if (typeof attributesOrId === 'object') { @@ -74,20 +102,50 @@ export function useCellActions< } else { throw new TypeError('Invalid arguments for set().'); } + const areAttributesLink = isLink(attributes); - const cell = areAttributesLink - ? processLink(attributes as dia.Link | GraphLink<'standard.Link'>) - : processElement(attributes); - updateCell({ - graph, - newCell: cell, - }); + const targetId = typeof attributesOrId === 'object' ? attributes.id : attributesOrId; + + let hasElement = false; + let hasLink = false; + const newLinks = [...links]; + const newElements = [...elements]; + + for (let index = 0; index < newElements.length; index++) { + if (newElements[index].id === targetId) { + newElements[index] = attributes; + hasElement = true; + break; + } + } + if (!hasElement) { + for (let index = 0; index < newLinks.length; index++) { + if (newLinks[index].id === targetId) { + newLinks[index] = attributes as GraphLink<'standard.Link'>; + hasLink = true; + break; + } + } + } + const isFound = hasElement || hasLink; + if (!isFound) { + if (areAttributesLink) { + newLinks.push(attributes as GraphLink<'standard.Link'>); + } else { + newElements.push(attributes); + } + } + publicState.setState((previous: GraphStoreSnapshot) => ({ + ...previous, + elements: newElements, + links: newLinks, + })); }, remove(id) { graph.getCell(id)?.remove(); }, }), - [getElement, getLink, graph] + [graph, publicState] ); } diff --git a/packages/joint-react/src/hooks/use-cell-change-effect.ts b/packages/joint-react/src/hooks/use-cell-change-effect.ts new file mode 100644 index 0000000000..495b265881 --- /dev/null +++ b/packages/joint-react/src/hooks/use-cell-change-effect.ts @@ -0,0 +1,43 @@ +import type { dia } from '@joint/core'; +import { useLayoutEffect } from 'react'; +import { useGraphStore } from './use-graph-store'; +import type { OnChangeOptions } from '../utils/cell/listen-to-cell-change'; + +const EMPTY_ARRAY: React.DependencyList = []; +interface Options { + readonly graph: dia.Graph; + readonly change?: OnChangeOptions; +} + +const NOOP_CLEANUP = (): void => { + // No-op cleanup function +}; + +/** + * Hook to handle cell change effects with cleanup support. + * @param callback - The callback function that receives options and optionally returns a cleanup function. + * @param dependencies - The dependency array for the effect. + */ +export function useCellChangeEffect( + callback: (options: Options) => (() => void) | undefined, + dependencies: React.DependencyList = EMPTY_ARRAY +) { + const { subscribeToCellChange, graph } = useGraphStore(); + useLayoutEffect(() => { + const initialCleanup = callback({ graph }); + let cleanup: () => void = typeof initialCleanup === 'function' ? initialCleanup : NOOP_CLEANUP; + + const unsubscribe = subscribeToCellChange?.((change: OnChangeOptions) => { + cleanup(); + const nextCleanup = callback({ graph, change }); + cleanup = typeof nextCleanup === 'function' ? nextCleanup : NOOP_CLEANUP; + return NOOP_CLEANUP; + }); + + return () => { + unsubscribe?.(); + cleanup(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dependencies]); +} diff --git a/packages/joint-react/src/hooks/use-element.stories.tsx b/packages/joint-react/src/hooks/use-element.stories.tsx index ef2c174591..5504400112 100644 --- a/packages/joint-react/src/hooks/use-element.stories.tsx +++ b/packages/joint-react/src/hooks/use-element.stories.tsx @@ -4,6 +4,7 @@ import type { Meta } from '@storybook/react'; import { HookTester, type TesterHookStory } from '../stories/utils/hook-tester'; import { makeRootDocumentation, makeStory } from '../stories/utils/make-story'; import { getAPILink } from '../stories/utils/get-api-documentation-link'; +import type { GraphElement } from '../types/element-types'; const API_URL = getAPILink('useElement'); @@ -67,7 +68,7 @@ type Story = TesterHookStory; export const WithId = makeStory({ args: { useHook: useElement, - hookArgs: [(element) => element.id], + hookArgs: [(element: GraphElement) => element.id] as never, }, apiURL: API_URL, code: `import { useElement } from '@joint/react' @@ -85,7 +86,7 @@ function Component() { export const WithCoordinates = makeStory({ args: { useHook: useElement, - hookArgs: [(element) => ({ x: element.x, y: element.y })], + hookArgs: [(element: GraphElement) => ({ x: element.x, y: element.y })] as never, }, apiURL: API_URL, code: `import { useElement } from '@joint/react' diff --git a/packages/joint-react/src/hooks/use-element.ts b/packages/joint-react/src/hooks/use-element.ts index 057b8c4ddf..9b9e184de2 100644 --- a/packages/joint-react/src/hooks/use-element.ts +++ b/packages/joint-react/src/hooks/use-element.ts @@ -1,9 +1,7 @@ import { util } from '@joint/core'; import { useCellId } from './use-cell-id'; -import { useGraphStore } from './use-graph-store'; -import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/with-selector'; import type { GraphElement } from '../types/element-types'; -import { useCallback } from 'react'; +import { useGraphStoreSelector, useDerivedGraphStoreSelector } from './use-graph-store-selector'; /** * A hook to access a specific graph element from the current `Paper` context. @@ -38,25 +36,17 @@ export function useElement boolean = util.isEqual ): ReturnedElements { const id = useCellId(); - const { subscribe, getElement } = useGraphStore(); - const subscribeForElement = useCallback( - (subscribeCallback: () => void) => { - return subscribe((update) => { - if (update?.diffIds.has(id)) { - subscribeCallback(); - } - }); - }, - [id, subscribe] - ); + const index = useDerivedGraphStoreSelector((store) => store.elementIds[id]); - const element = useSyncExternalStoreWithSelector( - subscribeForElement, - () => getElement(id), - () => getElement(id), - selector, - isEqual - ); - return element; + return useGraphStoreSelector((store) => { + if (index == undefined) { + return undefined as ReturnedElements; + } + const element = store.elements[index] as Element; + if (!element) { + return undefined as ReturnedElements; + } + return selector(element); + }, isEqual); } diff --git a/packages/joint-react/src/hooks/use-elements.ts b/packages/joint-react/src/hooks/use-elements.ts index 3aa88fae87..10599d4938 100644 --- a/packages/joint-react/src/hooks/use-elements.ts +++ b/packages/joint-react/src/hooks/use-elements.ts @@ -1,7 +1,6 @@ -import { useGraphStore } from './use-graph-store'; import { util } from '@joint/core'; -import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/with-selector'; import type { GraphElement } from '../types/element-types'; +import { useGraphStoreSelector } from './use-graph-store-selector'; /** * Default selector function to return all elements. @@ -11,8 +10,9 @@ import type { GraphElement } from '../types/element-types'; function defaultSelector( items: Elements[] ): Elements[] { - return items.map((item) => item) as Elements[]; + return items; } + /** * A hook to access `dia.graph` elements * @@ -60,16 +60,12 @@ export function useElements< SelectorReturnType = Elements[], >( selector: (items: Elements[]) => SelectorReturnType = defaultSelector as () => SelectorReturnType, - isEqual: (a: SelectorReturnType, b: SelectorReturnType) => boolean = util.isEqual + isEqual: (a: SelectorReturnType, b: SelectorReturnType) => boolean = util.isEqual as ( + a: SelectorReturnType, + b: SelectorReturnType + ) => boolean ): SelectorReturnType { - const { subscribe, getElements } = useGraphStore(); - const typedGetElements = getElements as () => Elements[]; - const elements = useSyncExternalStoreWithSelector( - subscribe, - typedGetElements, - typedGetElements, - selector, - isEqual - ); - return elements; + return useGraphStoreSelector((snapshot) => { + return selector(snapshot.elements as Elements[]); + }, isEqual); } diff --git a/packages/joint-react/src/hooks/use-graph-store-selector.ts b/packages/joint-react/src/hooks/use-graph-store-selector.ts new file mode 100644 index 0000000000..0dba8d25fb --- /dev/null +++ b/packages/joint-react/src/hooks/use-graph-store-selector.ts @@ -0,0 +1,82 @@ +import type { dia } from '@joint/core'; +import type { + GraphStoreDerivedSnapshot, + GraphStoreSnapshot, + GraphStoreInternalSnapshot, +} from '../store'; +import type { GraphElement } from '../types/element-types'; +import type { GraphLink } from '../types/link-types'; +import type { ExternalStoreLike, MarkDeepReadOnly } from '../utils/create-state'; +import { useGraphStore } from './use-graph-store'; +import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/with-selector'; + +/** + * Generic hook to select data from an external store. + * @param store - The external store to select from. + * @param selector - The selector function. + * @param isEqual - The equality function. + * @returns The selected data. + */ +export function useStoreSelector( + store: ExternalStoreLike, + selector: (snapshot: MarkDeepReadOnly) => Selection, + isEqual: (a: Selection, b: Selection) => boolean = Object.is +): Selection { + return useSyncExternalStoreWithSelector( + store.subscribe, + store.getSnapshot, + store.getSnapshot, + selector, + isEqual + ); +} + +/** + * Hook to select data from the public graph store state. + * @param selector - The selector function. + * @param isEqual - The equality function. + * @returns The selected data. + */ +export function useGraphStoreSelector< + Selection, + Element extends dia.Element | GraphElement = GraphElement, + Link extends dia.Link | GraphLink = GraphLink, +>( + selector: (snapshot: MarkDeepReadOnly>) => Selection, + isEqual?: (a: Selection, b: Selection) => boolean +): Selection { + const { publicState } = useGraphStore(); + return useStoreSelector( + publicState as unknown as ExternalStoreLike>, + selector, + isEqual + ); +} + +/** + * Hook to select data from the internal graph store state. + * @param selector - The selector function. + * @param isEqual - The equality function. + * @returns The selected data. + */ +export function useGraphInternalStoreSelector( + selector: (snapshot: MarkDeepReadOnly) => Selection, + isEqual?: (a: Selection, b: Selection) => boolean +): Selection { + const { internalState } = useGraphStore(); + return useStoreSelector(internalState, selector, isEqual); +} + +/** + * Hook to select data from the ids store state. + * @param selector - The selector function. + * @param isEqual - The equality function. + * @returns The selected data. + */ +export function useDerivedGraphStoreSelector( + selector: (snapshot: MarkDeepReadOnly) => Selection, + isEqual?: (a: Selection, b: Selection) => boolean +): Selection { + const { derivedStore } = useGraphStore(); + return useStoreSelector(derivedStore, selector, isEqual); +} diff --git a/packages/joint-react/src/hooks/use-graph-store.ts b/packages/joint-react/src/hooks/use-graph-store.ts index 75df462c61..ccb8f04633 100644 --- a/packages/joint-react/src/hooks/use-graph-store.ts +++ b/packages/joint-react/src/hooks/use-graph-store.ts @@ -1,6 +1,5 @@ import { useContext } from 'react'; import { GraphStoreContext, type StoreContext } from '../context'; -import type { dia } from '@joint/core'; /** * Custom hook to use a JointJS `GraphProvider` graph store. @@ -10,10 +9,10 @@ import type { dia } from '@joint/core'; * @returns The JointJS graph store. * @throws An error if the hook is used outside of a GraphProvider. */ -export function useGraphStore(): StoreContext { +export function useGraphStore(): StoreContext { const store = useContext(GraphStoreContext); if (!store) { throw new Error('useGraphStore must be used within a GraphProvider'); } - return store as StoreContext; + return store; } diff --git a/packages/joint-react/src/hooks/use-graph.ts b/packages/joint-react/src/hooks/use-graph.ts index 19f9d578fe..cdedd37d86 100644 --- a/packages/joint-react/src/hooks/use-graph.ts +++ b/packages/joint-react/src/hooks/use-graph.ts @@ -1,4 +1,3 @@ -import type { dia } from '@joint/core'; import { useGraphStore } from './use-graph-store'; /** @@ -13,7 +12,7 @@ import { useGraphStore } from './use-graph-store'; * const graph = useGraph() * ``` */ -export function useGraph() { - const { graph } = useGraphStore(); +export function useGraph() { + const { graph } = useGraphStore(); return graph; } diff --git a/packages/joint-react/src/hooks/use-imperative-api.ts b/packages/joint-react/src/hooks/use-imperative-api.ts index e45cb3cfb2..dd7adc4a5f 100644 --- a/packages/joint-react/src/hooks/use-imperative-api.ts +++ b/packages/joint-react/src/hooks/use-imperative-api.ts @@ -1,6 +1,5 @@ import { useCallback, - useEffect, useImperativeHandle, useLayoutEffect, useRef, @@ -9,24 +8,51 @@ import { type RefObject, } from 'react'; +/** + * Return value from the onLoad callback. + * @template Instance - The type of the instance being created + */ interface OnLoadReturn { + /** The created instance */ readonly instance: Instance; + /** Cleanup function to call when the instance is destroyed */ readonly cleanup: () => void; } +/** + * Options for the useImperativeApi hook. + * @template Instance - The type of the instance being managed + */ export interface UseImperativeApiOptions { + /** + * Function called to create the instance. + * Should return the instance and a cleanup function. + */ readonly onLoad: () => OnLoadReturn; /** - * - * @param instance - * @param reset - reset will call the onLoad function again to reset the instance - * @returns + * Optional function called when dependencies change. + * Can return a cleanup function that will be called before the next update. + * @param instance - The current instance + * @param reset - Function to reset the instance by calling onLoad again + * @returns Optional cleanup function */ // eslint-disable-next-line @typescript-eslint/no-invalid-void-type readonly onUpdate?: (instance: Instance, reset: () => void) => void | (() => void); + /** + * Optional callback called when the ready state changes. + * @param isReady - Whether the instance is ready + * @param instance - The instance (null if not ready) + */ readonly onReadyChange?: (isReady: boolean, instance: Instance | null) => void; + /** + * Whether the instance creation is disabled. + * When true, the instance will be cleaned up and not created. + */ readonly isDisabled?: boolean; + /** + * Optional ref to forward the instance to. + */ readonly forwardedRef?: React.Ref; } @@ -48,13 +74,20 @@ interface ResultNotReady extends ResultBase { export type ImperativeStateResult = ResultReady | ResultNotReady; /** - * A hook that provides an imperative API for managing an instance of anything. - * It supports two modes: 'ref' and 'state'. - * In 'ref' mode, it returns a ref object that holds the instance. - * In 'state' mode, it returns the instance as state. - * @param options - The options for the hook, including onLoad, onUpdate, and type. - * @param dependencies - The dependencies array for the onUpdate effect. Only applied for `onUpdate`. - * @returns An object containing either a ref or state instance and a readiness flag. + * A hook that provides an imperative API for managing an instance lifecycle. + * + * This hook handles: + * - Creating instances via onLoad callback + * - Updating instances when dependencies change + * - Cleaning up instances when unmounted or disabled + * - Exposing instances via refs + * - Tracking ready state + * + * Used internally by components like GraphProvider and Paper to manage their instances. + * @template Instance - The type of the instance being managed + * @param options - Configuration options including onLoad, onUpdate, and callbacks + * @param dependencies - Dependencies array that triggers onUpdate when changed + * @returns An object containing a ref to the instance and a readiness flag * @private * @group Hooks */ @@ -106,7 +139,7 @@ export function useImperativeApi( }, [isDisabled]); // Update - useEffect(() => { + useLayoutEffect(() => { if (!onUpdate || !hasMounted.current) { hasMounted.current = true; // Skip first render return; diff --git a/packages/joint-react/src/hooks/use-layout-size.ts b/packages/joint-react/src/hooks/use-layout-size.ts deleted file mode 100644 index d04d381bc6..0000000000 --- a/packages/joint-react/src/hooks/use-layout-size.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { useEffect, useState } from 'react'; -import { createElementSizeObserver } from '../utils/create-element-size-observer'; -interface Options { - readonly element: React.RefObject; - readonly isEnabled: boolean; -} -/** - * A hook to get the layout size of an element. - * It uses the `createElementSizeObserver` utility to observe size changes. - * @param options - The options for the hook, including the element to observe and whether to enable the observer. - * @returns The layout size of the element. - */ -export function useLayoutSize(options: Options) { - const { element, isEnabled } = options; - const [layout, setLayout] = useState<{ width: number; height: number }>({ width: 0, height: 0 }); - - useEffect(() => { - if (!isEnabled) return; - if (!element.current) return; - const cleanup = createElementSizeObserver(element.current, ({ width, height }) => { - setLayout({ width, height }); - }); - return () => cleanup(); - }, [element, isEnabled]); - - return layout; -} diff --git a/packages/joint-react/src/hooks/use-links.ts b/packages/joint-react/src/hooks/use-links.ts index bcc194d918..a45d5c6be9 100644 --- a/packages/joint-react/src/hooks/use-links.ts +++ b/packages/joint-react/src/hooks/use-links.ts @@ -4,62 +4,69 @@ import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/with-s import type { GraphLink } from '../types/link-types'; /** - * Default selector function to return all links. - * This function is used when no selector is provided. - * It simply returns the items passed to it. - * @param items - The items to select from. - * @returns - The selected items. - * @group utils - * @description + * Default selector function that returns all links unchanged. + * Used when no custom selector is provided to useLinks. + * @template Link - The type of links + * @param items - The links array to select from + * @returns The same links array + * @internal */ function defaultSelector(items: Link[]): Link[] { return items.map((item) => item) as Link[]; } /** - * A hook to access the graph store's links. + * Hook to access and subscribe to links (edges) from the graph store. * - * This hook returns the selected links from the graph store. It accepts: - * - a selector function, which extracts the desired portion from the links map. - * (By default, it returns all links.) - * - an optional `isEqual` function, used to compare previous and new values to prevent unnecessary re-renders. + * This hook provides reactive access to links with optional selection and custom equality comparison. + * It uses React's useSyncExternalStore internally for optimal performance. * - * How it works: - * 1. The hook subscribes to the links of the graph store. - * 2. It retrieves the links and then applies the selector. - * 3. The `isEqual` comparator (defaulting to a deep comparison) checks if the selected value has really changed. + * **Features:** + * - Subscribes to link changes in the graph store + * - Supports custom selectors to extract specific data + * - Custom equality comparison to prevent unnecessary re-renders + * - Type-safe with TypeScript generics + * + * **How it works:** + * 1. Subscribes to the links in the graph store + * 2. Retrieves the current links snapshot + * 3. Applies the selector function (if provided) + * 4. Compares the result with the previous value using isEqual + * 5. Only triggers re-render if the selected value actually changed + * @template Link - The type of links in the graph + * @template SelectorReturnType - The return type of the selector function + * @param selector - Optional function to select/extract a portion of the links. Defaults to returning all links. + * @param isEqual - Optional function to compare previous and new values. Defaults to deep equality. + * @returns The selected links data (or all links if no selector provided) + * @group Hooks * @example * ```ts - * // Using without a selector (returns all links): + * // Get all links * const links = useLinks(); * ``` * @example * ```ts - * // Using with a selector (extract part of the links data): + * // Extract only link IDs * const linkIds = useLinks((links) => links.map(link => link.id)); * ``` * @example - * // Using with a custom isEqual function: * ```ts - * const filteredLinks = useLinks( - * (links) => links, - * (prev, next) => prev.length === next.length + * // Custom equality check (only re-render if count changes) + * const linkCount = useLinks( + * (links) => links.length, + * (prev, next) => prev === next * ); * ``` - * @group Hooks - * @param selector - A function to select a portion of the links. - * @param isEqual - A function to compare the previous and new values. - * @returns - The selected links. */ export function useLinks( selector: (items: Link[]) => SelectorReturnType = defaultSelector as () => SelectorReturnType, isEqual: (a: SelectorReturnType, b: SelectorReturnType) => boolean = util.isEqual ): SelectorReturnType { - const { subscribe, getLinks } = useGraphStore(); - const typedGetLinks = getLinks as () => Link[]; + const { publicState } = useGraphStore(); + const getLinks = () => publicState.getSnapshot().links as Link[]; const elements = useSyncExternalStoreWithSelector( - subscribe, - typedGetLinks, - typedGetLinks, + publicState.subscribe, + getLinks, + getLinks, selector, isEqual ); diff --git a/packages/joint-react/src/hooks/use-measure-node-size.tsx b/packages/joint-react/src/hooks/use-measure-node-size.tsx index 1c24242585..3740548f81 100644 --- a/packages/joint-react/src/hooks/use-measure-node-size.tsx +++ b/packages/joint-react/src/hooks/use-measure-node-size.tsx @@ -1,17 +1,8 @@ -import { useEffect, useRef, type RefObject } from 'react'; +import { useLayoutEffect, type RefObject } from 'react'; import { useCellId } from './use-cell-id'; -import { - createElementSizeObserver, - type SizeObserver, -} from '../utils/create-element-size-observer'; -import type { dia } from '@joint/core'; import { useGraphStore } from './use-graph-store'; +import type { OnSetSize } from '../store/create-elements-size-observer'; -export interface OnSetOptions { - readonly element: dia.Element; - readonly size: SizeObserver; -} -export type OnSetSize = (options: OnSetOptions) => void; export interface MeasureNodeOptions { /** * Overwrite default node set function with custom handling. @@ -22,8 +13,6 @@ export interface MeasureNodeOptions { } const EMPTY_OBJECT: MeasureNodeOptions = {}; -// Epsilon value to avoid jitter due to sub-pixel rendering -const EPSILON = 0.5; /** * Custom hook to measure the size of a node and update its size in the graph. @@ -67,27 +56,20 @@ const EPSILON = 0.5; * } * ``` */ -export function useMeasureNodeSize( - elementRef: RefObject, +export function useMeasureNodeSize( + elementRef: RefObject, options?: MeasureNodeOptions ) { const { setSize } = options ?? EMPTY_OBJECT; const { graph, setMeasuredNode, hasMeasuredNode } = useGraphStore(); const id = useCellId(); - const onSetSizeRef = useRef(setSize); - - useEffect(() => { - onSetSizeRef.current = setSize; - }, [setSize]); - - useEffect(() => { + useLayoutEffect(() => { const element = elementRef.current; if (!element) throw new Error('MeasuredNode must have a child element'); const cell = graph.getCell(id); if (!cell?.isElement()) throw new Error('Cell not valid'); - // Check if another MeasuredNode is already measuring this element if (hasMeasuredNode(id)) { const errorMessage = @@ -103,48 +85,12 @@ export function useMeasureNodeSize { - // normalize to avoid float jitter in Safari - const nextWidth = Math.round(width); - const nextHeight = Math.round(height); - - if ( - Math.abs(previous.width - nextWidth) < EPSILON && - Math.abs(previous.height - nextHeight) < EPSILON - ) { - return; - } - - // Only update when dimensions actually change meaningfully - if (previous.width === nextWidth && previous.height === nextHeight) { - return; - } - - previous.width = nextWidth; - previous.height = nextHeight; - - if (onSetSizeRef.current) { - onSetSizeRef.current({ - element: cell, - size: { width: nextWidth, height: nextHeight }, - }); - } else { - cell.set('size', { width: nextWidth, height: nextHeight }, { async: false }); - } - }); - - // Cleanup on unmount or when dependencies change. - - return () => { - clean(); - stop(); - }; - }, [id, elementRef, graph, hasMeasuredNode, setMeasuredNode]); + if (!elementRef.current) { + return; + } + const clean = setMeasuredNode({ id, element: elementRef.current, setSize }); + return clean; + }, [elementRef, graph, hasMeasuredNode, id, setMeasuredNode, setSize]); // This hook itself does not return anything. } diff --git a/packages/joint-react/src/hooks/use-paper-context.ts b/packages/joint-react/src/hooks/use-paper-context.ts index 139162f17e..261bfd20da 100644 --- a/packages/joint-react/src/hooks/use-paper-context.ts +++ b/packages/joint-react/src/hooks/use-paper-context.ts @@ -1,5 +1,6 @@ import { useContext } from 'react'; -import { PaperContext } from '../context'; +import { PaperStoreContext } from '../context'; +import type { PaperStore } from '../store'; /** * Hook to access the current GraphProvider View context or a specific view by id from the GraphProvider Store. @@ -7,14 +8,14 @@ import { PaperContext } from '../context'; * @param isNullable - If true, the hook will return null instead of throwing an error when used outside of a GraphProvider View context. Default is false. * @returns The current GraphProvider View context or the view with the specified id from the store, or null if not found. */ -export function usePaperContext( +export function usePaperStoreContext( isNullable: T = false as T -): T extends true ? PaperContext | null : PaperContext { - const ctx = useContext(PaperContext); +): T extends true ? PaperStore | null : PaperStore { + const ctx = useContext(PaperStoreContext); if (!ctx && !isNullable) { - throw new Error('usePaperContext must be used within a Paper or RenderElement'); + throw new Error('usePaperStoreContext must be used within a Paper or RenderElement'); } const value = ctx ?? null; - return value as T extends true ? PaperContext | null : PaperContext; + return value as T extends true ? PaperStore | null : PaperStore; } diff --git a/packages/joint-react/src/hooks/use-paper-events.ts b/packages/joint-react/src/hooks/use-paper-events.ts index 1685a30d1b..1e5df6a330 100644 --- a/packages/joint-react/src/hooks/use-paper-events.ts +++ b/packages/joint-react/src/hooks/use-paper-events.ts @@ -2,12 +2,12 @@ import { useLayoutEffect, type DependencyList } from 'react'; import type { PaperEvents } from '../types/event.types'; import { handlePaperEvents } from '../utils/handle-paper-events'; import { useGraph } from './use-graph'; -import type { PaperContext } from '../context'; -import { usePaperContext } from './use-paper-context'; import { useRefValue } from './use-ref-value'; +import type { PaperStore } from '../store'; +import { usePaperStoreContext } from './use-paper-context'; interface Options extends PaperEvents { - readonly paperRef?: React.RefObject; + readonly paperRef?: React.RefObject; } /** @@ -26,11 +26,13 @@ interface Options extends PaperEvents { */ export function usePaperEvents(events: Options, dependencies: DependencyList = []) { const { paperRef } = events; - const paperCtxMaybe = usePaperContext(true); + const paperCtxMaybe = usePaperStoreContext(true); const paperRefMaybe = useRefValue(paperRef); const paper = paperCtxMaybe?.paper ?? paperRefMaybe?.paper; if (!paper) { - throw new Error('Paper is not available, either use usePaperContext or provide a paperRef'); + throw new Error( + 'Paper is not available, either use usePaperStoreContext or provide a paperRef' + ); } const graph = useGraph(); useLayoutEffect(() => { diff --git a/packages/joint-react/src/hooks/use-paper.ts b/packages/joint-react/src/hooks/use-paper.ts index 260d81e034..69bee79b4b 100644 --- a/packages/joint-react/src/hooks/use-paper.ts +++ b/packages/joint-react/src/hooks/use-paper.ts @@ -1,5 +1,5 @@ import type { dia } from '@joint/core'; -import { usePaperContext } from './use-paper-context'; +import { usePaperStoreContext } from './use-paper-context'; /** * Return JointJS `dia.Paper` instance from the current `Paper` context. @@ -18,6 +18,6 @@ import { usePaperContext } from './use-paper-context'; * ``` */ export function usePaper(): dia.Paper { - const viewConfig = usePaperContext(); - return viewConfig.paper; + const paperStore = usePaperStoreContext(); + return paperStore.paper; } diff --git a/packages/joint-react/src/hooks/use-state-to-external-store.ts b/packages/joint-react/src/hooks/use-state-to-external-store.ts new file mode 100644 index 0000000000..4fc48df3c0 --- /dev/null +++ b/packages/joint-react/src/hooks/use-state-to-external-store.ts @@ -0,0 +1,116 @@ +import { useLayoutEffect, useMemo, useRef, type Dispatch, type SetStateAction } from 'react'; +import type { GraphElement } from '../types/element-types'; +import type { GraphLink } from '../types/link-types'; +import type { ExternalStoreLike } from '../utils/create-state'; +import type { GraphStoreSnapshot } from '../store'; +import { isUpdater } from '../utils/is'; +import { util } from '@joint/core'; +import { sendToDevTool } from '../utils/dev-tools'; + +import type { dia } from '@joint/core'; + +/** + * Options for converting React state to an external store interface. + * @template Element - The type of elements + * @template Link - The type of links + */ +interface Options { + /** Current elements array from React state */ + readonly elements?: Element[]; + /** Current links array from React state */ + readonly links?: Link[]; + /** Callback function called when elements change */ + readonly onElementsChange?: Dispatch>; + /** Callback function called when links change */ + readonly onLinksChange?: Dispatch>; +} + +/** + * Converts React state (elements, links, and their change handlers) into an external store-like interface. + * + * This function enables React-controlled mode by wrapping React state setters in an ExternalStoreLike + * interface that GraphStore can use. It handles: + * - Subscribing to prop changes and notifying subscribers + * - Converting setState calls back to React state updates + * - Maintaining a snapshot for efficient comparisons + * + * Returns undefined if no change handlers are provided (uncontrolled mode). + * @template Element - The type of elements + * @template Link - The type of links + * @param options - The options containing elements, links, and their change handlers + * @returns An external store-like interface compatible with GraphStore, or undefined if uncontrolled + */ +export function useStateToExternalStore< + Element extends dia.Element | GraphElement, + Link extends dia.Link | GraphLink, +>( + options: Options +): ExternalStoreLike> | undefined { + const { elements = [], links = [], onElementsChange, onLinksChange } = options; + const subscribers = useRef void>>(new Set()); + const snapshot = useRef>({ elements, links }); + + const hasOnChange = typeof onElementsChange === 'function' || typeof onLinksChange === 'function'; + const notifySubscribers = useRef(() => { + if (!hasOnChange) { + return; + } + for (const subscriber of subscribers.current) { + subscriber(); + } + }); + + useLayoutEffect(() => { + if (!hasOnChange) { + return; + } + // Sync external prop changes (changes not initiated by setState) + const newSnapshot = { elements, links }; + if (util.isEqual(newSnapshot, snapshot.current)) { + return; + } + snapshot.current = newSnapshot; + notifySubscribers.current(); + }, [elements, hasOnChange, links]); + + const store = useMemo((): + | ExternalStoreLike> + | undefined => { + if (!hasOnChange) { + return undefined; + } + return { + getSnapshot: () => { + return snapshot.current; + }, + subscribe: (listener) => { + subscribers.current.add(listener); + + return () => { + subscribers.current.delete(listener); + }; + }, + setState: (updater) => { + const updatedSnapshot = isUpdater(updater) ? updater({ ...snapshot.current }) : updater; + if (util.isEqual(updatedSnapshot, snapshot.current)) { + return; + } + + sendToDevTool({ + name: 'AHA', + type: 'set', + value: updatedSnapshot, + }); + snapshot.current = updatedSnapshot; + // Notify subscribers immediately (synchronous, like createState) + + // Then trigger React state updates which will cause re-render + // When new props come in, useLayoutEffect will see they match snapshot.current + // and won't notify again (avoiding double notifications) + onElementsChange?.(updatedSnapshot.elements); + onLinksChange?.(updatedSnapshot.links); + }, + }; + }, [hasOnChange, onElementsChange, onLinksChange]); + return store; +} diff --git a/packages/joint-react/src/index.ts b/packages/joint-react/src/index.ts index bc2f205e56..75d0238fd3 100644 --- a/packages/joint-react/src/index.ts +++ b/packages/joint-react/src/index.ts @@ -6,7 +6,13 @@ export * from './components'; export * from './hooks'; export * from './utils/create'; -export * from './utils/cell/cell-utilities'; +export { + elementFromGraph, + linkFromGraph, + linkToGraph, + syncGraph, + type CellOrJsonCell, +} from './utils/cell/cell-utilities'; export * from './utils/joint-jsx/jsx-to-markup'; export * from './utils/link-utilities'; export * from './utils/object-utilities'; @@ -19,4 +25,4 @@ export * from './types/cell.types'; export * from './types/event.types'; export * from './context'; -export * from './data'; +export * from './store'; diff --git a/packages/joint-react/src/models/__tests__/react-element.test.ts b/packages/joint-react/src/models/__tests__/react-element.test.ts index ce1253a433..722ed44af6 100644 --- a/packages/joint-react/src/models/__tests__/react-element.test.ts +++ b/packages/joint-react/src/models/__tests__/react-element.test.ts @@ -102,3 +102,12 @@ describe('react-element', () => { }); }); }); + + + + + + + + + diff --git a/packages/joint-react/src/store/__tests__/graph-sync.test.ts b/packages/joint-react/src/store/__tests__/graph-sync.test.ts new file mode 100644 index 0000000000..aa7d892368 --- /dev/null +++ b/packages/joint-react/src/store/__tests__/graph-sync.test.ts @@ -0,0 +1,656 @@ +import { dia } from '@joint/core'; +import { DEFAULT_CELL_NAMESPACE, type GraphStoreSnapshot } from '../graph-store'; +import { graphSync } from '../graph-sync'; +import { createState } from '../../utils/create-state'; +import { createElements } from '../../utils/create'; +import type { GraphElement } from '../../types/element-types'; +import type { GraphLink } from '../../types/link-types'; +import { syncGraph } from '../../utils/cell/cell-utilities'; + +jest.mock('../../utils/cell/cell-utilities', () => { + const actual = jest.requireActual('../../utils/cell/cell-utilities'); + return { + ...actual, + syncGraph: jest.fn().mockImplementation(actual.syncGraph), + }; +}); + +const mockedSyncGraph = syncGraph as jest.Mock; +describe('graphSync', () => { + beforeEach(() => { + mockedSyncGraph.mockClear(); + }); + + it('should sync dia.graph <-> state effectively', () => { + const graph = new dia.Graph( + {}, + { + cellNamespace: { + ...DEFAULT_CELL_NAMESPACE, + }, + } + ); + const elements = createElements([ + { + id: '1', + width: 100, + height: 100, + type: 'ReactElement', + }, + { + id: '2', + width: 100, + height: 100, + type: 'ReactElement', + }, + ]); + const state = createState>({ + newState: () => ({ elements, links: [] }), + name: 'elements', + }); + + // Mock the setState method to be able to test the state updates. + const mockedSetState = jest.fn().mockImplementation(state.setState); + state.setState = mockedSetState; + + // Here we initially sync the graph with the state. + // State should not be updated yet. + graphSync({ graph, store: state }); + expect(graph.getElements()).toHaveLength(2); + expect(state.getSnapshot().elements).toHaveLength(2); + expect(mockedSyncGraph).toHaveBeenCalledTimes(1); + expect(mockedSetState).toHaveBeenCalledTimes(0); + // Here we update state via state API. + // State should not be updated yet. + state.setState((previous: GraphStoreSnapshot) => ({ + ...previous, + elements: [...previous.elements, { id: '3', width: 100, height: 100, type: 'ReactElement' }], + })); + expect(graph.getElements()).toHaveLength(3); + expect(state.getSnapshot().elements).toHaveLength(3); + expect(mockedSyncGraph).toHaveBeenCalledTimes(2); + expect(mockedSetState).toHaveBeenCalledTimes(1); + + // Here we update dia.graph itself via dia.graph API. + // State should be updated now with 1 update call. + const newElements = [ + ...state.getSnapshot().elements, + { id: '4', width: 100, height: 100, type: 'ReactElement' }, + ]; + syncGraph({ + graph, + elements: newElements as Array, + links: [], + }); + expect(graph.getElements()).toHaveLength(4); + expect(state.getSnapshot().elements).toHaveLength(4); + expect(mockedSyncGraph).toHaveBeenCalledTimes(3); + expect(mockedSetState).toHaveBeenCalledTimes(2); + }); + + it('should sync dia.graph <-> state effectively using normal JointJS API', () => { + const graph = new dia.Graph( + {}, + { + cellNamespace: { + ...DEFAULT_CELL_NAMESPACE, + }, + } + ); + const elements = createElements([ + { + id: '1', + width: 100, + height: 100, + type: 'ReactElement', + }, + { + id: '2', + width: 100, + height: 100, + type: 'ReactElement', + }, + ]); + const state = createState>({ + newState: () => ({ elements, links: [] }), + name: 'elements', + }); + + // Mock the setState method to be able to test the state updates. + const mockedSetState = jest.fn().mockImplementation(state.setState); + state.setState = mockedSetState; + + // Here we initially sync the graph with the state. + // State should not be updated yet. + graphSync({ graph, store: state }); + expect(graph.getElements()).toHaveLength(2); + expect(state.getSnapshot().elements).toHaveLength(2); + expect(mockedSetState).toHaveBeenCalledTimes(0); + + // Here we update state via state API. + // State should not be updated yet. + state.setState((previous: GraphStoreSnapshot) => ({ + ...previous, + elements: [...previous.elements, { id: '3', width: 100, height: 100, type: 'ReactElement' }], + })); + expect(graph.getElements()).toHaveLength(3); + expect(state.getSnapshot().elements).toHaveLength(3); + expect(mockedSetState).toHaveBeenCalledTimes(1); + + // Here we update dia.graph itself via normal JointJS API (not syncCells/batch). + // State should be updated now with 1 update call. + const newElement = new dia.Element({ + id: '4', + type: 'ReactElement', + position: { x: 0, y: 0 }, + size: { width: 100, height: 100 }, + }); + graph.addCell(newElement); + expect(graph.getElements()).toHaveLength(4); + expect(state.getSnapshot().elements).toHaveLength(4); + expect(mockedSetState).toHaveBeenCalledTimes(2); + }); + + it('should sync existing graph cells to store when store is empty', () => { + const graph = new dia.Graph( + {}, + { + cellNamespace: { + ...DEFAULT_CELL_NAMESPACE, + }, + } + ); + + // Add elements directly to graph before creating store + const existingElements = createElements([ + { + id: '1', + width: 100, + height: 100, + type: 'ReactElement', + }, + { + id: '2', + width: 100, + height: 100, + type: 'ReactElement', + }, + ]); + + // Add elements to graph using syncGraph + syncGraph({ + graph, + elements: existingElements as Array, + links: [], + }); + + // Create empty store + const state = createState>({ + newState: () => ({ elements: [], links: [] }), + name: 'elements', + }); + + // Mock the setState method to be able to test the state updates. + const mockedSetState = jest.fn().mockImplementation(state.setState); + state.setState = mockedSetState; + + // Graph has 2 elements, store is empty + expect(graph.getElements()).toHaveLength(2); + expect(state.getSnapshot().elements).toHaveLength(0); + + // Initialize graphSync - it should sync existing graph cells to store + graphSync({ graph, store: state }); + + // Store should now have the 2 elements from the graph + expect(state.getSnapshot().elements).toHaveLength(2); + expect(state.getSnapshot().elements[0].id).toBe('1'); + expect(state.getSnapshot().elements[1].id).toBe('2'); + expect(mockedSetState).toHaveBeenCalledTimes(1); + + // Graph should still have 2 elements + expect(graph.getElements()).toHaveLength(2); + }); + + it('should not sync existing graph cells to store when store already has elements', () => { + const graph = new dia.Graph( + {}, + { + cellNamespace: { + ...DEFAULT_CELL_NAMESPACE, + }, + } + ); + + // Add elements directly to graph + const graphElements = createElements([ + { + id: '1', + width: 100, + height: 100, + type: 'ReactElement', + }, + ]); + + syncGraph({ + graph, + elements: graphElements as Array, + links: [], + }); + + // Create store with different elements + const storeElements = createElements([ + { + id: '2', + width: 100, + height: 100, + type: 'ReactElement', + }, + ]); + + const state = createState>({ + newState: () => ({ elements: storeElements, links: [] }), + name: 'elements', + }); + + // Mock the setState method to be able to test the state updates. + const mockedSetState = jest.fn().mockImplementation(state.setState); + state.setState = mockedSetState; + + // Graph has 1 element, store has 1 different element + expect(graph.getElements()).toHaveLength(1); + expect(state.getSnapshot().elements).toHaveLength(1); + expect(state.getSnapshot().elements[0].id).toBe('2'); + + // Initialize graphSync - it should NOT sync graph cells to store since store is not empty + graphSync({ graph, store: state }); + + // Store should still have its original element + expect(state.getSnapshot().elements).toHaveLength(1); + expect(state.getSnapshot().elements[0].id).toBe('2'); + // setState should be called to sync store to graph, not the other way around + expect(mockedSetState).toHaveBeenCalledTimes(0); + + // Graph should now have the element from store (synced from store to graph) + expect(graph.getElements()).toHaveLength(1); + expect(graph.getCell('2')).toBeDefined(); + }); + + it('should sync existing graph links to store when store is empty', () => { + const graph = new dia.Graph( + {}, + { + cellNamespace: { + ...DEFAULT_CELL_NAMESPACE, + }, + } + ); + + // Add elements and links directly to graph before creating store + const existingElements = createElements([ + { + id: '1', + width: 100, + height: 100, + type: 'ReactElement', + }, + { + id: '2', + width: 100, + height: 100, + type: 'ReactElement', + }, + ]); + + syncGraph({ + graph, + elements: existingElements as Array, + links: [ + { + id: 'link1', + source: '1', + target: '2', + }, + ], + }); + + // Create empty store + const state = createState>({ + newState: () => ({ elements: [], links: [] }), + name: 'elements', + }); + + // Mock the setState method to be able to test the state updates. + const mockedSetState = jest.fn().mockImplementation(state.setState); + state.setState = mockedSetState; + + // Graph has 2 elements and 1 link, store is empty + expect(graph.getElements()).toHaveLength(2); + expect(graph.getLinks()).toHaveLength(1); + expect(state.getSnapshot().elements).toHaveLength(0); + expect(state.getSnapshot().links).toHaveLength(0); + + // Initialize graphSync - it should sync existing graph cells (elements and links) to store + graphSync({ graph, store: state }); + + // Store should now have the 2 elements and 1 link from the graph + expect(state.getSnapshot().elements).toHaveLength(2); + expect(state.getSnapshot().links).toHaveLength(1); + expect(state.getSnapshot().links[0].id).toBe('link1'); + expect(mockedSetState).toHaveBeenCalledTimes(1); + + // Graph should still have 2 elements and 1 link + expect(graph.getElements()).toHaveLength(2); + expect(graph.getLinks()).toHaveLength(1); + }); + + it('should handle cleanup properly and unsubscribe from all listeners', () => { + const graph = new dia.Graph( + {}, + { + cellNamespace: { + ...DEFAULT_CELL_NAMESPACE, + }, + } + ); + const state = createState>({ + newState: () => ({ elements: [], links: [] }), + name: 'elements', + }); + + const unsubscribeSpy = jest.fn(); + state.subscribe = jest.fn(() => unsubscribeSpy); + + const sync = graphSync({ graph, store: state }); + + // Verify subscription was set up + expect(state.subscribe).toHaveBeenCalledTimes(1); + + // Cleanup + sync.cleanup(); + + // Verify unsubscribe was called + expect(unsubscribeSpy).toHaveBeenCalledTimes(1); + }); + + it('should subscribe to cell changes and allow unsubscribing', () => { + const graph = new dia.Graph( + {}, + { + cellNamespace: { + ...DEFAULT_CELL_NAMESPACE, + }, + } + ); + const state = createState>({ + newState: () => ({ elements: [], links: [] }), + name: 'elements', + }); + + const sync = graphSync({ graph, store: state }); + + // eslint-disable-next-line unicorn/consistent-function-scoping + const cellChangeCallback = jest.fn(() => () => {}); + const unsubscribe = sync.subscribeToCellChange(cellChangeCallback); + + // Add a cell to trigger change + const element = new dia.Element({ + id: 'test', + type: 'ReactElement', + position: { x: 0, y: 0 }, + size: { width: 100, height: 100 }, + }); + graph.addCell(element); + + // Wait a bit for the change to propagate + // The callback should have been called + // Note: This is a simplified test - in reality, the callback is called by listenToCellChange + + // Unsubscribe + unsubscribe(); + + // Add another cell - callback should not be called again (though we can't easily test this without more setup) + // The important thing is that unsubscribe doesn't throw + expect(() => unsubscribe()).not.toThrow(); + }); + + it('should not sync existing graph cells when store has setState but it is undefined', () => { + const graph = new dia.Graph( + {}, + { + cellNamespace: { + ...DEFAULT_CELL_NAMESPACE, + }, + } + ); + + // Add elements to graph + const existingElements = createElements([ + { + id: '1', + width: 100, + height: 100, + type: 'ReactElement', + }, + ]); + + syncGraph({ + graph, + elements: existingElements as Array, + links: [], + }); + + // Create store without setState + const state = createState>({ + newState: () => ({ elements: [], links: [] }), + name: 'elements', + }); + + // Remove setState to simulate a read-only store + const originalSetState = state.setState; + // @ts-expect-error Testing edge case where setState might be undefined + state.setState = undefined; + + // Graph has 1 element, store is empty + expect(graph.getElements()).toHaveLength(1); + expect(state.getSnapshot().elements).toHaveLength(0); + + // Initialize graphSync - it should not crash and should not sync since setState is undefined + expect(() => { + graphSync({ graph, store: state }); + }).not.toThrow(); + + // Store should still be empty since setState was undefined + expect(state.getSnapshot().elements).toHaveLength(0); + + // Restore setState for cleanup + state.setState = originalSetState; + }); + + it('should handle graph with existing cells and initial elements/links - initial elements take precedence', () => { + const graph = new dia.Graph( + {}, + { + cellNamespace: { + ...DEFAULT_CELL_NAMESPACE, + }, + } + ); + + // Add elements to graph + const graphElements = createElements([ + { + id: 'graph-element', + width: 100, + height: 100, + type: 'ReactElement', + }, + ]); + + syncGraph({ + graph, + elements: graphElements as Array, + links: [], + }); + + // Create store with initial elements (different from graph) + const initialElements = createElements([ + { + id: 'initial-element', + width: 100, + height: 100, + type: 'ReactElement', + }, + ]); + + const state = createState>({ + newState: () => ({ elements: initialElements, links: [] }), + name: 'elements', + }); + + // Mock the setState method + const mockedSetState = jest.fn().mockImplementation(state.setState); + state.setState = mockedSetState; + + // Graph has 1 element, store has 1 different element + expect(graph.getElements()).toHaveLength(1); + expect(state.getSnapshot().elements).toHaveLength(1); + expect(state.getSnapshot().elements[0].id).toBe('initial-element'); + + // Initialize graphSync + graphSync({ graph, store: state }); + + // Since store has initial elements, they should take precedence + // The graph should be synced to match the store (initial elements) + expect(graph.getElements()).toHaveLength(1); + expect(graph.getCell('initial-element')).toBeDefined(); + expect(graph.getCell('graph-element')).toBeUndefined(); + }); + + it('should handle external store with existing graph cells - store takes precedence', () => { + const graph = new dia.Graph( + {}, + { + cellNamespace: { + ...DEFAULT_CELL_NAMESPACE, + }, + } + ); + + // Add elements to graph + const graphElements = createElements([ + { + id: 'graph-element', + width: 100, + height: 100, + type: 'ReactElement', + }, + ]); + + syncGraph({ + graph, + elements: graphElements as Array, + links: [], + }); + + // Create external store with different elements + const externalElements = createElements([ + { + id: 'external-element', + width: 100, + height: 100, + type: 'ReactElement', + }, + ]); + + const externalStore = createState>({ + newState: () => ({ elements: externalElements, links: [] }), + name: 'external', + }); + + // Mock the setState method + const mockedSetState = jest.fn().mockImplementation(externalStore.setState); + externalStore.setState = mockedSetState; + + // Graph has 1 element, external store has 1 different element + expect(graph.getElements()).toHaveLength(1); + expect(externalStore.getSnapshot().elements).toHaveLength(1); + expect(externalStore.getSnapshot().elements[0].id).toBe('external-element'); + + // Initialize graphSync with external store + graphSync({ graph, store: externalStore }); + + // External store should take precedence - graph should be synced to match external store + expect(graph.getElements()).toHaveLength(1); + expect(graph.getCell('external-element')).toBeDefined(); + expect(graph.getCell('graph-element')).toBeUndefined(); + + // External store should not be modified (it's the source of truth) + expect(externalStore.getSnapshot().elements).toHaveLength(1); + expect(externalStore.getSnapshot().elements[0].id).toBe('external-element'); + }); + + it('should prevent circular updates when syncing from state to graph', () => { + const graph = new dia.Graph( + {}, + { + cellNamespace: { + ...DEFAULT_CELL_NAMESPACE, + }, + } + ); + + const elements = createElements([ + { + id: '1', + width: 100, + height: 100, + type: 'ReactElement', + }, + ]); + + const state = createState>({ + newState: () => ({ elements, links: [] }), + name: 'elements', + }); + + // Initialize graphSync first + graphSync({ graph, store: state }); + + // Verify initial state + expect(graph.getElements()).toHaveLength(1); + expect(state.getSnapshot().elements).toHaveLength(1); + + // Update state - this should trigger graph sync, but not cause circular updates + // The graph sync should update the graph, but the graph change should not trigger + // another state update because of the isSyncingFromState flag + state.setState((previous) => ({ + ...previous, + elements: [...previous.elements, { id: '2', width: 100, height: 100, type: 'ReactElement' }], + })); + + // Graph should have 2 elements (synced from state) + expect(graph.getElements()).toHaveLength(2); + expect(graph.getCell('1')).toBeDefined(); + expect(graph.getCell('2')).toBeDefined(); + + // State should still have 2 elements + expect(state.getSnapshot().elements).toHaveLength(2); + + // Now update graph directly - this should update state, but not cause circular updates + const newElement = new dia.Element({ + id: '3', + type: 'ReactElement', + position: { x: 0, y: 0 }, + size: { width: 100, height: 100 }, + }); + graph.addCell(newElement); + + // Graph should have 3 elements + expect(graph.getElements()).toHaveLength(3); + + // State should be updated with the new element (from graph) + // This verifies that graph -> state sync works without causing state -> graph -> state loops + expect(state.getSnapshot().elements).toHaveLength(3); + expect(state.getSnapshot().elements.find((element) => element.id === '3')).toBeDefined(); + }); +}); diff --git a/packages/joint-react/src/store/create-elements-size-observer.ts b/packages/joint-react/src/store/create-elements-size-observer.ts new file mode 100644 index 0000000000..ecbc4ac6b0 --- /dev/null +++ b/packages/joint-react/src/store/create-elements-size-observer.ts @@ -0,0 +1,194 @@ +import type { dia } from '@joint/core'; +import type { GraphElement } from '../types/element-types'; +import type { GraphStoreDerivedSnapshot, GraphStoreSnapshot } from './graph-store'; +import type { MarkDeepReadOnly } from '../utils/create-state'; + +const DEFAULT_OBSERVER_OPTIONS: ResizeObserverOptions = { box: 'border-box' }; +// Epsilon value to avoid jitter due to sub-pixel rendering +// especially on Safari +const EPSILON = 0.5; + +/** + * Size information for an observed element. + */ +export interface SizeObserver { + /** Width of the element in pixels */ + readonly width: number; + /** Height of the element in pixels */ + readonly height: number; +} + +/** + * Options passed to the setSize callback when an element's size changes. + */ +export interface OnSetOptions { + /** The JointJS element instance */ + readonly element: dia.Element; + /** The new size of the element */ + readonly size: SizeObserver; +} + +/** + * Callback function called when an element's size is measured. + * Allows custom handling of size updates before they're applied to the graph. + */ +export type OnSetSize = (options: OnSetOptions) => void; + +/** + * Options for registering an element to be measured for size changes. + */ +export interface SetMeasuredNodeOptions { + /** The DOM element (HTML or SVG) to observe for size changes */ + readonly element: HTMLElement | SVGElement; + /** Optional callback to handle size updates before they're applied */ + readonly setSize?: OnSetSize; + /** The ID of the cell in the graph that corresponds to this DOM element */ + readonly id: dia.Cell.ID; +} + +interface ElementItem { + readonly element: HTMLElement | SVGElement; + readonly setSize?: OnSetSize; +} + +/** + * Options for creating an elements size observer. + */ +interface Options { + /** Options to pass to the ResizeObserver constructor */ + readonly resizeObserverOptions?: ResizeObserverOptions; + /** Function to get the current size of a cell from the graph */ + readonly getCellSize: (id: dia.Cell.ID) => SizeObserver; + /** Function to get the current IDs snapshot for efficient lookups */ + readonly getIdsSnapshot: () => MarkDeepReadOnly; + /** Function to get the current public snapshot containing all elements */ + readonly getPublicSnapshot: () => MarkDeepReadOnly; + /** Callback function called when a batch of elements needs to be updated */ + readonly onBatchUpdate: (elements: GraphElement[]) => void; +} + +/** + * Observer interface for tracking element size changes. + * Uses ResizeObserver to automatically detect when DOM elements change size + * and updates the corresponding graph elements. + */ +export interface GraphStoreObserver { + /** + * Adds an element to be observed for size changes. + * @param options - Configuration for the measured node + * @returns Cleanup function to stop observing + */ + readonly add: (options: SetMeasuredNodeOptions) => () => void; + /** + * Cleans up all observers and resources. + */ + readonly clean: () => void; + /** + * Checks if a node is currently being observed. + * @param id - The ID of the cell to check + * @returns True if the node is being observed + */ + readonly has: (id: dia.Cell.ID) => boolean; +} + +/** + * Creates an observer for element size changes using the ResizeObserver API. + * + * This function sets up automatic size tracking for DOM elements that correspond to graph elements. + * When a DOM element's size changes (e.g., due to content changes or CSS updates), the observer + * automatically updates the corresponding graph element's size. + * + * **Features:** + * - Uses ResizeObserver for efficient size tracking + * - Batches multiple size changes together for performance + * - Compares sizes with epsilon to avoid jitter from sub-pixel rendering + * - Supports custom size update handlers + * @param options - The options for creating the size observer + * @returns A GraphStoreObserver instance with methods to add/remove observers + */ +export function createElementsSizeObserver(options: Options): GraphStoreObserver { + const { + resizeObserverOptions = DEFAULT_OBSERVER_OPTIONS, + getCellSize, + getIdsSnapshot, + onBatchUpdate, + getPublicSnapshot, + } = options; + const elements = new Map(); + const invertedIndex = new Map(); + const observer = new ResizeObserver((entries) => { + // we can consider this as single batch of + let hasChange = false; + const idsSnapshot = getIdsSnapshot(); + const publicSnapshot = getPublicSnapshot(); + const newElements: GraphElement[] = [...publicSnapshot.elements] as GraphElement[]; + for (const entry of entries) { + // We must be careful to not mutate the snapshot data. + const { target, borderBoxSize } = entry; + + const id = invertedIndex.get(target as HTMLElement | SVGElement); + if (!id) { + throw new Error(`Element with id ${id} not found in resize observer`); + } + + // If borderBoxSize is not available or empty, continue to the next entry. + if (!borderBoxSize || borderBoxSize.length === 0) continue; + + const [size] = borderBoxSize; + const { inlineSize, blockSize } = size; + + const width = inlineSize; + const height = blockSize; + const actualSize = getCellSize(id); + // Here we compare the actual size with the border box size + const isChanged = + Math.abs(actualSize.width - width) > EPSILON || + Math.abs(actualSize.height - height) > EPSILON; + + if (!isChanged) { + return; + } + + const elementIndex = idsSnapshot.elementIds[id]; + if (elementIndex == undefined) { + throw new Error(`Element with id ${id} not found in graph data ref`); + } + const element = newElements[elementIndex]; + if (!element) { + throw new Error(`Element with id ${id} not found in graph data ref`); + } + newElements[elementIndex] = { ...element, width, height }; + hasChange = true; + } + + if (!hasChange) { + return; + } + + onBatchUpdate(newElements); + }); + + return { + add({ id, element, setSize }: SetMeasuredNodeOptions) { + observer.observe(element, resizeObserverOptions); + elements.set(id, { element, setSize }); + invertedIndex.set(element, id); + return () => { + observer.unobserve(element); + elements.delete(id); + invertedIndex.delete(element); + }; + }, + clean() { + for (const [, { element }] of elements.entries()) { + observer.unobserve(element); + } + elements.clear(); + invertedIndex.clear(); + observer.disconnect(); + }, + has(id: dia.Cell.ID) { + return elements.has(id); + }, + }; +} diff --git a/packages/joint-react/src/store/graph-store.ts b/packages/joint-react/src/store/graph-store.ts new file mode 100644 index 0000000000..dd7c0fee21 --- /dev/null +++ b/packages/joint-react/src/store/graph-store.ts @@ -0,0 +1,429 @@ +import { dia, shapes, util } from '@joint/core'; +import type { GraphLink } from '../types/link-types'; +import type { GraphElement } from '../types/element-types'; +import type { Dispatch, SetStateAction } from 'react'; +import type { AddPaperOptions, PaperStoreSnapshot } from './paper-store'; +import { PaperStore } from './paper-store'; +import { + createElementsSizeObserver, + type GraphStoreObserver, + type SetMeasuredNodeOptions, +} from './create-elements-size-observer'; +import { ReactElement } from '../models/react-element'; +import type { ExternalStoreLike, State } from '../utils/create-state'; +import { createState, derivedState, getValue } from '../utils/create-state'; +import { graphSync, type GraphSync } from './graph-sync'; +import type { OnChangeOptions } from '../utils/cell/listen-to-cell-change'; + +export const DEFAULT_CELL_NAMESPACE: Record = { ...shapes, ReactElement }; + +/** + * External store interface compatible with GraphStore. + * Used for integrating with external state management libraries (Redux, Zustand, etc.). + */ +export type ExternalGraphStore = ExternalStoreLike; + +/** + * Internal state type for GraphStore. + * Contains the full internal snapshot including paper-specific data. + */ +export type GraphState = State; + +/** + * Public snapshot of the graph store containing elements and links. + * This is the shape of data exposed to React components and external stores. + * @template Element - The type of elements in the graph + * @template Link - The type of links in the graph + */ +export interface GraphStoreSnapshot< + Element extends dia.Element | GraphElement = GraphElement, + Link extends dia.Link | GraphLink = GraphLink, +> { + /** Array of all elements (nodes) in the graph */ + readonly elements: Element[]; + /** Array of all links (edges) in the graph */ + readonly links: Link[]; +} + +/** + * Snapshot containing index mappings for elements and links by their IDs. + * Used for efficient lookups and synchronization. + */ +export interface GraphStoreDerivedSnapshot { + /** Map of element IDs to their index in the elements array */ + readonly elementIds: Record; + /** Map of link IDs to their index in the links array */ + readonly linkIds: Record; + readonly areElementsMeasured: boolean; +} + +/** + * Full internal snapshot of the graph store. + * Contains paper-specific data and is not directly exposed to consumers. + */ +export interface GraphStoreInternalSnapshot { + /** Map of paper IDs to their store snapshots */ + readonly papers: Record; +} + +/** + * Configuration options for creating a GraphStore instance. + */ +export interface GraphStoreOptions { + /** + * Graph instance to use. If not provided, a new graph instance will be created. + * Useful when you need to share a graph instance across multiple stores or integrate with existing JointJS code. + * @see https://docs.jointjs.com/api/dia/Graph + * @default new dia.Graph({}, { cellNamespace: shapes }) + */ + readonly graph?: dia.Graph; + /** + * Namespace for cell models. Defines which cell types are available in the graph. + * When provided, it will be merged with the default namespace (shapes + ReactElement). + * It's loaded just once during initialization, so it cannot be used as React state. + * @default shapes + * @see https://docs.jointjs.com/api/shapes + */ + readonly cellNamespace?: unknown; + /** + * Custom cell model to use as the base class for all cells in the graph. + * It's loaded just once during initialization, so it cannot be used as React state. + * @see https://docs.jointjs.com/api/dia/Cell + */ + readonly cellModel?: typeof dia.Cell; + /** + * Initial elements to be added to the graph on creation. + * These are loaded just once during initialization, so they cannot be used as React state. + * For dynamic elements, use controlled mode with `elements` and `onElementsChange` props on GraphProvider. + */ + readonly initialElements?: GraphElement[]; + + /** + * Initial links to be added to the graph on creation. + * These are loaded just once during initialization, so they cannot be used as React state. + * For dynamic links, use controlled mode with `links` and `onLinksChange` props on GraphProvider. + */ + readonly initialLinks?: GraphLink[]; + + /** + * External store to use as the source of truth for elements and links. + * When provided, GraphStore will treat this as the authoritative source and sync bidirectionally. + * Compatible with any store that implements the ExternalStoreLike interface (Redux, Zustand, etc.). + * Takes precedence over React-controlled mode (onElementsChange/onLinksChange). + */ + readonly externalStore?: ExternalGraphStore; +} + +/** + * Central store for managing graph state, synchronization, and paper instances. + * Handles bidirectional synchronization between React state and JointJS graph, + * manages element size observations, and coordinates multiple paper views. + */ +export class GraphStore { + /** + * Internal state containing paper-specific snapshots. + * @internal + */ + public readonly internalState: State; + /** + * Public state containing elements and links, exposed to React components. + * Can be replaced with an external store for controlled mode. + * @internal + */ + public publicState: ExternalStoreLike; + /** + * Derived state containing ID-to-index mappings for efficient lookups. + * @internal + */ + public readonly derivedStore: State; + private wasElementsMeasuredBefore = false; + + /** The underlying JointJS graph instance */ + public readonly graph: dia.Graph; + + private unsubscribeFromExternal?: () => void; + + private onElementsChange?: Dispatch>; + private onLinksChange?: Dispatch>; + + private papers = new Map(); + private observer: GraphStoreObserver; + private graphSync: GraphSync; + private isControlled: boolean; + + constructor(config: GraphStoreOptions) { + const { + initialElements = [], + initialLinks = [], + cellModel, + cellNamespace = DEFAULT_CELL_NAMESPACE, + graph, + externalStore: externalState, + } = config; + + const hasExternalState = typeof externalState === 'object'; + this.isControlled = hasExternalState; + + this.graph = + graph ?? + new dia.Graph( + {}, + { + cellNamespace: { + ...DEFAULT_CELL_NAMESPACE, + // @ts-expect-error Shapes is not a valid type for cellNamespace + ...cellNamespace, + }, + cellModel, + } + ); + + if (externalState) { + this.publicState = externalState; + } else { + this.publicState = createState({ + name: 'JointJs/Cells', + newState: () => ({ + elements: [], + links: [], + }), + isEqual: util.isEqual, + }); + } + this.internalState = createState({ + name: 'Jointjs/Internal', + newState: () => ({ + papers: {}, + }), + isEqual: util.isEqual, + }); + + this.derivedStore = derivedState({ + name: 'Jointjs/Derived', + state: this.publicState, + selector: (snapshot) => { + const elementIds: Record = {}; + const linkIds: Record = {}; + + let areElementsMeasured = true; + for (const [index, element] of snapshot.elements.entries()) { + elementIds[element.id] = index; + } + for (const element of snapshot.elements) { + const { width = 0, height = 0 } = element; + if (width <= 1 || height <= 1) { + areElementsMeasured = false; + break; + } + } + for (const [index, link] of snapshot.links.entries()) { + linkIds[link.id] = index; + } + if (areElementsMeasured) { + this.wasElementsMeasuredBefore = true; + } + if (this.wasElementsMeasuredBefore) { + areElementsMeasured = true; + } + return { elementIds, linkIds, areElementsMeasured }; + }, + isEqual: util.isEqual, + }); + + this.graphSync = graphSync({ + useRealtimeUpdated: true, + graph: this.graph, + store: { + getSnapshot: this.publicState.getSnapshot, + subscribe: this.publicState.subscribe, + setState: (updater) => { + this.publicState.setState((previous) => ({ + ...previous, + ...getValue(previous, updater), + })); + }, + }, + }); + + // Observer for element sizes (uses state.getSnapshot) + + this.observer = createElementsSizeObserver({ + getIdsSnapshot: this.derivedStore.getSnapshot, + getPublicSnapshot: this.publicState.getSnapshot, + onBatchUpdate: (newElements) => { + this.publicState.setState((previous) => ({ + ...previous, + elements: newElements, + })); + }, + getCellSize: (id) => { + const cell = this.graph.getCell(id); + if (!cell?.isElement()) throw new Error('Cell not valid'); + const size = cell.get('size'); + if (!size) throw new Error('Size not found'); + return { + width: size.width, + height: size.height, + }; + }, + }); + + // Initial sync: either from external store or from constructor elements/links + // Only set initial elements/links if graph doesn't have existing cells + // (if graph has cells, syncExistingGraphCellsToStore in graphSync will handle it) + const graphHasCells = this.graph.getElements().length > 0 || this.graph.getLinks().length > 0; + if (!graphHasCells || initialElements.length > 0 || initialLinks.length > 0) { + this.publicState.setState((previous) => ({ + ...previous, + elements: initialElements, + links: initialLinks, + })); + } + } + + /** + * Cleans up all resources and subscriptions. + * Should be called when the GraphStore is no longer needed. + * @param isGraphExternal - Whether the graph instance was provided externally (should not be cleared) + */ + public destroy = (isGraphExternal: boolean) => { + this.internalState.clean(); + this.observer.clean(); + this.unsubscribeFromExternal?.(); + this.graphSync.cleanup(); + if (!isGraphExternal) { + this.graph.clear(); + } + }; + + /** + * Updates the snapshot for a specific paper instance. + * @param paperId - The unique identifier of the paper + * @param updater - Function that receives the previous snapshot and returns the new one + */ + public updatePaperSnapshot( + paperId: string, + updater: (previous: PaperStoreSnapshot | undefined) => PaperStoreSnapshot + ) { + this.internalState.setState((previous) => { + const currentPaper = previous.papers[paperId]; + const nextPaper = updater(currentPaper); + + if (currentPaper === nextPaper) { + return previous; + } + + return { + ...previous, + papers: { + ...previous.papers, + [paperId]: nextPaper, + }, + }; + }); + } + + /** + * Updates the element view reference for a specific cell in a paper. + * Used internally to track element views for rendering and interaction. + * @param paperId - The unique identifier of the paper + * @param cellId - The ID of the cell whose view is being updated + * @param view - The JointJS element view instance + */ + public updatePaperElementView(paperId: string, cellId: dia.Cell.ID, view: dia.ElementView) { + // silent update of the data. + this.updatePaperSnapshot(paperId, (current) => { + const base = current ?? { paperElementViews: {}, portsData: {} }; + + const existingView = base.paperElementViews?.[cellId]; + if (existingView === view) return base; + + return { + paperElementViews: { + ...base.paperElementViews, + [cellId]: view, + }, + }; + }); + } + + private removePaper = (id: string) => { + this.papers.delete(id); + this.internalState.setState((previous) => { + const newPapers: Record = {}; + for (const [key, value] of Object.entries(previous.papers)) { + if (key !== id) { + newPapers[key] = value; + } + } + return { + ...previous, + papers: newPapers, + }; + }); + }; + /** + * Adds a new paper instance to the store. + * @param id - Unique identifier for the paper + * @param paperOptions - Configuration options for the paper + * @returns Cleanup function to remove the paper + */ + public addPaper = (id: string, paperOptions: AddPaperOptions) => { + const paperStore = new PaperStore({ + ...paperOptions, + graphStore: this, + id, + }); + this.papers.set(id, paperStore); + return () => { + this.removePaper(id); + }; + }; + + /** + * Checks if a node is currently being measured for size. + * @param id - The ID of the cell to check + * @returns True if the node is being observed for size changes + */ + public hasMeasuredNode = (id: dia.Cell.ID) => { + return this.observer.has(id); + }; + + /** + * Registers a node for size measurement observation. + * The observer will automatically update the element's size when the DOM element changes. + * @param options - Configuration for the measured node + * @returns Cleanup function to stop observing + */ + public setMeasuredNode = (options: SetMeasuredNodeOptions) => { + return this.observer.add(options); + }; + + /** + * Retrieves a paper store instance by its ID. + * @param id - The unique identifier of the paper + * @returns The PaperStore instance, or undefined if not found + */ + public getPaperStore = (id: string) => { + return this.papers.get(id); + }; + + /** + * Subscribes to cell change events in the graph. + * Useful for reacting to changes that occur outside of React's control. + * @param callback - Function that receives change options and returns a cleanup function + * @returns Unsubscribe function + */ + public subscribeToCellChange = (callback: (change: OnChangeOptions) => () => void) => { + return this.graphSync.subscribeToCellChange(callback); + }; + + /** + * Updates the external store reference. + * Used internally when switching between controlled and uncontrolled modes. + * @param newStore - The new external store to use + */ + public updateExternalStore = (newStore: ExternalStoreLike) => { + this.publicState = newStore; + }; +} diff --git a/packages/joint-react/src/store/graph-sync.ts b/packages/joint-react/src/store/graph-sync.ts new file mode 100644 index 0000000000..e6d3375134 --- /dev/null +++ b/packages/joint-react/src/store/graph-sync.ts @@ -0,0 +1,413 @@ +/* eslint-disable sonarjs/cognitive-complexity */ +import type { GraphStoreSnapshot } from './graph-store'; +import { listenToCellChange, type OnChangeOptions } from '../utils/cell/listen-to-cell-change'; +import { elementFromGraph, linkFromGraph, syncGraph } from '../utils/cell/cell-utilities'; +import { removeDeepReadOnly, type ExternalStoreLike } from '../utils/create-state'; +import { util, type dia } from '@joint/core'; +import type { GraphElement } from '../types/element-types'; +import type { GraphLink } from '../types/link-types'; + +/** + * Configuration options for graph synchronization. + * @template Graph - The type of JointJS graph instance + * @template Element - The type of elements in the graph + * @template Link - The type of links in the graph + */ +interface Options< + Graph extends dia.Graph, + Element extends dia.Element | GraphElement, + Link extends dia.Link | GraphLink, +> { + /** The JointJS graph instance to synchronize */ + readonly graph: Graph; + /** The external store containing elements and links to sync with */ + readonly store: ExternalStoreLike>; + /** + * If true, the synchronization will be real-time. Otherwise, it will be based on batches. + * @default false + */ + readonly useRealtimeUpdated?: boolean; +} +const BATCH_START_EVENT_NAME = 'batch:start'; +const BATCH_STOP_EVENT_NAME = 'batch:stop'; + +/** + * Creates a bidirectional synchronization system between a JointJS graph and an external store. + * + * This function handles: + * - Syncing changes from the graph to the store (when users interact with the graph) + * - Syncing changes from the store to the graph (when React state updates) + * - Preventing circular update loops using flags and batch tracking + * - Handling incremental updates efficiently + * - Supporting batch operations for performance + * + * The synchronization uses several mechanisms to prevent infinite loops: + * - `isSyncingFromState`: Prevents syncing graph changes back when we're syncing from state + * - `isUpdatingStateFromGraph`: Prevents syncing state changes to graph when we're updating from graph + * - Batch tracking: Groups multiple changes together to avoid intermediate syncs + * @template Graph - The type of JointJS graph instance + * @template Element - The type of elements in the graph + * @template Link - The type of links in the graph + * @param options - Configuration options for graph synchronization + * @returns GraphSync instance with subscription and cleanup methods + */ +export function graphSync< + Graph extends dia.Graph, + Element extends dia.Element | GraphElement, + Link extends dia.Link | GraphLink, +>(options: Options): GraphSync { + const { graph, store, useRealtimeUpdated = false } = options; + // We need to ensure several things: + // 1. Graph can update itself, via onCellChange or via onBatchStop - this change is internal and must update the external store - but only if the external store do not trigger the same change. + // 2. External store can update the graph via new elements or links - this change is external and must update the internal graph + // But issue in the 2. is that the change of graph will re-run internal updates, so it will trigger onCellChange or onBatchStop. + + const changedCellIds = new Set(); + let hasReset = false; + let isSyncingFromState = false; + let syncFromStateCounter = 0; + let isUpdatingStateFromGraph = false; + let updatingStateFromGraphCounter = 0; + let batchCounter = 0; + + const onIncrementalChange = () => { + // Skip syncing back to React if we're currently syncing from React + // This prevents circular updates: React → Graph → React + if (isSyncingFromState) { + return; + } + + // Check if there are any changes to process + if (!hasReset && changedCellIds.size === 0) return; + + // Capture current state of changes + const isReset = hasReset; + const ids = [...changedCellIds]; + + // Clear for next batch + hasReset = false; + changedCellIds.clear(); + + if (isReset) { + // unfortunately this will create always new object references, so we need to compare them with more deeply + const graphElements = graph.getElements().map((element) => elementFromGraph(element)); + const graphLinks = graph.getLinks().map((link) => linkFromGraph(link)); + const snapshot = store.getSnapshot(); + const elements = removeDeepReadOnly(snapshot.elements); + const links = removeDeepReadOnly(snapshot.links); + + const isEqual = util.isEqual(elements, graphElements) && util.isEqual(links, graphLinks); + if (isEqual) return; + + return; + } + if (!store.setState) { + throw new Error('Store does not have setState method'); + } + // Incremental update + // Set flag to prevent this state update from triggering another graph sync + updatingStateFromGraphCounter++; + isUpdatingStateFromGraph = true; + store.setState((previous: GraphStoreSnapshot) => { + const updates = new Map(); + const removals = new Set(); + + for (const id of ids) { + const cell = graph.getCell(id); + if (cell) { + if (cell.isLink()) { + updates.set(id, { type: 'link', data: linkFromGraph(cell) }); + } else { + updates.set(id, { type: 'element', data: elementFromGraph(cell) }); + } + } else { + removals.add(id); + } + } + + // Elements + const nextElements: Element[] = []; + let elementsChanged = false; + + for (const cellElement of previous.elements) { + const id = cellElement.id.toString(); + if (removals.has(id)) { + elementsChanged = true; + continue; + } + if (updates.has(id)) { + const update = updates.get(id); + if (update && update.type === 'element') { + if (util.isEqual(cellElement, update.data)) { + nextElements.push(cellElement); + } else { + nextElements.push(update.data); + elementsChanged = true; + } + updates.delete(id); + } else { + nextElements.push(cellElement); + } + } else { + nextElements.push(cellElement); + } + } + + // Add new elements + for (const [id, update] of updates) { + if (update.type === 'element') { + nextElements.push(update.data); + elementsChanged = true; + updates.delete(id); + } + } + + // Links + const nextLinks: Link[] = []; + let linksChanged = false; + + for (const link of previous.links) { + const id = link.id.toString(); + if (removals.has(id)) { + linksChanged = true; + continue; + } + if (updates.has(id)) { + const update = updates.get(id); + if (update && update.type === 'link') { + if (util.isEqual(link, update.data)) { + nextLinks.push(link); + } else { + nextLinks.push(update.data); + linksChanged = true; + } + updates.delete(id); + } else { + nextLinks.push(link); + } + } else { + nextLinks.push(link); + } + } + + // Add new links + for (const [, update] of updates) { + if (update.type === 'link') { + nextLinks.push(update.data); + linksChanged = true; + } + } + + if (!elementsChanged && !linksChanged) return previous; + + const newElements = elementsChanged ? nextElements : previous.elements; + const newLinks = linksChanged ? nextLinks : previous.links; + return { + ...previous, + elements: newElements as Element[], + links: newLinks as Link[], + }; + }); + // Reset the flag after setState completes + // The subscription callback runs synchronously within setState (via ReactDOM.unstable_batchedUpdates), + // so we can reset the flag immediately after setState returns + // If there's a batch, onBatchStop will also handle resetting it as a safety measure + updatingStateFromGraphCounter--; + if (updatingStateFromGraphCounter === 0) { + isUpdatingStateFromGraph = false; + } + }; + + const cellChangeListeners = new Set<(change: OnChangeOptions) => () => void>(); + // Here we handle graph internal changes, it's skipped when there is an active batch + const destroy = listenToCellChange(graph, (change) => { + for (const listener of cellChangeListeners) { + listener(change); + } + if (change.type === 'reset') { + hasReset = true; + changedCellIds.clear(); + } else { + changedCellIds.add(change.cell.id.toString()); + } + + // Skip if we're syncing from state to prevent circular updates + if (isSyncingFromState) return; + if (!useRealtimeUpdated && graph.hasActiveBatch()) return; + onIncrementalChange(); + }); + + // Track when batches start + const onBatchStart = (event: { batchName?: string }) => { + batchCounter++; + // If we're syncing from state and this is a sync-cells batch, track it + if (isSyncingFromState && event.batchName === 'sync-cells') { + // This batch is from our syncCells call, so we should ignore its stop event + } + }; + + // We only update batch when there is last one. + const onBatchStop = (_event: { batchName?: string }) => { + batchCounter--; + if (batchCounter > 0) return; // Still in a nested batch + if (!useRealtimeUpdated && graph.hasActiveBatch()) return; + + // Reset the flag after batch completes + const wasSyncingFromState = isSyncingFromState; + if (syncFromStateCounter > 0) { + syncFromStateCounter--; + isSyncingFromState = syncFromStateCounter > 0; + } + + // Reset the updatingStateFromGraph flag after batch completes + // This ensures the flag stays set during the entire batch lifecycle + if (updatingStateFromGraphCounter > 0) { + updatingStateFromGraphCounter--; + if (updatingStateFromGraphCounter === 0) { + isUpdatingStateFromGraph = false; + } + } + + // Skip syncing back to React if we were syncing from React + // This prevents circular updates: React → Graph (via syncCells) → batch:stop → React + if (wasSyncingFromState) return; + onIncrementalChange(); + }; + + // If graph has existing cells but store is empty, sync those cells to the store first + const syncExistingGraphCellsToStore = () => { + if (!store.setState) { + return; + } + + const snapshot = store.getSnapshot(); + const storeElements = removeDeepReadOnly(snapshot.elements); + const storeLinks = removeDeepReadOnly(snapshot.links); + + // Only sync if store is empty and graph has cells + if (storeElements.length === 0 && storeLinks.length === 0) { + const existingElements = graph.getElements().map((element) => elementFromGraph(element)); + const existingLinks = graph.getLinks().map((link) => linkFromGraph(link)); + + if (existingElements.length > 0 || existingLinks.length > 0) { + // Set flag to prevent syncing graph changes back to React during initialization + updatingStateFromGraphCounter++; + isUpdatingStateFromGraph = true; + + store.setState((previous) => ({ + ...previous, + elements: existingElements as Element[], + links: existingLinks as Link[], + })); + + updatingStateFromGraphCounter--; + if (updatingStateFromGraphCounter === 0) { + isUpdatingStateFromGraph = false; + } + } + } + }; + + const updateGraph = () => { + if (graph.hasActiveBatch()) return; + // Skip if we're updating state from graph changes + // This prevents circular updates: Graph → State → Graph + if (isUpdatingStateFromGraph) { + return; + } + + const snapshot = store.getSnapshot(); + const elements = removeDeepReadOnly(snapshot.elements); + const links = removeDeepReadOnly(snapshot.links); + + // Compare current graph state with store state to avoid unnecessary syncs + // This prevents syncing when graph and store are already in sync + const graphElements = graph.getElements().map((element) => elementFromGraph(element)); + const graphLinks = graph.getLinks().map((link) => linkFromGraph(link)); + + // Check if graph is already in sync with store + if (util.isEqual(elements, graphElements) && util.isEqual(links, graphLinks)) { + return; + } + + // Set flag to prevent syncing graph changes back to React + // This prevents circular updates: React → Graph → React + syncFromStateCounter++; + isSyncingFromState = true; + + syncGraph({ + graph, + elements: elements as Array, + links: links as Array, + }); + + // Only reset the flag if there's no batch (events were processed synchronously) + // If there's a batch, onBatchStop will handle resetting it + // We need to check after syncGraph because it might have started a batch + if (batchCounter === 0 && !graph.hasActiveBatch()) { + // Decrement counter and reset flag if counter reaches 0 + syncFromStateCounter--; + isSyncingFromState = syncFromStateCounter > 0; + } + }; + // Here we get the external changes and update the graph + const clean = store.subscribe(() => { + // Check if we should skip this update (we're updating state from graph) + if (isUpdatingStateFromGraph) { + return; + } + updateGraph(); + }); + // listen to batch start and stop events to track batch lifecycle + graph.on(BATCH_START_EVENT_NAME, onBatchStart); + graph.on(BATCH_STOP_EVENT_NAME, onBatchStop); + const cleanup = () => { + cellChangeListeners.clear(); + destroy(); + clean(); + graph.off(BATCH_START_EVENT_NAME, onBatchStart); + graph.off(BATCH_STOP_EVENT_NAME, onBatchStop); + }; + + /** + * Subscribes to cell changes in the graph. + * Allows external code to react to changes that occur in the graph. + * The callback receives change information and should return a cleanup function that will be called + * when the cell is removed or the subscription is cancelled. + * @param callback - The callback function that receives change options and returns a cleanup function + * @returns A function to unsubscribe from cell changes + */ + function subscribeToCellChange(callback: (change: OnChangeOptions) => () => void) { + cellChangeListeners.add(callback); + return () => { + cellChangeListeners.delete(callback); + }; + } + // First, sync existing graph cells to store if store is empty + syncExistingGraphCellsToStore(); + // Then, sync store to graph + updateGraph(); + return { + subscribeToCellChange, + cleanup, + }; +} + +/** + * Interface for graph synchronization instance. + * Provides methods to subscribe to cell changes and clean up resources. + */ +export interface GraphSync { + /** + * Subscribes to cell change events in the graph. + * The callback receives change information and should return a cleanup function. + * @param callback - Function that receives change options and returns a cleanup function + * @returns Unsubscribe function to remove the listener + */ + subscribeToCellChange: (callback: (change: OnChangeOptions) => () => void) => () => void; + /** + * Cleans up all subscriptions and event listeners. + * Should be called when the synchronization is no longer needed. + */ + cleanup: () => void; +} diff --git a/packages/joint-react/src/store/index.ts b/packages/joint-react/src/store/index.ts new file mode 100644 index 0000000000..4023b87961 --- /dev/null +++ b/packages/joint-react/src/store/index.ts @@ -0,0 +1,3 @@ +export * from './graph-store'; +export * from './paper-store'; +export type { OnSetSize } from './create-elements-size-observer'; diff --git a/packages/joint-react/src/store/paper-store.ts b/packages/joint-react/src/store/paper-store.ts new file mode 100644 index 0000000000..09af0aabab --- /dev/null +++ b/packages/joint-react/src/store/paper-store.ts @@ -0,0 +1,248 @@ +import { dia, util, type Vectorizer } from '@joint/core'; +import type { OverWriteResult } from '../context'; +import type { RenderElement } from '../components'; +import type { GraphElement } from '../types/element-types'; +import type { GraphState, GraphStore } from './graph-store'; +import { createScheduler } from '../utils/scheduler'; + +const DEFAULT_CLICK_THRESHOLD = 10; +export const PORTAL_SELECTOR = 'react-port-portal'; + +/** + * Cache entry for port-related DOM elements and selectors. + * Used internally to track port rendering state. + */ +export interface PortElementsCacheEntry { + /** The main port element vectorizer */ + portElement: Vectorizer; + /** Optional port label element vectorizer */ + portLabelElement?: Vectorizer | null; + /** Selectors for port SVG elements */ + portSelectors: Record; + /** Optional selectors for port label SVG elements */ + portLabelSelectors?: Record; + /** The port content element vectorizer */ + portContentElement: Vectorizer; + /** Optional selectors for port content SVG elements */ + portContentSelectors?: Record; +} + +/** + * Options for adding a new paper instance to the graph store. + */ +export interface AddPaperOptions { + /** JointJS Paper configuration options */ + readonly paperOptions: dia.Paper.Options; + /** Optional function to override the default paper element rendering */ + readonly overWrite?: (paperStore: PaperStore) => OverWriteResult; + /** The DOM element (HTML or SVG) where the paper will be rendered */ + readonly paperElement: HTMLElement | SVGElement; + /** Optional initial scale for the paper */ + readonly scale?: number; + /** Optional custom renderer for elements */ + readonly renderElement?: RenderElement; +} + +/** + * Options for creating a PaperStore instance. + * Extends AddPaperOptions with required graph store and ID. + */ +export interface PaperStoreOptions extends AddPaperOptions { + /** The graph store instance this paper belongs to */ + readonly graphStore: GraphStore; + /** Unique identifier for this paper instance */ + readonly id: string; +} + +/** + * Snapshot of paper-specific state. + * Contains element views and port data for this paper instance. + */ +export interface PaperStoreSnapshot { + /** Map of cell IDs to their element views in this paper */ + paperElementViews?: Record; + /** Map of port IDs to their SVG elements */ + portsData?: Record; +} + +/** + * Store for managing a single Paper instance and its associated state. + * + * Each Paper component creates a PaperStore instance that: + * - Manages the JointJS Paper instance + * - Tracks element views for rendering + * - Handles port rendering and caching + * - Coordinates with the GraphStore for state updates + */ +export class PaperStore { + /** The underlying JointJS Paper instance */ + public paper: dia.Paper; + /** Unique identifier for this paper instance */ + public paperId: string; + /** Reference to the overwrite result if custom rendering is used */ + public overWriteResultRef?: OverWriteResult; + /** Optional custom element renderer */ + private renderElement?: RenderElement; + + constructor(options: PaperStoreOptions) { + const { + graphStore, + paperOptions = {}, + overWrite, + paperElement, + scale, + renderElement, + id, + } = options; + const { width, height } = paperOptions; + const { graph } = graphStore; + this.paperId = id; + this.renderElement = renderElement; + const cache: { + portsData: Record; + elementViews: Record; + } = { + portsData: {}, + elementViews: {}, + }; + const scheduler = createScheduler(() => { + graphStore.updatePaperSnapshot(options.id, (current) => { + return { + ...current, + portsData: cache.portsData, + paperElementViews: cache.elementViews, + }; + }); + }); + // eslint-disable-next-line unicorn/no-this-assignment, @typescript-eslint/no-this-alias + const store = this; + const elementView = dia.ElementView.extend({ + onRender() { + // eslint-disable-next-line unicorn/no-this-assignment, @typescript-eslint/no-this-alias + const view: dia.ElementView = this; + const cellId = view.model.id as dia.Cell.ID; + cache.elementViews = { + ...cache.elementViews, + [cellId]: view, + }; + scheduler(); + }, + _renderPorts() { + // @ts-expect-error we use private jointjs api + dia.ElementView.prototype._renderPorts.call(this); + // eslint-disable-next-line unicorn/no-this-assignment, @typescript-eslint/no-this-alias + const view: dia.ElementView = this; + const portElementsCache: Record = this._portElementsCache; + const newPorts = store.getNewPorts({ + state: graphStore.internalState, + cellId: view.model.id as dia.Cell.ID, + portElementsCache, + portsData: cache.portsData, + }); + cache.portsData = newPorts ?? {}; + + scheduler(); + }, + }); + // Create a new JointJS Paper with the provided options + this.paper = new dia.Paper({ + async: true, + sorting: dia.Paper.sorting.APPROX, + preventDefaultBlankAction: false, + frozen: true, + model: graph, + elementView, + // 👇 override to always allow connection + validateConnection: () => true, + // 👇 also, allow links to start or end on empty space + validateMagnet: () => true, + ...paperOptions, + viewManagement: paperOptions.viewManagement ?? true, + clickThreshold: paperOptions.clickThreshold ?? DEFAULT_CLICK_THRESHOLD, + }); + + if (!paperElement) { + throw new Error('Paper HTML element is not available'); + } + + if (scale !== undefined) { + this.paper.scale(scale); + } + + this.renderPaper({ overWrite, element: paperElement }); + + if (width !== undefined && height !== undefined) { + this.paper.setDimensions(width, height); + } + } + + renderPaper = (options: { + overWrite?: (ctx: PaperStore) => OverWriteResult; + element: HTMLElement | SVGElement; + }): OverWriteResult | undefined => { + const { overWrite, element } = options; + if (!this.paper) { + throw new Error('Paper is not created'); + } + let elementToRender: HTMLElement | SVGElement = this.paper.el; + if (overWrite) { + const overWriteResult = overWrite(this); + + elementToRender = overWriteResult?.element; + this.overWriteResultRef = overWriteResult; + } + + if (!elementToRender) { + throw new Error('overwriteDefaultPaperElement must return a valid HTML or SVG element'); + } + + element.replaceChildren(elementToRender); + this.paper.unfreeze(); + return this.overWriteResultRef; + }; + + private getNewPorts = (options: { + portsData: Record; + state: GraphState; + cellId: dia.Cell.ID; + portElementsCache: Record; + }) => { + // silently update the ports data + const { cellId, portElementsCache, portsData } = options; + + const nextPorts = { ...portsData }; + let isChanged = false; + for (const portId in portElementsCache) { + const { portSelectors } = portElementsCache[portId]; + const portalElement = portSelectors[PORTAL_SELECTOR]; + if (!portalElement) { + throw new Error( + `Portal element not found for port id: ${portId} via ${PORTAL_SELECTOR} selector` + ); + } + + const element = Array.isArray(portalElement) ? portalElement[0] : portalElement; + const id = this.getPortId(cellId, portId); + if (util.isEqual(nextPorts[id], element)) { + continue; + } + isChanged = true; + nextPorts[id] = element; + } + if (!isChanged) { + return portsData; + } + const newPorts = { ...portsData, ...nextPorts }; + return newPorts; + }; + + /** + * Generates a unique port ID by combining cell ID and port ID. + * @param cellId - The ID of the cell containing the port + * @param portId - The ID of the port + * @returns A unique identifier for the port + */ + public getPortId(cellId: dia.Cell.ID, portId: string) { + return `${cellId}-${portId}`; + } +} diff --git a/packages/joint-react/src/stories/demos/introduction-demo/code.tsx b/packages/joint-react/src/stories/demos/introduction-demo/code.tsx index 4fcf4db25b..5d9e50fa28 100644 --- a/packages/joint-react/src/stories/demos/introduction-demo/code.tsx +++ b/packages/joint-react/src/stories/demos/introduction-demo/code.tsx @@ -20,7 +20,7 @@ import { useGraph, useLinks, type GraphElement, - type PaperContext, + type PaperStore, type PaperProps, type RenderElement, } from '@joint/react'; @@ -324,7 +324,7 @@ interface ToolbarProps { readonly setSelectedId: (id: dia.Cell.ID | null) => void; readonly showElementsInfo: boolean; readonly setShowElementsInfo: (show: boolean) => void; - readonly paperCtxRef: React.RefObject; + readonly paperCtxRef: React.RefObject; } // Toolbar component with some actions function ToolBar(props: Readonly) { @@ -456,7 +456,7 @@ function Main() { const [isMinimapVisible, setIsMinimapVisible] = useState(false); const [selectedElement, setSelectedElement] = useState(null); const [showElementsInfo, setShowElementsInfo] = useState(false); - const paperCtxRef = useRef(null); + const paperCtxRef = useRef(null); const renderElement = useCallback( (element: Element) => { diff --git a/packages/joint-react/src/stories/examples/with-auto-layout/code.tsx b/packages/joint-react/src/stories/examples/with-auto-layout/code.tsx index 5707d45d59..1bb62484a0 100644 --- a/packages/joint-react/src/stories/examples/with-auto-layout/code.tsx +++ b/packages/joint-react/src/stories/examples/with-auto-layout/code.tsx @@ -20,15 +20,15 @@ import { PAPER_CLASSNAME } from 'storybook-config/theme'; import { useCellActions } from '../../../hooks/use-cell-actions'; const initialElements = createElements([ - { id: '1', label: 'Node 1' }, - { id: '2', label: 'Node 2' }, - { id: '3', label: 'Node 3' }, - { id: '4', label: 'Node 4' }, - { id: '5', label: 'Node 5' }, - { id: '6', label: 'Node 6' }, - { id: '7', label: 'Node 7' }, - { id: '8', label: 'Node 8' }, - { id: '9', label: 'Node 9' }, + { id: '1', label: 'Node 1', width: 100, height: 50 }, + { id: '2', label: 'Node 2', width: 100, height: 50 }, + { id: '3', label: 'Node 3', width: 100, height: 50 }, + { id: '4', label: 'Node 4', width: 100, height: 50 }, + { id: '5', label: 'Node 5', width: 100, height: 50 }, + { id: '6', label: 'Node 6', width: 100, height: 50 }, + { id: '7', label: 'Node 7', width: 100, height: 50 }, + { id: '8', label: 'Node 8', width: 100, height: 50 }, + { id: '9', label: 'Node 9', width: 100, height: 50 }, ]); type BaseElementWithData = InferElement; @@ -109,11 +109,9 @@ function Main() { set({ id: `${Math.random()}`, label: `Node ${elementsLength + 1}`, - height: 40, - width: 100, + height: 0, // we recompute the size after the element is added + width: 0, // we recompute the size after the element is added }); - // Layout again with the new element - makeLayoutWithGrid({ graph, gridXSize }); }} type="button" className="bg-blue-500 cursor-pointer hover:bg-blue-700 text-white font-bold py-2 px-4 rounded" diff --git a/packages/joint-react/src/stories/examples/with-custom-link/code-with-create-links-classname.tsx b/packages/joint-react/src/stories/examples/with-custom-link/code-with-create-links-classname.tsx index 3586b3f990..a4e1eb61f4 100644 --- a/packages/joint-react/src/stories/examples/with-custom-link/code-with-create-links-classname.tsx +++ b/packages/joint-react/src/stories/examples/with-custom-link/code-with-create-links-classname.tsx @@ -13,7 +13,6 @@ import { useCallback } from 'react'; import { HTMLNode } from 'storybook-config/decorators/with-simple-data'; import './code-with-create-links-classname.css'; import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; -import type { dia } from '@joint/core'; const initialElements = createElements([ { id: '1', label: 'Node 1', x: 100, y: 0 }, @@ -34,7 +33,6 @@ const initialEdges = createLinks([ ]); type BaseElementWithData = InferElement; -type CustomLink = (typeof initialEdges)[number]; function Main() { const renderElement: RenderElement = useCallback( @@ -48,9 +46,7 @@ function Main() { ); } -export default function App( - props: Readonly> -) { +export default function App(props: Readonly) { return (
diff --git a/packages/joint-react/src/stories/examples/with-custom-link/code-with-create-links.tsx b/packages/joint-react/src/stories/examples/with-custom-link/code-with-create-links.tsx index a3fe0e4dcc..0a4623f816 100644 --- a/packages/joint-react/src/stories/examples/with-custom-link/code-with-create-links.tsx +++ b/packages/joint-react/src/stories/examples/with-custom-link/code-with-create-links.tsx @@ -11,7 +11,6 @@ import { } from '@joint/react'; import { useCallback } from 'react'; import { HTMLNode } from 'storybook-config/decorators/with-simple-data'; -import type { dia } from '@joint/core'; const initialElements = createElements([ { id: '1', label: 'Node 1', x: 100, y: 0 }, @@ -33,7 +32,6 @@ const initialEdges = createLinks([ ]); type BaseElementWithData = InferElement; -type CustomLink = (typeof initialEdges)[number]; function Main() { const renderElement: RenderElement = useCallback( @@ -47,9 +45,7 @@ function Main() { ); } -export default function App( - props: Readonly> -) { +export default function App(props: Readonly) { return (
diff --git a/packages/joint-react/src/stories/examples/with-custom-link/code-with-dia-links.tsx b/packages/joint-react/src/stories/examples/with-custom-link/code-with-dia-links.tsx index 188bd17e94..2163e88cc1 100644 --- a/packages/joint-react/src/stories/examples/with-custom-link/code-with-dia-links.tsx +++ b/packages/joint-react/src/stories/examples/with-custom-link/code-with-dia-links.tsx @@ -1,8 +1,7 @@ -/* eslint-disable react-perf/jsx-no-new-array-as-prop */ /* eslint-disable react-perf/jsx-no-new-object-as-prop */ /* eslint-disable react-perf/jsx-no-new-function-as-prop */ import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; -import { shapes, util, type dia } from '@joint/core'; +import { shapes, util } from '@joint/core'; import { createElements, GraphProvider, @@ -63,11 +62,8 @@ const links = [ attrs: { line: { stroke: PRIMARY } }, }, ]; -type CustomLink = (typeof links)[number]; -export default function App( - props: Readonly> -) { +export default function App(props: Readonly) { return ( , + currentLinkIds: Set +): void { + for (const linkId of managedLinks) { + if (!currentLinkIds.has(linkId)) { + graph.getCell(linkId)?.remove(); + managedLinks.delete(linkId); + } + } +} + +function createProximityLinks( + graph: dia.Graph, + elementId: dia.Cell.ID, + closeIds: readonly dia.Cell.ID[], + managedLinks: Set +): void { + for (const closeId of closeIds) { + const linkId = getLinkId(elementId, closeId); + const linkIdString = String(linkId); + // Check if the link or the reverse link already exists + if (graph.getCell(linkId)) { + managedLinks.add(linkIdString); + continue; + } + if (graph.getCell(getLinkId(closeId, elementId))) { + continue; + } + + const link = new DashedLink({ + id: linkId, + source: { id: elementId }, + target: { id: closeId }, + }); + graph.addCell(link, { async: false }); + managedLinks.add(linkIdString); + } +} + function ResizableNode({ id, label, width, height }: Readonly) { - const graph = useGraph(); const nodeRef = useRef(null); - const element = graph.getCell(id); - - const closeIds = useElements(() => { - const area = element.getBBox().inflate(PROXIMITY_THRESHOLD); - const proximityElements = graph - .findElementsInArea(area) - .filter((element_) => element_.id !== id); - return proximityElements.map((element_) => element_.id); - }); - - useEffect(() => { - for (const closeId of closeIds) { - const linkId = getLinkId(id, closeId); - // Check if the link or the reverse link already exists - if (graph.getCell(linkId)) continue; - if (graph.getCell(getLinkId(closeId, id))) continue; - - const link = new DashedLink({ - id: linkId, - source: { id }, - target: { id: closeId }, - }); - graph.addCell(link, { async: false }); - } - return () => { + const managedLinksRef = useRef>(new Set()); + + useCellChangeEffect( + ({ graph, change }) => { + if (!shouldReactToChange(change, id)) { + return; + } + + const element = graph.getCell(id); + if (!element || element.isLink()) { + return; + } + + const area = element.getBBox().inflate(PROXIMITY_THRESHOLD); + const proximityElements = graph + .findElementsInArea(area) + .filter((element_) => element_.id !== id); + const closeIds = proximityElements.map((element_) => element_.id); + + // Clean up old links that are no longer needed + const currentLinkIds = new Set(); for (const closeId of closeIds) { const linkId = getLinkId(id, closeId); - graph.getCell(linkId)?.remove(); + currentLinkIds.add(String(linkId)); } - }; - }, [closeIds, graph, id]); + + removeOldLinks(graph, managedLinksRef.current, currentLinkIds); + createProximityLinks(graph, id, closeIds, managedLinksRef.current); + + return () => { + for (const linkId of managedLinksRef.current) { + graph.getCell(linkId)?.remove(); + } + managedLinksRef.current.clear(); + }; + }, + [id] + ); return ( diff --git a/packages/joint-react/src/stories/tutorials/redux/code.tsx b/packages/joint-react/src/stories/tutorials/redux/code.tsx deleted file mode 100644 index f201ec7c3c..0000000000 --- a/packages/joint-react/src/stories/tutorials/redux/code.tsx +++ /dev/null @@ -1,284 +0,0 @@ -/* eslint-disable sonarjs/pseudo-random */ -/* eslint-disable react-perf/jsx-no-new-function-as-prop */ - -// Import necessary modules and components from the library and other dependencies -import { - createElements, - createLinks, - GraphProvider, - useGraph, - setElements as setElementsViaGraph, - setLinks as setLinksViaGraph, - type GraphProps, - type InferElement, - Paper, -} from '@joint/react'; -import '../../examples/index.css'; // Import custom styles -import { BUTTON_CLASSNAME, PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; // Storybook-specific styles -import { createSlice, configureStore } from '@reduxjs/toolkit'; // Redux Toolkit for state management -import { Provider, useSelector } from 'react-redux'; // React-Redux bindings -import { dia } from '@joint/plus'; -import { useRef } from 'react'; -const defaultElements = createElements([ - { id: '1', label: 'Hello', x: 100, y: 0, width: 100, height: 50 }, - { id: '2', label: 'World', x: 100, y: 200, width: 100, height: 50 }, -]); -// Define a Redux slice for managing elements (nodes) -const elementsSlice = createSlice({ - name: 'elements', - initialState: defaultElements, - reducers: { - // Add a new element to the state - addElement: (state, action) => { - state.push(action.payload); - }, - resetToDefault: () => { - return defaultElements; - }, - removeLast: (state) => { - state.pop(); - }, - // Replace all elements in the state - setElements: (_, action) => { - return action.payload; - }, - - addTwoRandomElements: (state) => { - state.push( - { - id: Math.random().toString(36).slice(7), - label: 'Random 1', - x: Math.random() * 200, - y: Math.random() * 200, - width: 100, - height: 50, - }, - { - id: Math.random().toString(36).slice(7), - label: 'Random 2', - x: Math.random() * 200, - y: Math.random() * 200, - width: 100, - height: 50, - } - ); - }, - }, -}); - -// Define a Redux slice for managing links (edges) -const linksSlice = createSlice({ - name: 'links', - initialState: createLinks([ - { - id: 'e1-2', - source: '1', - target: '2', - attrs: { - line: { - stroke: PRIMARY, // Use the primary color from the theme - }, - }, - }, - ]), - reducers: { - // Add a new link to the state - resetLinkToDefault: () => { - return createLinks([ - { - id: 'e1-2', - source: '1', - target: '2', - attrs: { - line: { - stroke: PRIMARY, // Use the primary color from the theme - }, - }, - }, - ]); - }, - // Replace all links in the state - setLinks: (state, action) => { - return action.payload; - }, - removeLinks: () => { - return []; - }, - }, -}); - -// Extract actions from the elements slice -const { addElement, setElements, resetToDefault, removeLast, addTwoRandomElements } = - elementsSlice.actions; -const { resetLinkToDefault, setLinks, removeLinks } = linksSlice.actions; - -// Configure the Redux store with the elements and links reducers -const store = configureStore({ - reducer: { - elements: elementsSlice.reducer, - links: linksSlice.reducer, - }, -}); - -// Define the RootState type for use with selectors -type RootState = ReturnType; - -// Infer the type of a custom element from the elements state -type CustomElement = InferElement; -type CustomLink = RootState['links'][number]; - -// Component to render a custom node (element) -function RenderItem(props: CustomElement) { - const { label, width, height } = props; - return ( - - {/* */} -
{label}
- {/*
*/} -
- ); -} - -// Component to render the Paper and provide controls -function PaperApp() { - const graph = useGraph(); // Access the graph instance - - const commandManager = useRef(new dia.CommandManager({ graph })); - return ( -
- {/* Render the Paper component */} - - {/* Control buttons */} -
- - - - - - - -
-
- ); -} - -// Main component that connects the Redux store to the GraphProvider -function Main(props: Readonly>) { - // Select links and elements from the Redux store - const links = useSelector((state: RootState) => state.links); - const elements = useSelector((state: RootState) => state.elements); - - return ( - <> - {/* Provide the graph context with initial elements and links */} - { - // Dispatch an action to update elements in the Redux store - store.dispatch(setElements(items)); - }} - onLinksChange={(items) => { - store.dispatch(setLinks(items)); - }} - > - - - - ); -} - -// Root component that wraps the application with the Redux Provider -export default function App() { - return ( - -
- - ); -} diff --git a/packages/joint-react/src/stories/tutorials/redux/docs.mdx b/packages/joint-react/src/stories/tutorials/redux/docs.mdx deleted file mode 100644 index 2d7c4689f0..0000000000 --- a/packages/joint-react/src/stories/tutorials/redux/docs.mdx +++ /dev/null @@ -1,75 +0,0 @@ -import { Meta, Canvas, Markdown } from '@storybook/blocks'; -import * as Stories from './story'; - - - -# Controlled Mode with Redux - -This tutorial demonstrates how to use `@joint/react` in controlled mode with Redux for state management. In controlled mode, the graph state is managed externally (in this case, via Redux), and changes to the graph are synchronized back to your state store. - -## Overview - -Controlled mode allows you to: -- Manage graph state in your Redux store -- Keep graph state synchronized with your application state -- Use Redux actions to modify the graph -- Leverage Redux DevTools for debugging - -## Demo - - - -## Key Concepts - -### 1. Redux Store Setup - -Create Redux slices for managing elements and links: - -```tsx -const elementsSlice = createSlice({ - name: 'elements', - initialState: defaultElements, - reducers: { - addElement: (state, action) => { - state.push(action.payload); - }, - setElements: (_, action) => { - return action.payload; - }, - // ... other reducers - }, -}); -``` - -### 2. Controlled Mode Configuration - -Use `onElementsChange` and `onLinksChange` callbacks to sync graph changes back to Redux: - -```tsx - { - store.dispatch(setElements(items)); - }} - onLinksChange={(items) => { - store.dispatch(setLinks(items)); - }} -> - - -``` - -### 3. Updating State - -You can update the graph state either: -- **Via Redux actions**: Dispatch actions to update the Redux store, which will sync to the graph -- **Via graph directly**: Use graph methods, and changes will sync back via the callbacks - -## Benefits - -- **Single source of truth**: Redux store is the authoritative state -- **Time-travel debugging**: Use Redux DevTools to inspect and replay state changes -- **Predictable updates**: All state changes go through Redux reducers -- **Integration**: Easy to integrate with other Redux-managed application state - diff --git a/packages/joint-react/src/stories/tutorials/redux/story.tsx b/packages/joint-react/src/stories/tutorials/redux/story.tsx deleted file mode 100644 index 8a2bd3dbcd..0000000000 --- a/packages/joint-react/src/stories/tutorials/redux/story.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react'; -import '../../examples/index.css'; -import Code from './code'; -import RawCode from './code?raw'; - -export type Story = StoryObj; - -export default { - title: 'Tutorials/Redux', - component: Code, - tags: ['tutorial'], - parameters: { - docs: { - description: { - story: 'Tutorial on using Redux with JointJS React', - }, - source: { - code: RawCode, - }, - }, - }, -} satisfies Meta; - -export const Default: Story = {}; diff --git a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-jotai.tsx b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-jotai.tsx new file mode 100644 index 0000000000..2cd8413370 --- /dev/null +++ b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-jotai.tsx @@ -0,0 +1,333 @@ +/* eslint-disable sonarjs/pseudo-random */ +/* eslint-disable react-perf/jsx-no-new-function-as-prop */ + +/** + * ============================================================================ + * JOTAI INTEGRATION GUIDE + * ============================================================================ + * + * This example demonstrates how to integrate @joint/react with Jotai for + * state management. Jotai is an atomic state management library that uses + * atoms as the building blocks for state. + * + * KEY CONCEPTS: + * + * 1. **Atoms**: Jotai uses atoms - small, independent pieces of state. + * Each atom can be read and written independently. + * + * 2. **ExternalGraphStore Interface**: We adapt Jotai atoms to the + * ExternalGraphStore interface, which allows GraphProvider to work with it. + * + * 3. **Atomic State**: Jotai's atomic approach means you can split state + * into small pieces and compose them together. + * + * HOW IT WORKS: + * + * 1. Create Jotai atoms for elements and links + * 2. Create derived atoms or use them directly + * 3. Adapt the atoms to ExternalGraphStore using jotaiAdapter + * 4. Pass the externalStore to GraphProvider + * 5. All state changes automatically sync to the graph + * + * ============================================================================ + */ + +import { + createElements, + createLinks, + GraphProvider, + type GraphProps, + type GraphElement, + type GraphLink, + type InferElement, + Paper, + type ExternalGraphStore, +} from '@joint/react'; +import '../../examples/index.css'; +import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; +import { useMemo } from 'react'; +import { atom, createStore } from 'jotai'; +import type { GraphStoreSnapshot } from '../../../store/graph-store'; +import type { Update } from '../../../utils/create-state'; + +// ============================================================================ +// STEP 1: Define Initial Graph Data +// ============================================================================ + +const defaultElements = createElements([ + { id: '1', label: 'Hello', x: 100, y: 0, width: 100, height: 50 }, + { id: '2', label: 'World', x: 100, y: 200, width: 100, height: 50 }, +]); + +const defaultLinks = createLinks([ + { + id: 'e1-2', + source: '1', + target: '2', + attrs: { + line: { + stroke: PRIMARY, + }, + }, + }, +]); + +type CustomElement = InferElement; + +// ============================================================================ +// STEP 2: Custom Element Renderer +// ============================================================================ + +function RenderItem(props: CustomElement) { + const { label, width, height } = props; + return ( + +
{label}
+
+ ); +} + +// ============================================================================ +// STEP 3: Create Jotai Atoms and Store +// ============================================================================ + +/** + * Create a Jotai store for managing atom state. + * The store allows us to subscribe to atom changes and access atoms outside React. + */ +const jotaiStore = createStore(); + +/** + * Jotai atom for graph elements. + * Atoms are the building blocks of Jotai - they hold state. + */ +const elementsAtom = atom(defaultElements as GraphElement[]); + +/** + * Jotai atom for graph links. + */ +const linksAtom = atom(defaultLinks as GraphLink[]); + +// ============================================================================ +// STEP 4: Create Jotai Adapter Hook +// ============================================================================ + +/** + * Hook that creates an ExternalGraphStore adapter from Jotai atoms. + * + * This adapter is the bridge between Jotai and @joint/react's GraphProvider. + * It uses Jotai atoms and adapts them to the ExternalStoreLike interface, + * which allows GraphStore to: + * - Read the current state (getSnapshot) + * - Subscribe to state changes (subscribe) + * - Update the state (setState) + * + * The adapter automatically reads from the Jotai atoms, so no parameters + * are needed. Just call this hook inside a component. + * + * @returns An ExternalGraphStore compatible with GraphProvider + * + * @example + * ```tsx + * + * + * + * ``` + */ +function useJotaiAdapter(): ExternalGraphStore { + return useMemo(() => { + // Track subscribers + const subscribers = new Set<() => void>(); + + const notifySubscribers = () => { + for (const subscriber of subscribers) subscriber(); + }; + + return { + /** + * Returns the current snapshot of the graph state. + * GraphStore calls this to read the current elements and links. + */ + getSnapshot: (): GraphStoreSnapshot => { + return { + elements: jotaiStore.get(elementsAtom), + links: jotaiStore.get(linksAtom), + }; + }, + + /** + * Subscribes to Jotai atom changes. + * When the atoms change, the listener is called, which notifies + * GraphStore to re-read the state and sync with JointJS. + * + * @param listener - Callback function to call when state changes + * @returns Unsubscribe function to remove the listener + */ + subscribe: (listener: () => void) => { + subscribers.add(listener); + + // Subscribe to both atoms using Jotai's store.sub method + // The sub method signature is: sub(atom, callback) -> unsubscribe + const unsubscribeElements = jotaiStore.sub(elementsAtom, () => { + notifySubscribers(); + }); + + const unsubscribeLinks = jotaiStore.sub(linksAtom, () => { + notifySubscribers(); + }); + + return () => { + subscribers.delete(listener); + unsubscribeElements(); + unsubscribeLinks(); + }; + }, + + /** + * Updates the Jotai atoms. + * GraphStore calls this when JointJS graph changes (e.g., user drags a node). + * + * The updater can be: + * - A direct value: { elements: [...], links: [...] } + * - A function: (previous) => ({ elements: [...], links: [...] }) + * + * @param updater - The new state or a function to compute new state + */ + setState: (updater: Update) => { + const currentSnapshot: GraphStoreSnapshot = { + elements: jotaiStore.get(elementsAtom), + links: jotaiStore.get(linksAtom), + }; + + const newSnapshot = typeof updater === 'function' ? updater(currentSnapshot) : updater; + + // Update atoms using the store + jotaiStore.set(elementsAtom, newSnapshot.elements); + jotaiStore.set(linksAtom, newSnapshot.links); + }, + }; + }, []); +} + +// ============================================================================ +// STEP 5: Component Implementation +// ============================================================================ + +/** + * PaperApp component that uses the external store. + */ +interface PaperAppProps { + readonly store: ExternalGraphStore; +} + +function PaperApp({ store }: PaperAppProps) { + return ( +
+ + {/* Dark-themed controls */} +
+ + +
+
+ ); +} + +/** + * Main component that sets up Jotai and connects it to GraphProvider. + */ +function Main(props: Readonly) { + // Get the adapter from Jotai atoms + // This hook automatically reads from the Jotai store + const externalStore = useJotaiAdapter(); + + return ( + + + + ); +} + +/** + * ============================================================================ + * USAGE SUMMARY + * ============================================================================ + * + * To use Jotai with @joint/react: + * + * 1. Create Jotai atoms for elements and links using atom() + * 2. Use useAtom() hook to read and write atom values + * 3. Create an ExternalGraphStore adapter that uses the atoms + * 4. Pass the externalStore to GraphProvider + * 5. Use useAtom() hooks in components to access and update state + * + * Benefits: + * - Atomic state management - split state into small pieces + * - No providers needed - atoms work globally + * - TypeScript support out of the box + * - Simple API - just atoms and hooks + * - All graph state changes automatically sync + * + * ============================================================================ + */ + +export default function App(props: Readonly) { + return
; +} diff --git a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-peerjs.tsx b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-peerjs.tsx new file mode 100644 index 0000000000..6ceab77237 --- /dev/null +++ b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-peerjs.tsx @@ -0,0 +1,615 @@ +/* eslint-disable sonarjs/pseudo-random */ +/* eslint-disable react-perf/jsx-no-new-function-as-prop */ + +/** + * ============================================================================ + * PEERJS COLLABORATIVE MODE TUTORIAL + * ============================================================================ + * + * This example demonstrates how to share graph state between multiple peers + * using PeerJS for real-time collaboration. Multiple users can connect and + * see each other's changes in real-time. + * + * KEY CONCEPTS: + * + * 1. **PeerJS**: A WebRTC library that enables peer-to-peer connections + * between browsers without a server (except for signaling). + * + * 2. **State Synchronization**: When one peer updates the graph, the change + * is sent to all connected peers via PeerJS data channels. + * + * 3. **Controlled Mode**: We use React-controlled mode to manage state, + * and sync that state across peers using PeerJS. + * + * 4. **Connection Flow**: + * - Each peer gets a unique ID when they load the page + * - One peer can connect to another by entering their ID + * - Once connected, state changes are synchronized bidirectionally + * + * HOW IT WORKS: + * + * 1. Peer A loads page → Gets ID "abc123" + * 2. Peer B loads page → Gets ID "xyz789" + * 3. Peer A enters "xyz789" → Connects to Peer B + * 4. Peer A adds element → State updates → Sent to Peer B via PeerJS + * 5. Peer B receives update → Updates local state → Graph updates + * + * ============================================================================ + */ + +import { + createElements, + createLinks, + GraphProvider, + type GraphProps, + type GraphElement, + type GraphLink, + type InferElement, + Paper, + type ExternalGraphStore, +} from '@joint/react'; +import '../../examples/index.css'; +import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; +import { useState, useEffect, useRef } from 'react'; +import Peer, { type DataConnection } from 'peerjs'; +import type { GraphStoreSnapshot } from '../../../store/graph-store'; +import type { Update } from '../../../utils/create-state'; + +// ============================================================================ +// STEP 1: Define Initial Graph Data +// ============================================================================ + +const defaultElements = createElements([ + { id: '1', label: 'Hello', x: 100, y: 0, width: 100, height: 50 }, + { id: '2', label: 'World', x: 100, y: 200, width: 100, height: 50 }, +]); + +const defaultLinks = createLinks([ + { + id: 'e1-2', + source: '1', + target: '2', + attrs: { + line: { + stroke: PRIMARY, + }, + }, + }, +]); + +type CustomElement = InferElement; + +// ============================================================================ +// STEP 2: Custom Element Renderer +// ============================================================================ + +function RenderItem(props: CustomElement) { + const { label, width, height } = props; + return ( + +
{label}
+
+ ); +} + +// ============================================================================ +// STEP 3: PeerJS External Store +// ============================================================================ + +/** + * Message types for PeerJS communication. + * We send structured messages to synchronize state between peers. + */ +interface StateSyncMessage { + type: 'state-update'; + elements: GraphElement[]; + links: GraphLink[]; +} + +/** + * Creates an ExternalGraphStore that syncs state via PeerJS. + * + * This store: + * 1. Manages local state (elements and links) + * 2. Sends state updates to connected peers when state changes + * 3. Receives state updates from peers and updates local state + * 4. Implements ExternalStoreLike interface for GraphProvider + * + * The key advantage: ALL state changes (including position changes from dragging) + * are automatically captured and synced, because GraphProvider calls setState + * on the external store for every change. + */ +type ConnectionStatus = 'disconnected' | 'connecting' | 'connected'; + +function createPeerJSStore( + initialElements: GraphElement[], + initialLinks: GraphLink[] +): { + store: ExternalGraphStore; + peerId: string | null; + connectedPeerId: string | null; + connectionStatus: ConnectionStatus; + connectToPeer: (remotePeerId: string) => void; + setCallbacks: ( + peerIdCb: (id: string | null) => void, + statusCb: (status: ConnectionStatus) => void, + connectedIdCb: (id: string | null) => void + ) => void; +} { + // Local state + let currentState: GraphStoreSnapshot = { + elements: initialElements, + links: initialLinks, + }; + + // Subscribers (for ExternalStoreLike interface) + const subscribers = new Set<() => void>(); + + // PeerJS connection management + let peerId: string | null = null; + let connectedPeerId: string | null = null; + let connectionStatus: ConnectionStatus = 'disconnected'; + let peerRef: Peer | null = null; + const connectionsRef: DataConnection[] = []; + const isReceivingUpdateRef = { current: false }; + + // Notify subscribers of state changes + const notifySubscribers = () => { + for (const subscriber of subscribers) { + subscriber(); + } + }; + + // Send state update to all connected peers + const sendStateUpdate = (state: GraphStoreSnapshot) => { + // Don't send if we're currently receiving an update (prevent loops) + if (isReceivingUpdateRef.current) { + return; + } + + const message: StateSyncMessage = { + type: 'state-update', + elements: state.elements, + links: state.links, + }; + + // Send to all connected peers + for (const conn of connectionsRef) { + if (conn.open) { + conn.send(message); + } + } + }; + + // Handle incoming state update from peer + const handlePeerUpdate = (message: StateSyncMessage) => { + if (message.type === 'state-update') { + isReceivingUpdateRef.current = true; + currentState = { + elements: message.elements, + links: message.links, + }; + notifySubscribers(); + // Reset flag after a short delay + setTimeout(() => { + isReceivingUpdateRef.current = false; + }, 100); + } + }; + + // Create the external store + const store: ExternalGraphStore = { + getSnapshot: (): GraphStoreSnapshot => { + return currentState; + }, + + subscribe: (listener: () => void) => { + subscribers.add(listener); + return () => { + subscribers.delete(listener); + }; + }, + + setState: (updater: Update) => { + // Update local state + const newState = typeof updater === 'function' ? updater(currentState) : updater; + currentState = newState; + + // Notify subscribers + notifySubscribers(); + + // Send to peers (if connected and not receiving an update) + if (connectionStatus === 'connected' && !isReceivingUpdateRef.current) { + sendStateUpdate(newState); + } + }, + }; + + // Callbacks for React state updates + let onPeerIdChange: ((id: string | null) => void) | null = null; + let onConnectionStatusChange: ((status: ConnectionStatus) => void) | null = null; + let onConnectedPeerIdChange: ((id: string | null) => void) | null = null; + + // Initialize PeerJS peer + const initializePeer = () => { + const peer = new Peer(); + peerRef = peer; + + peer.on('open', (id) => { + peerId = id; + if (onPeerIdChange) { + onPeerIdChange(id); + } + }); + + // Handle incoming connections + peer.on('connection', (conn) => { + connectionStatus = 'connected'; + connectedPeerId = conn.peer; + connectionsRef.push(conn); + if (onConnectionStatusChange) { + onConnectionStatusChange('connected'); + } + if (onConnectedPeerIdChange) { + onConnectedPeerIdChange(conn.peer); + } + + // Handle incoming data + conn.on('data', (data) => { + handlePeerUpdate(data as StateSyncMessage); + }); + + // Handle connection close + conn.on('close', () => { + const index = connectionsRef.indexOf(conn); + if (index !== -1) { + connectionsRef.splice(index, 1); + } + if (connectionsRef.length === 0) { + connectionStatus = 'disconnected'; + connectedPeerId = null; + if (onConnectionStatusChange) { + onConnectionStatusChange('disconnected'); + } + if (onConnectedPeerIdChange) { + onConnectedPeerIdChange(null); + } + } + }); + }); + + peer.on('error', (error) => { + // eslint-disable-next-line no-console + console.error('PeerJS error:', error); + if (error.type === 'peer-unavailable') { + connectionStatus = 'disconnected'; + if (onConnectionStatusChange) { + onConnectionStatusChange('disconnected'); + } + alert('Peer not found. Make sure the peer ID is correct and the peer is online.'); + } + }); + }; + + // Connect to another peer + const connectToPeer = (remotePeerId: string) => { + if (!peerRef) { + return; + } + + connectionStatus = 'connecting'; + if (onConnectionStatusChange) { + onConnectionStatusChange('connecting'); + } + + const conn = peerRef.connect(remotePeerId); + + conn.on('open', () => { + connectionStatus = 'connected'; + connectedPeerId = remotePeerId; + connectionsRef.push(conn); + if (onConnectionStatusChange) { + onConnectionStatusChange('connected'); + } + if (onConnectedPeerIdChange) { + onConnectedPeerIdChange(remotePeerId); + } + + // Send current state to the newly connected peer + sendStateUpdate(currentState); + }); + + conn.on('data', (data) => { + handlePeerUpdate(data as StateSyncMessage); + }); + + conn.on('close', () => { + const index = connectionsRef.indexOf(conn); + if (index !== -1) { + connectionsRef.splice(index, 1); + } + if (connectionsRef.length === 0) { + connectionStatus = 'disconnected'; + connectedPeerId = null; + if (onConnectionStatusChange) { + onConnectionStatusChange('disconnected'); + } + if (onConnectedPeerIdChange) { + onConnectedPeerIdChange(null); + } + } + }); + + conn.on('error', (error) => { + // eslint-disable-next-line no-console + console.error('Connection error:', error); + connectionStatus = 'disconnected'; + if (onConnectionStatusChange) { + onConnectionStatusChange('disconnected'); + } + }); + }; + + // Initialize peer + initializePeer(); + + return { + store, + get peerId() { + return peerId; + }, + get connectedPeerId() { + return connectedPeerId; + }, + get connectionStatus() { + return connectionStatus; + }, + connectToPeer, + setCallbacks: ( + peerIdCb: (id: string | null) => void, + statusCb: (status: ConnectionStatus) => void, + connectedIdCb: (id: string | null) => void + ) => { + onPeerIdChange = peerIdCb; + onConnectionStatusChange = statusCb; + onConnectedPeerIdChange = connectedIdCb; + }, + }; +} + +// ============================================================================ +// STEP 4: Paper Component with Controls +// ============================================================================ + +interface PaperAppProps { + readonly store: ExternalGraphStore; +} + +function PaperApp({ store }: PaperAppProps) { + return ( +
+ + {/* Dark-themed controls matching the connection panel */} +
+ + +
+
+ ); +} + +// ============================================================================ +// STEP 5: Main Component with PeerJS Integration +// ============================================================================ + +function Main(props: Readonly) { + const [remotePeerId, setRemotePeerId] = useState(''); + const [peerId, setPeerId] = useState(null); + const [connectedPeerId, setConnectedPeerId] = useState(null); + const [connectionStatus, setConnectionStatus] = useState('disconnected'); + const [copyFeedback, setCopyFeedback] = useState(false); + + // Create PeerJS store (only once) + const peerJSStoreRef = useRef(createPeerJSStore(defaultElements, defaultLinks)); + + // Set up callbacks for React state updates + useEffect(() => { + peerJSStoreRef.current.setCallbacks(setPeerId, setConnectionStatus, setConnectedPeerId); + }, []); + + const handleConnect = () => { + if (remotePeerId.trim()) { + peerJSStoreRef.current.connectToPeer(remotePeerId.trim()); + setConnectionStatus('connecting'); + } + }; + + const handleCopyId = async () => { + if (peerId) { + try { + await navigator.clipboard.writeText(peerId); + // Show feedback + setCopyFeedback(true); + setTimeout(() => { + setCopyFeedback(false); + }, 2000); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Failed to copy ID:', error); + } + } + }; + + return ( +
+ {/* Peer Connection UI - Dark Theme */} +
+
+ Your ID: + {peerId ? ( +
+ + {peerId} + + +
+ ) : ( + Connecting... + )} +
+ +
+ Status: + {(() => { + let statusClassName = 'px-3 py-1.5 rounded text-sm font-medium '; + let statusText = ''; + + if (connectionStatus === 'connected') { + statusClassName += 'bg-green-900 text-green-200'; + statusText = `Connected to ${connectedPeerId}`; + } else if (connectionStatus === 'connecting') { + statusClassName += 'bg-yellow-900 text-yellow-200'; + statusText = 'Connecting...'; + } else { + statusClassName += 'bg-gray-700 text-gray-300'; + statusText = 'Disconnected'; + } + + return {statusText}; + })()} +
+ +
+ setRemotePeerId(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + handleConnect(); + } + }} + className="px-3 py-1.5 bg-gray-900 border border-gray-600 rounded text-gray-100 placeholder-gray-500 flex-1 focus:outline-none focus:ring-2 focus:ring-blue-500" + disabled={!peerId || connectionStatus === 'connected'} + /> + +
+
+ + {/* Graph */} + + + +
+ ); +} + +/** + * ============================================================================ + * USAGE SUMMARY + * ============================================================================ + * + * To use PeerJS collaborative mode: + * + * 1. Open this page in two browser windows/tabs + * 2. Each window will get a unique peer ID + * 3. Copy the ID from one window + * 4. Paste it into the "Enter peer ID to connect" field in the other window + * 5. Click "Connect" + * 6. Now both peers are connected and will see each other's changes in real-time + * + * HOW IT WORKS: + * + * - Each peer creates a PeerJS connection with a unique ID + * - When connected, state changes are sent via WebRTC data channels + * - Received updates are applied to local state + * - GraphProvider syncs state changes to the JointJS graph + * + * Benefits: + * - Real-time collaboration + * - No server required (except PeerJS signaling server) + * - Direct peer-to-peer communication + * - Low latency + * + * ============================================================================ + */ + +export default function App(props: Readonly) { + return
; +} diff --git a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-redux.tsx b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-redux.tsx new file mode 100644 index 0000000000..e5dad89516 --- /dev/null +++ b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-redux.tsx @@ -0,0 +1,495 @@ +/* eslint-disable sonarjs/pseudo-random */ +/* eslint-disable react-perf/jsx-no-new-function-as-prop */ + +import { + createElements, + createLinks, + GraphProvider, + type GraphProps, + type GraphElement, + type GraphLink, + type InferElement, + Paper, + type ExternalGraphStore, +} from '@joint/react'; +import '../../examples/index.css'; +import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; +import { useMemo, useState, useEffect } from 'react'; +import { configureStore, createSlice, type PayloadAction } from '@reduxjs/toolkit'; +import { Provider, useStore } from 'react-redux'; +import undoable, { ActionCreators } from 'redux-undo'; +import type { GraphStoreSnapshot } from '../../../store/graph-store'; +import type { Update } from '../../../utils/create-state'; + +/** + * ============================================================================ + * REDUX INTEGRATION GUIDE + * ============================================================================ + * + * This example demonstrates how to integrate @joint/react with Redux for + * state management. Using Redux (or any external state management library) + * provides several advantages over React-controlled mode: + * + * 1. **Centralized State Management**: All graph state lives in your Redux store, + * making it easier to integrate with other parts of your application. + * + * 2. **Time-Travel Debugging**: Redux DevTools allows you to inspect and replay + * state changes, making debugging much easier. + * + * 3. **Predictable Updates**: All state changes go through Redux actions, making + * the data flow explicit and traceable. + * + * 4. **Better Performance**: Redux's selector system allows fine-grained subscriptions, + * reducing unnecessary re-renders. + * + * 5. **Integration with Other Features**: Easy to add middleware for persistence, + * undo/redo, or other cross-cutting concerns. + * + * ============================================================================ + * KEY CONCEPT: ExternalGraphStore Interface + * ============================================================================ + * + * Instead of using React state (useState) with onElementsChange/onLinksChange, + * we use the ExternalGraphStore interface. This interface is compatible with + * any state management library that implements: + * + * - getSnapshot(): Returns the current state snapshot + * - subscribe(listener): Subscribes to state changes + * - setState(updater): Updates the state (can accept a function or direct value) + * + * The reduxAdapter function below converts a Redux store to this interface, + * allowing seamless integration with GraphProvider. + * + * ============================================================================ + */ + +// ============================================================================ +// STEP 1: Define the Graph State Shape +// ============================================================================ + +/** + * The shape of our graph state in Redux. + * This matches GraphStoreSnapshot, which contains elements and links. + * History is managed automatically by redux-undo, so we don't need to include it here. + */ +interface GraphState { + /** Array of all elements (nodes) in the graph */ + readonly elements: GraphElement[]; + /** Array of all links (edges) in the graph */ + readonly links: GraphLink[]; +} + +// ============================================================================ +// STEP 2: Create Redux Slice with Actions +// ============================================================================ + +/** + * Initial elements for the graph. + * Defined separately to enable type inference for CustomElement. + */ +const defaultElements = createElements([ + { id: '1', label: 'Hello', x: 100, y: 0, width: 100, height: 50 }, + { id: '2', label: 'World', x: 100, y: 200, width: 100, height: 50 }, +]); + +/** + * Initial links for the graph. + * Defined separately to enable type inference for CustomLink. + */ +const defaultLinks = createLinks([ + { + id: 'e1-2', + source: '1', + target: '2', + attrs: { + line: { + stroke: PRIMARY, + }, + }, + }, +]); + +/** + * Redux slice for managing graph state. + * This slice defines actions for adding, removing, and updating elements and links. + * Undo/redo functionality is handled automatically by redux-undo wrapper. + */ +const graphSlice = createSlice({ + name: 'graph', + initialState: { + elements: defaultElements as GraphElement[], + links: defaultLinks as GraphLink[], + } satisfies GraphState, + reducers: { + /** + * Adds a new element to the graph. + */ + addElement: (state, action: PayloadAction) => { + state.elements.push(action.payload); + }, + /** + * Removes the last element from the graph. + * Also removes all links connected to that element. + */ + removeLastElement: (state) => { + if (state.elements.length === 0) { + return; + } + // Remove the last element + const removedElementId = state.elements.at(-1)?.id; + state.elements.pop(); + // Remove all links connected to the removed element + if (removedElementId) { + state.links = state.links.filter( + (link) => link.source !== removedElementId && link.target !== removedElementId + ); + } + }, + /** + * Updates both elements and links atomically. + * Used by the reduxAdapter when GraphStore syncs changes. + */ + setGraphState: (state, action: PayloadAction) => { + state.elements = action.payload.elements; + state.links = action.payload.links; + }, + }, +}); + +// Export actions for use in components +export const { addElement, removeLastElement, setGraphState } = graphSlice.actions; + +// Export undo/redo actions from redux-undo +// These will be used to undo/redo state changes +export const undo = () => ActionCreators.undo(); +export const redo = () => ActionCreators.redo(); + +// ============================================================================ +// STEP 3: Create Redux Store +// ============================================================================ + +/** + * Creates a Redux store configured for the graph. + * In a real application, you might combine this with other slices. + * The store is created at module level and provided via Redux Provider. + * + * The graph reducer is wrapped with redux-undo's undoable() function, + * which automatically handles undo/redo functionality. + */ +const store = configureStore({ + reducer: { + // Wrap the graph reducer with undoable to enable undo/redo + // redux-undo automatically tracks history and provides undo/redo actions + graph: undoable(graphSlice.reducer, { + // Limit history to prevent memory issues (optional) + limit: 50, + }), + }, + // Enable Redux DevTools for debugging + devTools: true, +}); + +// Infer the store type for TypeScript +type GraphStore = typeof store; +type GraphRootState = ReturnType; + +/** + * Type for the undoable state structure created by redux-undo. + * redux-undo wraps the state in a structure with past, present, and future arrays. + */ +type UndoableGraphState = { + past: readonly GraphState[]; + present: GraphState; + future: readonly GraphState[]; +}; + +// ============================================================================ +// STEP 4: Create Redux Adapter Hook +// ============================================================================ + +/** + * Hook that creates an ExternalGraphStore adapter from the Redux store. + * + * This adapter is the bridge between Redux and @joint/react's GraphProvider. + * It uses the Redux store from context (via useStore hook) and adapts it to + * the ExternalStoreLike interface, which allows GraphStore to: + * - Read the current state (getSnapshot) + * - Subscribe to state changes (subscribe) + * - Update the state (setState) + * + * The adapter automatically reads from the Redux store context, so no parameters + * are needed. Just call this hook inside a component wrapped with Redux Provider. + * + * @returns An ExternalGraphStore compatible with GraphProvider + * + * @example + * ```tsx + * + * + * + * + * + * ``` + */ +function useReduxAdapter(): ExternalGraphStore { + const reduxStore = useStore(); + + return useMemo(() => { + return { + /** + * Returns the current snapshot of the graph state. + * GraphStore calls this to read the current elements and links. + */ + getSnapshot: (): GraphStoreSnapshot => { + const state = reduxStore.getState(); + // redux-undo wraps the state in a { past, present, future } structure + // We need to access the 'present' property to get the current state + const graphState = (state.graph as UndoableGraphState).present; + return { + elements: graphState.elements, + links: graphState.links, + }; + }, + + /** + * Subscribes to Redux store changes. + * When the Redux state changes, the listener is called, which notifies + * GraphStore to re-read the state and sync with JointJS. + * + * @param listener - Callback function to call when state changes + * @returns Unsubscribe function to remove the listener + */ + subscribe: (listener: () => void) => { + let previousState = (reduxStore.getState().graph as UndoableGraphState).present; + + // Subscribe to Redux store changes + const unsubscribe = reduxStore.subscribe(() => { + const currentState = (reduxStore.getState().graph as UndoableGraphState).present; + + // Only notify if the graph state actually changed + // This prevents unnecessary re-renders when other parts of Redux state change + if (currentState !== previousState) { + previousState = currentState; + listener(); + } + }); + + return unsubscribe; + }, + + /** + * Updates the Redux store state. + * GraphStore calls this when JointJS graph changes (e.g., user drags a node). + * + * The updater can be: + * - A direct value: { elements: [...], links: [...] } + * - A function: (previous) => ({ elements: [...], links: [...] }) + * + * @param updater - The new state or a function to compute new state + */ + setState: (updater: Update) => { + const currentState = (reduxStore.getState().graph as UndoableGraphState).present; + const currentSnapshot: GraphStoreSnapshot = { + elements: currentState.elements, + links: currentState.links, + }; + + // Handle both function and direct value updaters + const newSnapshot = typeof updater === 'function' ? updater(currentSnapshot) : updater; + + // Dispatch Redux action to update the state atomically + // We use setGraphState to update both elements and links in a single action + // redux-undo automatically saves this to history + reduxStore.dispatch(setGraphState(newSnapshot)); + }, + }; + }, [reduxStore]); +} + +// ============================================================================ +// STEP 5: Component Implementation +// ============================================================================ + +/** + * Type inference for custom elements. + * Uses InferElement to extract the element type from the initial elements array. + */ +type CustomElement = InferElement; + +/** + * Custom render function for graph elements. + * This defines how each element is rendered in the SVG. + */ +function RenderItem(props: CustomElement) { + const { label, width, height } = props; + return ( + +
{label}
+
+ ); +} + +/** + * Inner component that uses the Redux adapter hook. + * This must be inside a Redux Provider to access the store. + */ +function GraphWithRedux(props: Readonly) { + // Get the adapter from Redux store context + // This hook automatically reads from the Redux Provider + const externalStore = useReduxAdapter(); + + return ( + + + + ); +} + +/** + * Container component that wraps everything with Redux Provider. + * + * This component: + * 1. Wraps the app with Redux Provider (using the store created at module level) + * 2. The inner component uses useReduxAdapter to get the ExternalGraphStore + * 3. Passes the external store to GraphProvider + */ +function Main(props: Readonly) { + return ( + + + + ); +} + +/** + * PaperApp component connected to Redux. + * This component uses Redux hooks to dispatch actions and read state. + * Undo/redo is handled entirely through Redux actions. + */ +function ReduxConnectedPaperApp() { + const reduxStore = useStore(); + const { dispatch } = reduxStore; + + // Subscribe to Redux state changes to update undo/redo availability + // In a real app, you'd use react-redux's useSelector: + // const canUndo = useSelector((state) => (state.graph as UndoableGraphState).past.length > 0); + // const canRedo = useSelector((state) => (state.graph as UndoableGraphState).future.length > 0); + // redux-undo provides past and future arrays in the state + const [canUndo, setCanUndo] = useState( + () => (reduxStore.getState().graph as UndoableGraphState).past.length > 0 + ); + const [canRedo, setCanRedo] = useState( + () => (reduxStore.getState().graph as UndoableGraphState).future.length > 0 + ); + + useEffect(() => { + const updateState = () => { + const currentState = reduxStore.getState(); + const graphState = currentState.graph as UndoableGraphState; + const newCanUndo = graphState.past.length > 0; + const newCanRedo = graphState.future.length > 0; + setCanUndo(newCanUndo); + setCanRedo(newCanRedo); + }; + + // Subscribe to store changes + const unsubscribe = reduxStore.subscribe(updateState); + + // Update state on mount + updateState(); + + // Cleanup subscription on unmount + return unsubscribe; + }, [reduxStore]); + return ( +
+ + {/* Dark-themed controls */} +
+ + + + +
+
+ ); +} + +/** + * ============================================================================ + * USAGE SUMMARY + * ============================================================================ + * + * To use Redux with @joint/react: + * + * 1. Create a Redux slice for your graph state (elements and links) + * 2. Create a Redux store with your slice (at module level or app root) + * 3. Wrap your app with Redux Provider + * 4. Use useReduxAdapter() hook to get ExternalGraphStore (no parameters needed!) + * 5. Pass the externalStore to GraphProvider + * 6. Dispatch Redux actions to update the graph state + * + * Benefits: + * - All graph state is in Redux, making it easy to integrate with other features + * - Redux DevTools for debugging and time-travel + * - Predictable state updates through actions + * - Simple adapter hook - just call useReduxAdapter() with no parameters + * - Easy undo/redo using redux-undo library - just wrap your reducer! + * - Easy to add middleware (persistence, etc.) + * + * ============================================================================ + */ + +export default function App(props: Readonly) { + return
; +} diff --git a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-zustand.tsx b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-zustand.tsx new file mode 100644 index 0000000000..101401165c --- /dev/null +++ b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-zustand.tsx @@ -0,0 +1,324 @@ +/* eslint-disable sonarjs/pseudo-random */ +/* eslint-disable react-perf/jsx-no-new-function-as-prop */ + +/** + * ============================================================================ + * ZUSTAND INTEGRATION GUIDE + * ============================================================================ + * + * This example demonstrates how to integrate @joint/react with Zustand for + * state management. Zustand is a lightweight, unopinionated state management + * library that's perfect for React applications. + * + * KEY CONCEPTS: + * + * 1. **Zustand Store**: A simple store created with `create()` that manages + * state and provides actions to update it. + * + * 2. **ExternalGraphStore Interface**: We adapt the Zustand store to the + * ExternalGraphStore interface, which allows GraphProvider to work with it. + * + * 3. **Simple API**: Zustand has a very simple API - just create a store, + * define state and actions, and use hooks to access them. + * + * HOW IT WORKS: + * + * 1. Create a Zustand store with elements and links state + * 2. Create actions to update the state (addElement, removeLastElement, etc.) + * 3. Adapt the store to ExternalGraphStore using zustandAdapter + * 4. Pass the externalStore to GraphProvider + * 5. All state changes automatically sync to the graph + * + * ============================================================================ + */ + +import { + createElements, + createLinks, + GraphProvider, + type GraphProps, + type GraphElement, + type GraphLink, + type InferElement, + Paper, + type ExternalGraphStore, +} from '@joint/react'; +import '../../examples/index.css'; +import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; +import { useMemo } from 'react'; +import { create } from 'zustand'; +import type { GraphStoreSnapshot } from '../../../store/graph-store'; +import type { Update } from '../../../utils/create-state'; + +// ============================================================================ +// STEP 1: Define Initial Graph Data +// ============================================================================ + +const defaultElements = createElements([ + { id: '1', label: 'Hello', x: 100, y: 0, width: 100, height: 50 }, + { id: '2', label: 'World', x: 100, y: 200, width: 100, height: 50 }, +]); + +const defaultLinks = createLinks([ + { + id: 'e1-2', + source: '1', + target: '2', + attrs: { + line: { + stroke: PRIMARY, + }, + }, + }, +]); + +type CustomElement = InferElement; + +// ============================================================================ +// STEP 2: Custom Element Renderer +// ============================================================================ + +function RenderItem(props: CustomElement) { + const { label, width, height } = props; + return ( + +
{label}
+
+ ); +} + +// ============================================================================ +// STEP 3: Create Zustand Store +// ============================================================================ + +/** + * Zustand store interface for graph state. + */ +interface GraphStore { + /** Array of all elements (nodes) in the graph */ + elements: GraphElement[]; + /** Array of all links (edges) in the graph */ + links: GraphLink[]; + /** Action to add a new element */ + addElement: (element: GraphElement) => void; + /** Action to remove the last element */ + removeLastElement: () => void; + /** Action to update the graph state (used by adapter) */ + setGraphState: (snapshot: GraphStoreSnapshot) => void; +} + +/** + * Create a Zustand store for graph state. + * Zustand stores are simple - just define state and actions in one place. + */ +const useGraphStore = create((set) => ({ + elements: defaultElements as GraphElement[], + links: defaultLinks as GraphLink[], + + addElement: (element) => { + set((state) => ({ + elements: [...state.elements, element], + })); + }, + + removeLastElement: () => { + set((state) => { + if (state.elements.length === 0) { + return state; + } + const removedElementId = state.elements.at(-1)?.id; + const newElements = state.elements.slice(0, -1); + const newLinks = removedElementId + ? state.links.filter( + (link) => link.source !== removedElementId && link.target !== removedElementId + ) + : state.links; + + return { + elements: newElements, + links: newLinks, + }; + }); + }, + + setGraphState: (snapshot) => { + set({ + elements: snapshot.elements, + links: snapshot.links, + }); + }, +})); + +// ============================================================================ +// STEP 4: Create Zustand Adapter Hook +// ============================================================================ + +/** + * Hook that creates an ExternalGraphStore adapter from the Zustand store. + * + * This adapter is the bridge between Zustand and @joint/react's GraphProvider. + * It uses the Zustand store and adapts it to the ExternalStoreLike interface, + * which allows GraphStore to: + * - Read the current state (getSnapshot) + * - Subscribe to state changes (subscribe) + * - Update the state (setState) + * + * The adapter automatically reads from the Zustand store, so no parameters + * are needed. Just call this hook inside a component. + * + * @returns An ExternalGraphStore compatible with GraphProvider + * + * @example + * ```tsx + * + * + * + * ``` + */ +function useZustandAdapter(): ExternalGraphStore { + const store = useGraphStore; + + return useMemo(() => { + return { + /** + * Returns the current snapshot of the graph state. + * GraphStore calls this to read the current elements and links. + */ + getSnapshot: (): GraphStoreSnapshot => { + const state = store.getState(); + return { + elements: state.elements, + links: state.links, + }; + }, + + /** + * Subscribes to Zustand store changes. + * When the Zustand state changes, the listener is called, which notifies + * GraphStore to re-read the state and sync with JointJS. + * + * @param listener - Callback function to call when state changes + * @returns Unsubscribe function to remove the listener + */ + subscribe: (listener: () => void) => { + // Zustand's subscribe method subscribes to all state changes + return store.subscribe(listener); + }, + + /** + * Updates the Zustand store state. + * GraphStore calls this when JointJS graph changes (e.g., user drags a node). + * + * The updater can be: + * - A direct value: { elements: [...], links: [...] } + * - A function: (previous) => ({ elements: [...], links: [...] }) + * + * @param updater - The new state or a function to compute new state + */ + setState: (updater: Update) => { + const currentState = store.getState(); + const currentSnapshot: GraphStoreSnapshot = { + elements: currentState.elements, + links: currentState.links, + }; + + // Handle both function and direct value updaters + const newSnapshot = typeof updater === 'function' ? updater(currentSnapshot) : updater; + + // Update Zustand store + store.getState().setGraphState(newSnapshot); + }, + }; + }, [store]); +} + +// ============================================================================ +// STEP 5: Component Implementation +// ============================================================================ + +/** + * PaperApp component that uses Zustand store actions. + */ +function PaperApp() { + const addElement = useGraphStore((state) => state.addElement); + const removeLastElement = useGraphStore((state) => state.removeLastElement); + + return ( +
+ + {/* Dark-themed controls */} +
+ + +
+
+ ); +} + +/** + * Main component that sets up Zustand and connects it to GraphProvider. + */ +function Main(props: Readonly) { + // Get the adapter from Zustand store + // This hook automatically reads from the Zustand store + const externalStore = useZustandAdapter(); + + return ( + + + + ); +} + +/** + * ============================================================================ + * USAGE SUMMARY + * ============================================================================ + * + * To use Zustand with @joint/react: + * + * 1. Create a Zustand store with create() containing elements and links + * 2. Define actions to update the state (addElement, removeLastElement, etc.) + * 3. Use useZustandAdapter() hook to get ExternalGraphStore (no parameters needed!) + * 4. Pass the externalStore to GraphProvider + * 5. Use Zustand hooks (useGraphStore) to access actions in components + * + * Benefits: + * - Simple, lightweight API + * - No boilerplate (no actions, reducers, or providers needed) + * - TypeScript support out of the box + * - Easy to use hooks for accessing state and actions + * - All graph state changes automatically sync + * + * ============================================================================ + */ + +export default function App(props: Readonly) { + return
; +} diff --git a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode.tsx b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode.tsx index 18449a50d4..250a52415d 100644 --- a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode.tsx +++ b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode.tsx @@ -1,25 +1,82 @@ /* eslint-disable sonarjs/pseudo-random */ /* eslint-disable react-perf/jsx-no-new-function-as-prop */ +/** + * ============================================================================ + * REACT-CONTROLLED MODE TUTORIAL + * ============================================================================ + * + * This example demonstrates React-controlled mode, where React state is the + * single source of truth for the graph. All changes to elements and links + * flow through React state, giving you full control over the graph state. + * + * KEY CONCEPTS: + * + * 1. **Controlled Mode**: When you provide `onElementsChange` and/or + * `onLinksChange` props to GraphProvider, you enable React-controlled mode. + * In this mode, React state controls the graph, and all changes must go + * through React state updates. + * + * 2. **Bidirectional Sync**: GraphProvider automatically synchronizes changes + * in both directions: + * - React state → JointJS graph (when you update React state) + * - JointJS graph → React state (when user interacts with the graph) + * + * 3. **State Flow**: + * - User interacts with graph → GraphProvider detects change → + * - Calls onElementsChange/onLinksChange → Updates React state → + * - React re-renders → GraphProvider syncs new state to graph + * + * 4. **Benefits**: + * - Full control over graph state + * - Easy to implement undo/redo (save state history) + * - Easy to persist state (save to localStorage, server, etc.) + * - Easy to integrate with other React state management + * + * ============================================================================ + */ + import { createElements, createLinks, GraphProvider, - useGraph, type GraphProps, + type GraphElement, + type GraphLink, type InferElement, Paper, } from '@joint/react'; import '../../examples/index.css'; -import { BUTTON_CLASSNAME, PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; -import { dia } from '@joint/plus'; -import { useRef, useState, useCallback } from 'react'; +import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; +import { useState, type Dispatch, type SetStateAction } from 'react'; + +// ============================================================================ +// STEP 1: Define Initial Graph Data +// ============================================================================ +/** + * Initial elements (nodes) for the graph. + * createElements is a helper function that creates properly formatted element + * objects compatible with JointJS. Each element needs: + * - id: unique identifier + * - label: text to display (custom property) + * - x, y: position on the canvas + * - width, height: dimensions + */ const defaultElements = createElements([ { id: '1', label: 'Hello', x: 100, y: 0, width: 100, height: 50 }, { id: '2', label: 'World', x: 100, y: 200, width: 100, height: 50 }, ]); +/** + * Initial links (edges) for the graph. + * createLinks is a helper function that creates properly formatted link objects. + * Each link needs: + * - id: unique identifier + * - source: id of the source element + * - target: id of the target element + * - attrs: visual attributes (colors, stroke width, etc.) + */ const defaultLinks = createLinks([ { id: 'e1-2', @@ -33,9 +90,42 @@ const defaultLinks = createLinks([ }, ]); +// ============================================================================ +// STEP 2: Type Inference for Custom Elements and Links +// ============================================================================ + +/** + * InferElement extracts the TypeScript type from the elements array. + * This gives us a CustomElement type that includes all properties from + * the initial elements, including our custom 'label' property. + * + * Example: CustomElement = { id: string, label: string, x: number, ... } + */ type CustomElement = InferElement; + +/** + * Extract the link type from the links array. + * This gives us proper TypeScript typing for our links. + */ type CustomLink = (typeof defaultLinks)[number]; +// ============================================================================ +// STEP 3: Custom Element Renderer +// ============================================================================ + +/** + * Custom render function for graph elements. + * + * This function defines how each element is rendered in the SVG. It receives + * the element's properties and returns JSX that will be rendered inside the + * element's SVG container. + * + * In this example, we use SVG's to embed HTML content, + * allowing us to use regular HTML/CSS for styling instead of SVG attributes. + * + * @param props - The element properties (includes id, label, x, y, width, height, etc.) + * @returns JSX to render inside the element + */ function RenderItem(props: CustomElement) { const { label, width, height } = props; return ( @@ -45,114 +135,328 @@ function RenderItem(props: CustomElement) { ); } +// ============================================================================ +// STEP 4: Paper Component with Controls +// ============================================================================ + +/** + * Props for the PaperApp component. + * These are the state setters passed down from the parent component. + * In controlled mode, all state changes must go through these setters. + */ interface PaperAppProps { - readonly elements: readonly CustomElement[]; - readonly links: readonly CustomLink[]; - readonly onElementsChange: (items: readonly CustomElement[]) => void; - readonly onLinksChange: (items: readonly CustomLink[]) => void; + /** Function to update the elements array */ + readonly onElementsChange: Dispatch>; + /** Function to update the links array */ + readonly onLinksChange: Dispatch>; } -function PaperApp({ elements, links, onElementsChange, onLinksChange }: PaperAppProps) { - const graph = useGraph(); - const commandManager = useRef(new dia.CommandManager({ graph })); - +/** + * PaperApp component that renders the graph and provides controls. + * + * This component: + * 1. Renders the Paper component (the visual graph canvas) + * 2. Provides buttons to add/remove elements + * 3. Updates state through the onElementsChange/onLinksChange callbacks + * + * IMPORTANT: In controlled mode, you should NOT directly modify the graph + * through JointJS APIs. Instead, always update React state, and GraphProvider + * will automatically sync the changes to the graph. + */ +function PaperApp({ onElementsChange, onLinksChange }: PaperAppProps) { return (
+ {/* + Paper component renders the graph canvas. + - width/height: dimensions of the canvas + - renderElement: custom renderer for elements (defined above) + - The Paper automatically reads elements and links from GraphProvider context + */} -
+ + {/* + ======================================================================== + CONTROLS SECTION - Understanding How State Updates Work + ======================================================================== + + These buttons demonstrate how to update the graph in controlled mode. + The key principle: NEVER directly modify the JointJS graph. Instead, + always update React state, and GraphProvider will automatically sync + the changes to the graph. + + STATE UPDATE FLOW: + 1. User clicks button → onClick handler executes + 2. Handler calls onElementsChange/onLinksChange with new state + 3. React state updates (setElements/setLinks) + 4. Component re-renders with new state + 5. GraphProvider receives new elements/links props + 6. GraphProvider detects change and syncs to JointJS graph + 7. Graph visually updates + + WHY FUNCTIONAL UPDATES? + We use the functional form: (prev) => newValue + This ensures we always work with the latest state, even if multiple + updates are queued. It's the recommended pattern for state updates + that depend on previous state. + */} + {/* Dark-themed controls */} +
+ {/* + ====================================================================== + ADD ELEMENT BUTTON + ====================================================================== + + WHAT IT DOES: + Creates a new element and adds it to the graph. + + HOW IT WORKS: + 1. Creates a new element object with: + - Random ID (using Math.random for uniqueness) + - Label "New Node" + - Random position (x, y between 0-200) + - Fixed dimensions (100x50) + + 2. Updates state using functional update: + onElementsChange((elements) => [...elements, newElement]) + + This: + - Takes the current elements array + - Spreads it into a new array + - Adds the new element at the end + - Returns the new array + + 3. React updates state → Component re-renders + + 4. GraphProvider detects the new elements prop + + 5. GraphProvider syncs the new element to JointJS graph + - Creates a new JointJS element + - Adds it to the graph + - Graph visually updates + + WHY THIS WORKS: + - We're updating React state, not the graph directly + - GraphProvider handles all the JointJS synchronization + - The graph automatically reflects the new state + */} + + {/* + ====================================================================== + REMOVE LAST ELEMENT BUTTON + ====================================================================== + + WHAT IT DOES: + Removes the last element from the graph. If no elements remain, + also clears all links (since links need source/target elements). + + HOW IT WORKS: + 1. Updates elements state using functional update: + onElementsChange((elements) => elements.slice(0, -1)) + + This: + - Takes the current elements array + - Uses slice(0, -1) to get all elements except the last one + - Returns the new array + + 2. Checks if any elements remain: + - If no elements: clears all links (links need elements to exist) + - If elements remain: links stay (GraphProvider will handle + removing orphaned links automatically) + + 3. React updates state → Component re-renders + + 4. GraphProvider detects the change: + - Removes the element from JointJS graph + - Automatically removes any links connected to that element + - Graph visually updates + + WHY WE CLEAR LINKS: + Links require source and target elements. If we remove all elements, + any remaining links would be invalid. GraphProvider will handle + removing links when their source/target elements are removed, but + it's good practice to clear them explicitly when removing the last element. + */} - - -
); } -function Main(props: Readonly>) { - const [elements, setElements] = useState(defaultElements); - const [links, setLinks] = useState(defaultLinks); - - const handleElementsChange = useCallback((items: readonly CustomElement[]) => { - setElements(items); - }, []); +// ============================================================================ +// STEP 5: Main Component with Controlled State +// ============================================================================ - const handleLinksChange = useCallback((items: readonly CustomLink[]) => { - setLinks(items); - }, []); +/** + * Main component that sets up React-controlled mode. + * + * This component: + * 1. Creates React state for elements and links (the single source of truth) + * 2. Wraps the app with GraphProvider in controlled mode + * 3. Passes state and setters to GraphProvider and child components + * + * HOW CONTROLLED MODE WORKS: + * + * 1. State Management: + * - elements and links are stored in React state (useState) + * - These arrays are the single source of truth for the graph + * + * 2. GraphProvider Setup: + * - elements={elements}: Provides current elements to GraphProvider + * - links={links}: Provides current links to GraphProvider + * - onElementsChange={setElements}: Tells GraphProvider to call setElements + * whenever the graph changes (e.g., user drags a node) + * - onLinksChange={setLinks}: Same for links + * + * 3. Bidirectional Sync: + * - When you update React state → GraphProvider syncs to JointJS graph + * - When user interacts with graph → GraphProvider calls onElementsChange/ + * onLinksChange → Updates React state → Triggers re-render + * + * 4. State Flow Example (user drags a node): + * User drags node → JointJS detects change → GraphProvider calls + * onElementsChange with new positions → setElements updates React state → + * Component re-renders → GraphProvider receives new elements prop → + * GraphProvider syncs to graph (but graph already has the change, so no + * duplicate update occurs) + */ +function Main(props: Readonly) { + // Create React state for elements and links + // These are the single source of truth for the graph + const [elements, setElements] = useState(defaultElements); + const [links, setLinks] = useState(defaultLinks); return ( + {/* + Pass state setters to child component so it can update the graph + by updating React state. The type assertions are needed because + GraphElement/GraphLink are more generic than CustomElement/CustomLink. + */} >} + onLinksChange={setLinks as Dispatch>} /> ); } -export default function App(props: Readonly>) { +/** + * ============================================================================ + * USAGE SUMMARY + * ============================================================================ + * + * To use React-controlled mode: + * + * 1. Create React state for elements and links using useState + * 2. Wrap your app with GraphProvider and provide: + * - elements={elements} + * - links={links} + * - onElementsChange={setElements} + * - onLinksChange={setLinks} + * 3. Update the graph by updating React state (never directly modify the graph) + * 4. GraphProvider automatically syncs changes in both directions + * + * Benefits: + * - Full control over graph state + * - Easy to implement undo/redo (save state history) + * - Easy to persist state (localStorage, server, etc.) + * - Easy to integrate with other React state management + * + * When to use: + * - You need full control over graph state + * - You want to implement undo/redo + * - You need to persist graph state + * - You're integrating with other React state management + * + * When NOT to use: + * - Simple graphs that don't need state control + * - Performance-critical scenarios (uncontrolled mode is faster) + * - You don't need to track state changes + * + * ============================================================================ + */ + +export default function App(props: Readonly) { return
; } - diff --git a/packages/joint-react/src/stories/tutorials/step-by-step/code-html-renderer.tsx b/packages/joint-react/src/stories/tutorials/step-by-step/code-html-renderer.tsx index 088e900928..6f7c5f9da9 100644 --- a/packages/joint-react/src/stories/tutorials/step-by-step/code-html-renderer.tsx +++ b/packages/joint-react/src/stories/tutorials/step-by-step/code-html-renderer.tsx @@ -11,7 +11,7 @@ import { } from '@joint/react'; import '../../examples/index.css'; import { BUTTON_CLASSNAME } from 'storybook-config/theme'; -import type { dia } from '@joint/core'; + // Define initial elements const initialElements = createElements([ { id: '1', data: { label: 'Hello' }, x: 100, y: 0, width: 100, height: 25 }, @@ -35,7 +35,6 @@ const initialEdges = createLinks([ ]); type CustomElement = InferElement; -type CustomLink = (typeof initialEdges)[number]; let zoomLevel = 1; @@ -110,7 +109,7 @@ function Main() { ); } -export default function App(props: Readonly>) { +export default function App(props: Readonly) { return (
diff --git a/packages/joint-react/src/stories/tutorials/step-by-step/code-html.tsx b/packages/joint-react/src/stories/tutorials/step-by-step/code-html.tsx index 7b24c84dcf..b31a9c3510 100644 --- a/packages/joint-react/src/stories/tutorials/step-by-step/code-html.tsx +++ b/packages/joint-react/src/stories/tutorials/step-by-step/code-html.tsx @@ -11,7 +11,6 @@ import { } from '@joint/react'; import '../../examples/index.css'; import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; -import type { dia } from '@joint/core'; // define initial elements const initialElements = createElements([ { id: '1', label: 'Hello', x: 100, y: 0, width: 100, height: 50 }, @@ -36,7 +35,6 @@ const initialEdges = createLinks([ // infer element type from the initial elements (this type can be used for later usage like RenderItem props) type CustomElement = InferElement; -type CustomLink = (typeof initialEdges)[number]; function RenderItem(props: CustomElement) { const { label, width, height } = props; return ( @@ -56,7 +54,7 @@ function Main() { ); } -export default function App(props: Readonly>) { +export default function App(props: Readonly) { return (
diff --git a/packages/joint-react/src/stories/tutorials/step-by-step/code-svg.tsx b/packages/joint-react/src/stories/tutorials/step-by-step/code-svg.tsx index 9abc6e1b83..f9d3adedf7 100644 --- a/packages/joint-react/src/stories/tutorials/step-by-step/code-svg.tsx +++ b/packages/joint-react/src/stories/tutorials/step-by-step/code-svg.tsx @@ -9,7 +9,6 @@ import { type GraphProps, type InferElement, } from '@joint/react'; -import type { dia } from '@joint/core'; // define initial elements const initialElements = createElements([ @@ -35,7 +34,6 @@ const initialEdges = createLinks([ // infer element type from the initial elements (this type can be used for later usage like RenderItem props) type CustomElement = InferElement; -type CustomLink = (typeof initialEdges)[number]; function RenderItem({ width, height, color }: CustomElement) { return ; @@ -49,7 +47,7 @@ function Main() { ); } -export default function App(props: Readonly>) { +export default function App(props: Readonly) { return (
diff --git a/packages/joint-react/src/stories/tutorials/step-by-step/docs.mdx b/packages/joint-react/src/stories/tutorials/step-by-step/docs.mdx index 1c36cce231..10ce041c57 100644 --- a/packages/joint-react/src/stories/tutorials/step-by-step/docs.mdx +++ b/packages/joint-react/src/stories/tutorials/step-by-step/docs.mdx @@ -4,6 +4,10 @@ import CodeSVG from './code-svg?raw'; import CodeHTML from './code-html?raw'; import CodeHTMLRenderer from './code-html-renderer?raw'; import CodeControlledMode from './code-controlled-mode?raw'; +import CodeControlledModeRedux from './code-controlled-mode-redux?raw'; +import CodeControlledModeZustand from './code-controlled-mode-zustand?raw'; +import CodeControlledModeJotai from './code-controlled-mode-jotai?raw'; +import CodeControlledModePeerJS from './code-controlled-mode-peerjs?raw'; import { getAPIDocumentationLink, getAPIPropsLink } from '../../utils/get-api-documentation-link'; @@ -208,7 +212,7 @@ ${CodeHTMLRenderer} ### Controlled Mode with useState -This example demonstrates how to use `@joint/react` in controlled mode with React's `useState` hook. In controlled mode, the graph state is managed in React state, and changes to the graph are synchronized back to your state. +This example demonstrates how to use `@joint/react` in **React-controlled mode** with React's `useState` hook. In controlled mode, React state is the single source of truth for the graph, and all changes flow through React state. @@ -217,12 +221,12 @@ ${CodeControlledMode} \`\`\``} -#### Key Points: +#### How It Works: 1. **State Management**: Use `useState` to manage elements and links: ```tsx - const [elements, setElements] = useState(defaultElements); - const [links, setLinks] = useState(defaultLinks); + const [elements, setElements] = useState(defaultElements); + const [links, setLinks] = useState(defaultLinks); ``` 2. **Controlled Mode**: Pass state and change handlers to `GraphProvider`: @@ -230,28 +234,266 @@ ${CodeControlledMode} ``` -3. **Bidirectional Sync**: Changes made via graph methods (like dragging nodes) automatically sync back to React state through the callbacks. +3. **Bidirectional Sync**: + - **React → Graph**: When you update React state, GraphProvider syncs to JointJS graph + - **Graph → React**: When user interacts (drags nodes), GraphProvider calls `onElementsChange`/`onLinksChange` to update React state + +4. **State Flow**: User drags node → GraphProvider detects change → Calls `onElementsChange` → Updates React state → Component re-renders → GraphProvider syncs new state to graph + +#### When to Use: +- ✅ You need full control over graph state +- ✅ You want to implement undo/redo (save state history) +- ✅ You need to persist state (localStorage, server, etc.) +- ✅ You're integrating with other React state management + +--- + +### External Store Integration: Redux + +This example demonstrates how to integrate `@joint/react` with **Redux** using the `ExternalGraphStore` interface. Redux provides centralized state management with time-travel debugging via Redux DevTools. + + + +{`\`\`\`tsx +${CodeControlledModeRedux} +\`\`\``} + + +#### How It Works: + +1. **Redux Store**: Create a Redux store with a slice for graph state (elements and links) +2. **Undo/Redo**: Use `redux-undo` library to automatically handle undo/redo functionality +3. **Adapter Hook**: Use `useReduxAdapter()` hook to convert Redux store to `ExternalGraphStore` +4. **GraphProvider**: Pass the `externalStore` prop to `GraphProvider` instead of `elements`/`links`/`onElementsChange` + +#### Key Benefits: +- ✅ **Centralized State**: All graph state lives in Redux store +- ✅ **Time-Travel Debugging**: Redux DevTools for inspecting and replaying state changes +- ✅ **Undo/Redo**: Easy undo/redo with `redux-undo` library +- ✅ **Predictable Updates**: All changes go through Redux actions +- ✅ **Integration**: Easy to integrate with other Redux features + +#### The Adapter Pattern: +The `useReduxAdapter()` hook creates an adapter that implements the `ExternalGraphStore` interface: +- `getSnapshot()`: Reads current state from Redux +- `subscribe()`: Subscribes to Redux store changes +- `setState()`: Dispatches Redux actions to update state + +**Important**: When using `externalStore`, GraphProvider captures **ALL** state changes, including position changes from dragging nodes. This is the key advantage over React-controlled mode! --- -## 9. Key terms +### External Store Integration: Zustand + +This example demonstrates how to integrate `@joint/react` with **Zustand**, a lightweight state management library. Zustand has a simple API with minimal boilerplate. + + + +{`\`\`\`tsx +${CodeControlledModeZustand} +\`\`\``} + + +#### How It Works: + +1. **Zustand Store**: Create a store with `create()` containing state and actions +2. **Adapter Hook**: Use `useZustandAdapter()` hook to convert Zustand store to `ExternalGraphStore` +3. **GraphProvider**: Pass the `externalStore` prop to `GraphProvider` +4. **Actions**: Use Zustand hooks to access actions in components + +#### Key Benefits: +- ✅ **Simple API**: Minimal boilerplate, no actions/reducers needed +- ✅ **TypeScript Support**: Full type safety out of the box +- ✅ **Lightweight**: Small bundle size +- ✅ **Easy Hooks**: Simple hooks for accessing state and actions +- ✅ **All Changes Synced**: Position changes from dragging are automatically captured + +--- + +### External Store Integration: Jotai + +This example demonstrates how to integrate `@joint/react` with **Jotai**, an atomic state management library. Jotai uses atoms as building blocks for state. + + + +{`\`\`\`tsx +${CodeControlledModeJotai} +\`\`\``} + + +#### How It Works: + +1. **Atoms**: Create atoms for elements and links using `atom()` +2. **Store**: Create a Jotai store to manage atom subscriptions +3. **Adapter Hook**: Use `useJotaiAdapter()` hook to convert atoms to `ExternalGraphStore` +4. **GraphProvider**: Pass the `externalStore` prop to `GraphProvider` +5. **Hooks**: Use `useAtom()` hooks to access and update atoms in components + +#### Key Benefits: +- ✅ **Atomic State**: Split state into small, independent pieces +- ✅ **No Providers**: Atoms work globally without providers +- ✅ **TypeScript Support**: Full type safety +- ✅ **Simple API**: Just atoms and hooks +- ✅ **All Changes Synced**: Position changes from dragging are automatically captured + +--- + +### External Store Integration: PeerJS (Collaborative Mode) + +This example demonstrates how to share graph state between multiple peers using **PeerJS** for real-time collaboration. Multiple users can connect and see each other's changes in real-time. + + + +{`\`\`\`tsx +${CodeControlledModePeerJS} +\`\`\``} + + +#### How It Works: -- **GraphProvider:** React context for graph data. -- **Paper:** Renders the graph visually. -- **Element:** Node in the graph. -- **Link:** Edge between nodes. -- **Port:** Named connection point on an element. -- **MeasuredNode:** Auto-measures and updates node size. +1. **PeerJS Store**: Create a custom store that implements `ExternalGraphStore` and syncs via PeerJS +2. **Peer Connection**: Each peer gets a unique ID and can connect to other peers +3. **State Sync**: When one peer updates the graph, changes are sent to all connected peers via WebRTC +4. **Bidirectional**: Changes sync in both directions - all peers see each other's updates + +#### Key Features: +- ✅ **Real-Time Collaboration**: Multiple users can edit the same graph simultaneously +- ✅ **Peer-to-Peer**: Direct browser-to-browser communication (no server needed except signaling) +- ✅ **All Changes Synced**: Position changes, additions, removals - everything syncs automatically +- ✅ **Copy ID Button**: Easy way to copy your peer ID to share with others +- ✅ **Connection Status**: Visual feedback showing connection state + +#### Usage: +1. Open this page in two browser windows/tabs +2. Each window gets a unique peer ID +3. Copy the ID from one window +4. Paste it into the "Enter peer ID to connect" field in the other window +5. Click "Connect" +6. Both peers are now connected and will see each other's changes in real-time! + +--- + +## Understanding State Management Modes + +`@joint/react` supports three modes of operation: + +### 1. Uncontrolled Mode (Default) +The graph manages its own state internally. Use this for simple, read-only diagrams. + +```tsx + + + +``` + +### 2. React-Controlled Mode +React state controls the graph. Use `onElementsChange` and `onLinksChange` props. + +```tsx +const [elements, setElements] = useState(initialElements); +const [links, setLinks] = useState(initialLinks); + + + + +``` + +**Note**: This mode captures changes from user interactions, but you need to ensure all updates go through React state. + +### 3. External Store Mode (Recommended for Complex Apps) +An external store (Redux, Zustand, Jotai, etc.) controls the graph. Use the `externalStore` prop. + +```tsx +const store = createExternalStore(); // Redux, Zustand, Jotai, etc. + + + + +``` + +**Why External Store Mode?** +- ✅ **Captures ALL changes**: Position changes from dragging are automatically captured +- ✅ **Better integration**: Works seamlessly with your existing state management +- ✅ **More control**: Full control over when and how state updates +- ✅ **Easier debugging**: Use your state management's dev tools + +#### The ExternalGraphStore Interface + +All external stores must implement this interface: + +```tsx +interface ExternalGraphStore { + getSnapshot(): GraphStoreSnapshot; // Returns current state + subscribe(listener: () => void): () => void; // Subscribe to changes + setState(updater: Update): void; // Update state +} +``` + +This interface is compatible with: +- Redux (via adapter) +- Zustand (via adapter) +- Jotai (via adapter) +- PeerJS (custom implementation) +- Any other store that implements these three methods + +--- + +## 9. Key Terms and Concepts + +### Core Components +- **GraphProvider:** React context provider for managing graph state. Supports three modes: uncontrolled, React-controlled, and external-store-controlled. +- **Paper:** The main rendering component that displays the graph visually. Can be used multiple times in one GraphProvider for features like minimaps. +- **Element (Node):** A visual item in your diagram (e.g., a rectangle, circle, or custom shape). +- **Link (Edge):** A connection between two elements. + +### State Management +- **Uncontrolled Mode:** Graph manages its own state internally. Best for simple, read-only diagrams. +- **React-Controlled Mode:** React state controls the graph via `onElementsChange`/`onLinksChange` props. Good for basic state management needs. +- **External Store Mode:** External state management (Redux, Zustand, Jotai, etc.) controls the graph via `externalStore` prop. **Recommended for complex apps** as it captures ALL changes including position updates. + +### Advanced Features +- **Port:** Named connection point on an element for precise linking. +- **MeasuredNode:** Utility component that auto-measures and updates node size based on content. - **useHTMLOverlay:** Renders HTML nodes outside SVG for full HTML support. +- **ExternalGraphStore:** Interface that allows any state management library to work with GraphProvider. Requires `getSnapshot()`, `subscribe()`, and `setState()` methods. + +--- + +## 10. Choosing the Right Mode + +### Use Uncontrolled Mode When: +- ✅ Simple, read-only diagrams +- ✅ No need to track state changes +- ✅ Performance is critical (fastest mode) + +### Use React-Controlled Mode When: +- ✅ You need basic state control +- ✅ You want to implement simple undo/redo +- ✅ You need to persist state to localStorage +- ⚠️ **Note**: Make sure all updates go through React state setters + +### Use External Store Mode When: +- ✅ **You need to capture ALL changes** (including position changes from dragging) +- ✅ You're already using Redux, Zustand, Jotai, or another state management library +- ✅ You need advanced features (undo/redo, persistence, collaboration) +- ✅ You want better integration with your app's state management +- ✅ You need time-travel debugging (Redux DevTools, etc.) + +**Recommendation**: For production apps, prefer External Store Mode as it provides the most reliable state synchronization. --- -## 10. More resources +## 11. More Resources - [@joint/react API Reference](https://github.com/clientIO/joint-plus/tree/main/packages/joint-react) - [JointJS Documentation](https://docs.jointjs.com/) diff --git a/packages/joint-react/src/stories/tutorials/step-by-step/story.tsx b/packages/joint-react/src/stories/tutorials/step-by-step/story.tsx index fa7f39724a..9b993d5927 100644 --- a/packages/joint-react/src/stories/tutorials/step-by-step/story.tsx +++ b/packages/joint-react/src/stories/tutorials/step-by-step/story.tsx @@ -4,6 +4,10 @@ import CodeSVG from './code-svg'; import CodeHTML from './code-html'; import CodeHTMLPortal from './code-html-renderer'; import CodeControlledMode from './code-controlled-mode'; +import CodeControlledModeRedux from './code-controlled-mode-redux'; +import CodeControlledModePeerJS from './code-controlled-mode-peerjs'; +import CodeControlledModeZustand from './code-controlled-mode-zustand'; +import CodeControlledModeJotai from './code-controlled-mode-jotai'; export type Story = StoryObj; @@ -25,3 +29,19 @@ export const HTMLRenderer: Story = { export const ControlledMode: Story = { render: CodeControlledMode as never, }; + +export const ControlledModeRedux: Story = { + render: CodeControlledModeRedux as never, +}; + +export const ControlledModeZustand: Story = { + render: CodeControlledModeZustand as never, +}; + +export const ControlledModeJotai: Story = { + render: CodeControlledModeJotai as never, +}; + +export const ControlledModePeerJS: Story = { + render: CodeControlledModePeerJS as never, +}; diff --git a/packages/joint-react/src/types/element-types.ts b/packages/joint-react/src/types/element-types.ts index a9d449465f..fdf5458f3b 100644 --- a/packages/joint-react/src/types/element-types.ts +++ b/packages/joint-react/src/types/element-types.ts @@ -1,5 +1,5 @@ import type { attributes, dia, shapes } from '@joint/core'; -import type { Ports } from '../utils/cell/get-cell'; +import type { Ports } from '../components'; export interface ReactElementAttributes { root?: attributes.SVGAttributes; @@ -28,38 +28,38 @@ export interface GraphElement { /** * Unique identifier of the element. */ - readonly id: dia.Cell.ID; + id: dia.Cell.ID; /** * Optional element type. * @default `REACT_TYPE` */ - readonly type?: string | keyof StandardShapesTypeMapper; + type?: string | keyof StandardShapesTypeMapper; /** * Ports of the element. */ - readonly ports?: Ports; + ports?: Ports; /** * X position of the element. */ - readonly x?: number; + x?: number; /** * Y position of the element. */ - readonly y?: number; + y?: number; /** * Optional width of the element. */ - readonly width?: number; + width?: number; /** * Optional height of the element. */ - readonly height?: number; + height?: number; /** * Optional markup of the element. */ - readonly markup?: string | dia.MarkupJSON; + markup?: string | dia.MarkupJSON; /** * Optional angle of the element. */ - readonly angle?: number; + angle?: number; } diff --git a/packages/joint-react/src/utils/__tests__/create-element-size-observer.test.ts b/packages/joint-react/src/utils/__tests__/create-element-size-observer.test.ts deleted file mode 100644 index 359be52f0a..0000000000 --- a/packages/joint-react/src/utils/__tests__/create-element-size-observer.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { waitFor } from '@testing-library/react'; -import { createElementSizeObserver } from '../create-element-size-observer'; - -describe('createElementSizeObserver', () => { - let mockElement: HTMLElement; - let mockObserve: jest.Mock; - let mockDisconnect: jest.Mock; - let originalResizeObserver: typeof ResizeObserver; - - beforeEach(() => { - // Mock element - mockElement = document.createElement('div'); - // @ts-expect-error: Mocking getBoundingClientRect - mockElement.getBoundingClientRect = jest.fn(() => ({ - width: 123, - height: 456, - // ...other properties - })); - - // Mock ResizeObserver - mockObserve = jest.fn(); - mockDisconnect = jest.fn(); - originalResizeObserver = globalThis.ResizeObserver; - (globalThis as any).ResizeObserver = jest.fn(function (cb) { - (this as any).cb = cb; - return { - observe: mockObserve, - disconnect: mockDisconnect, - }; - }); - }); - - afterEach(() => { - globalThis.ResizeObserver = originalResizeObserver; - jest.clearAllMocks(); - }); - - it('should call onResize immediately with element size', async () => { - const onResize = jest.fn(); - createElementSizeObserver(mockElement, onResize); - await waitFor(() => { - expect(onResize).toHaveBeenCalledWith({ width: 123, height: 456 }); - }); - }); - - it('should observe the element with border-box', () => { - createElementSizeObserver(mockElement, jest.fn()); - expect(mockObserve).toHaveBeenCalledWith(mockElement, { box: 'border-box' }); - }); - - it('should call onResize when ResizeObserver callback fires', () => { - const onResize = jest.fn(); - createElementSizeObserver(mockElement, onResize); - - // Simulate ResizeObserver callback - // eslint-disable-next-line prefer-destructuring - const instance = (ResizeObserver as jest.Mock).mock.instances[0]; - const { cb } = instance; - cb([ - { - borderBoxSize: [{ inlineSize: 200, blockSize: 100 }], - }, - ]); - expect(onResize).toHaveBeenCalledWith({ width: 200, height: 100 }); - }); - - it('should ignore entries with missing or empty borderBoxSize', async () => { - const onResize = jest.fn(); - createElementSizeObserver(mockElement, onResize); - - // eslint-disable-next-line prefer-destructuring - const instance = (ResizeObserver as jest.Mock).mock.instances[0]; - const { cb } = instance; - cb([{ borderBoxSize: undefined }]); - cb([{ borderBoxSize: [] }]); - // Only the initial call should be present - await waitFor(() => { - expect(onResize).toHaveBeenCalledTimes(1); - }); - }); - - it('should cleanup and disconnect observer', () => { - const cleanup = createElementSizeObserver(mockElement, jest.fn()); - cleanup(); - expect(mockDisconnect).toHaveBeenCalled(); - }); -}); diff --git a/packages/joint-react/src/utils/__tests__/create-state.test.ts b/packages/joint-react/src/utils/__tests__/create-state.test.ts new file mode 100644 index 0000000000..b3844b1599 --- /dev/null +++ b/packages/joint-react/src/utils/__tests__/create-state.test.ts @@ -0,0 +1,470 @@ +import { createState } from '../create-state'; + +describe('createState', () => { + describe('basic state management', () => { + it('should create state with initial value', () => { + const state = createState({ + name: 'test', + newState: () => ({ count: 0 }), + }); + expect(state.getSnapshot()).toEqual({ count: 0 }); + }); + + it('should update state with direct value', () => { + const state = createState({ + name: 'test', + newState: () => ({ count: 0 }), + }); + state.setState({ count: 5 }); + expect(state.getSnapshot()).toEqual({ count: 5 }); + }); + + it('should update state with updater function', () => { + const state = createState({ + name: 'test', + newState: () => ({ count: 0 }), + }); + state.setState((previous) => ({ ...previous, count: previous.count + 1 })); + expect(state.getSnapshot()).toEqual({ count: 1 }); + }); + + it('should handle many updates', () => { + const subscriber = jest.fn(); + const state = createState({ + name: 'test', + newState: () => ({ count: 0 }), + }); + state.subscribe(subscriber); + for (let index = 0; index < 1000; index++) { + state.setState((previous) => ({ ...previous, count: previous.count + 1 })); + } + expect(state.getSnapshot().count).toBe(1000); + expect(subscriber).toHaveBeenCalledTimes(1000); + }); + }); + + describe('subscribers', () => { + it('should notify subscribers on state change', () => { + const subscriber = jest.fn(); + const state = createState({ + name: 'test', + newState: () => ({ count: 0 }), + }); + state.subscribe(subscriber); + state.setState({ count: 1 }); + expect(subscriber).toHaveBeenCalledTimes(1); + }); + + it('should notify multiple subscribers', () => { + const subscriber1 = jest.fn(); + const subscriber2 = jest.fn(); + const subscriber3 = jest.fn(); + const state = createState({ + name: 'test', + newState: () => ({ count: 0 }), + }); + state.subscribe(subscriber1); + state.subscribe(subscriber2); + state.subscribe(subscriber3); + state.setState({ count: 1 }); + expect(subscriber1).toHaveBeenCalledTimes(1); + expect(subscriber2).toHaveBeenCalledTimes(1); + expect(subscriber3).toHaveBeenCalledTimes(1); + }); + + it('should allow unsubscribing', () => { + const subscriber1 = jest.fn(); + const subscriber2 = jest.fn(); + const state = createState({ + name: 'test', + newState: () => ({ count: 0 }), + }); + const unsubscribe1 = state.subscribe(subscriber1); + state.subscribe(subscriber2); + state.setState({ count: 1 }); + expect(subscriber1).toHaveBeenCalledTimes(1); + expect(subscriber2).toHaveBeenCalledTimes(1); + unsubscribe1(); + state.setState({ count: 2 }); + expect(subscriber1).toHaveBeenCalledTimes(1); + expect(subscriber2).toHaveBeenCalledTimes(2); + }); + + it('should not notify subscribers if state has not changed', () => { + const subscriber = jest.fn(); + const state = createState({ + name: 'test', + newState: () => ({ count: 0 }), + }); + state.subscribe(subscriber); + const sameObject = { count: 1 }; + state.setState(sameObject); + expect(subscriber).toHaveBeenCalledTimes(1); + state.setState(sameObject); + expect(subscriber).toHaveBeenCalledTimes(1); + }); + + it('should notify subscribers when state changes', () => { + const subscriber = jest.fn(); + const state = createState({ + name: 'test', + newState: () => ({ count: 0 }), + }); + state.subscribe(subscriber); + state.setState({ count: 1 }); + expect(subscriber).toHaveBeenCalledTimes(1); + expect(state.getSnapshot().count).toBe(1); + }); + }); + + describe('equality checks', () => { + it('should use default equality (deep equality with util.isEqual)', () => { + const subscriber = jest.fn(); + const state = createState({ + name: 'test', + newState: () => ({ count: 0 }), + }); + state.subscribe(subscriber); + // First setState with different value should notify + state.setState({ count: 1 }); + expect(subscriber).toHaveBeenCalledTimes(1); + // Second setState with same value (deep equal) should not notify + state.setState({ count: 1 }); + // With util.isEqual, same value won't trigger another notification + expect(subscriber).toHaveBeenCalledTimes(1); + }); + + it('should use custom equality function', () => { + const subscriber = jest.fn(); + const state = createState({ + name: 'test', + newState: () => ({ count: 0, name: 'test' }), + isEqual: (a, b) => a.count === b.count, + }); + state.subscribe(subscriber); + state.setState({ count: 0, name: 'changed' }); + expect(subscriber).not.toHaveBeenCalled(); + state.setState({ count: 1, name: 'test' }); + expect(subscriber).toHaveBeenCalledTimes(1); + }); + + it('should not notify when custom equality returns true', () => { + const subscriber = jest.fn(); + const state = createState({ + name: 'test', + newState: () => ({ count: 0 }), + isEqual: () => true, + }); + state.subscribe(subscriber); + state.setState({ count: 100 }); + expect(subscriber).not.toHaveBeenCalled(); + }); + }); + + describe('state updates', () => { + it('should update state with updater function', () => { + const subscriber = jest.fn(); + const state = createState({ + name: 'test', + newState: () => ({ count: 0 }), + }); + state.subscribe(subscriber); + state.setState((previous) => ({ ...previous, count: previous.count + 1 })); + expect(state.getSnapshot().count).toBe(1); + expect(subscriber).toHaveBeenCalledTimes(1); + }); + + it('should handle multiple state updates', () => { + const state = createState({ + name: 'test', + newState: () => ({ count: 0, previous: 0 }), + }); + state.setState({ count: 5, previous: 0 }); + const snapshot = state.getSnapshot(); + expect(snapshot.count).toBe(5); + expect(snapshot.previous).toBe(0); + }); + }); + + describe('selectors', () => { + it('should create selector that tracks selected state', () => { + const subscriber = jest.fn(); + const state = createState({ + name: 'test', + newState: () => ({ count: 0, name: 'test' }), + }); + const selectorState = state.select( + 'count', + (s) => s.count, + (a, b) => a === b + ); + selectorState.subscribe(subscriber); + expect(selectorState.getSnapshot()).toBe(0); + state.setState({ count: 5, name: 'test' }); + expect(selectorState.getSnapshot()).toBe(5); + expect(subscriber).toHaveBeenCalledTimes(1); + }); + + it('should not notify selector if selected value has not changed', () => { + const subscriber = jest.fn(); + const state = createState({ + name: 'test', + newState: () => ({ count: 0, name: 'test' }), + }); + const selectorState = state.select( + 'count', + (s) => s.count, + (a, b) => a === b + ); + selectorState.subscribe(subscriber); + state.setState({ count: 0, name: 'changed' }); + expect(subscriber).not.toHaveBeenCalled(); + state.setState({ count: 1, name: 'test' }); + expect(subscriber).toHaveBeenCalledTimes(1); + }); + + it('should use custom equality for selector', () => { + const subscriber = jest.fn(); + const state = createState({ + name: 'test', + newState: () => ({ user: { id: 1, name: 'Alice' } }), + }); + const selectorState = state.select( + 'user', + (s) => s.user, + (a, b) => a.id === b.id + ); + selectorState.subscribe(subscriber); + state.setState({ user: { id: 1, name: 'Bob' } }); + expect(subscriber).not.toHaveBeenCalled(); + state.setState({ user: { id: 2, name: 'Alice' } }); + expect(subscriber).toHaveBeenCalledTimes(1); + }); + + it('should allow multiple selectors on same state', () => { + const subscriber1 = jest.fn(); + const subscriber2 = jest.fn(); + const state = createState({ + name: 'test', + newState: () => ({ count: 0, name: 'test' }), + }); + const countSelector = state.select( + 'count', + (s) => s.count, + (a, b) => a === b + ); + const nameSelector = state.select( + 'name', + (s) => s.name, + (a, b) => a === b + ); + countSelector.subscribe(subscriber1); + nameSelector.subscribe(subscriber2); + state.setState({ count: 5, name: 'test' }); + expect(subscriber1).toHaveBeenCalledTimes(1); + expect(subscriber2).not.toHaveBeenCalled(); + state.setState({ count: 5, name: 'changed' }); + expect(subscriber1).toHaveBeenCalledTimes(1); + expect(subscriber2).toHaveBeenCalledTimes(1); + }); + + it('should allow unsubscribing from selector', () => { + const subscriber = jest.fn(); + const state = createState({ + name: 'test', + newState: () => ({ count: 0 }), + }); + const selectorState = state.select( + 'count', + (s) => s.count, + (a, b) => a === b + ); + const unsubscribe = selectorState.subscribe(subscriber); + state.setState({ count: 5 }); + expect(selectorState.getSnapshot()).toBe(5); + expect(subscriber).toHaveBeenCalledTimes(1); + unsubscribe(); + state.setState({ count: 10 }); + expect(selectorState.getSnapshot()).toBe(10); + expect(subscriber).toHaveBeenCalledTimes(1); + }); + + it('should handle nested selectors', () => { + const subscriber = jest.fn(); + const state = createState({ + name: 'test', + newState: () => ({ user: { profile: { name: 'Alice' } } }), + }); + const userSelector = state.select( + 'user', + (s) => s.user, + (a, b) => a === b + ); + const nameSelector = userSelector.select( + 'name', + (u: { profile: { name: string } }) => u.profile.name, + (a, b) => a === b + ); + nameSelector.subscribe(subscriber); + state.setState({ user: { profile: { name: 'Bob' } } }); + expect(nameSelector.getSnapshot()).toBe('Bob'); + expect(subscriber).toHaveBeenCalledTimes(1); + }); + }); + + describe('reset and clean', () => { + it('should reset state to initial value', () => { + const subscriber = jest.fn(); + const state = createState({ + name: 'test', + newState: () => ({ count: 0 }), + }); + state.subscribe(subscriber); + state.setState({ count: 10 }); + expect(state.getSnapshot().count).toBe(10); + state.clean(); + expect(state.getSnapshot().count).toBe(0); + expect(subscriber).toHaveBeenCalledTimes(1); + }); + + it('should clean state and clear subscribers', () => { + const subscriber1 = jest.fn(); + const subscriber2 = jest.fn(); + const state = createState({ + name: 'test', + newState: () => ({ count: 0 }), + }); + state.subscribe(subscriber1); + state.subscribe(subscriber2); + state.setState({ count: 5 }); + expect(subscriber1).toHaveBeenCalledTimes(1); + expect(subscriber2).toHaveBeenCalledTimes(1); + state.clean(); + expect(state.getSnapshot().count).toBe(0); + state.setState({ count: 10 }); + expect(subscriber1).toHaveBeenCalledTimes(1); + expect(subscriber2).toHaveBeenCalledTimes(1); + expect(state.getSnapshot().count).toBe(10); + }); + }); + + describe('getAreComponentsNotified', () => { + it('should return false initially', () => { + const state = createState({ + name: 'test', + newState: () => ({ count: 0 }), + }); + expect(state.getAreComponentsNotified()).toBe(false); + }); + + it('should return true after notifying subscribers', () => { + const subscriber = jest.fn(); + const state = createState({ + name: 'test', + newState: () => ({ count: 0 }), + }); + state.subscribe(subscriber); + state.setState({ count: 1 }); + expect(state.getAreComponentsNotified()).toBe(true); + }); + + it('should return true after state update and notification', () => { + const subscriber = jest.fn(); + const state = createState({ + name: 'test', + newState: () => ({ count: 0 }), + }); + state.subscribe(subscriber); + state.setState({ count: 1 }); + expect(state.getAreComponentsNotified()).toBe(true); + }); + + it('should return false when state does not change', () => { + const subscriber = jest.fn(); + const state = createState({ + name: 'test', + newState: () => ({ count: 0 }), + }); + state.subscribe(subscriber); + const sameObject = { count: 1 }; + state.setState(sameObject); + expect(state.getAreComponentsNotified()).toBe(true); + state.setState(sameObject); + expect(state.getAreComponentsNotified()).toBe(false); + }); + + it('should reset to false on new state update', () => { + const subscriber = jest.fn(); + const state = createState({ + name: 'test', + newState: () => ({ count: 0 }), + }); + state.subscribe(subscriber); + state.setState({ count: 1 }); + expect(state.getAreComponentsNotified()).toBe(true); + state.setState({ count: 2 }); + expect(state.getAreComponentsNotified()).toBe(true); + }); + }); + + describe('edge cases', () => { + it('should handle primitive state values', () => { + const subscriber = jest.fn(); + const state = createState({ + name: 'test', + newState: () => 0, + }); + state.subscribe(subscriber); + state.setState(5); + expect(state.getSnapshot()).toBe(5); + expect(subscriber).toHaveBeenCalledTimes(1); + }); + + it('should handle array state values', () => { + const subscriber = jest.fn(); + const state = createState({ + name: 'test', + newState: () => [1, 2, 3], + }); + state.subscribe(subscriber); + state.setState([4, 5, 6]); + expect(state.getSnapshot()).toEqual([4, 5, 6]); + expect(subscriber).toHaveBeenCalledTimes(1); + }); + + it('should handle null and undefined states', () => { + const subscriber = jest.fn(); + const state = createState({ + name: 'test', + newState: () => null as null | string, + }); + state.subscribe(subscriber); + state.setState('test'); + expect(state.getSnapshot()).toBe('test'); + expect(subscriber).toHaveBeenCalledTimes(1); + }); + + it('should handle complex nested objects', () => { + const subscriber = jest.fn(); + const state = createState({ + name: 'test', + newState: () => ({ + users: [{ id: 1, name: 'Alice' }], + metadata: { version: 1 }, + }), + }); + state.subscribe(subscriber); + state.setState({ + users: [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ], + metadata: { version: 2 }, + }); + const snapshot = state.getSnapshot(); + expect(snapshot.users).toHaveLength(2); + expect(snapshot.metadata.version).toBe(2); + expect(subscriber).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/joint-react/src/utils/__tests__/get-cell.test.ts b/packages/joint-react/src/utils/__tests__/get-cell.test.ts index c10f90744e..ead82624d2 100644 --- a/packages/joint-react/src/utils/__tests__/get-cell.test.ts +++ b/packages/joint-react/src/utils/__tests__/get-cell.test.ts @@ -1,7 +1,7 @@ -import { getElement, getLink } from '../cell/get-cell'; +import { elementFromGraph, linkFromGraph } from '../cell/cell-utilities'; import type { dia } from '@joint/core'; -describe('getCell', () => { +describe('cell utilities', () => { let mockCell: dia.Cell; beforeEach(() => { @@ -15,29 +15,24 @@ describe('getCell', () => { ports: { items: [] }, }, get: jest.fn((key) => { - const mockData = { + const mockData: Record = { source: 'source-id', target: 'target-id', z: 1, markup: '', defaultLabel: 'default-label', ports: { items: [] }, - size: { width: 100, height: 50 }, - position: { x: 10, y: 20 }, - data: { key: 'value' }, }; - // @ts-expect-error its just mock return mockData[key]; }), } as unknown as dia.Cell; }); - describe('getElement', () => { + describe('elementFromGraph', () => { it('should extract element attributes correctly', () => { - const element = getElement(mockCell); - expect(element).toEqual({ + const element = elementFromGraph(mockCell); + expect(element).toMatchObject({ id: 'mock-id', - data: { key: 'value' }, type: 'mock-type', ports: { items: [] }, x: 10, @@ -48,10 +43,10 @@ describe('getCell', () => { }); }); - describe('getLink', () => { + describe('linkFromGraph', () => { it('should extract link attributes correctly', () => { - const link = getLink(mockCell); - expect(link).toEqual({ + const link = linkFromGraph(mockCell); + expect(link).toMatchObject({ id: 'mock-id', source: 'source-id', target: 'target-id', @@ -59,10 +54,10 @@ describe('getCell', () => { z: 1, markup: '', defaultLabel: 'default-label', - ports: { items: [] }, + data: { key: 'value' }, size: { width: 100, height: 50 }, position: { x: 10, y: 20 }, - data: { key: 'value' }, + ports: { items: [] }, }); }); }); diff --git a/packages/joint-react/src/utils/__tests__/is-react-element.test.ts b/packages/joint-react/src/utils/__tests__/is-react-element.test.ts index 13e9cc9cdd..d61f66e249 100644 --- a/packages/joint-react/src/utils/__tests__/is-react-element.test.ts +++ b/packages/joint-react/src/utils/__tests__/is-react-element.test.ts @@ -42,3 +42,12 @@ describe('is-react-element', () => { expect(isReactElement([])).toBe(false); }); }); + + + + + + + + + diff --git a/packages/joint-react/src/utils/__tests__/is.test.ts b/packages/joint-react/src/utils/__tests__/is.test.ts index 35a34d559c..8100e9c720 100644 --- a/packages/joint-react/src/utils/__tests__/is.test.ts +++ b/packages/joint-react/src/utils/__tests__/is.test.ts @@ -54,12 +54,6 @@ describe('is.ts utility functions', () => { expect(is.isCellInstance({})).toBe(false); }); - test('isUnsized', () => { - expect(is.isUnsized(undefined, 10)).toBe(true); - expect(is.isUnsized(10, undefined)).toBe(true); - expect(is.isUnsized(10, 10)).toBe(false); - }); - test('hasChildren', () => { expect(is.hasChildren({ children: [] })).toBe(true); expect(is.hasChildren({})).toBe(false); diff --git a/packages/joint-react/src/utils/__tests__/noop-selector.test.ts b/packages/joint-react/src/utils/__tests__/noop-selector.test.ts index b8a9b84491..aa37a53b2d 100644 --- a/packages/joint-react/src/utils/__tests__/noop-selector.test.ts +++ b/packages/joint-react/src/utils/__tests__/noop-selector.test.ts @@ -30,3 +30,12 @@ describe('noop-selector', () => { expect(result).toBe(object); }); }); + + + + + + + + + diff --git a/packages/joint-react/src/utils/__tests__/subscriber-handler.test.ts b/packages/joint-react/src/utils/__tests__/subscriber-handler.test.ts deleted file mode 100644 index 275aca3964..0000000000 --- a/packages/joint-react/src/utils/__tests__/subscriber-handler.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { subscribeHandler } from '../subscriber-handler'; - -describe('subscriber-handler', () => { - beforeEach(() => { - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('should subscribe and notify subscribers', async () => { - const handler = subscribeHandler(); - const subscriber = jest.fn(); - - const unsubscribe = handler.subscribe(subscriber); - handler.notifySubscribers(); - - // Flush promises and timers - await Promise.resolve(); - jest.runAllTimers(); - - expect(subscriber).toHaveBeenCalled(); - unsubscribe(); - }); - - it('should unsubscribe correctly', async () => { - const handler = subscribeHandler(); - const subscriber = jest.fn(); - - const unsubscribe = handler.subscribe(subscriber); - unsubscribe(); - handler.notifySubscribers(); - - await Promise.resolve(); - jest.runAllTimers(); - - expect(subscriber).not.toHaveBeenCalled(); - }); - - it('should coalesce multiple notifications in the same frame', async () => { - const handler = subscribeHandler(); - const subscriber = jest.fn(); - - handler.subscribe(subscriber); - handler.notifySubscribers(); - handler.notifySubscribers(); - handler.notifySubscribers(); - - await Promise.resolve(); - jest.runAllTimers(); - - expect(subscriber).toHaveBeenCalledTimes(1); - }); - - it('should handle different batch names separately', async () => { - const handler = subscribeHandler(); - const subscriber = jest.fn(); - - handler.subscribe(subscriber); - handler.notifySubscribers('batch1'); - handler.notifySubscribers('batch2'); - - await Promise.resolve(); - jest.runAllTimers(); - - expect(subscriber).toHaveBeenCalledTimes(2); - }); - - it('should call beforeSubscribe with batch name', async () => { - const beforeSubscribe = jest.fn(() => undefined as never); - const handler = subscribeHandler(beforeSubscribe); - const subscriber = jest.fn(); - - handler.subscribe(subscriber); - handler.notifySubscribers('test-batch'); - - await Promise.resolve(); - jest.runAllTimers(); - - expect(beforeSubscribe).toHaveBeenCalledWith('test-batch'); - }); - - it('should pass UpdateResult from beforeSubscribe to subscribers', async () => { - const updateResult = { - diffIds: new Set(['1', '2']), - areElementsChanged: true, - areLinksChanged: false, - }; - const beforeSubscribe = jest.fn(() => updateResult); - const handler = subscribeHandler(beforeSubscribe); - const subscriber = jest.fn(); - - handler.subscribe(subscriber); - handler.notifySubscribers(); - - await Promise.resolve(); - jest.runAllTimers(); - - expect(subscriber).toHaveBeenCalledWith(updateResult); - }); - - it('should handle multiple subscribers', async () => { - const handler = subscribeHandler(); - const subscriber1 = jest.fn(); - const subscriber2 = jest.fn(); - const subscriber3 = jest.fn(); - - handler.subscribe(subscriber1); - handler.subscribe(subscriber2); - handler.subscribe(subscriber3); - handler.notifySubscribers(); - - await Promise.resolve(); - jest.runAllTimers(); - - expect(subscriber1).toHaveBeenCalled(); - expect(subscriber2).toHaveBeenCalled(); - expect(subscriber3).toHaveBeenCalled(); - }); -}); diff --git a/packages/joint-react/src/utils/cell/__tests__/cell-utilities.test.ts b/packages/joint-react/src/utils/cell/__tests__/cell-utilities.test.ts deleted file mode 100644 index e76ca36afe..0000000000 --- a/packages/joint-react/src/utils/cell/__tests__/cell-utilities.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { ReactElement } from '../../../models/react-element'; -import { createElements } from '../../create'; -import { setElements } from '../cell-utilities'; -import { dia } from '@joint/core'; - -// Mocks - -describe('cell-utilities', () => { - it('set elements', () => { - const graph = new dia.Graph({}, { cellNamespace: { ReactElement } }); - const elements = createElements([{ id: '1' }, { id: '2' }]); - setElements({ graph, elements }); - expect(graph.getElements().length).toBe(2); - setElements({ graph, elements: [] }); - expect(graph.getElements().length).toBe(0); - // add new again - setElements({ graph, elements }); - expect(graph.getElements().length).toBe(2); - - // update - - const updatedElements = createElements([ - { id: '1', color: 'red' }, - { id: '2', color: 'blue' }, - ]); - setElements({ graph, elements: updatedElements }); - expect(graph.getElements().length).toBe(2); - expect(graph.getCell('1').get('color')).toBe('red'); - expect(graph.getCell('2').get('color')).toBe('blue'); - }); -}); diff --git a/packages/joint-react/src/utils/cell/__tests__/get-link-targe-and-source-ids.test.ts b/packages/joint-react/src/utils/cell/__tests__/get-link-targe-and-source-ids.test.ts index 0d508617b1..8f085a200c 100644 --- a/packages/joint-react/src/utils/cell/__tests__/get-link-targe-and-source-ids.test.ts +++ b/packages/joint-react/src/utils/cell/__tests__/get-link-targe-and-source-ids.test.ts @@ -58,3 +58,12 @@ describe('get-link-targe-and-source-ids', () => { }); }); }); + + + + + + + + + diff --git a/packages/joint-react/src/utils/cell/cell-utilities.ts b/packages/joint-react/src/utils/cell/cell-utilities.ts index 448fb3da88..6c96060522 100644 --- a/packages/joint-react/src/utils/cell/cell-utilities.ts +++ b/packages/joint-react/src/utils/cell/cell-utilities.ts @@ -1,31 +1,19 @@ -import type { dia } from '@joint/core'; +import { util, type dia } from '@joint/core'; import { REACT_TYPE } from '../../models/react-element'; import type { GraphLink } from '../../types/link-types'; import type { GraphElement } from '../../types/element-types'; -import { isCellInstance, isLinkInstance, isUnsized } from '../is'; +import { isCellInstance, isLinkInstance } from '../is'; import { getTargetOrSource } from './get-link-targe-and-source-ids'; -import { isReactElement } from '../is-react-element'; -import { updateGraph } from '../graph/update-graph'; export type CellOrJsonCell = dia.Cell | dia.Cell.JSON; + /** - * Process a link: convert GraphLink to a standard JointJS link if needed. - * @param link - The link to process. - * @group utils - * @description - * This function is used to process a link and convert it to a standard JointJS link if needed. - * It also converts the source and target of the link to a standard format. - * @returns - * A standard JointJS link or a JSON representation of the link. - * @example - * ```ts - * import { processLink } from '@joint/react'; - * - * const link = { id: '1', source: 'a', target: 'b', type: 'standard.Link' }; - * const processed = processLink(link); - * ``` + * Converts a link to a graph cell. + * @param link - The link to convert. + * @param graph - The graph instance. + * @returns The cell or JSON cell representation. */ -export function processLink(link: dia.Link | GraphLink): CellOrJsonCell { +export function linkToGraph(link: dia.Link | GraphLink, graph: dia.Graph): CellOrJsonCell { if (isLinkInstance(link)) { const json = link.toJSON(); @@ -40,60 +28,29 @@ export function processLink(link: dia.Link | GraphLink): CellOrJsonCell { const source = getTargetOrSource(link.source); const target = getTargetOrSource(link.target); + const { attrs, type = 'standard.Link', ...rest } = link; + + // TODO: this is not optimal solution + const defaults = util.result( + util.getByPath(graph.layerCollection.cellNamespace, type, '.').prototype, + 'defaults', + {} + ); + + const mergedLink = { + ...rest, + type, + attrs: util.defaultsDeep({}, attrs as never, defaults.attrs), + }; return { - ...link, + ...mergedLink, type: link.type ?? 'standard.Link', source, target, } as dia.Cell.JSON; } -export interface SetLinksOptions { - readonly graph: dia.Graph; - readonly links?: Array; -} - -/** - * Set links to the graph. - * @param options - The options for setting links. - * @group utils - * @description - * This function is used to set links to the graph. - * It processes the links and adds them to the graph. - * It also converts the source and target of the links to a standard format. - * @example - * ```ts - * import { setLinks } from '@joint/react'; - * import type { dia } from '@joint/core'; - * - * const graph = new dia.Graph(); - * const links = [ - * { id: '1', source: 'a', target: 'b' }, - * { id: '2', source: 'b', target: 'c' }, - * ]; - * setLinks({ graph, links }); - * ``` - */ -export function setLinks(options: SetLinksOptions) { - const { graph, links } = options; - if (links === undefined) { - return; - } - - updateGraph({ - graph, - cells: links.map((item) => { - const link = processLink(item); - if (link.z === undefined) { - link.z = 0; - } - return link; - }), - isLink: true, - }); -} - /** * Process an element: create a ReactElement if applicable, otherwise a standard Cell. * @param element - The element to process. @@ -114,22 +71,11 @@ export function setLinks(options: SetLinksOptions) { * const processed = processElement(element, unsizedIds); * ``` */ -export function processElement( - element: T, - unsizedIds?: Set -): CellOrJsonCell { - const stringId = String(element.id); +export function elementToGraph(element: T): CellOrJsonCell { if (isCellInstance(element)) { - const size = element.size(); - if (isReactElement(element) && isUnsized(size.width, size.height)) { - unsizedIds?.add(stringId); - } return element; } const { type = REACT_TYPE, x, y, width, height } = element; - if (isUnsized(width, height)) { - unsizedIds?.add(stringId); - } return { type, @@ -139,43 +85,97 @@ export function processElement( } as dia.Cell.JSON; } -export interface SetElementsOptions { - readonly graph: dia.Graph; - readonly elements?: Array; +export interface Ports { + readonly groups?: Record; + readonly items?: dia.Element.Port[]; } +export type GraphCell = Element | GraphLink; + /** - * Set elements to the graph. - * @param options - The options for setting elements. + * Get element via cell + * @param cell - The cell to get the element from. + * @returns - The element. * @group utils + * @private * @description - * This function is used to set elements to the graph. - * @returns A set of unsized element IDs. - * It processes the elements and adds them to the graph. - * It also checks for unsized elements and returns their IDs. + * This function is used to get an element from a cell. + * It extracts the size, position, and attributes from the cell and returns them as an element. + * It also adds the id, isElement, isLink, data, type, and ports to the element. * @example * ```ts - * import { setElements } from '@joint/react'; - * import type { dia } from '@joint/core'; - * - * const graph = new dia.Graph(); - * const elements = [ - * { id: '1', x: 10, y: 20, width: 100, height: 50 }, - * { id: '2', x: 150, y: 20, width: 100, height: 50 }, - * ]; - * const unsizedIds = setElements({ graph, elements }); + * const element = getElement(cell); + * console.log(element); * ``` */ -export function setElements(options: SetElementsOptions) { - const { graph, elements } = options; - if (elements === undefined) { - return new Set(); +export function elementFromGraph( + cell: dia.Cell +): Element { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { size, position, data, attrs, type, ...attributes } = cell.attributes; + const element: GraphElement = { + ...attributes, + ...position, + ...size, + id: cell.id, + ports: cell.get('ports'), + }; + if (type !== REACT_TYPE) { + element.type = type; } - const unsizedIds = new Set(); - updateGraph({ - graph, - cells: elements.map((item) => processElement(item, unsizedIds)), - isLink: false, - }); - return unsizedIds; + + return element as Element; +} + +/** + * Get link via cell + * @param cell - The cell to get the link from. + * @returns - The link. + * @group utils + * @private + * @description + * This function is used to get a link from a cell. + * It extracts the source, target, and attributes from the cell and returns them as a link. + * It also adds the id, isElement, isLink, type, z, markup, and defaultLabel to the link. + * @example + * ```ts + * const link = getLink(cell); + * console.log(link); + * ``` + */ +export function linkFromGraph( + cell: dia.Cell +): Link { + return { + ...cell.attributes, + id: cell.id, + source: cell.get('source') as dia.Cell.ID, + target: cell.get('target') as dia.Cell.ID, + type: cell.attributes.type, + z: cell.get('z'), + markup: cell.get('markup'), + defaultLabel: cell.get('defaultLabel'), + } as Link; +} + +export interface SyncGraphOptions { + readonly graph: dia.Graph; + readonly elements?: Array; + readonly links?: Array; +} + +/** + * Synchronizes elements and links with the graph. + * @param options - The options containing graph, elements, and links. + */ +export function syncGraph(options: SyncGraphOptions) { + const { graph, elements = [], links = [] } = options; + const items = [ + ...elements.map((element) => elementToGraph(element)), + ...links.map((link) => linkToGraph(link, graph)), + ]; + + // syncCells already wraps itself in a batch internally (see joint-core Graph.mjs:428-459) + // So we don't need to wrap it again - it will trigger batch:start and batch:stop events + graph.syncCells(items, { remove: true }); } diff --git a/packages/joint-react/src/utils/cell/get-cell.ts b/packages/joint-react/src/utils/cell/get-cell.ts deleted file mode 100644 index da4811cedc..0000000000 --- a/packages/joint-react/src/utils/cell/get-cell.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { type dia } from '@joint/core'; -import type { GraphElement } from '../../types/element-types'; -import type { GraphLink } from '../../types/link-types'; - -export interface Ports { - readonly groups?: Record; - readonly items?: dia.Element.Port[]; -} - -export type GraphCell = Element | GraphLink; - -/** - * Get element via cell - * @param cell - The cell to get the element from. - * @returns - The element. - * @group utils - * @private - * @description - * This function is used to get an element from a cell. - * It extracts the size, position, and attributes from the cell and returns them as an element. - * It also adds the id, isElement, isLink, data, type, and ports to the element. - * @example - * ```ts - * const element = getElement(cell); - * console.log(element); - * ``` - */ -export function getElement(cell: dia.Cell): Element { - const { size, position, ...attributes } = cell.attributes; - return { - ...attributes, - ...position, - ...size, - id: cell.id, - data: cell.attributes.data, - type: cell.attributes.type, - ports: cell.get('ports'), - }; -} - -/** - * Get link via cell - * @param cell - The cell to get the link from. - * @returns - The link. - * @group utils - * @private - * @description - * This function is used to get a link from a cell. - * It extracts the source, target, and attributes from the cell and returns them as a link. - * It also adds the id, isElement, isLink, type, z, markup, and defaultLabel to the link. - * @example - * ```ts - * const link = getLink(cell); - * console.log(link); - * ``` - */ -export function getLink(cell: dia.Cell): GraphLink { - return { - ...cell.attributes, - id: cell.id, - source: cell.get('source') as dia.Cell.ID, - target: cell.get('target') as dia.Cell.ID, - type: cell.attributes.type, - z: cell.get('z'), - markup: cell.get('markup'), - defaultLabel: cell.get('defaultLabel'), - }; -} - -/** - * Get cell via cell - * @param cell - The cell to get the cell from. - * @returns - The cell. - * @group utils - * @private - * @description - * This function is used to get a cell from a cell. - * It checks if the cell is an element or a link and returns the appropriate value. - * @example - * ```ts - * const cell = getCell(cell); - * console.log(cell); - * ``` - */ -export function getCell( - cell: dia.Cell -): GraphCell { - if (cell.isElement()) { - return getElement(cell); - } - return getLink(cell); -} diff --git a/packages/joint-react/src/utils/cell/listen-to-cell-change.ts b/packages/joint-react/src/utils/cell/listen-to-cell-change.ts index 99453a8aad..38813a30c8 100644 --- a/packages/joint-react/src/utils/cell/listen-to-cell-change.ts +++ b/packages/joint-react/src/utils/cell/listen-to-cell-change.ts @@ -1,6 +1,24 @@ import { mvc, type dia } from '@joint/core'; -export type ChangeEvent = 'change' | 'add' | 'remove'; +export type ChangeEvent = 'change' | 'add' | 'remove' | 'reset'; + +interface OnChangeOptionsBase { + readonly type: ChangeEvent; + readonly cells?: dia.Cell[]; + readonly cell?: dia.Cell; +} +interface OnChangeOptionsUpdate extends OnChangeOptionsBase { + readonly type: 'change' | 'add' | 'remove'; + readonly cell: dia.Cell; +} +interface OnResetOptionsReset extends OnChangeOptionsBase { + readonly type: 'reset'; + readonly cells: dia.Cell[]; +} +export type OnChangeOptions = OnChangeOptionsUpdate | OnResetOptionsReset; + +type OnChangeHandler = (options: OnChangeOptions) => void; + /** * Listens to changes in the graph's cells and triggers the provided callback. * @group Cell @@ -10,14 +28,19 @@ export type ChangeEvent = 'change' | 'add' | 'remove'; */ export function listenToCellChange( graph: dia.Graph, - handleCellsChange: (cell: dia.Cell, type: ChangeEvent) => void + handleCellsChange: OnChangeHandler ): () => void { const controller = new mvc.Listener(); - controller.listenTo(graph, 'change', (cell: dia.Cell) => handleCellsChange(cell, 'change')); - controller.listenTo(graph, 'add', (cell: dia.Cell) => handleCellsChange(cell, 'add')); - controller.listenTo(graph, 'remove', (cell: dia.Cell) => { - handleCellsChange(cell, 'remove'); - }); + controller.listenTo(graph, 'change', (cell: dia.Cell) => + handleCellsChange({ type: 'change', cell }) + ); + controller.listenTo(graph, 'add', (cell: dia.Cell) => handleCellsChange({ type: 'add', cell })); + controller.listenTo(graph, 'remove', (cell: dia.Cell) => + handleCellsChange({ type: 'remove', cell }) + ); + controller.listenTo(graph, 'reset', (cells: dia.Cell[]) => + handleCellsChange({ type: 'reset', cells }) + ); return () => controller.stopListening(); } diff --git a/packages/joint-react/src/utils/create-element-size-observer.ts b/packages/joint-react/src/utils/create-element-size-observer.ts deleted file mode 100644 index 6d030e2aa4..0000000000 --- a/packages/joint-react/src/utils/create-element-size-observer.ts +++ /dev/null @@ -1,101 +0,0 @@ -export interface SizeObserver { - readonly width: number; - readonly height: number; -} - -// Epsilon value to avoid jitter due to sub-pixel rendering -const EPSILON = 0.5; - -/** - * Create element size observer with cleanup function. - * It uses ResizeObserver to observe changes in the size of the HTML element. - * @param element The HTML element to observe. - * @param onResize The callback function to call when the size of the element changes. - * @group Utils - * @returns A cleanup function to disconnect the observer. - * @example - * ```tsx - * const element = document.getElementById('element-id'); - * const onResize = (position) => { - * console.log('Element size changed:', position); - * }; - * const cleanup = createElementSizeObserver(element, onResize); - * ``` - */ -export function createElementSizeObserver( - element: AnyHTMLOrSVGElement | null | undefined, - onResize: (position: SizeObserver) => void -) { - // Safety check: return no-op cleanup if element is invalid - if (!element) { - return () => { - // No-op cleanup - }; - } - - let isCleanedUp = false; - let previousSize: SizeObserver | null = null; - - // Helper to check if size has meaningfully changed - const hasSizeChanged = (newSize: SizeObserver): boolean => { - if (previousSize === null) { - return true; - } - return ( - Math.abs(previousSize.width - newSize.width) >= EPSILON || - Math.abs(previousSize.height - newSize.height) >= EPSILON - ); - }; - - // Helper to safely call onResize only if size changed and not cleaned up - const safeOnResize = (size: SizeObserver): void => { - if (isCleanedUp) { - return; - } - if (hasSizeChanged(size)) { - previousSize = size; - onResize(size); - } - }; - - // Create a ResizeObserver to observe changes in the size of the HTML element. - const observer = new ResizeObserver((entries) => { - if (isCleanedUp) { - return; - } - - for (const entry of entries) { - const { borderBoxSize } = entry; - - // If borderBoxSize is not available or empty, continue to the next entry. - if (!borderBoxSize || borderBoxSize.length === 0) continue; - - const [size] = borderBoxSize; - const { inlineSize, blockSize } = size; - safeOnResize({ width: inlineSize, height: blockSize }); - break; // We only care about the first entry - } - }); - - // Trigger initial measurement - requestAnimationFrame(() => { - if (isCleanedUp || !element) { - return; - } - - const rect = element.getBoundingClientRect(); - const { width, height } = rect; - if (width > 0 && height > 0) { - safeOnResize({ width, height }); - } - }); - - // Start observing the HTML element. - observer.observe(element, { box: 'border-box' }); - - // Cleanup function to disconnect the observer when the component unmounts or dependencies change. - return () => { - isCleanedUp = true; - observer.disconnect(); - }; -} diff --git a/packages/joint-react/src/utils/create-state.ts b/packages/joint-react/src/utils/create-state.ts new file mode 100644 index 0000000000..093c843a4c --- /dev/null +++ b/packages/joint-react/src/utils/create-state.ts @@ -0,0 +1,234 @@ +import ReactDOM from 'react-dom'; +import { sendToDevTool } from './dev-tools'; +import { util } from '@joint/core'; +import { isUpdater } from './is'; + +/** + * Update function or direct value for state updates. + * Can be either a function that receives the previous state and returns the new state, + * or a direct value to replace the current state. + */ +export type Update = ((previous: T) => T) | T; + +/** + * Interface compatible with React's useSyncExternalStore and external state management libraries. + * This interface allows GraphStore to work with any store implementation (Redux, Zustand, etc.). + * @template T - The type of the store snapshot + */ +export interface ExternalStoreLike { + /** Returns the current snapshot of the store */ + getSnapshot: () => MarkDeepReadOnly; + /** Subscribes to store changes. Returns an unsubscribe function */ + subscribe: (listener: () => void) => () => void; + /** Updates the store state with a new value or updater function */ + setState: (updater: Update) => void; +} + +/** + * Recursively makes all properties in a type readonly. + * Used to ensure store snapshots are immutable. + */ +export type MarkDeepReadOnly = { + readonly [K in keyof T]: T[K] extends object ? MarkDeepReadOnly : T[K]; +}; + +/** + * Recursively removes readonly modifiers from a type. + * Used when we need to mutate data internally. + */ +export type RemoveDeepReadOnly = { + -readonly [K in keyof T]: T[K]; +}; + +/** + * Removes deep readonly modifiers from a type. + * @param value - The value to remove readonly modifiers from. + * @returns The value with readonly modifiers removed. + */ +export function removeDeepReadOnly(value: T): RemoveDeepReadOnly { + return value as RemoveDeepReadOnly; +} +/** + * Gets the updated value from an updater function or direct value. + * @param previous - The previous value. + * @param updater - The updater function or direct value. + * @returns The updated value. + */ +export function getValue(previous: T, updater: Update): T { + return isUpdater(updater) ? updater(previous) : updater; +} + +/** + * State management interface with subscription and selection capabilities. + * Extends ExternalStoreLike to be compatible with React's useSyncExternalStore. + * @template T - The type of the state + */ +export interface State extends ExternalStoreLike { + /** + * Creates a derived state by selecting a portion of the current state. + * The derived state will automatically update when the selected portion changes. + * @template S - The type of the selected state + * @param name - Name for the derived state (used for debugging) + * @param selector - Function that extracts the desired portion from the state + * @param isSelectorEqual - Optional equality function to prevent unnecessary updates + * @returns A new State instance for the selected portion + */ + select: ( + name: string, + selector: (state: T) => S, + isSelectorEqual: (a: S, b: S) => boolean + ) => State; + /** + * Cleans up all subscriptions and resets the state to its initial value. + */ + clean: () => void; + /** + * Returns whether components have been notified of the last state change. + * Used internally for debugging and optimization. + */ + getAreComponentsNotified: () => boolean; + /** + * Updates the state with a new value or updater function. + * Subscribers will be notified if the state actually changed. + */ + setState: (updater: Update) => void; +} +/** + * Options for creating a new state instance. + * @template T - The type of the state + */ +interface Options { + /** Function that returns the initial state value */ + readonly newState: () => T; + /** Optional equality function to determine if state has changed. Defaults to deep equality. */ + readonly isEqual?: (a: T, b: T) => boolean; + /** Name for the state (used for debugging and dev tools) */ + readonly name: string; +} + +/** + * Creates a new state instance with subscription support. + * @param options - The options for creating the state. + * @returns A State instance with subscription and selection capabilities. + */ +export function createState(options: Options): State { + const { newState, isEqual = util.isEqual, name } = options; + + const stateRef = { + current: newState(), + }; + + const subscribers = new Set<() => void>(); + let areComponentsNotified = false; + + const notifySubscribers = () => { + ReactDOM.unstable_batchedUpdates(() => { + for (const subscriber of subscribers) { + subscriber(); + } + areComponentsNotified = true; + }); + }; + + const state = { + // ✅ correct shape for useSyncExternalStore + subscribe: (onStoreChange: () => void) => { + subscribers.add(onStoreChange); + return () => { + subscribers.delete(onStoreChange); + }; + }, + + getSnapshot: (): MarkDeepReadOnly => stateRef.current, + + setState: (updater: Update) => { + areComponentsNotified = false; + const updatedState = isUpdater(updater) ? updater(stateRef.current) : updater; + // fast compare if the state has changed + if (isEqual(updatedState, stateRef.current)) { + return; + } + stateRef.current = updatedState; + sendToDevTool({ name, type: 'set', value: updatedState }); + notifySubscribers(); + }, + + select: ( + selectName: string, + selector: (state: T) => S, + isSelectorEqual: (a: S, b: S) => boolean = (a, b) => a === b + ) => { + const selectorState = createState({ + newState: () => selector(stateRef.current), + isEqual: isSelectorEqual, + name: `${name}/select/${selectName}`, + }); + const unsubscribe = state.subscribe(() => { + const selected = selector(stateRef.current); + return selectorState.setState(selected); + }); + const clean = () => { + unsubscribe(); + selectorState.clean(); + }; + selectorState.clean = clean; + return selectorState; + }, + + reset: () => { + stateRef.current = newState(); + }, + + clean: () => { + subscribers.clear(); + stateRef.current = newState(); + }, + + getAreComponentsNotified: () => areComponentsNotified, + }; + return state; +} + +/** + * Options for creating a derived state from an external store. + * @template T - The type of the source state + * @template S - The type of the derived state + */ +interface DerivedOptions { + /** The source external store to derive from */ + readonly state: ExternalStoreLike; + /** Function that extracts the desired portion from the source state */ + readonly selector: (state: T) => S; + /** Optional equality function to prevent unnecessary updates. Defaults to deep equality. */ + readonly isEqual?: (a: S, b: S) => boolean; + /** Name for the derived state (used for debugging and dev tools) */ + readonly name: string; +} +/** + * Creates a derived state from an external store using a selector function. + * The derived state automatically updates when the selected portion of the source state changes. + * This is useful for creating computed values or selecting subsets of data. + * @template T - The type of the source state + * @template S - The type of the derived state + * @param options - The options for creating the derived state + * @returns A State instance that automatically syncs with the selected portion of the source state + */ +export function derivedState(options: DerivedOptions): State { + const { state, selector, isEqual = util.isEqual, name } = options; + const getSnapshot = (): S => { + return selector(state.getSnapshot() as T); + }; + const stateValue = createState({ + newState: getSnapshot, + name, + isEqual, + }); + const unsubscribe = state.subscribe(() => { + stateValue.setState(getSnapshot()); + }); + stateValue.clean = () => { + unsubscribe(); + stateValue.clean(); + }; + return stateValue; +} diff --git a/packages/joint-react/src/utils/dev-tools.ts b/packages/joint-react/src/utils/dev-tools.ts new file mode 100644 index 0000000000..572f22d875 --- /dev/null +++ b/packages/joint-react/src/utils/dev-tools.ts @@ -0,0 +1,31 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-expect-error +const reduxDevelopmentTools = globalThis?.__REDUX_DEVTOOLS_EXTENSION__?.connect({ + name: 'CustomState', // This will name your instance in the DevTools + trace: true, // Enables trace if needed +}); + +if (reduxDevelopmentTools) { + reduxDevelopmentTools.init({ message: 'Initial state' }); +} + +interface SendOptions { + message?: string; + type: string; + value: unknown; + name: string; +} +/** + * Send state information to Redux DevTools if available + * @param options Options containing message, type, value, and name + */ +export function sendToDevTool(options: SendOptions) { + if (process.env.NODE_ENV === 'production') { + return; + } + if (!reduxDevelopmentTools) { + return; + } + const { message, type, value, name } = options; + reduxDevelopmentTools.send(name, { value, type, message }, type); +} diff --git a/packages/joint-react/src/utils/graph/__tests__/update-graph.test.ts b/packages/joint-react/src/utils/graph/__tests__/update-graph.test.ts deleted file mode 100644 index ba2e7e489f..0000000000 --- a/packages/joint-react/src/utils/graph/__tests__/update-graph.test.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { dia, shapes } from '@joint/core'; -import { updateCell, updateGraph } from '../update-graph'; - -describe('update-graph', () => { - let graph: dia.Graph; - - beforeEach(() => { - graph = new dia.Graph({}, { cellNamespace: shapes }); - }); - - describe('updateCell', () => { - it('should add new cell to graph', () => { - const newCell = { - id: '1', - type: 'standard.Rectangle', - position: { x: 10, y: 20 }, - size: { width: 100, height: 50 }, - }; - - updateCell({ graph, newCell }); - - const cell = graph.getCell('1'); - expect(cell).toBeDefined(); - expect(cell?.get('position')).toEqual({ x: 10, y: 20 }); - expect(cell?.get('size')).toEqual({ width: 100, height: 50 }); - }); - - it('should update existing cell', () => { - const element = new shapes.standard.Rectangle({ - id: '1', - position: { x: 10, y: 20 }, - size: { width: 100, height: 50 }, - }); - graph.addCell(element); - - const newCell = { - id: '1', - type: 'standard.Rectangle', - position: { x: 30, y: 40 }, - size: { width: 200, height: 100 }, - }; - - updateCell({ graph, newCell }); - - const cell = graph.getCell('1'); - expect(cell?.get('position')).toEqual({ x: 30, y: 40 }); - expect(cell?.get('size')).toEqual({ width: 200, height: 100 }); - }); - - it('should update link source and target', () => { - const link = new shapes.standard.Link({ - id: 'link-1', - source: { id: 'a' }, - target: { id: 'b' }, - }); - graph.addCell(link); - - const newLink = { - id: 'link-1', - type: 'standard.Link', - source: { id: 'c' }, - target: { id: 'd' }, - }; - - updateCell({ graph, newCell: newLink }); - - const updatedLink = graph.getCell('link-1') as dia.Link; - expect(updatedLink.source().id).toBe('c'); - expect(updatedLink.target().id).toBe('d'); - }); - - it('should replace cell when type changes', () => { - const element = new shapes.standard.Rectangle({ - id: '1', - position: { x: 10, y: 20 }, - size: { width: 100, height: 50 }, - }); - graph.addCell(element); - - const newCell = { - id: '1', - type: 'standard.Circle', - position: { x: 10, y: 20 }, - size: { width: 100, height: 100 }, - }; - - updateCell({ graph, newCell }); - - const cell = graph.getCell('1'); - expect(cell?.get('type')).toBe('standard.Circle'); - }); - - it('should not update if id is missing', () => { - const newCell = { - type: 'standard.Rectangle', - position: { x: 10, y: 20 }, - size: { width: 100, height: 50 }, - } as unknown as dia.Cell.JSON; - - updateCell({ graph, newCell }); - - expect(graph.getCells().length).toBe(0); - }); - }); - - describe('updateGraph', () => { - it('should add new elements to graph', () => { - const cells = [ - { - id: '1', - type: 'standard.Rectangle', - position: { x: 10, y: 20 }, - size: { width: 100, height: 50 }, - }, - { - id: '2', - type: 'standard.Circle', - position: { x: 50, y: 60 }, - size: { width: 80, height: 80 }, - }, - ]; - - updateGraph({ graph, cells, isLink: false }); - - expect(graph.getElements().length).toBe(2); - expect(graph.getCell('1')).toBeDefined(); - expect(graph.getCell('2')).toBeDefined(); - }); - - it('should remove elements not in new cells', () => { - const element1 = new shapes.standard.Rectangle({ - id: '1', - position: { x: 10, y: 20 }, - size: { width: 100, height: 50 }, - }); - const element2 = new shapes.standard.Rectangle({ - id: '2', - position: { x: 50, y: 60 }, - size: { width: 100, height: 50 }, - }); - graph.addCell([element1, element2]); - - const cells = [ - { - id: '1', - type: 'standard.Rectangle', - position: { x: 10, y: 20 }, - size: { width: 100, height: 50 }, - }, - ]; - - updateGraph({ graph, cells, isLink: false }); - - expect(graph.getElements().length).toBe(1); - expect(graph.getCell('1')).toBeDefined(); - expect(graph.getCell('2')).toBeUndefined(); - }); - - it('should update existing elements', () => { - const element = new shapes.standard.Rectangle({ - id: '1', - position: { x: 10, y: 20 }, - size: { width: 100, height: 50 }, - }); - graph.addCell(element); - - const cells = [ - { - id: '1', - type: 'standard.Rectangle', - position: { x: 30, y: 40 }, - size: { width: 200, height: 100 }, - }, - ]; - - updateGraph({ graph, cells, isLink: false }); - - const updated = graph.getCell('1'); - expect(updated?.get('position')).toEqual({ x: 30, y: 40 }); - expect(updated?.get('size')).toEqual({ width: 200, height: 100 }); - }); - - it('should handle links', () => { - const cells = [ - { - id: 'link-1', - type: 'standard.Link', - source: { id: 'a' }, - target: { id: 'b' }, - }, - ]; - - updateGraph({ graph, cells, isLink: true }); - - expect(graph.getLinks().length).toBe(1); - const link = graph.getCell('link-1') as dia.Link; - expect(link.source().id).toBe('a'); - expect(link.target().id).toBe('b'); - }); - - it('should remove links not in new cells', () => { - const link1 = new shapes.standard.Link({ - id: 'link-1', - source: { id: 'a' }, - target: { id: 'b' }, - }); - const link2 = new shapes.standard.Link({ - id: 'link-2', - source: { id: 'c' }, - target: { id: 'd' }, - }); - graph.addCell([link1, link2]); - - const cells = [ - { - id: 'link-1', - type: 'standard.Link', - source: { id: 'a' }, - target: { id: 'b' }, - }, - ]; - - updateGraph({ graph, cells, isLink: true }); - - expect(graph.getLinks().length).toBe(1); - expect(graph.getCell('link-1')).toBeDefined(); - expect(graph.getCell('link-2')).toBeUndefined(); - }); - }); -}); diff --git a/packages/joint-react/src/utils/graph/update-graph.ts b/packages/joint-react/src/utils/graph/update-graph.ts deleted file mode 100644 index 13dd40db86..0000000000 --- a/packages/joint-react/src/utils/graph/update-graph.ts +++ /dev/null @@ -1,119 +0,0 @@ -/* eslint-disable sonarjs/cognitive-complexity */ -/* eslint-disable sonarjs/no-unused-vars */ -import type { dia } from '@joint/core'; -import type { CellOrJsonCell } from '../cell/cell-utilities'; -import { isCellInstance } from '../is'; -import type { SVGAttributes } from 'react'; - -export const GRAPH_UPDATE_BATCH_NAME = 'update-graph'; - -/** - * Safely set attributes on a link, merging with existing attributes. - * @param link - The link to set attributes on. - * @param attributes - The attributes to set. - * @group utils - */ -function setLinkAttributesSafely( - link: dia.Link, - attributes?: SVGAttributes | undefined -) { - if (!attributes) return; - // Deep-merge into existing attrs; do NOT replace the whole 'attrs' object. - link.attr(attributes as dia.Cell.Selectors); -} - -interface UpdateCellOptions { - readonly graph: dia.Graph; - readonly newCell: CellOrJsonCell; - readonly newCellsMap?: Record; - readonly isLink?: boolean; -} -/** - * Update a single cell in the graph. - * @param options - The options for updating the cell. - * @group utils - */ -export function updateCell(options: UpdateCellOptions) { - const { graph, newCell, newCellsMap = {} } = options; - const { id } = newCell; - if (!id) return; - - newCellsMap[id] = newCell; - - const current = graph.getCell(id); - const newType = isCellInstance(newCell) ? newCell.get('type') : newCell.type; - const attributesAll = isCellInstance(newCell) ? newCell.attributes : { ...newCell }; - - if (current) { - const isLink = current.isLink(); - - if (current.get('type') === newType) { - if (isLink) { - // Pull out fields that need special handling - const { - source, - target, - attrs, - id: _ignoreId, - type: _ignoreType, - ...rest // z, labels, vertices, router, connector, etc. - } = attributesAll; - - // 1) endpoints - if (source) (current as dia.Link).source(source); - if (target) (current as dia.Link).target(target); - - // 2) merge visual attrs (don’t replace) - if (attrs) setLinkAttributesSafely(current as dia.Link, attrs); - - // 3) apply other properties — but avoid setting `attrs` again - for (const [k, v] of Object.entries(rest)) { - // If you worry about something being a deep object, set individually - // but do NOT include 'attrs' here. - current.set(k, v); - } - } else { - // Element path: also avoid replacing attrs - const { attrs, id: _ignoreId, type: _ignoreType, ...rest } = attributesAll; - if (attrs) current.attr(attrs); - if (Object.keys(rest).length > 0) current.set(rest); - } - } else { - // Type changed — replace - current.remove({ disconnectLinks: true }); - graph.addCell(newCell); - } - } else { - graph.addCell(newCell); - } -} - -interface Options { - readonly graph: dia.Graph; - readonly cells: CellOrJsonCell[]; - readonly isLink: boolean; -} - -/** - * Update the graph with new cells. - * @param options - The options for updating the graph. - */ -export function updateGraph(options: Options) { - const { graph, cells, isLink } = options; - const originalCells = isLink ? graph.getLinks() : graph.getElements(); - const newCellsMap: Record = {}; - - // Here we do not want to remove the existing elements but only update them if they exist. - // e.g. Using resetCells() would remove all elements from the graph and add new ones. - for (const newCell of cells) { - updateCell({ graph, newCell, newCellsMap, isLink }); - } - - if (originalCells) { - for (const cell of originalCells) { - if (!newCellsMap[cell.id]) { - cell.remove(); - } - } - } -} diff --git a/packages/joint-react/src/utils/is.ts b/packages/joint-react/src/utils/is.ts index b281e98197..0335d551de 100644 --- a/packages/joint-react/src/utils/is.ts +++ b/packages/joint-react/src/utils/is.ts @@ -1,8 +1,8 @@ /* eslint-disable jsdoc/require-jsdoc */ import { dia, util } from '@joint/core'; -import type { GraphCell } from './cell/get-cell'; import type { GraphElement } from '../types/element-types'; import type { FunctionComponent, JSX } from 'react'; +import type { GraphCell } from './cell/cell-utilities'; export type Setter = (item: Value) => Value; @@ -40,10 +40,6 @@ export function isCellInstance(value: unknown): value is dia.Cell { return value instanceof dia.Cell; } -export function isUnsized(width: number | undefined, height: number | undefined) { - return width === undefined || height === undefined; -} - export function hasChildren(props: Record) { return 'children' in props; } @@ -69,3 +65,13 @@ export function isReactComponentFunction(value: unknown): value is FunctionCompo export function isWithChildren(value: unknown): value is { children: JSX.Element[] } { return isRecord(value) && hasChildren(value); } + +export function assertGraph(graph?: Graph): asserts graph is Graph { + if (!graph) { + throw new Error('Graph instance is required'); + } +} + +export function isUpdater(updater: ((previous: T) => T) | T): updater is (previous: T) => T { + return typeof updater === 'function' && 'call' in updater; +} diff --git a/packages/joint-react/src/utils/scheduler.ts b/packages/joint-react/src/utils/scheduler.ts new file mode 100644 index 0000000000..5ec990378d --- /dev/null +++ b/packages/joint-react/src/utils/scheduler.ts @@ -0,0 +1,57 @@ +// eslint-disable-next-line camelcase +import { unstable_getCurrentPriorityLevel, unstable_scheduleCallback } from 'scheduler'; +/** + * Creates a debounced function that uses React's internal scheduler for timing. + * Multiple calls within the same synchronous execution cycle are batched into a single + * execution in the next available event loop tick. All callbacks stored by id will be + * executed when the scheduled work flushes. + * @param userCallback The function to be debounced and scheduled. + * @param priorityLevel The priority level to run the task at. + * @returns A function you call to schedule your work with an optional callback and id. + */ +export function createScheduler(userCallback: () => void, priorityLevel?: number) { + let callbackId: unknown | null = null; + const callbacks = new Map void>(); + + const effectivePriority = + priorityLevel === undefined ? unstable_getCurrentPriorityLevel() : priorityLevel; + + /** + * The actual function that processes the batched callbacks. + * This runs asynchronously via the scheduler. + */ + const flushScheduledWork = () => { + callbackId = null; + + // Collect all ids before clearing + + // Execute all stored callbacks with their respective ids + for (const [id, callback] of callbacks) { + callback(id); + } + + // Execute the main callback for each id after stored callbacks + userCallback(); + + // Clear all stored callbacks after execution + callbacks.clear(); + }; + + /** + * This is the function the user calls to schedule a task. + * It stores the callback with its id and ensures the flush function is scheduled once. + * @param id Optional id string to pass to the callback. + * @param callback Optional callback function that receives an id parameter. + */ + return (id?: string, callback?: (id: string) => void) => { + if (id !== undefined && callback !== undefined) { + // Store callback in map with id as key + callbacks.set(id, callback); + } + + if (callbackId === null) { + // Schedule the flush function if it hasn't been scheduled already + callbackId = unstable_scheduleCallback(effectivePriority, flushScheduledWork); + } + }; +} diff --git a/packages/joint-react/src/utils/subscriber-handler.ts b/packages/joint-react/src/utils/subscriber-handler.ts deleted file mode 100644 index e646a6c379..0000000000 --- a/packages/joint-react/src/utils/subscriber-handler.ts +++ /dev/null @@ -1,74 +0,0 @@ -import type { UpdateResult } from '../data/create-store-data'; - -export interface SubscribeHandler { - readonly subscribe: (onStoreChange: (changedIds?: UpdateResult) => void) => () => void; - readonly notifySubscribers: (batchName?: string) => void; -} - -/** - * Per-batch scheduler: coalesces multiple notify calls in the same frame, - * but runs once per unique batchName (including undefined as its own batch). - * Calls to `beforeSubscribe` can return an `UpdateResult` to pass to subscribers. - * @param beforeSubscribe - Optional function to call before notifying subscribers. - * @returns A SubscribeHandler with subscribe and notifySubscribers methods. - */ -export function subscribeHandler( - beforeSubscribe?: (batchName?: string) => UpdateResult | undefined -): SubscribeHandler { - const subscribers = new Set<(changedIds?: UpdateResult) => void>(); - - // Treat "no batch name" as its own distinct key via a Symbol - const DEFAULT_BATCH = Symbol('default-batch'); - type BatchKey = string | symbol; - - const pending = new Set(); // preserves insertion order - let frameScheduled = false; - - const raf = - typeof requestAnimationFrame === 'function' - ? requestAnimationFrame - : (cb: FrameRequestCallback) => setTimeout(() => cb(performance.now()), 0); - - /** - * Schedules a flush of pending batches to notify subscribers. - * Ensures that the flush happens in the next animation frame. - */ - function scheduleFlush() { - if (frameScheduled) return; - frameScheduled = true; - - // microtask → next frame - Promise.resolve().then(() => { - raf(() => { - frameScheduled = false; - - // snapshot current batches and clear, so newly queued batches go to next frame - const batches = [...pending]; - pending.clear(); - - for (const key of batches) { - const batchName = key === DEFAULT_BATCH ? undefined : (key as string); - const changedIds = beforeSubscribe?.(batchName); - for (const subscriber of subscribers) { - subscriber(changedIds); - } - } - }); - }); - } - - return { - subscribe(onStoreChange) { - subscribers.add(onStoreChange); - return () => { - subscribers.delete(onStoreChange); - }; - }, - - notifySubscribers(batchName?: string) { - const key: BatchKey = batchName ?? DEFAULT_BATCH; - pending.add(key); // de-dupe per batch per frame - scheduleFlush(); - }, - }; -} diff --git a/packages/joint-react/src/utils/test-wrappers.tsx b/packages/joint-react/src/utils/test-wrappers.tsx index 15ec23eb7a..95d7c0bb6a 100644 --- a/packages/joint-react/src/utils/test-wrappers.tsx +++ b/packages/joint-react/src/utils/test-wrappers.tsx @@ -1,8 +1,5 @@ import { useCallback } from 'react'; import { GraphProvider, Paper, type GraphProps, type PaperProps } from '../components'; -import type { dia } from '@joint/core'; -import type { GraphElement } from '../types/element-types'; -import type { GraphLink } from '../types/link-types'; /** * Testing helper to render a `GraphProvider` provider. @@ -11,9 +8,7 @@ import type { GraphLink } from '../types/link-types'; * @internal * @group utils */ -export function graphProviderWrapper( - props: GraphProps -): React.JSXElementConstructor<{ +export function graphProviderWrapper(props: GraphProps): React.JSXElementConstructor<{ children: React.ReactNode; }> { return function GraphProviderWrapper({ children }) { @@ -23,7 +18,7 @@ export function graphProviderWrapper( interface Options { paperProps?: PaperProps; - graphProviderProps?: GraphProps; + graphProviderProps?: GraphProps; } /** * Testing helper to render a `Paper` inside a `GraphProvider` provider. diff --git a/packages/joint-react/src/utils/typed-memo.ts b/packages/joint-react/src/utils/typed-react.ts similarity index 100% rename from packages/joint-react/src/utils/typed-memo.ts rename to packages/joint-react/src/utils/typed-react.ts diff --git a/yarn.lock b/yarn.lock index 1996fb6179..bd05c9ab4a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -83,7 +83,7 @@ __metadata: languageName: node linkType: hard -"@babel/core@npm:7.28.5, @babel/core@npm:^7.12.3, @babel/core@npm:^7.18.9, @babel/core@npm:^7.23.9, @babel/core@npm:^7.27.4, @babel/core@npm:^7.28.0, @babel/core@npm:^7.28.4": +"@babel/core@npm:7.28.5, @babel/core@npm:^7.12.3, @babel/core@npm:^7.18.9, @babel/core@npm:^7.23.9, @babel/core@npm:^7.26.0, @babel/core@npm:^7.27.4, @babel/core@npm:^7.28.0, @babel/core@npm:^7.28.4": version: 7.28.5 resolution: "@babel/core@npm:7.28.5" dependencies: @@ -106,7 +106,7 @@ __metadata: languageName: node linkType: hard -"@babel/generator@npm:^7.26.10, @babel/generator@npm:^7.28.5": +"@babel/generator@npm:^7.26.10, @babel/generator@npm:^7.26.2, @babel/generator@npm:^7.28.5": version: 7.28.5 resolution: "@babel/generator@npm:7.28.5" dependencies: @@ -1473,7 +1473,7 @@ __metadata: languageName: node linkType: hard -"@babel/types@npm:^7.0.0, @babel/types@npm:^7.18.9, @babel/types@npm:^7.20.7, @babel/types@npm:^7.26.10, @babel/types@npm:^7.27.1, @babel/types@npm:^7.27.3, @babel/types@npm:^7.28.2, @babel/types@npm:^7.28.5, @babel/types@npm:^7.4.4": +"@babel/types@npm:^7.0.0, @babel/types@npm:^7.18.9, @babel/types@npm:^7.20.7, @babel/types@npm:^7.26.0, @babel/types@npm:^7.26.10, @babel/types@npm:^7.27.1, @babel/types@npm:^7.27.3, @babel/types@npm:^7.28.2, @babel/types@npm:^7.28.5, @babel/types@npm:^7.4.4": version: 7.28.5 resolution: "@babel/types@npm:7.28.5" dependencies: @@ -1515,6 +1515,27 @@ __metadata: languageName: node linkType: hard +"@clack/core@npm:0.3.5, @clack/core@npm:^0.3.5": + version: 0.3.5 + resolution: "@clack/core@npm:0.3.5" + dependencies: + picocolors: "npm:^1.0.0" + sisteransi: "npm:^1.0.5" + checksum: 10/329840301b91df2957d6d3a5832946d6a3c8683aeccf98b77f559c518a9e7b75f5e59392228a51fc97ae950cf21438f1b77fb5529affd93df0106f52d9cc0881 + languageName: node + linkType: hard + +"@clack/prompts@npm:^0.8.2": + version: 0.8.2 + resolution: "@clack/prompts@npm:0.8.2" + dependencies: + "@clack/core": "npm:0.3.5" + picocolors: "npm:^1.0.0" + sisteransi: "npm:^1.0.5" + checksum: 10/06859acc2cc8919255592150f898d08c93e6d6041d22b92fafa55f48265a681ab3506bde76fad5a03be3ea6f46e8408e1f1b1d88d259a0169e30b6f8b28acbfe + languageName: node + linkType: hard + "@colors/colors@npm:1.5.0": version: 1.5.0 resolution: "@colors/colors@npm:1.5.0" @@ -1671,6 +1692,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/aix-ppc64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/aix-ppc64@npm:0.25.12" + conditions: os=aix & cpu=ppc64 + languageName: node + linkType: hard + "@esbuild/android-arm64@npm:0.18.20": version: 0.18.20 resolution: "@esbuild/android-arm64@npm:0.18.20" @@ -1692,6 +1720,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-arm64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/android-arm64@npm:0.25.12" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/android-arm@npm:0.18.20": version: 0.18.20 resolution: "@esbuild/android-arm@npm:0.18.20" @@ -1713,6 +1748,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-arm@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/android-arm@npm:0.25.12" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + "@esbuild/android-x64@npm:0.18.20": version: 0.18.20 resolution: "@esbuild/android-x64@npm:0.18.20" @@ -1734,6 +1776,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-x64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/android-x64@npm:0.25.12" + conditions: os=android & cpu=x64 + languageName: node + linkType: hard + "@esbuild/darwin-arm64@npm:0.18.20": version: 0.18.20 resolution: "@esbuild/darwin-arm64@npm:0.18.20" @@ -1755,6 +1804,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/darwin-arm64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/darwin-arm64@npm:0.25.12" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/darwin-x64@npm:0.18.20": version: 0.18.20 resolution: "@esbuild/darwin-x64@npm:0.18.20" @@ -1776,6 +1832,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/darwin-x64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/darwin-x64@npm:0.25.12" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + "@esbuild/freebsd-arm64@npm:0.18.20": version: 0.18.20 resolution: "@esbuild/freebsd-arm64@npm:0.18.20" @@ -1797,6 +1860,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/freebsd-arm64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/freebsd-arm64@npm:0.25.12" + conditions: os=freebsd & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/freebsd-x64@npm:0.18.20": version: 0.18.20 resolution: "@esbuild/freebsd-x64@npm:0.18.20" @@ -1818,6 +1888,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/freebsd-x64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/freebsd-x64@npm:0.25.12" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/linux-arm64@npm:0.18.20": version: 0.18.20 resolution: "@esbuild/linux-arm64@npm:0.18.20" @@ -1839,6 +1916,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-arm64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/linux-arm64@npm:0.25.12" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/linux-arm@npm:0.18.20": version: 0.18.20 resolution: "@esbuild/linux-arm@npm:0.18.20" @@ -1860,6 +1944,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-arm@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/linux-arm@npm:0.25.12" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + "@esbuild/linux-ia32@npm:0.18.20": version: 0.18.20 resolution: "@esbuild/linux-ia32@npm:0.18.20" @@ -1881,6 +1972,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-ia32@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/linux-ia32@npm:0.25.12" + conditions: os=linux & cpu=ia32 + languageName: node + linkType: hard + "@esbuild/linux-loong64@npm:0.18.20": version: 0.18.20 resolution: "@esbuild/linux-loong64@npm:0.18.20" @@ -1902,6 +2000,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-loong64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/linux-loong64@npm:0.25.12" + conditions: os=linux & cpu=loong64 + languageName: node + linkType: hard + "@esbuild/linux-mips64el@npm:0.18.20": version: 0.18.20 resolution: "@esbuild/linux-mips64el@npm:0.18.20" @@ -1923,6 +2028,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-mips64el@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/linux-mips64el@npm:0.25.12" + conditions: os=linux & cpu=mips64el + languageName: node + linkType: hard + "@esbuild/linux-ppc64@npm:0.18.20": version: 0.18.20 resolution: "@esbuild/linux-ppc64@npm:0.18.20" @@ -1944,6 +2056,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-ppc64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/linux-ppc64@npm:0.25.12" + conditions: os=linux & cpu=ppc64 + languageName: node + linkType: hard + "@esbuild/linux-riscv64@npm:0.18.20": version: 0.18.20 resolution: "@esbuild/linux-riscv64@npm:0.18.20" @@ -1965,6 +2084,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-riscv64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/linux-riscv64@npm:0.25.12" + conditions: os=linux & cpu=riscv64 + languageName: node + linkType: hard + "@esbuild/linux-s390x@npm:0.18.20": version: 0.18.20 resolution: "@esbuild/linux-s390x@npm:0.18.20" @@ -1986,6 +2112,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-s390x@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/linux-s390x@npm:0.25.12" + conditions: os=linux & cpu=s390x + languageName: node + linkType: hard + "@esbuild/linux-x64@npm:0.18.20": version: 0.18.20 resolution: "@esbuild/linux-x64@npm:0.18.20" @@ -2007,6 +2140,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-x64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/linux-x64@npm:0.25.12" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + "@esbuild/netbsd-arm64@npm:0.25.11": version: 0.25.11 resolution: "@esbuild/netbsd-arm64@npm:0.25.11" @@ -2014,6 +2154,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/netbsd-arm64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/netbsd-arm64@npm:0.25.12" + conditions: os=netbsd & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/netbsd-x64@npm:0.18.20": version: 0.18.20 resolution: "@esbuild/netbsd-x64@npm:0.18.20" @@ -2035,6 +2182,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/netbsd-x64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/netbsd-x64@npm:0.25.12" + conditions: os=netbsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/openbsd-arm64@npm:0.25.11": version: 0.25.11 resolution: "@esbuild/openbsd-arm64@npm:0.25.11" @@ -2042,6 +2196,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/openbsd-arm64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/openbsd-arm64@npm:0.25.12" + conditions: os=openbsd & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/openbsd-x64@npm:0.18.20": version: 0.18.20 resolution: "@esbuild/openbsd-x64@npm:0.18.20" @@ -2063,6 +2224,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/openbsd-x64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/openbsd-x64@npm:0.25.12" + conditions: os=openbsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/openharmony-arm64@npm:0.25.11": version: 0.25.11 resolution: "@esbuild/openharmony-arm64@npm:0.25.11" @@ -2070,6 +2238,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/openharmony-arm64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/openharmony-arm64@npm:0.25.12" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/sunos-x64@npm:0.18.20": version: 0.18.20 resolution: "@esbuild/sunos-x64@npm:0.18.20" @@ -2091,6 +2266,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/sunos-x64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/sunos-x64@npm:0.25.12" + conditions: os=sunos & cpu=x64 + languageName: node + linkType: hard + "@esbuild/win32-arm64@npm:0.18.20": version: 0.18.20 resolution: "@esbuild/win32-arm64@npm:0.18.20" @@ -2112,6 +2294,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-arm64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/win32-arm64@npm:0.25.12" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/win32-ia32@npm:0.18.20": version: 0.18.20 resolution: "@esbuild/win32-ia32@npm:0.18.20" @@ -2133,6 +2322,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-ia32@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/win32-ia32@npm:0.25.12" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + "@esbuild/win32-x64@npm:0.18.20": version: 0.18.20 resolution: "@esbuild/win32-x64@npm:0.18.20" @@ -2154,6 +2350,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-x64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/win32-x64@npm:0.25.12" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@eslint-community/eslint-utils@npm:^4.2.0, @eslint-community/eslint-utils@npm:^4.4.0, @eslint-community/eslint-utils@npm:^4.7.0, @eslint-community/eslint-utils@npm:^4.8.0, @eslint-community/eslint-utils@npm:^4.9.0": version: 4.9.0 resolution: "@eslint-community/eslint-utils@npm:4.9.0" @@ -3277,23 +3480,28 @@ __metadata: "@types/react": "npm:19.1.12" "@types/react-dom": "npm:19.1.9" "@types/react-test-renderer": "npm:19.1.0" + "@types/scheduler": "npm:^0" "@types/use-sync-external-store": "npm:1.5.0" "@vitejs/plugin-react": "npm:^5.0.2" - "@welldone-software/why-did-you-render": "npm:10.0.1" canvas: "npm:^3.1.0" eslint: "npm:9.33.0" glob: "npm:^11.0.1" jest: "npm:30.1.2" jest-environment-jsdom: "npm:30.1.2" + jotai: "npm:^2.15.2" knip: "npm:5.63.0" + peerjs: "npm:^1.5.5" prettier: "npm:3.3.3" prettier-plugin-tailwindcss: "npm:^0.6.5" react: "npm:^19.1.1" react-docgen-typescript-plugin: "npm:^1.0.8" react-dom: "npm:^19.1.1" react-redux: "npm:^9.2.0" + react-scan: "npm:^0.4.3" react-test-renderer: "npm:^19.1.1" redux: "npm:^5.0.1" + redux-undo: "npm:1.1.0" + scheduler: "npm:^0.27.0" storybook: "npm:^8.6.14" storybook-addon-performance: "npm:0.17.3" storybook-multilevel-sort: "npm:2.1.0" @@ -3310,6 +3518,7 @@ __metadata: vite-plugin-node-polyfills: "npm:^0.24.0" vite-tsconfig-paths: "npm:^5.1.4" vitest: "npm:^3.0.4" + zustand: "npm:^5.0.9" peerDependencies: react: ">=18 <20" react-dom: ">=18 <20" @@ -3614,6 +3823,13 @@ __metadata: languageName: node linkType: hard +"@msgpack/msgpack@npm:^2.8.0": + version: 2.8.0 + resolution: "@msgpack/msgpack@npm:2.8.0" + checksum: 10/d90ab780c2c96fa5af22f38e0b76871d7c77d06fcf40786b64ada4e0ae02e17b216b38a5505fb4b7d1c339d95caee0669f5ec9004a2b392ce0cbe16afdbd9333 + languageName: node + linkType: hard + "@napi-rs/wasm-runtime@npm:^0.2.11": version: 0.2.12 resolution: "@napi-rs/wasm-runtime@npm:0.2.12" @@ -3971,6 +4187,16 @@ __metadata: languageName: node linkType: hard +"@pivanov/utils@npm:0.0.2": + version: 0.0.2 + resolution: "@pivanov/utils@npm:0.0.2" + peerDependencies: + react: ">=18" + react-dom: ">=18" + checksum: 10/790cd7e7afe9f9ef5c0fc16ed8752a2f283c4ba77a60925aef7f436465913a1c81b90c0cb99c6bc27398c93d7b0d17144cd75762fc99f44c02c8c883fbdde9e4 + languageName: node + linkType: hard + "@pkgjs/parseargs@npm:^0.11.0": version: 0.11.0 resolution: "@pkgjs/parseargs@npm:0.11.0" @@ -3992,6 +4218,24 @@ __metadata: languageName: node linkType: hard +"@preact/signals-core@npm:^1.7.0": + version: 1.12.1 + resolution: "@preact/signals-core@npm:1.12.1" + checksum: 10/c77264136f99cb57fdd2ebecce8e011cde54ccbb5218e55508b876f9347787c5338bcf271d4f02c75aa1b5bc6c89988ea507440507b189959b8b6702be3e5147 + languageName: node + linkType: hard + +"@preact/signals@npm:^1.3.1": + version: 1.3.2 + resolution: "@preact/signals@npm:1.3.2" + dependencies: + "@preact/signals-core": "npm:^1.7.0" + peerDependencies: + preact: 10.x + checksum: 10/5214932e54458005b2a5c52b593ec8837550e8950a9ca851edbf7b5125922fe2f8047cedb8ed29b978b032035afa8beedecdad53b19eb17d6338c91054b8c7c0 + languageName: node + linkType: hard + "@promptbook/utils@npm:0.69.5": version: 0.69.5 resolution: "@promptbook/utils@npm:0.69.5" @@ -4200,7 +4444,7 @@ __metadata: languageName: node linkType: hard -"@rollup/pluginutils@npm:^5.0.1, @rollup/pluginutils@npm:^5.0.2, @rollup/pluginutils@npm:^5.1.0": +"@rollup/pluginutils@npm:^5.0.1, @rollup/pluginutils@npm:^5.0.2, @rollup/pluginutils@npm:^5.1.0, @rollup/pluginutils@npm:^5.1.3": version: 5.3.0 resolution: "@rollup/pluginutils@npm:5.3.0" dependencies: @@ -5854,6 +6098,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:^20.17.9": + version: 20.19.25 + resolution: "@types/node@npm:20.19.25" + dependencies: + undici-types: "npm:~6.21.0" + checksum: 10/f0ed863599da289df5838380e211f9e010414dc7df8127b79f6a7eeca80a8d6e3daf3e58ddad1884f8dfafd17c16b02f56cee1689b7ebdf5df6d0e5770165ac1 + languageName: node + linkType: hard + "@types/node@npm:^24.3.0": version: 24.7.2 resolution: "@types/node@npm:24.7.2" @@ -5895,6 +6148,15 @@ __metadata: languageName: node linkType: hard +"@types/react-reconciler@npm:^0.28.9": + version: 0.28.9 + resolution: "@types/react-reconciler@npm:0.28.9" + peerDependencies: + "@types/react": "*" + checksum: 10/2450e3df6169fb887591f2a949ca8f08439ac76c756124feeee7370f1a79b5060ba8e5f612302dc70169433e043da7f617dd7ea3b14a3c2bb39559d66b7a0986 + languageName: node + linkType: hard + "@types/react-test-renderer@npm:19.1.0": version: 19.1.0 resolution: "@types/react-test-renderer@npm:19.1.0" @@ -5968,6 +6230,13 @@ __metadata: languageName: node linkType: hard +"@types/scheduler@npm:^0": + version: 0.26.0 + resolution: "@types/scheduler@npm:0.26.0" + checksum: 10/295ede5e7f991c7c52f9ed8e58d3076526be9a560e59ae11bf1c1414f9755a17bd750f3bfed4657b118283d1eb082bb27dcbe2eadf335a982b0c3b6a562771c2 + languageName: node + linkType: hard + "@types/send@npm:*": version: 1.2.1 resolution: "@types/send@npm:1.2.1" @@ -7240,17 +7509,6 @@ __metadata: languageName: node linkType: hard -"@welldone-software/why-did-you-render@npm:10.0.1": - version: 10.0.1 - resolution: "@welldone-software/why-did-you-render@npm:10.0.1" - dependencies: - lodash: "npm:^4" - peerDependencies: - react: ^19 - checksum: 10/da37a677b7e275bf5b4b615cd45bd23c83125d04c9a292b8af5920837d5c65ebf63c8229ff963feb038787938e024dc6d3cfa6c3277e68e24b8b066292453117 - languageName: node - linkType: hard - "@xstate/react@npm:^3.2.2": version: 3.2.2 resolution: "@xstate/react@npm:3.2.2" @@ -8500,6 +8758,17 @@ __metadata: languageName: node linkType: hard +"bippy@npm:^0.3.8": + version: 0.3.34 + resolution: "bippy@npm:0.3.34" + dependencies: + "@types/react-reconciler": "npm:^0.28.9" + peerDependencies: + react: ">=17.0.1" + checksum: 10/0055ba5a4cbe78e2e314d6f1b1f97c5a559c4213125300598e2919b3ee0bc6ceec3e3117d5450bd52d544880340b14e8fff57e8fe22a5ca42afaa0ca52a6abd5 + languageName: node + linkType: hard + "birecord@npm:^0.1.1": version: 0.1.1 resolution: "birecord@npm:0.1.1" @@ -11788,6 +12057,95 @@ __metadata: languageName: node linkType: hard +"esbuild@npm:~0.25.0": + version: 0.25.12 + resolution: "esbuild@npm:0.25.12" + dependencies: + "@esbuild/aix-ppc64": "npm:0.25.12" + "@esbuild/android-arm": "npm:0.25.12" + "@esbuild/android-arm64": "npm:0.25.12" + "@esbuild/android-x64": "npm:0.25.12" + "@esbuild/darwin-arm64": "npm:0.25.12" + "@esbuild/darwin-x64": "npm:0.25.12" + "@esbuild/freebsd-arm64": "npm:0.25.12" + "@esbuild/freebsd-x64": "npm:0.25.12" + "@esbuild/linux-arm": "npm:0.25.12" + "@esbuild/linux-arm64": "npm:0.25.12" + "@esbuild/linux-ia32": "npm:0.25.12" + "@esbuild/linux-loong64": "npm:0.25.12" + "@esbuild/linux-mips64el": "npm:0.25.12" + "@esbuild/linux-ppc64": "npm:0.25.12" + "@esbuild/linux-riscv64": "npm:0.25.12" + "@esbuild/linux-s390x": "npm:0.25.12" + "@esbuild/linux-x64": "npm:0.25.12" + "@esbuild/netbsd-arm64": "npm:0.25.12" + "@esbuild/netbsd-x64": "npm:0.25.12" + "@esbuild/openbsd-arm64": "npm:0.25.12" + "@esbuild/openbsd-x64": "npm:0.25.12" + "@esbuild/openharmony-arm64": "npm:0.25.12" + "@esbuild/sunos-x64": "npm:0.25.12" + "@esbuild/win32-arm64": "npm:0.25.12" + "@esbuild/win32-ia32": "npm:0.25.12" + "@esbuild/win32-x64": "npm:0.25.12" + dependenciesMeta: + "@esbuild/aix-ppc64": + optional: true + "@esbuild/android-arm": + optional: true + "@esbuild/android-arm64": + optional: true + "@esbuild/android-x64": + optional: true + "@esbuild/darwin-arm64": + optional: true + "@esbuild/darwin-x64": + optional: true + "@esbuild/freebsd-arm64": + optional: true + "@esbuild/freebsd-x64": + optional: true + "@esbuild/linux-arm": + optional: true + "@esbuild/linux-arm64": + optional: true + "@esbuild/linux-ia32": + optional: true + "@esbuild/linux-loong64": + optional: true + "@esbuild/linux-mips64el": + optional: true + "@esbuild/linux-ppc64": + optional: true + "@esbuild/linux-riscv64": + optional: true + "@esbuild/linux-s390x": + optional: true + "@esbuild/linux-x64": + optional: true + "@esbuild/netbsd-arm64": + optional: true + "@esbuild/netbsd-x64": + optional: true + "@esbuild/openbsd-arm64": + optional: true + "@esbuild/openbsd-x64": + optional: true + "@esbuild/openharmony-arm64": + optional: true + "@esbuild/sunos-x64": + optional: true + "@esbuild/win32-arm64": + optional: true + "@esbuild/win32-ia32": + optional: true + "@esbuild/win32-x64": + optional: true + bin: + esbuild: bin/esbuild + checksum: 10/bc9c03d64e96a0632a926662c9d29decafb13a40e5c91790f632f02939bc568edc9abe0ee5d8055085a2819a00139eb12e223cfb8126dbf89bbc569f125d91fd + languageName: node + linkType: hard + "escalade@npm:^3.1.1, escalade@npm:^3.2.0": version: 3.2.0 resolution: "escalade@npm:3.2.0" @@ -12540,7 +12898,7 @@ __metadata: languageName: node linkType: hard -"eventemitter3@npm:^4.0.0": +"eventemitter3@npm:^4.0.0, eventemitter3@npm:^4.0.7": version: 4.0.7 resolution: "eventemitter3@npm:4.0.7" checksum: 10/8030029382404942c01d0037079f1b1bc8fed524b5849c237b80549b01e2fc49709e1d0c557fa65ca4498fc9e24cff1475ef7b855121fcc15f9d61f93e282346 @@ -13500,6 +13858,16 @@ __metadata: languageName: node linkType: hard +"fsevents@npm:2.3.2": + version: 2.3.2 + resolution: "fsevents@npm:2.3.2" + dependencies: + node-gyp: "npm:latest" + checksum: 10/6b5b6f5692372446ff81cf9501c76e3e0459a4852b3b5f1fc72c103198c125a6b8c72f5f166bdd76ffb2fca261e7f6ee5565daf80dca6e571e55bcc589cc1256 + conditions: os=darwin + languageName: node + linkType: hard + "fsevents@npm:^1.2.7": version: 1.2.13 resolution: "fsevents@npm:1.2.13" @@ -13521,6 +13889,15 @@ __metadata: languageName: node linkType: hard +"fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin": + version: 2.3.2 + resolution: "fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin::version=2.3.2&hash=df0bf1" + dependencies: + node-gyp: "npm:latest" + conditions: os=darwin + languageName: node + linkType: hard + "fsevents@patch:fsevents@npm%3A^1.2.7#optional!builtin": version: 1.2.13 resolution: "fsevents@patch:fsevents@npm%3A1.2.13#optional!builtin::version=1.2.13&hash=d11327" @@ -13709,6 +14086,15 @@ __metadata: languageName: node linkType: hard +"get-tsconfig@npm:^4.7.5": + version: 4.13.0 + resolution: "get-tsconfig@npm:4.13.0" + dependencies: + resolve-pkg-maps: "npm:^1.0.0" + checksum: 10/3603c6da30e312636e4c20461e779114c9126601d1eca70ee4e36e3e3c00e3c21892d2d920027333afa2cc9e20998a436b14abe03a53cde40742581cb0e9ceb2 + languageName: node + linkType: hard + "get-uri@npm:^6.0.1": version: 6.0.5 resolution: "get-uri@npm:6.0.5" @@ -16375,6 +16761,27 @@ __metadata: languageName: unknown linkType: soft +"jotai@npm:^2.15.2": + version: 2.15.2 + resolution: "jotai@npm:2.15.2" + peerDependencies: + "@babel/core": ">=7.0.0" + "@babel/template": ">=7.0.0" + "@types/react": ">=17.0.0" + react: ">=17.0.0" + peerDependenciesMeta: + "@babel/core": + optional: true + "@babel/template": + optional: true + "@types/react": + optional: true + react: + optional: true + checksum: 10/5da99d9d43a7b4fd0ab5517ec12bfc384b819b94a4b9fba7085b79821d6a8d68ca786896e475866dbd30201fb30effa208cf319b93b29d1dbe7be8f8111f00b9 + languageName: node + linkType: hard + "jquery@npm:~3.7.1": version: 3.7.1 resolution: "jquery@npm:3.7.1" @@ -16783,7 +17190,7 @@ __metadata: languageName: node linkType: hard -"kleur@npm:^4.1.4": +"kleur@npm:^4.1.4, kleur@npm:^4.1.5": version: 4.1.5 resolution: "kleur@npm:4.1.5" checksum: 10/44d84cc4eedd4311099402ef6d4acd9b2d16e08e499d6ef3bb92389bd4692d7ef09e35248c26e27f98acac532122acb12a1bfee645994ae3af4f0a37996da7df @@ -17104,7 +17511,7 @@ __metadata: languageName: node linkType: hard -"lodash@npm:4.17.21, lodash@npm:^4, lodash@npm:^4.1.2, lodash@npm:^4.17.10, lodash@npm:^4.17.11, lodash@npm:^4.17.12, lodash@npm:^4.17.14, lodash@npm:^4.17.15, lodash@npm:^4.17.20, lodash@npm:^4.17.21, lodash@npm:^4.17.4, lodash@npm:~4.17.10, lodash@npm:~4.17.15, lodash@npm:~4.17.19, lodash@npm:~4.17.21": +"lodash@npm:4.17.21, lodash@npm:^4.1.2, lodash@npm:^4.17.10, lodash@npm:^4.17.11, lodash@npm:^4.17.12, lodash@npm:^4.17.14, lodash@npm:^4.17.15, lodash@npm:^4.17.20, lodash@npm:^4.17.21, lodash@npm:^4.17.4, lodash@npm:~4.17.10, lodash@npm:~4.17.15, lodash@npm:~4.17.19, lodash@npm:~4.17.21": version: 4.17.21 resolution: "lodash@npm:4.17.21" checksum: 10/c08619c038846ea6ac754abd6dd29d2568aa705feb69339e836dfa8d8b09abbb2f859371e86863eda41848221f9af43714491467b5b0299122431e202bb0c532 @@ -17987,6 +18394,13 @@ __metadata: languageName: node linkType: hard +"mri@npm:^1.2.0": + version: 1.2.0 + resolution: "mri@npm:1.2.0" + checksum: 10/6775a1d2228bb9d191ead4efc220bd6be64f943ad3afd4dcb3b3ac8fc7b87034443f666e38805df38e8d047b29f910c3cc7810da0109af83e42c82c73bd3f6bc + languageName: node + linkType: hard + "mrmime@npm:^1.0.0": version: 1.0.1 resolution: "mrmime@npm:1.0.1" @@ -19270,6 +19684,25 @@ __metadata: languageName: node linkType: hard +"peerjs-js-binarypack@npm:^2.1.0": + version: 2.1.0 + resolution: "peerjs-js-binarypack@npm:2.1.0" + checksum: 10/590e8796ffa1ac35a6a2c0d69cc8ad2173d3a720405fd5159c7801629af65cb70a9cb505fd4320c2cee4a1e485d42497cb26a82479a1300385a82406e8387a5a + languageName: node + linkType: hard + +"peerjs@npm:^1.5.5": + version: 1.5.5 + resolution: "peerjs@npm:1.5.5" + dependencies: + "@msgpack/msgpack": "npm:^2.8.0" + eventemitter3: "npm:^4.0.7" + peerjs-js-binarypack: "npm:^2.1.0" + webrtc-adapter: "npm:^9.0.0" + checksum: 10/e31de79580958752505b8f9a29c15a5c99b1a84010457ec98ba32680d804b11d765ec9f5a2415bf37571aee19a5dd4b4f863ebf82105c22ff3db4ddec599f469 + languageName: node + linkType: hard + "pend@npm:~1.2.0": version: 1.2.0 resolution: "pend@npm:1.2.0" @@ -19284,7 +19717,7 @@ __metadata: languageName: node linkType: hard -"picocolors@npm:1.1.1, picocolors@npm:^1.1.1": +"picocolors@npm:1.1.1, picocolors@npm:^1.0.0, picocolors@npm:^1.1.1": version: 1.1.1 resolution: "picocolors@npm:1.1.1" checksum: 10/e1cf46bf84886c79055fdfa9dcb3e4711ad259949e3565154b004b260cd356c5d54b31a1437ce9782624bf766272fe6b0154f5f0c744fb7af5d454d2b60db045 @@ -19378,6 +19811,30 @@ __metadata: languageName: node linkType: hard +"playwright-core@npm:1.57.0": + version: 1.57.0 + resolution: "playwright-core@npm:1.57.0" + bin: + playwright-core: cli.js + checksum: 10/ec066602f0196f036006caee14a30d0a57533a76673bb9a0c609ef56e21decf018f0e8d402ba2fb18251393be6a1c9e193c83266f1670fe50838c5340e220de0 + languageName: node + linkType: hard + +"playwright@npm:^1.49.0": + version: 1.57.0 + resolution: "playwright@npm:1.57.0" + dependencies: + fsevents: "npm:2.3.2" + playwright-core: "npm:1.57.0" + dependenciesMeta: + fsevents: + optional: true + bin: + playwright: cli.js + checksum: 10/241559210f98ef11b6bd6413f2d29da7ef67c7865b72053192f0d164fab9e0d3bd47913b3351d5de6433a8aff2d8424d4b8bd668df420bf4dda7ae9fcd37b942 + languageName: node + linkType: hard + "plur@npm:^1.0.0": version: 1.0.0 resolution: "plur@npm:1.0.0" @@ -19548,6 +20005,13 @@ __metadata: languageName: node linkType: hard +"preact@npm:^10.25.1": + version: 10.27.2 + resolution: "preact@npm:10.27.2" + checksum: 10/e568fb968579e73921119232fcdfa6a5b6a57632742b905ec5127b8ef77abee3a8040d8342022af7845e3b43e97ca06faafbf734aa234dd95c0d62474cd0d03f + languageName: node + linkType: hard + "prebuild-install@npm:^7.1.3": version: 7.1.3 resolution: "prebuild-install@npm:7.1.3" @@ -20301,6 +20765,53 @@ __metadata: languageName: node linkType: hard +"react-scan@npm:^0.4.3": + version: 0.4.3 + resolution: "react-scan@npm:0.4.3" + dependencies: + "@babel/core": "npm:^7.26.0" + "@babel/generator": "npm:^7.26.2" + "@babel/types": "npm:^7.26.0" + "@clack/core": "npm:^0.3.5" + "@clack/prompts": "npm:^0.8.2" + "@pivanov/utils": "npm:0.0.2" + "@preact/signals": "npm:^1.3.1" + "@rollup/pluginutils": "npm:^5.1.3" + "@types/node": "npm:^20.17.9" + bippy: "npm:^0.3.8" + esbuild: "npm:^0.25.0" + estree-walker: "npm:^3.0.3" + kleur: "npm:^4.1.5" + mri: "npm:^1.2.0" + playwright: "npm:^1.49.0" + preact: "npm:^10.25.1" + tsx: "npm:^4.19.3" + unplugin: "npm:2.1.0" + peerDependencies: + "@remix-run/react": ">=1.0.0" + next: ">=13.0.0" + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-router: ^5.0.0 || ^6.0.0 || ^7.0.0 + react-router-dom: ^5.0.0 || ^6.0.0 || ^7.0.0 + dependenciesMeta: + unplugin: + optional: true + peerDependenciesMeta: + "@remix-run/react": + optional: true + next: + optional: true + react-router: + optional: true + react-router-dom: + optional: true + bin: + react-scan: bin/cli.js + checksum: 10/467cf1ddaefe7347efd0d3c631e1b93dd62229edd336e0b3829edc5446fd7a9182fce1b08d3e03e2003a746fa93cfce3549996823c9991f19a2c677923f94ee9 + languageName: node + linkType: hard + "react-test-renderer@npm:^19.1.1": version: 19.2.0 resolution: "react-test-renderer@npm:19.2.0" @@ -20470,6 +20981,13 @@ __metadata: languageName: node linkType: hard +"redux-undo@npm:1.1.0": + version: 1.1.0 + resolution: "redux-undo@npm:1.1.0" + checksum: 10/87b097167b5c90512b3294ae9d3f53b3c9b8d2bb61b9c1541fd2896fd5f9c6a5af2a8ea866a4cc4409e138e35046ae2531baad02c5e2c225b08e9d5c10ea19dd + languageName: node + linkType: hard + "redux@npm:^5.0.1": version: 5.0.1 resolution: "redux@npm:5.0.1" @@ -20740,6 +21258,13 @@ __metadata: languageName: node linkType: hard +"resolve-pkg-maps@npm:^1.0.0": + version: 1.0.0 + resolution: "resolve-pkg-maps@npm:1.0.0" + checksum: 10/0763150adf303040c304009231314d1e84c6e5ebfa2d82b7d94e96a6e82bacd1dcc0b58ae257315f3c8adb89a91d8d0f12928241cba2df1680fbe6f60bf99b0e + languageName: node + linkType: hard + "resolve-pkg@npm:^2.0.0": version: 2.0.0 resolution: "resolve-pkg@npm:2.0.0" @@ -21462,6 +21987,13 @@ __metadata: languageName: node linkType: hard +"sdp@npm:^3.2.0": + version: 3.2.1 + resolution: "sdp@npm:3.2.1" + checksum: 10/6b23a202430fa2128bf32285f880ad9f9c2a6ff65cb2767aeca9d5c1860f4844a5762cb820028acd503b6c773106285b9a6d7c11964412328426886527167b8f + languageName: node + linkType: hard + "section-matter@npm:^1.0.0": version: 1.0.0 resolution: "section-matter@npm:1.0.0" @@ -23646,6 +24178,22 @@ __metadata: languageName: node linkType: hard +"tsx@npm:^4.19.3": + version: 4.20.6 + resolution: "tsx@npm:4.20.6" + dependencies: + esbuild: "npm:~0.25.0" + fsevents: "npm:~2.3.3" + get-tsconfig: "npm:^4.7.5" + dependenciesMeta: + fsevents: + optional: true + bin: + tsx: dist/cli.mjs + checksum: 10/16396df25c474d7526f7adf9cd0c1f0b71a8c42f70bb93c2399c561eae3998abc015e8fe36a1e149fd289472919fb02816c5b46d72cf9f4335932419ecf2de8b + languageName: node + linkType: hard + "tty-browserify@npm:0.0.1": version: 0.0.1 resolution: "tty-browserify@npm:0.0.1" @@ -24203,6 +24751,16 @@ __metadata: languageName: node linkType: hard +"unplugin@npm:2.1.0": + version: 2.1.0 + resolution: "unplugin@npm:2.1.0" + dependencies: + acorn: "npm:^8.14.0" + webpack-virtual-modules: "npm:^0.6.2" + checksum: 10/b01041839c25ff5b0997677e00ffe8d98aa532443833968d652c0165eab3159702c3c3d1237afa58db35af5ccf25dd0a05782d78b7c22000742797e382362bcd + languageName: node + linkType: hard + "unplugin@npm:^1.3.1": version: 1.16.1 resolution: "unplugin@npm:1.16.1" @@ -25405,6 +25963,15 @@ __metadata: languageName: node linkType: hard +"webrtc-adapter@npm:^9.0.0": + version: 9.0.3 + resolution: "webrtc-adapter@npm:9.0.3" + dependencies: + sdp: "npm:^3.2.0" + checksum: 10/5544cb38093a20b485d1d24eb23483f9ad3cdab0b0edd277cc44a90e871269dd78d124d9c45e91381e16361f79744648575fce762b258bc02c12c3c28ef5e138 + languageName: node + linkType: hard + "websocket-driver@npm:>=0.5.1, websocket-driver@npm:^0.7.4": version: 0.7.4 resolution: "websocket-driver@npm:0.7.4" @@ -25960,3 +26527,24 @@ __metadata: checksum: 10/c5f04e6ac306515c4db6ef73cf7705f521c7a2107c8c8912416a0658d689f361db9bee829b0bf01ef4a22492f1065c5cbcdb523ce532606ac6792fd714f3c326 languageName: node linkType: hard + +"zustand@npm:^5.0.9": + version: 5.0.9 + resolution: "zustand@npm:5.0.9" + peerDependencies: + "@types/react": ">=18.0.0" + immer: ">=9.0.6" + react: ">=18.0.0" + use-sync-external-store: ">=1.2.0" + peerDependenciesMeta: + "@types/react": + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + checksum: 10/af24d8cc07383b85bf713d1be55a788d4559c5b20980852095c2352bfd0a3bc64d0ba1f08f2f440d5c298ea2856d34d67e5e74fafe29d8796e8b4a09781ca86c + languageName: node + linkType: hard From 32c12546d761a4120a870f88b79440115c4be597 Mon Sep 17 00:00:00 2001 From: samuelgja Date: Mon, 15 Dec 2025 18:58:09 +0700 Subject: [PATCH 11/24] chore(joint-react): add empty lines to test files for consistency - Added empty lines at the end of multiple test files to ensure consistency across the codebase. --- .../src/components/highlighters/__tests__/stroke.test.tsx | 1 + packages/joint-react/src/hooks/__tests__/use-element.test.tsx | 1 + packages/joint-react/src/hooks/__tests__/use-graph.test.ts | 1 + packages/joint-react/src/models/__tests__/react-element.test.ts | 1 + .../tutorials/step-by-step/code-controlled-mode-jotai.tsx | 1 + .../tutorials/step-by-step/code-controlled-mode-peerjs.tsx | 1 + .../tutorials/step-by-step/code-controlled-mode-redux.tsx | 1 + .../tutorials/step-by-step/code-controlled-mode-zustand.tsx | 1 + .../joint-react/src/utils/__tests__/is-react-element.test.ts | 1 + packages/joint-react/src/utils/__tests__/noop-selector.test.ts | 1 + .../utils/cell/__tests__/get-link-targe-and-source-ids.test.ts | 1 + 11 files changed, 11 insertions(+) diff --git a/packages/joint-react/src/components/highlighters/__tests__/stroke.test.tsx b/packages/joint-react/src/components/highlighters/__tests__/stroke.test.tsx index 250d623501..025bf244e7 100644 --- a/packages/joint-react/src/components/highlighters/__tests__/stroke.test.tsx +++ b/packages/joint-react/src/components/highlighters/__tests__/stroke.test.tsx @@ -86,3 +86,4 @@ describe('Stroke highlighter', () => { + diff --git a/packages/joint-react/src/hooks/__tests__/use-element.test.tsx b/packages/joint-react/src/hooks/__tests__/use-element.test.tsx index 5d094ed4eb..1646f6e602 100644 --- a/packages/joint-react/src/hooks/__tests__/use-element.test.tsx +++ b/packages/joint-react/src/hooks/__tests__/use-element.test.tsx @@ -41,3 +41,4 @@ describe('use-element', () => { + diff --git a/packages/joint-react/src/hooks/__tests__/use-graph.test.ts b/packages/joint-react/src/hooks/__tests__/use-graph.test.ts index ca7a84ed4f..3aa0712821 100644 --- a/packages/joint-react/src/hooks/__tests__/use-graph.test.ts +++ b/packages/joint-react/src/hooks/__tests__/use-graph.test.ts @@ -46,3 +46,4 @@ describe('use-graph', () => { + diff --git a/packages/joint-react/src/models/__tests__/react-element.test.ts b/packages/joint-react/src/models/__tests__/react-element.test.ts index 722ed44af6..03d8268b6c 100644 --- a/packages/joint-react/src/models/__tests__/react-element.test.ts +++ b/packages/joint-react/src/models/__tests__/react-element.test.ts @@ -111,3 +111,4 @@ describe('react-element', () => { + diff --git a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-jotai.tsx b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-jotai.tsx index 2cd8413370..14373a7d0a 100644 --- a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-jotai.tsx +++ b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-jotai.tsx @@ -331,3 +331,4 @@ function Main(props: Readonly) { export default function App(props: Readonly) { return
; } + diff --git a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-peerjs.tsx b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-peerjs.tsx index 6ceab77237..6723359cc0 100644 --- a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-peerjs.tsx +++ b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-peerjs.tsx @@ -613,3 +613,4 @@ function Main(props: Readonly) { export default function App(props: Readonly) { return
; } + diff --git a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-redux.tsx b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-redux.tsx index e5dad89516..94543d5cb5 100644 --- a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-redux.tsx +++ b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-redux.tsx @@ -493,3 +493,4 @@ function ReduxConnectedPaperApp() { export default function App(props: Readonly) { return
; } + diff --git a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-zustand.tsx b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-zustand.tsx index 101401165c..e4bb1582a0 100644 --- a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-zustand.tsx +++ b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-zustand.tsx @@ -322,3 +322,4 @@ function Main(props: Readonly) { export default function App(props: Readonly) { return
; } + diff --git a/packages/joint-react/src/utils/__tests__/is-react-element.test.ts b/packages/joint-react/src/utils/__tests__/is-react-element.test.ts index d61f66e249..f1ce424489 100644 --- a/packages/joint-react/src/utils/__tests__/is-react-element.test.ts +++ b/packages/joint-react/src/utils/__tests__/is-react-element.test.ts @@ -51,3 +51,4 @@ describe('is-react-element', () => { + diff --git a/packages/joint-react/src/utils/__tests__/noop-selector.test.ts b/packages/joint-react/src/utils/__tests__/noop-selector.test.ts index aa37a53b2d..68ce0ce3c4 100644 --- a/packages/joint-react/src/utils/__tests__/noop-selector.test.ts +++ b/packages/joint-react/src/utils/__tests__/noop-selector.test.ts @@ -39,3 +39,4 @@ describe('noop-selector', () => { + diff --git a/packages/joint-react/src/utils/cell/__tests__/get-link-targe-and-source-ids.test.ts b/packages/joint-react/src/utils/cell/__tests__/get-link-targe-and-source-ids.test.ts index 8f085a200c..165fc290f1 100644 --- a/packages/joint-react/src/utils/cell/__tests__/get-link-targe-and-source-ids.test.ts +++ b/packages/joint-react/src/utils/cell/__tests__/get-link-targe-and-source-ids.test.ts @@ -67,3 +67,4 @@ describe('get-link-targe-and-source-ids', () => { + From be7367ae6a3572ad4445b2458414dcfe2b94871b Mon Sep 17 00:00:00 2001 From: samuelgja Date: Thu, 18 Dec 2025 16:20:03 +0700 Subject: [PATCH 12/24] feat(joint-react): - work only with json -remove dia.element and dia.link from supported types in graphProvider. - graphProvider elements and links are source of truth - dia.graph do not change elements and links defined by the user - BY DeFAULT MAPPING - graph elements and links should be flat - remove measure node - useNodeSize | useNodeSize - custom mapper + default mappers from links and elements (???) --- .../decorators/with-simple-data.tsx | 10 +- .../graph/graph-provider.stories.tsx | 37 +- .../src/components/graph/graph-provider.tsx | 25 +- .../highlighters/__tests__/stroke.test.tsx | 2 + packages/joint-react/src/components/index.ts | 1 - .../__snapshots__/measured-node.test.tsx.snap | 7 - .../__tests__/measured-node.test.tsx | 9 - .../measured-node/measured-node.stories.tsx | 121 -- .../measured-node/measured-node.tsx | 79 - .../graph-provider-controlled-mode.test.tsx | 34 +- .../graph-provider-coverage.test.tsx | 4 +- .../paper/__tests__/graph-provider.test.tsx | 14 +- .../components/paper/__tests__/paper.test.tsx | 13 +- .../src/components/paper/paper.stories.tsx | 46 +- .../src/components/paper/paper.tsx | 2 +- .../components/port/port-group.stories.tsx | 15 +- .../src/components/port/port-item.stories.tsx | 11 +- .../text-node/text-node.stories.tsx | 46 +- .../src/components/text-node/text-node.tsx | 4 +- .../src/hooks/__tests__/use-element.test.tsx | 2 + .../src/hooks/__tests__/use-graph.test.ts | 2 + .../__tests__/use-measure-node-size.test.tsx | 22 +- packages/joint-react/src/hooks/index.ts | 2 +- .../src/hooks/use-graph-store-selector.ts | 5 +- ...easure-node-size.tsx => use-node-size.tsx} | 36 +- .../src/hooks/use-state-to-external-store.ts | 9 +- packages/joint-react/src/index.ts | 6 +- .../models/__tests__/react-element.test.ts | 2 + .../__tests__/graph-state-selectors.test.ts | 1514 +++++++++++++++++ .../__tests__/state-sync.test.ts} | 207 ++- .../src/state/graph-state-selectors.ts | 209 +++ .../graph-sync.ts => state/state-sync.ts} | 165 +- .../src/store/__tests__/graph-store.test.ts | 608 +++++++ packages/joint-react/src/store/graph-store.ts | 38 +- packages/joint-react/src/store/index.ts | 2 +- .../src/stories/demos/flowchart/code.tsx | 64 +- .../stories/demos/introduction-demo/code.tsx | 48 +- .../src/stories/demos/pulsing-port/code.tsx | 29 +- .../code-with-build-in-shapes.tsx | 12 +- .../examples/with-auto-layout/code.tsx | 12 +- .../src/stories/examples/with-card/code.tsx | 35 +- .../examples/with-intersection/code.tsx | 12 +- .../src/stories/examples/with-json/code.tsx | 20 +- .../stories/examples/with-list-node/code.tsx | 71 +- .../stories/examples/with-minimap/code.tsx | 20 +- .../with-node-update/code-add-remove-node.tsx | 33 +- .../examples/with-node-update/code.tsx | 11 +- .../examples/with-proximity-link/code.tsx | 15 +- .../examples/with-resizable-node/code.tsx | 18 +- .../examples/with-rotable-node/code.tsx | 24 +- .../stories/examples/with-svg-node/code.tsx | 43 +- .../code-controlled-mode-jotai.tsx | 5 - .../code-controlled-mode-peerjs.tsx | 2 + .../code-controlled-mode-redux.tsx | 21 +- .../code-controlled-mode-zustand.tsx | 5 - .../step-by-step/code-controlled-mode.tsx | 1 - .../step-by-step/code-html-renderer.tsx | 16 +- .../tutorials/step-by-step/code-html.tsx | 12 +- .../src/utils/__tests__/get-cell.test.ts | 6 +- .../utils/__tests__/is-react-element.test.ts | 2 + .../src/utils/__tests__/noop-selector.test.ts | 2 + .../get-link-targe-and-source-ids.test.ts | 2 + .../src/utils/cell/cell-utilities.ts | 43 +- 63 files changed, 3110 insertions(+), 783 deletions(-) delete mode 100644 packages/joint-react/src/components/measured-node/__tests__/__snapshots__/measured-node.test.tsx.snap delete mode 100644 packages/joint-react/src/components/measured-node/__tests__/measured-node.test.tsx delete mode 100644 packages/joint-react/src/components/measured-node/measured-node.stories.tsx delete mode 100644 packages/joint-react/src/components/measured-node/measured-node.tsx rename packages/joint-react/src/hooks/{use-measure-node-size.tsx => use-node-size.tsx} (67%) create mode 100644 packages/joint-react/src/state/__tests__/graph-state-selectors.test.ts rename packages/joint-react/src/{store/__tests__/graph-sync.test.ts => state/__tests__/state-sync.test.ts} (80%) create mode 100644 packages/joint-react/src/state/graph-state-selectors.ts rename packages/joint-react/src/{store/graph-sync.ts => state/state-sync.ts} (74%) create mode 100644 packages/joint-react/src/store/__tests__/graph-store.test.ts diff --git a/packages/joint-react/.storybook/decorators/with-simple-data.tsx b/packages/joint-react/.storybook/decorators/with-simple-data.tsx index 02a363b8ec..0f14f4b2c1 100644 --- a/packages/joint-react/.storybook/decorators/with-simple-data.tsx +++ b/packages/joint-react/.storybook/decorators/with-simple-data.tsx @@ -3,13 +3,13 @@ // @ts-expect-error do not provide typings. import JsonViewer from '@andypf/json-viewer/dist/esm/react/JsonViewer'; -import { useCallback, type HTMLProps, type JSX, type PropsWithChildren } from 'react'; +import { useCallback, useRef, type HTMLProps, type JSX, type PropsWithChildren } from 'react'; import { createElements, createLinks, GraphProvider, - MeasuredNode, useElement, + useNodeSize, type InferElement, } from '@joint/react'; import { PAPER_CLASSNAME, PRIMARY } from '../theme'; @@ -126,11 +126,11 @@ export function SimpleRenderItemDecorator(Story: StoryFunction, { args }: StoryC export function HTMLNode(props: PropsWithChildren>) { const { width, height } = useElement(); + const elementRef = useRef(null); + useNodeSize(elementRef); return ( - -
- +
); } diff --git a/packages/joint-react/src/components/graph/graph-provider.stories.tsx b/packages/joint-react/src/components/graph/graph-provider.stories.tsx index 462264f969..ce8068cc27 100644 --- a/packages/joint-react/src/components/graph/graph-provider.stories.tsx +++ b/packages/joint-react/src/components/graph/graph-provider.stories.tsx @@ -7,8 +7,8 @@ import { type SimpleElement, } from '../../../.storybook/decorators/with-simple-data'; import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; -import { MeasuredNode } from '../measured-node/measured-node'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { useNodeSize } from '../../hooks/use-node-size'; import { getAPILink } from '../../stories/utils/get-api-documentation-link'; import { makeRootDocumentation } from '../../stories/utils/make-story'; import { GraphProvider } from './graph-provider'; @@ -76,24 +76,25 @@ function MyDiagram() { export default meta; function RenderHTMLElement({ width, height }: SimpleElement) { + const elementRef = useRef(null); + useNodeSize(elementRef); return ( - -
- Hello -
-
+
+ Hello +
); } diff --git a/packages/joint-react/src/components/graph/graph-provider.tsx b/packages/joint-react/src/components/graph/graph-provider.tsx index e31f3976fb..4135fe0c94 100644 --- a/packages/joint-react/src/components/graph/graph-provider.tsx +++ b/packages/joint-react/src/components/graph/graph-provider.tsx @@ -6,6 +6,7 @@ import { useImperativeApi } from '../../hooks/use-imperative-api'; import { GraphStoreContext } from '../../context'; import { GraphStore, type ExternalGraphStore } from '../../store'; import { useStateToExternalStore } from '../../hooks/use-state-to-external-store'; +import type { GraphStateSelectors } from '../../state/graph-state-selectors'; /** * Props for GraphProvider component. @@ -61,13 +62,28 @@ interface GraphProviderProps { * - Integration with other React state management */ readonly onLinksChange?: Dispatch>; + + // readonly linkMapper: { + // toStateSelector: (link: GraphLink) => dia.Link.JSON; + // toDiaGraphSelector: (link: dia.Link.JSON) => GraphLink; + // }; + // readonly elementMapper: { + // toStateSelector: (element: GraphElement) => dia.Element.JSON; + // toDiaGraphSelector: (element: dia.Element.JSON) => GraphElement; + // }; } /** * Props for the GraphProvider component. * Extends GraphProviderProps with additional configuration options. + * @template Element - The type of elements in the graph + * @template Link - The type of links in the graph */ -export interface GraphProps extends GraphProviderProps { +export interface GraphProps< + Element extends GraphElement = GraphElement, + Link extends GraphLink = GraphLink, +> extends GraphProviderProps, + GraphStateSelectors { /** * Graph instance to use. If not provided, a new graph instance will be created. * @@ -126,6 +142,13 @@ export interface GraphProps extends GraphProviderProps { * with most state management libraries. */ readonly externalStore?: ExternalGraphStore; + + /** + * If true, batch updates are disabled and synchronization will be real-time. + * If false (default), batch updates are enabled for better performance. + * @default false + */ + readonly areBatchUpdatesDisabled?: boolean; } /** diff --git a/packages/joint-react/src/components/highlighters/__tests__/stroke.test.tsx b/packages/joint-react/src/components/highlighters/__tests__/stroke.test.tsx index 025bf244e7..3b7c90376b 100644 --- a/packages/joint-react/src/components/highlighters/__tests__/stroke.test.tsx +++ b/packages/joint-react/src/components/highlighters/__tests__/stroke.test.tsx @@ -87,3 +87,5 @@ describe('Stroke highlighter', () => { + + diff --git a/packages/joint-react/src/components/index.ts b/packages/joint-react/src/components/index.ts index 77f38cae84..193a235f8b 100644 --- a/packages/joint-react/src/components/index.ts +++ b/packages/joint-react/src/components/index.ts @@ -1,6 +1,5 @@ export * from './graph/graph-provider'; export * from './paper'; export * from './highlighters'; -export * from './measured-node/measured-node'; export * from './port'; export * from './text-node/text-node'; diff --git a/packages/joint-react/src/components/measured-node/__tests__/__snapshots__/measured-node.test.tsx.snap b/packages/joint-react/src/components/measured-node/__tests__/__snapshots__/measured-node.test.tsx.snap deleted file mode 100644 index fc05f473a5..0000000000 --- a/packages/joint-react/src/components/measured-node/__tests__/__snapshots__/measured-node.test.tsx.snap +++ /dev/null @@ -1,7 +0,0 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing - -exports[`MeasuredNode "DivWithExactSize": MeasuredNode-DivWithExactSize 1`] = `"
"`; - -exports[`MeasuredNode "DivWithPaddingAndText": MeasuredNode-DivWithPaddingAndText 1`] = `"
"`; - -exports[`MeasuredNode "TailwindSizing": MeasuredNode-TailwindSizing 1`] = `"
"`; diff --git a/packages/joint-react/src/components/measured-node/__tests__/measured-node.test.tsx b/packages/joint-react/src/components/measured-node/__tests__/measured-node.test.tsx deleted file mode 100644 index 4b00370906..0000000000 --- a/packages/joint-react/src/components/measured-node/__tests__/measured-node.test.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { runStorybookSnapshot } from '../../../utils/run-storybook-snapshot'; -import { MeasuredNode } from '../measured-node'; -import * as stories from '../measured-node.stories'; -runStorybookSnapshot({ - Component: MeasuredNode, - stories, - name: 'MeasuredNode', - withRenderElementWrapper: true, -}); diff --git a/packages/joint-react/src/components/measured-node/measured-node.stories.tsx b/packages/joint-react/src/components/measured-node/measured-node.stories.tsx deleted file mode 100644 index e00f52d586..0000000000 --- a/packages/joint-react/src/components/measured-node/measured-node.stories.tsx +++ /dev/null @@ -1,121 +0,0 @@ -/* eslint-disable react-perf/jsx-no-new-object-as-prop */ - -import type { Meta, StoryObj } from '@storybook/react'; -import { SimpleRenderItemDecorator } from '../../../.storybook/decorators/with-simple-data'; -import { MeasuredNode } from './measured-node'; -import { PRIMARY } from 'storybook-config/theme'; -import { getAPILink } from '../../stories/utils/get-api-documentation-link'; -import { useElement } from '../../hooks'; -import { makeRootDocumentation, makeStory } from '../../stories/utils/make-story'; - -const API_URL = getAPILink('MeasuredNode', 'variables'); - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function ForeignObjectDecorator(Story: any) { - const { width, height } = useElement(); - return ( - - - - ); -} -export type Story = StoryObj; - -const meta: Meta = { - title: 'Components/MeasuredNode', - component: MeasuredNode, - decorators: [ForeignObjectDecorator, SimpleRenderItemDecorator], - tags: ['component'], - parameters: makeRootDocumentation({ - apiURL: API_URL, - description: ` -The **MeasuredNode** component automatically measures the size of its children and updates the parent element's dimensions in the graph. This is essential for HTML content where the size is determined by the content itself. - -**Key Features:** -- Automatically measures child component dimensions -- Updates element size in the graph when content changes -- Supports custom size calculation via \`setSize\` prop -- Must be used inside \`renderElement\` within a \`\` - `, - usage: ` -\`\`\`tsx -import { MeasuredNode } from '@joint/react'; - -function RenderElement({ width, height }) { - return ( - - -
- Dynamic content that determines size -
-
-
- ); -} -\`\`\` - `, - props: ` -- **children**: React node to measure (required) -- **setSize**: Optional callback to customize size calculation - - Receives: \`{ element, size }\` where size is the measured dimensions - - Can modify the size before it's applied to the element - `, - code: `import { MeasuredNode } from '@joint/react' - - - -
Content
-
-
- `, - }), -}; - -export default meta; - -export const DivWithExactSize = makeStory({ - args: { - children: ( -
- ), - }, - apiURL: API_URL, - name: 'Measured div with exact size', - description: 'Div with exact size.', -}); - -export const DivWithPaddingAndText = makeStory({ - args: { - children: ( -
- Hello world! -
- ), - }, - apiURL: API_URL, - name: 'Measured div with padding and text', - description: 'Div with padding and text content.', -}); - -export const TailwindSizing = makeStory({ - args: { - children: ( -
- Hello world! -
- ), - }, - apiURL: API_URL, - name: 'Tailwind sizing', - description: 'Div with tailwind classes.', -}); diff --git a/packages/joint-react/src/components/measured-node/measured-node.tsx b/packages/joint-react/src/components/measured-node/measured-node.tsx deleted file mode 100644 index e2cd36a250..0000000000 --- a/packages/joint-react/src/components/measured-node/measured-node.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { forwardRef, memo } from 'react'; -import { useChildrenRef } from '../../hooks/use-children-ref'; -import { useMeasureNodeSize, type MeasureNodeOptions } from '../../hooks/use-measure-node-size'; - -export interface MeasuredNodeProps extends MeasureNodeOptions { - /** - * The child element to measure. - * It can be only HTML or SVG element. - */ - readonly children: React.ReactNode | null; -} - -// eslint-disable-next-line jsdoc/require-jsdoc -function Component( - props: MeasuredNodeProps, - forwardedRef: React.ForwardedRef -) { - const { children, ...options } = props; - const { elementRef, elementChildren } = useChildrenRef(children, forwardedRef); - useMeasureNodeSize(elementRef, options); - return elementChildren; -} - -const ForwardedRefComponent = forwardRef(Component); - -/** - * Measured node component automatically detects the size of its `children` and updates the graph element (node) width and height automatically when elements resize. - * - * It must be used inside `renderElement` context - * @see Paper - * @see PaperProps - * @group Components - * @example - * Example with a simple div: - * ```tsx - * import { MeasuredNode } from '@joint/react'; - * - * function RenderElement() { - * return ( - * - *
Content
- *
- * ); - * } - * ``` - * - * Example with a simple div without explicit size defined: - * ```tsx - * import { MeasuredNode } from '@joint/react'; - * - * function RenderElement() { - * return ( - * - *
Content
- *
- * ); - * } - * ``` - * @example - * Example with custom size handling: - * ```tsx - * import { MeasuredNode } from '@joint/react'; - * import type { dia } from '@joint/core'; - * - * function RenderElement() { - * const handleSizeChange = (element: dia.Cell, size: { width: number; height: number }) => { - * console.log('New size:', size); - * element.set('size', { width: size.width + 10, height: size.height + 10 }); - * }; - * - * return ( - * - *
Content
- *
- * ); - * } - * ``` - */ -export const MeasuredNode = memo(ForwardedRefComponent); diff --git a/packages/joint-react/src/components/paper/__tests__/graph-provider-controlled-mode.test.tsx b/packages/joint-react/src/components/paper/__tests__/graph-provider-controlled-mode.test.tsx index 84bee62f3f..a8553c5c55 100644 --- a/packages/joint-react/src/components/paper/__tests__/graph-provider-controlled-mode.test.tsx +++ b/packages/joint-react/src/components/paper/__tests__/graph-provider-controlled-mode.test.tsx @@ -7,7 +7,7 @@ import { useElements, useLinks, useGraph } from '../../../hooks'; import { createElements } from '../../../utils/create'; import type { GraphElement } from '../../../types/element-types'; import type { GraphLink } from '../../../types/link-types'; -import { linkFromGraph } from '../../../utils/cell/cell-utilities'; +import { mapLinkFromGraph } from '../../../utils/cell/cell-utilities'; import { GraphProvider } from '../../graph/graph-provider'; describe('GraphProvider Controlled Mode', () => { @@ -29,7 +29,7 @@ describe('GraphProvider Controlled Mode', () => { } function ControlledGraph() { - const [elements, setElements] = useState(initialElements); + const [elements, setElements] = useState(() => initialElements); return ( @@ -63,7 +63,7 @@ describe('GraphProvider Controlled Mode', () => { let setElementsExternal: ((elements: GraphElement[]) => void) | null = null; function ControlledGraph() { - const [elements, setElements] = useState(initialElements); + const [elements, setElements] = useState(() => initialElements); setElementsExternal = setElements as (elements: GraphElement[]) => void; return ( @@ -119,8 +119,8 @@ describe('GraphProvider Controlled Mode', () => { let setLinksExternal: ((links: GraphLink[]) => void) | null = null; function ControlledGraph() { - const [elements, setElements] = useState(initialElements); - const [links, setLinks] = useState([linkFromGraph(initialLink)]); + const [elements, setElements] = useState(() => initialElements); + const [links, setLinks] = useState(() => [mapLinkFromGraph(initialLink)]); setElementsExternal = setElements as (elements: GraphElement[]) => void; setLinksExternal = setLinks as (links: GraphLink[]) => void; return ( @@ -160,7 +160,7 @@ describe('GraphProvider Controlled Mode', () => { // Update links only act(() => { setLinksExternal?.([ - linkFromGraph( + mapLinkFromGraph( new dia.Link({ id: 'link1', type: 'standard.Link', @@ -168,7 +168,7 @@ describe('GraphProvider Controlled Mode', () => { target: { id: '2' }, }) ), - linkFromGraph( + mapLinkFromGraph( new dia.Link({ id: 'link2', type: 'standard.Link', @@ -203,7 +203,7 @@ describe('GraphProvider Controlled Mode', () => { let setElementsExternal: ((elements: GraphElement[]) => void) | null = null; function ControlledGraph() { - const [elements, setElements] = useState(initialElements); + const [elements, setElements] = useState(() => initialElements); setElementsExternal = setElements as (elements: GraphElement[]) => void; return ( @@ -266,7 +266,7 @@ describe('GraphProvider Controlled Mode', () => { let setElementsExternal: ((elements: GraphElement[]) => void) | null = null; function ControlledGraph() { - const [elements, setElements] = useState(initialElements); + const [elements, setElements] = useState(() => initialElements); setElementsExternal = setElements as (elements: GraphElement[]) => void; return ( @@ -331,8 +331,8 @@ describe('GraphProvider Controlled Mode', () => { let setLinksExternal: ((links: GraphLink[]) => void) | null = null; function ControlledGraph() { - const [elements, setElements] = useState(initialElements); - const [links, setLinks] = useState([linkFromGraph(initialLink)]); + const [elements, setElements] = useState(() => initialElements); + const [links, setLinks] = useState(() => [mapLinkFromGraph(initialLink)]); setElementsExternal = setElements as (elements: GraphElement[]) => void; setLinksExternal = setLinks as (links: GraphLink[]) => void; return ( @@ -363,7 +363,7 @@ describe('GraphProvider Controlled Mode', () => { ]) ); setLinksExternal?.([ - linkFromGraph( + mapLinkFromGraph( new dia.Link({ id: 'link1', type: 'standard.Link', @@ -371,7 +371,7 @@ describe('GraphProvider Controlled Mode', () => { target: { id: '2' }, }) ), - linkFromGraph( + mapLinkFromGraph( new dia.Link({ id: 'link2', type: 'standard.Link', @@ -405,7 +405,7 @@ describe('GraphProvider Controlled Mode', () => { } function ControlledGraph() { - const [elements, setElements] = useState(initialElements); + const [elements, setElements] = useState(() => initialElements); const handleAddElement = useCallback(() => { setElements((previous) => [ @@ -467,7 +467,7 @@ describe('GraphProvider Controlled Mode', () => { } function ControlledGraph() { - const [elements, setElements] = useState(initialElements); + const [elements, setElements] = useState(() => initialElements); reactStateElements = elements; return ( @@ -532,7 +532,7 @@ describe('GraphProvider Controlled Mode', () => { let reactStateElements: GraphElement[] = []; function ControlledGraph() { - const [elements, setElements] = useState(initialElements); + const [elements, setElements] = useState(() => initialElements); reactStateElements = elements; return ( @@ -601,7 +601,7 @@ describe('GraphProvider Controlled Mode', () => { let setElementsExternal: ((elements: GraphElement[]) => void) | null = null; function ControlledGraph() { - const [elements, setElements] = useState(initialElements); + const [elements, setElements] = useState(() => initialElements); setElementsExternal = setElements as (elements: GraphElement[]) => void; return ( diff --git a/packages/joint-react/src/components/paper/__tests__/graph-provider-coverage.test.tsx b/packages/joint-react/src/components/paper/__tests__/graph-provider-coverage.test.tsx index b2637f6213..6ae414af53 100644 --- a/packages/joint-react/src/components/paper/__tests__/graph-provider-coverage.test.tsx +++ b/packages/joint-react/src/components/paper/__tests__/graph-provider-coverage.test.tsx @@ -7,7 +7,7 @@ import { useElements, useLinks } from '../../../hooks'; import { createElements } from '../../../utils/create'; import type { GraphElement } from '../../../types/element-types'; import type { GraphLink } from '../../../types/link-types'; -import { linkFromGraph } from '../../../utils/cell/cell-utilities'; +import { mapLinkFromGraph } from '../../../utils/cell/cell-utilities'; import { GraphProvider } from '../../graph/graph-provider'; import { GraphStore } from '../../../store'; @@ -110,7 +110,7 @@ describe('GraphProvider Coverage Tests', () => { } function ControlledGraph() { - const [links, setLinks] = useState([linkFromGraph(initialLink)]); + const [links, setLinks] = useState(() => [mapLinkFromGraph(initialLink)]); return ( diff --git a/packages/joint-react/src/components/paper/__tests__/graph-provider.test.tsx b/packages/joint-react/src/components/paper/__tests__/graph-provider.test.tsx index 9a9d82e74d..1af64589d1 100644 --- a/packages/joint-react/src/components/paper/__tests__/graph-provider.test.tsx +++ b/packages/joint-react/src/components/paper/__tests__/graph-provider.test.tsx @@ -7,7 +7,7 @@ import { useElements, useLinks } from '../../../hooks'; import { createElements } from '../../../utils/create'; import type { GraphElement } from '../../../types/element-types'; import type { GraphLink } from '../../../types/link-types'; -import { linkFromGraph } from '../../../utils/cell/cell-utilities'; +import { mapLinkFromGraph } from '../../../utils/cell/cell-utilities'; import { GraphProvider } from '../../graph/graph-provider'; describe('graph', () => { @@ -59,7 +59,7 @@ describe('graph', () => { } render( // eslint-disable-next-line react-perf/jsx-no-new-array-as-prop - + ); @@ -251,7 +251,7 @@ describe('graph', () => { } render( // eslint-disable-next-line react-perf/jsx-no-new-array-as-prop - + ); @@ -292,8 +292,8 @@ describe('graph', () => { let setLinksOutside: ((links: GraphLink[]) => void) | null = null; function Graph() { - const [elements, setElements] = useState(initialElements); - const [links, setLinks] = useState([linkFromGraph(initialLink)]); + const [elements, setElements] = useState(() => initialElements); + const [links, setLinks] = useState(() => [mapLinkFromGraph(initialLink)]); setElementsOutside = setElements as unknown as (elements: GraphElement[]) => void; setLinksOutside = setLinks as unknown as (links: GraphLink[]) => void; return ( @@ -341,7 +341,7 @@ describe('graph', () => { // add link act(() => { setLinksOutside?.([ - linkFromGraph( + mapLinkFromGraph( new dia.Link({ id: 'link2', type: 'standard.Link', @@ -349,7 +349,7 @@ describe('graph', () => { target: { id: 'element2' }, }) ), - linkFromGraph( + mapLinkFromGraph( new dia.Link({ id: 'link3', type: 'standard.Link', diff --git a/packages/joint-react/src/components/paper/__tests__/paper.test.tsx b/packages/joint-react/src/components/paper/__tests__/paper.test.tsx index 6ff8cc600f..74fb2ca558 100644 --- a/packages/joint-react/src/components/paper/__tests__/paper.test.tsx +++ b/packages/joint-react/src/components/paper/__tests__/paper.test.tsx @@ -5,7 +5,8 @@ import { render, screen, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom'; import { createElements, type InferElement } from '../../../utils/create'; -import { MeasuredNode } from '../../measured-node/measured-node'; +import React from 'react'; +import { useNodeSize } from '../../../hooks/use-node-size'; import { act, useEffect, useRef, useState, type RefObject } from 'react'; import type { PaperStore } from '../../../store'; import { useGraph, usePaperStoreContext } from '../../../hooks'; @@ -47,11 +48,15 @@ describe('Paper Component', () => { const renderElement = ({ label, width, height }: Element) => { size = { width, height }; + // eslint-disable-next-line react-hooks/rules-of-hooks + const elementRef = React.useRef(null); + // eslint-disable-next-line react-hooks/rules-of-hooks + useNodeSize(elementRef); return ( - -
{label}
-
+
+ {label} +
); }; diff --git a/packages/joint-react/src/components/paper/paper.stories.tsx b/packages/joint-react/src/components/paper/paper.stories.tsx index 040b4b9e1c..fb8989c120 100644 --- a/packages/joint-react/src/components/paper/paper.stories.tsx +++ b/packages/joint-react/src/components/paper/paper.stories.tsx @@ -3,6 +3,7 @@ /* eslint-disable react-perf/jsx-no-new-function-as-prop */ /* eslint-disable react-perf/jsx-no-new-object-as-prop */ +import React from 'react'; import type { Meta, StoryObj } from '@storybook/react'; import { SimpleGraphDecorator, @@ -11,7 +12,7 @@ import { import { action } from '@storybook/addon-actions'; import { dia, linkTools } from '@joint/core'; import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; -import { MeasuredNode } from '../measured-node/measured-node'; +import { useNodeSize } from '../../hooks/use-node-size'; import { getAPILink } from '../../stories/utils/get-api-documentation-link'; import { makeRootDocumentation } from '../../stories/utils/make-story'; import { jsx } from '../../utils/joint-jsx/jsx-to-markup'; @@ -98,24 +99,25 @@ function RenderRectElement({ width, height }: SimpleElement) { } function RenderHTMLElement({ width, height }: SimpleElement) { + const elementRef = React.useRef(null); + useNodeSize(elementRef); return ( - -
- Hello -
-
+
+ Hello +
); } @@ -358,7 +360,15 @@ export const WithOnClickColorChange: Story = { return ( ( if (!areElementsMeasured) { // eslint-disable-next-line no-console console.error( - 'The elements are not measured yet, please check if elements has defined width and height inside the nodes or using `MeasuredNode` component.' + 'The elements are not measured yet, please check if elements has defined width and height inside the nodes or using `useNodeSize` hook.' ); } }, 1000); diff --git a/packages/joint-react/src/components/port/port-group.stories.tsx b/packages/joint-react/src/components/port/port-group.stories.tsx index e6dfb86336..3fb032be02 100644 --- a/packages/joint-react/src/components/port/port-group.stories.tsx +++ b/packages/joint-react/src/components/port/port-group.stories.tsx @@ -1,14 +1,15 @@ /* eslint-disable react-perf/jsx-no-new-function-as-prop */ /* eslint-disable sonarjs/prefer-read-only-props */ +import React from 'react'; import type { Meta, StoryObj } from '@storybook/react'; import '../../stories/examples/index.css'; import { createElements, createLinks, GraphProvider, - MeasuredNode, Port, useElement, + useNodeSize, } from '@joint/react'; import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; import { PortGroup } from './port-group'; @@ -58,14 +59,14 @@ const API_URL = getAPILink('Port.Group', 'variables'); function RenderItem(Story: React.FC) { const { width, height } = useElement(); + const elementRef = React.useRef(null); + useNodeSize(elementRef); return ( - -
- Test - -
-
+
+ Test + +
); } diff --git a/packages/joint-react/src/components/port/port-item.stories.tsx b/packages/joint-react/src/components/port/port-item.stories.tsx index a82714d310..dfdb1482db 100644 --- a/packages/joint-react/src/components/port/port-item.stories.tsx +++ b/packages/joint-react/src/components/port/port-item.stories.tsx @@ -1,14 +1,15 @@ /* eslint-disable react-perf/jsx-no-new-function-as-prop */ /* eslint-disable sonarjs/prefer-read-only-props */ +import React from 'react'; import type { Meta, StoryObj } from '@storybook/react'; import '../../stories/examples/index.css'; import { createElements, createLinks, GraphProvider, - MeasuredNode, Port, useElement, + useNodeSize, } from '@joint/react'; import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; import { getAPILink } from '../../stories/utils/get-api-documentation-link'; @@ -65,13 +66,15 @@ export type Story = StoryObj; const API_URL = getAPILink('Port.Item', 'variables'); function RenderItem(Story: React.FC) { const { width, height } = useElement(); + const elementRef = React.useRef(null); + useNodeSize(elementRef); return ( <> - -
Test
-
+
+ Test +
); diff --git a/packages/joint-react/src/components/text-node/text-node.stories.tsx b/packages/joint-react/src/components/text-node/text-node.stories.tsx index 7f5adb5577..f1a4d1f520 100644 --- a/packages/joint-react/src/components/text-node/text-node.stories.tsx +++ b/packages/joint-react/src/components/text-node/text-node.stories.tsx @@ -1,11 +1,10 @@ - - +import React from 'react'; import type { Meta, StoryObj } from '@storybook/react'; import { SimpleRenderItemDecorator } from '../../../.storybook/decorators/with-simple-data'; import { TextNode } from './text-node'; import { PRIMARY } from 'storybook-config/theme'; import { useElement } from '../../hooks'; -import { MeasuredNode } from '../measured-node/measured-node'; +import { useNodeSize } from '../../hooks/use-node-size'; import { getAPILink } from '../../stories/utils/get-api-documentation-link'; import { makeRootDocumentation, makeStory } from '../../stories/utils/make-story'; @@ -15,6 +14,8 @@ export type Story = StoryObj; // eslint-disable-next-line @typescript-eslint/no-explicit-any function SVGDecorator(Story: any) { const { width = 0, height = 0 } = useElement(); + const gRef = React.useRef(null); + useNodeSize(gRef); const PADDING = 10; return ( @@ -27,11 +28,9 @@ function SVGDecorator(Story: any) { ry={PADDING} transform={`translate(-${PADDING}, -${PADDING})`} /> - - - - - + + + ); } @@ -44,31 +43,32 @@ const meta: Meta = { parameters: makeRootDocumentation({ apiURL: API_URL, description: ` -The **TextNode** component renders SVG text with automatic sizing and wrapping capabilities. It's designed to work seamlessly with MeasuredNode for dynamic text content. +The **TextNode** component renders SVG text with automatic sizing and wrapping capabilities. It's designed to work seamlessly with \`useNodeSize\` hook for dynamic text content. **Key Features:** - Renders SVG text elements - Supports automatic text wrapping -- Integrates with MeasuredNode for dynamic sizing +- Integrates with \`useNodeSize\` hook for dynamic sizing - Supports all standard SVG text properties `, usage: ` \`\`\`tsx -import { TextNode, MeasuredNode } from '@joint/react'; +import { TextNode, useNodeSize } from '@joint/react'; import { useElement } from '@joint/react'; +import { useRef } from 'react'; function RenderElement() { const { width, height } = useElement(); + const gRef = useRef(null); + useNodeSize(gRef); return ( <> - - - - Your text content here - - - + + + Your text content here + + ); } @@ -82,13 +82,17 @@ function RenderElement() { - **fontSize**: Text size (default: 14) - And other standard SVG text properties `, - code: `import { TextNode, MeasuredNode } from '@joint/react' + code: `import { TextNode, useNodeSize } from '@joint/react' +import { useRef } from 'react'; + +const gRef = useRef(null); +useNodeSize(gRef); - + Hello world - + `, }), }; diff --git a/packages/joint-react/src/components/text-node/text-node.tsx b/packages/joint-react/src/components/text-node/text-node.tsx index 4630acf61e..0cbd276e67 100644 --- a/packages/joint-react/src/components/text-node/text-node.tsx +++ b/packages/joint-react/src/components/text-node/text-node.tsx @@ -52,7 +52,9 @@ function Component(props: TextNodeProps, ref: React.ForwardedRef } else if (breakTextWidth == undefined) { const element = graph.getCell(cellId); if (!element.isElement()) { - throw new TypeError('TextNode must be used inside a MeasuredNode'); + throw new TypeError( + 'TextNode must be used with useNodeSize hook to measure the element size' + ); } breakTextWidth = element.size().width ?? 0; } diff --git a/packages/joint-react/src/hooks/__tests__/use-element.test.tsx b/packages/joint-react/src/hooks/__tests__/use-element.test.tsx index 1646f6e602..8cc84dfa31 100644 --- a/packages/joint-react/src/hooks/__tests__/use-element.test.tsx +++ b/packages/joint-react/src/hooks/__tests__/use-element.test.tsx @@ -42,3 +42,5 @@ describe('use-element', () => { + + diff --git a/packages/joint-react/src/hooks/__tests__/use-graph.test.ts b/packages/joint-react/src/hooks/__tests__/use-graph.test.ts index 3aa0712821..ed815bf76a 100644 --- a/packages/joint-react/src/hooks/__tests__/use-graph.test.ts +++ b/packages/joint-react/src/hooks/__tests__/use-graph.test.ts @@ -47,3 +47,5 @@ describe('use-graph', () => { + + diff --git a/packages/joint-react/src/hooks/__tests__/use-measure-node-size.test.tsx b/packages/joint-react/src/hooks/__tests__/use-measure-node-size.test.tsx index 5ea4bd57b8..6ec7e5031a 100644 --- a/packages/joint-react/src/hooks/__tests__/use-measure-node-size.test.tsx +++ b/packages/joint-react/src/hooks/__tests__/use-measure-node-size.test.tsx @@ -1,7 +1,7 @@ /* eslint-disable @eslint-react/hooks-extra/no-unnecessary-use-prefix */ import React, { useRef } from 'react'; import { render, act } from '@testing-library/react'; -import { useMeasureNodeSize } from '../use-measure-node-size'; +import { useNodeSize } from '../use-node-size'; // Mocks for @joint/core and useGraphStore @@ -44,7 +44,7 @@ jest.mock('../../store/create-elements-size-observer', () => ({ }, })); -describe('useMeasureNodeSize', () => { +describe('useNodeSize', () => { beforeEach(() => { // Reset mocks before each test mockHasMeasuredNode.mockReturnValue(false); @@ -61,7 +61,7 @@ describe('useMeasureNodeSize', () => { } function TestComponent({ style, children, setSize }: TestComponentProps) { const ref = useRef(null); - useMeasureNodeSize(ref, { setSize }); + useNodeSize(ref, { setSize }); return (
{children} @@ -118,8 +118,8 @@ describe('useMeasureNodeSize', () => { expect(mockSetMeasuredNode).toHaveBeenCalled(); }); - describe('multiple MeasuredNode error', () => { - it('should throw error when multiple MeasuredNode components are used for the same element', () => { + describe('multiple useNodeSize hook error', () => { + it('should throw error when multiple useNodeSize hooks are used for the same element', () => { // Mock that a measured node already exists mockHasMeasuredNode.mockReturnValue(true); @@ -137,7 +137,7 @@ describe('useMeasureNodeSize', () => { // Verify error was thrown expect(caughtError).toBeDefined(); expect(caughtError).toBeInstanceOf(Error); - expect(caughtError?.message).toContain('Multiple MeasuredNode components detected'); + expect(caughtError?.message).toContain('Multiple useNodeSize hooks detected'); consoleError.mockRestore(); }); @@ -166,11 +166,11 @@ describe('useMeasureNodeSize', () => { expect(caughtError).toBeInstanceOf(Error); const errorMessage = caughtError?.message ?? ''; expect(errorMessage).toContain( - 'Multiple MeasuredNode components detected for element with id "cell-1"' + 'Multiple useNodeSize hooks detected for element with id "cell-1"' ); - expect(errorMessage).toContain('Only one MeasuredNode can be used per element'); + expect(errorMessage).toContain('Only one useNodeSize hook can be used per element'); expect(errorMessage).toContain('Solution:'); - expect(errorMessage).toContain('Use only one MeasuredNode per element'); + expect(errorMessage).toContain('Use only one useNodeSize hook per element'); expect(errorMessage).toContain('custom `setSize` handler'); expect(errorMessage).toContain('Check your renderElement function'); @@ -203,7 +203,7 @@ describe('useMeasureNodeSize', () => { expect(caughtError).toBeInstanceOf(Error); const errorMessage = caughtError?.message ?? ''; expect(errorMessage).toBe( - 'Multiple MeasuredNode components detected for element "cell-1". Only one MeasuredNode can be used per element.' + 'Multiple useNodeSize hooks detected for element "cell-1". Only one useNodeSize hook can be used per element.' ); // Should not contain detailed solution in production expect(errorMessage).not.toContain('Solution:'); @@ -214,7 +214,7 @@ describe('useMeasureNodeSize', () => { consoleError.mockRestore(); }); - it('should not throw error when no MeasuredNode exists for the element', () => { + it('should not throw error when no useNodeSize hook exists for the element', () => { // Mock that no measured node exists mockHasMeasuredNode.mockReturnValue(false); diff --git a/packages/joint-react/src/hooks/index.ts b/packages/joint-react/src/hooks/index.ts index ffdc8bd93c..51086be0aa 100644 --- a/packages/joint-react/src/hooks/index.ts +++ b/packages/joint-react/src/hooks/index.ts @@ -3,7 +3,7 @@ export * from './use-paper'; export * from './use-links'; export * from './use-elements'; export * from './use-element'; -export * from './use-measure-node-size'; +export * from './use-node-size'; export * from './use-cell-id'; export * from './use-paper-events'; export * from './use-imperative-api'; diff --git a/packages/joint-react/src/hooks/use-graph-store-selector.ts b/packages/joint-react/src/hooks/use-graph-store-selector.ts index 0dba8d25fb..12b71fa20a 100644 --- a/packages/joint-react/src/hooks/use-graph-store-selector.ts +++ b/packages/joint-react/src/hooks/use-graph-store-selector.ts @@ -1,4 +1,3 @@ -import type { dia } from '@joint/core'; import type { GraphStoreDerivedSnapshot, GraphStoreSnapshot, @@ -39,8 +38,8 @@ export function useStoreSelector( */ export function useGraphStoreSelector< Selection, - Element extends dia.Element | GraphElement = GraphElement, - Link extends dia.Link | GraphLink = GraphLink, + Element extends GraphElement = GraphElement, + Link extends GraphLink = GraphLink, >( selector: (snapshot: MarkDeepReadOnly>) => Selection, isEqual?: (a: Selection, b: Selection) => boolean diff --git a/packages/joint-react/src/hooks/use-measure-node-size.tsx b/packages/joint-react/src/hooks/use-node-size.tsx similarity index 67% rename from packages/joint-react/src/hooks/use-measure-node-size.tsx rename to packages/joint-react/src/hooks/use-node-size.tsx index 3740548f81..effcdb45c7 100644 --- a/packages/joint-react/src/hooks/use-measure-node-size.tsx +++ b/packages/joint-react/src/hooks/use-node-size.tsx @@ -18,35 +18,35 @@ const EMPTY_OBJECT: MeasureNodeOptions = {}; * Custom hook to measure the size of a node and update its size in the graph. * It uses the `createElementSizeObserver` utility to observe size changes. * - * **Important:** Only one `MeasuredNode` (or `useMeasureNodeSize` hook) can be used per element. - * Using multiple `MeasuredNode` components for the same element will throw an error in development - * and cause unexpected behavior. If you need multiple measurements, use a single `MeasuredNode` - * with a custom `setSize` handler. + * **Important:** Only one `useNodeSize` hook can be used per element. + * Using multiple `useNodeSize` hooks for the same element will throw an error in development + * and cause unexpected behavior. If you need multiple measurements, use a single `useNodeSize` + * hook with a custom `setSize` handler. * @param elementRef - A reference to the HTML or SVG element to measure. * @param options - Options for measuring the node size. - * @throws {Error} If multiple `MeasuredNode` components are used for the same element. + * @throws {Error} If multiple `useNodeSize` hooks are used for the same element. * @group Hooks * @example * ```tsx - * import { useMeasureNodeSize } from '@joint/react'; + * import { useNodeSize } from '@joint/react'; * import { useRef } from 'react'; * * function RenderElement() { * const elementRef = useRef(null); - * useMeasureNodeSize(elementRef); + * useNodeSize(elementRef); * return
Content
; * } * ``` * @example * With custom size handler: * ```tsx - * import { useMeasureNodeSize } from '@joint/react'; + * import { useNodeSize } from '@joint/react'; * import { useRef } from 'react'; * import type { dia } from '@joint/core'; * * function RenderElement() { * const elementRef = useRef(null); - * useMeasureNodeSize(elementRef, { + * useNodeSize(elementRef, { * setSize: ({ element, size }) => { * // Custom size handling * element.set('size', { width: size.width + 10, height: size.height + 10 }); @@ -56,7 +56,7 @@ const EMPTY_OBJECT: MeasureNodeOptions = {}; * } * ``` */ -export function useMeasureNodeSize( +export function useNodeSize( elementRef: RefObject, options?: MeasureNodeOptions ) { @@ -66,22 +66,22 @@ export function useMeasureNodeSize( useLayoutEffect(() => { const element = elementRef.current; - if (!element) throw new Error('MeasuredNode must have a child element'); + if (!element) throw new Error('useNodeSize: elementRef.current must not be null'); const cell = graph.getCell(id); if (!cell?.isElement()) throw new Error('Cell not valid'); - // Check if another MeasuredNode is already measuring this element + // Check if another useNodeSize hook is already measuring this element if (hasMeasuredNode(id)) { const errorMessage = process.env.NODE_ENV === 'production' - ? `Multiple MeasuredNode components detected for element "${id}". Only one MeasuredNode can be used per element.` - : `Multiple MeasuredNode components detected for element with id "${id}".\n\n` + - `Only one MeasuredNode can be used per element. Multiple MeasuredNode components ` + + ? `Multiple useNodeSize hooks detected for element "${id}". Only one useNodeSize hook can be used per element.` + : `Multiple useNodeSize hooks detected for element with id "${id}".\n\n` + + `Only one useNodeSize hook can be used per element. Multiple useNodeSize hooks ` + `trying to set the size for the same element will cause conflicts and unexpected behavior.\n\n` + `Solution:\n` + - `- Use only one MeasuredNode per element\n` + - `- If you need multiple measurements, use a single MeasuredNode with a custom \`setSize\` handler\n` + - `- Check your renderElement function to ensure you're not rendering multiple MeasuredNode components for the same element`; + `- Use only one useNodeSize hook per element\n` + + `- If you need multiple measurements, use a single useNodeSize hook with a custom \`setSize\` handler\n` + + `- Check your renderElement function to ensure you're not using multiple useNodeSize hooks for the same element`; throw new Error(errorMessage); } diff --git a/packages/joint-react/src/hooks/use-state-to-external-store.ts b/packages/joint-react/src/hooks/use-state-to-external-store.ts index 4fc48df3c0..9d49b1e8a4 100644 --- a/packages/joint-react/src/hooks/use-state-to-external-store.ts +++ b/packages/joint-react/src/hooks/use-state-to-external-store.ts @@ -40,10 +40,7 @@ interface Options( +export function useStateToExternalStore( options: Options ): ExternalStoreLike> | undefined { const { elements = [], links = [], onElementsChange, onLinksChange } = options; @@ -73,9 +70,7 @@ export function useStateToExternalStore< notifySubscribers.current(); }, [elements, hasOnChange, links]); - const store = useMemo((): - | ExternalStoreLike> - | undefined => { + const store = useMemo((): ExternalStoreLike> | undefined => { if (!hasOnChange) { return undefined; } diff --git a/packages/joint-react/src/index.ts b/packages/joint-react/src/index.ts index 75d0238fd3..1465200a42 100644 --- a/packages/joint-react/src/index.ts +++ b/packages/joint-react/src/index.ts @@ -7,9 +7,9 @@ export * from './hooks'; export * from './utils/create'; export { - elementFromGraph, - linkFromGraph, - linkToGraph, + mapElementFromGraph as elementFromGraph, + mapLinkFromGraph as linkFromGraph, + mapLinkToGraph as linkToGraph, syncGraph, type CellOrJsonCell, } from './utils/cell/cell-utilities'; diff --git a/packages/joint-react/src/models/__tests__/react-element.test.ts b/packages/joint-react/src/models/__tests__/react-element.test.ts index 03d8268b6c..7112eacad8 100644 --- a/packages/joint-react/src/models/__tests__/react-element.test.ts +++ b/packages/joint-react/src/models/__tests__/react-element.test.ts @@ -112,3 +112,5 @@ describe('react-element', () => { + + diff --git a/packages/joint-react/src/state/__tests__/graph-state-selectors.test.ts b/packages/joint-react/src/state/__tests__/graph-state-selectors.test.ts new file mode 100644 index 0000000000..6e971c2449 --- /dev/null +++ b/packages/joint-react/src/state/__tests__/graph-state-selectors.test.ts @@ -0,0 +1,1514 @@ +/* eslint-disable sonarjs/no-nested-functions */ +/* eslint-disable sonarjs/no-alphabetical-sort */ +import { dia, shapes } from '@joint/core'; +import { ReactElement } from '../../models/react-element'; +import type { GraphElement } from '../../types/element-types'; +import type { GraphLink } from '../../types/link-types'; +import { + defaultElementFromGraphSelector, + defaultElementToGraphSelector, + defaultLinkFromGraphSelector, + defaultLinkToGraphSelector, + type ElementFromGraphOptions, + type ElementToGraphOptions, + type LinkFromGraphOptions, + type LinkToGraphOptions, +} from '../graph-state-selectors'; + +const DEFAULT_CELL_NAMESPACE = { ...shapes, ReactElement }; + +describe('graph-state-selectors', () => { + let graph: dia.Graph; + + beforeEach(() => { + graph = new dia.Graph({}, { cellNamespace: DEFAULT_CELL_NAMESPACE }); + }); + + afterEach(() => { + graph.clear(); + }); + + describe('defaultElementToGraphSelector', () => { + it('should map element to graph cell JSON', () => { + const element: GraphElement = { + id: 'element-1', + x: 10, + y: 20, + width: 100, + height: 50, + type: 'ReactElement', + }; + + const options: ElementToGraphOptions = { + element, + graph, + }; + + const elementAsGraphJson = defaultElementToGraphSelector(options); + + expect(elementAsGraphJson).toMatchObject({ + id: 'element-1', + type: 'ReactElement', + position: { x: 10, y: 20 }, + size: { width: 100, height: 50 }, + x: 10, + y: 20, + width: 100, + height: 50, + }); + + // Round-trip: element → graph → element + graph.syncCells([elementAsGraphJson], { remove: true }); + + const elementFromGraph = defaultElementFromGraphSelector({ + cell: graph.getCell('element-1') as dia.Element, + graph, + }); + + expect(elementFromGraph).toMatchObject({ + id: 'element-1', + x: 10, + y: 20, + width: 100, + height: 50, + }); + }); + + it('should handle element without type (defaults to REACT_TYPE)', () => { + const element: GraphElement = { + id: 'element-1', + x: 10, + y: 20, + width: 100, + height: 50, + }; + + const options: ElementToGraphOptions = { + element, + graph, + }; + + const elementAsGraphJson = defaultElementToGraphSelector(options); + + expect(elementAsGraphJson.type).toBe('ReactElement'); + + // Round-trip: element → graph → element + graph.syncCells([elementAsGraphJson], { remove: true }); + + const elementFromGraph = defaultElementFromGraphSelector({ + cell: graph.getCell('element-1') as dia.Element, + graph, + }); + + expect(elementFromGraph).toMatchObject({ + id: 'element-1', + x: 10, + y: 20, + width: 100, + height: 50, + }); + }); + + it('should preserve all element properties', () => { + const element: GraphElement = { + id: 'element-1', + x: 10, + y: 20, + width: 100, + height: 50, + type: 'ReactElement', + ports: { items: [] }, + angle: 45, + }; + + const options: ElementToGraphOptions = { + element, + graph, + }; + + const elementAsGraphJson = defaultElementToGraphSelector(options); + + expect(elementAsGraphJson).toMatchObject({ + id: 'element-1', + ports: { items: [] }, + angle: 45, + }); + + // Round-trip: element → graph → element + graph.syncCells([elementAsGraphJson], { remove: true }); + + const elementFromGraph = defaultElementFromGraphSelector({ + cell: graph.getCell('element-1') as dia.Element, + graph, + }); + + expect(elementFromGraph).toMatchObject({ + id: 'element-1', + x: 10, + y: 20, + width: 100, + height: 50, + ports: { items: [] }, + angle: 45, + }); + }); + }); + + describe('defaultElementFromGraphSelector', () => { + it('should map graph cell to element without previous state', () => { + const elementAsGraphJson = { + type: 'ReactElement', + position: { x: 10, y: 20 }, + size: { width: 100, height: 50 }, + id: 'element-1', + } as dia.Cell.JSON; + graph.syncCells([elementAsGraphJson], { remove: true }); + const cell = graph.getCell('element-1') as dia.Element; + + const options: ElementFromGraphOptions = { + cell, + graph, + }; + + const elementFromGraph = defaultElementFromGraphSelector(options); + + expect(elementFromGraph).toMatchObject({ + id: 'element-1', + x: 10, + y: 20, + width: 100, + height: 50, + }); + + // Round-trip: element → graph → element + const recreatedElementAsGraphJson = defaultElementToGraphSelector({ + element: elementFromGraph, + graph, + }); + graph.clear(); + graph.syncCells([recreatedElementAsGraphJson], { remove: true }); + + const elementFromRoundTrip = defaultElementFromGraphSelector({ + cell: graph.getCell('element-1') as dia.Element, + graph, + }); + + expect(elementFromRoundTrip).toMatchObject({ + id: 'element-1', + x: 10, + y: 20, + width: 100, + height: 50, + }); + }); + + it('should map graph cell to element with previous state, filtering properties', () => { + const elementAsGraphJson = { + type: 'ReactElement', + position: { x: 10, y: 20 }, + size: { width: 100, height: 50 }, + id: 'element-1', + customProp: 'from-graph', + extraProp: 'should-be-filtered', + } as dia.Cell.JSON; + graph.syncCells([elementAsGraphJson], { remove: true }); + const cell = graph.getCell('element-1') as dia.Element; + + type ExtendedElement = GraphElement & { + customProp?: string; + extraProp?: string; + }; + + const previous: ExtendedElement = { + id: 'element-1', + x: 5, + y: 15, + width: 80, + height: 40, + customProp: undefined, + // extraProp is not in previous, so it should be filtered out + }; + + const options: ElementFromGraphOptions = { + cell, + graph, + previous, + }; + + const result = defaultElementFromGraphSelector(options); + + // Should only include properties that exist in previous state + expect(result).toMatchObject({ + id: 'element-1', + x: 10, // Updated from graph + y: 20, // Updated from graph + width: 100, // Updated from graph + height: 50, // Updated from graph + customProp: 'from-graph', // Updated from graph + }); + + // Should NOT include extraProp because it doesn't exist in previous state + expect(result).not.toHaveProperty('extraProp'); + }); + + it('should preserve undefined properties from previous state', () => { + const elementAsGraphJson = { + type: 'ReactElement', + position: { x: 10, y: 20 }, + size: { width: 100, height: 50 }, + id: 'element-1', + } as dia.Cell.JSON; + graph.syncCells([elementAsGraphJson], { remove: true }); + const cell = graph.getCell('element-1') as dia.Element; + + type ExtendedElement = GraphElement & { + customProp?: string; + }; + + const previous: ExtendedElement = { + id: 'element-1', + x: 5, + y: 15, + width: 80, + height: 40, + customProp: undefined, // Explicitly undefined in previous + }; + + const options: ElementFromGraphOptions = { + cell, + graph, + previous, + }; + + const result = defaultElementFromGraphSelector(options); + + // Should include customProp even though it's undefined in previous + expect(result).toHaveProperty('customProp'); + expect((result as ExtendedElement).customProp).toBeUndefined(); + }); + + it('should handle element with non-REACT_TYPE', () => { + const elementAsGraphJson = { + type: 'standard.Rectangle', + position: { x: 10, y: 20 }, + size: { width: 100, height: 50 }, + id: 'element-1', + } as dia.Cell.JSON; + graph.syncCells([elementAsGraphJson], { remove: true }); + const cell = graph.getCell('element-1') as dia.Element; + + const options: ElementFromGraphOptions = { + cell, + graph, + }; + + const elementFromGraph = defaultElementFromGraphSelector(options); + + expect(elementFromGraph.type).toBe('standard.Rectangle'); + }); + + it('should extract ports from cell', () => { + const ports = { + items: [ + { + id: 'port-1', + group: 'group-1', + }, + ], + }; + const elementAsGraphJson = { + type: 'ReactElement', + position: { x: 10, y: 20 }, + size: { width: 100, height: 50 }, + id: 'element-1', + ports, + } as dia.Cell.JSON; + graph.syncCells([elementAsGraphJson], { remove: true }); + const cell = graph.getCell('element-1') as dia.Element; + + const options: ElementFromGraphOptions = { + cell, + graph, + }; + + const result = defaultElementFromGraphSelector(options); + + expect(result.ports).toEqual(ports); + }); + }); + + describe('defaultLinkToGraphSelector', () => { + it('should map link to graph cell JSON', () => { + const link: GraphLink = { + id: 'link-1', + source: 'element-1', + target: 'element-2', + type: 'standard.Link', + }; + + const options: LinkToGraphOptions = { + link, + graph, + }; + + const linkAsGraphJson = defaultLinkToGraphSelector(options); + + expect(linkAsGraphJson).toMatchObject({ + id: 'link-1', + source: { id: 'element-1' }, + target: { id: 'element-2' }, + type: 'standard.Link', + }); + + // Round-trip: link → graph → link + graph.syncCells([linkAsGraphJson], { remove: true }); + + const linkFromGraph = defaultLinkFromGraphSelector({ + cell: graph.getCell('link-1') as dia.Link, + graph, + }); + + expect(linkFromGraph).toMatchObject({ + id: 'link-1', + source: { id: 'element-1' }, + target: { id: 'element-2' }, + type: 'standard.Link', + }); + }); + + it('should handle link with object source and target', () => { + const link: GraphLink = { + id: 'link-1', + source: { id: 'element-1', port: 'port-1' }, + target: { id: 'element-2', port: 'port-2' }, + type: 'standard.Link', + }; + + const options: LinkToGraphOptions = { + link, + graph, + }; + + const linkAsGraphJson = defaultLinkToGraphSelector(options); + + expect(linkAsGraphJson).toMatchObject({ + id: 'link-1', + source: { id: 'element-1', port: 'port-1' }, + target: { id: 'element-2', port: 'port-2' }, + type: 'standard.Link', + }); + + // Round-trip: link → graph → link + graph.syncCells([linkAsGraphJson], { remove: true }); + + const linkFromGraph = defaultLinkFromGraphSelector({ + cell: graph.getCell('link-1') as dia.Link, + graph, + }); + + expect(linkFromGraph).toMatchObject({ + id: 'link-1', + source: { id: 'element-1', port: 'port-1' }, + target: { id: 'element-2', port: 'port-2' }, + type: 'standard.Link', + }); + }); + + it('should merge attrs with defaults from cell namespace', () => { + const link: GraphLink = { + id: 'link-1', + source: 'element-1', + target: 'element-2', + type: 'standard.Link', + attrs: { + line: { + stroke: 'red', + }, + }, + }; + + const options: LinkToGraphOptions = { + link, + graph, + }; + + const linkAsGraphJson = defaultLinkToGraphSelector(options); + + expect(linkAsGraphJson.attrs).toBeDefined(); + expect(linkAsGraphJson.attrs?.line?.stroke).toBe('red'); + }); + + it('should preserve all link properties', () => { + const link: GraphLink = { + id: 'link-1', + source: 'element-1', + target: 'element-2', + type: 'standard.Link', + z: 10, + markup: [{ tagName: 'path' }], + defaultLabel: { markup: [{ tagName: 'text' }] }, + }; + + const options: LinkToGraphOptions = { + link, + graph, + }; + + const linkAsGraphJson = defaultLinkToGraphSelector(options); + + expect(linkAsGraphJson).toMatchObject({ + id: 'link-1', + z: 10, + markup: [{ tagName: 'path' }], + defaultLabel: { markup: [{ tagName: 'text' }] }, + }); + }); + }); + + describe('defaultLinkFromGraphSelector', () => { + it('should map graph link to link without previous state', () => { + const linkAsGraphJson = { + type: 'standard.Link', + source: { id: 'element-1' }, + target: { id: 'element-2' }, + id: 'link-1', + z: 5, + } as dia.Cell.JSON; + graph.syncCells([linkAsGraphJson], { remove: true }); + const link = graph.getCell('link-1') as dia.Link; + + const options: LinkFromGraphOptions = { + cell: link, + graph, + }; + + const linkFromGraph = defaultLinkFromGraphSelector(options); + + expect(linkFromGraph).toMatchObject({ + id: 'link-1', + source: { id: 'element-1' }, + target: { id: 'element-2' }, + type: 'standard.Link', + z: 5, + }); + }); + + it('should map graph link to link with previous state, filtering properties', () => { + const linkAsGraphJson = { + type: 'standard.Link', + source: { id: 'element-1' }, + target: { id: 'element-2' }, + id: 'link-1', + z: 5, + customProp: 'from-graph', + extraProp: 'should-be-filtered', + } as dia.Cell.JSON; + graph.syncCells([linkAsGraphJson], { remove: true }); + const link = graph.getCell('link-1') as dia.Link; + + type ExtendedLink = GraphLink & { + customProp?: string; + extraProp?: string; + }; + + const previous: ExtendedLink = { + id: 'link-1', + source: { id: 'element-1' }, + target: { id: 'element-2' }, + type: 'standard.Link', + z: 3, + customProp: undefined, + // extraProp is not in previous, so it should be filtered out + }; + + const options: LinkFromGraphOptions = { + cell: link, + graph, + previous, + }; + + const linkFromGraph = defaultLinkFromGraphSelector(options); + + // Should only include properties that exist in previous state + expect(linkFromGraph).toMatchObject({ + id: 'link-1', + source: { id: 'element-1' }, + target: { id: 'element-2' }, + type: 'standard.Link', + z: 5, // Updated from graph + customProp: 'from-graph', // Updated from graph + }); + + // Should NOT include extraProp because it doesn't exist in previous state + expect(linkFromGraph).not.toHaveProperty('extraProp'); + }); + + it('should preserve undefined properties from previous state', () => { + const linkAsGraphJson = { + type: 'standard.Link', + source: { id: 'element-1' }, + target: { id: 'element-2' }, + id: 'link-1', + } as dia.Cell.JSON; + graph.syncCells([linkAsGraphJson], { remove: true }); + const link = graph.getCell('link-1') as dia.Link; + + type ExtendedLink = GraphLink & { + customProp?: string; + }; + + const previous: ExtendedLink = { + id: 'link-1', + source: { id: 'element-1' }, + target: { id: 'element-2' }, + type: 'standard.Link', + customProp: undefined, // Explicitly undefined in previous + }; + + const options: LinkFromGraphOptions = { + cell: link, + graph, + previous, + }; + + const linkFromGraph = defaultLinkFromGraphSelector(options); + + // Should include customProp even though it's undefined in previous + expect(linkFromGraph).toHaveProperty('customProp'); + expect((linkFromGraph as ExtendedLink).customProp).toBeUndefined(); + }); + + it('should extract source and target from cell', () => { + const linkAsGraphJson = { + type: 'standard.Link', + source: { id: 'element-1', port: 'port-1' }, + target: { id: 'element-2', port: 'port-2' }, + id: 'link-1', + } as dia.Cell.JSON; + graph.syncCells([linkAsGraphJson], { remove: true }); + const link = graph.getCell('link-1') as dia.Link; + + const options: LinkFromGraphOptions = { + cell: link, + graph, + }; + + const linkFromGraph = defaultLinkFromGraphSelector(options); + + expect(linkFromGraph.source).toEqual({ id: 'element-1', port: 'port-1' }); + expect(linkFromGraph.target).toEqual({ id: 'element-2', port: 'port-2' }); + }); + + it('should extract z, markup, and defaultLabel from cell', () => { + const linkAsGraphJson = { + type: 'standard.Link', + source: { id: 'element-1' }, + target: { id: 'element-2' }, + id: 'link-1', + z: 10, + markup: [{ tagName: 'path' }], + defaultLabel: { markup: [{ tagName: 'text' }] }, + } as dia.Cell.JSON; + graph.syncCells([linkAsGraphJson], { remove: true }); + const link = graph.getCell('link-1') as dia.Link; + + const options: LinkFromGraphOptions = { + cell: link, + graph, + }; + + const linkFromGraph = defaultLinkFromGraphSelector(options); + + expect(linkFromGraph.z).toBe(10); + expect(linkFromGraph.markup).toEqual([{ tagName: 'path' }]); + expect(linkFromGraph.defaultLabel).toEqual({ markup: [{ tagName: 'text' }] }); + }); + + it('should include all cell attributes when no previous state', () => { + const linkAsGraphJson = { + type: 'standard.Link', + source: { id: 'element-1' }, + target: { id: 'element-2' }, + id: 'link-1', + attrs: { + line: { + stroke: 'blue', + strokeWidth: 2, + }, + }, + } as dia.Cell.JSON; + graph.syncCells([linkAsGraphJson], { remove: true }); + const link = graph.getCell('link-1') as dia.Link; + + const options: LinkFromGraphOptions = { + cell: link, + graph, + }; + + const linkFromGraph = defaultLinkFromGraphSelector(options); + + expect(linkFromGraph.attrs).toBeDefined(); + expect(linkFromGraph.attrs).toMatchObject({ + line: { + stroke: 'blue', + strokeWidth: 2, + }, + }); + }); + }); + + describe('integration: state is source of truth', () => { + it('should not add new properties from graph when previous state exists', () => { + // Create element in graph with extra properties + const elementAsGraphJson = { + type: 'ReactElement', + position: { x: 10, y: 20 }, + size: { width: 100, height: 50 }, + id: 'element-1', + graphOnlyProp: 'should-not-appear', + anotherGraphProp: 'also-should-not-appear', + } as dia.Cell.JSON; + graph.syncCells([elementAsGraphJson], { remove: true }); + const cell = graph.getCell('element-1') as dia.Element; + + // Previous state only has specific properties + const previous: GraphElement = { + id: 'element-1', + x: 5, + y: 15, + width: 80, + height: 40, + // graphOnlyProp and anotherGraphProp are NOT in previous state + }; + + const options: ElementFromGraphOptions = { + cell, + graph, + previous, + }; + + const elementFromGraph = defaultElementFromGraphSelector(options); + + // Should only have properties from previous state + expect(elementFromGraph).not.toHaveProperty('graphOnlyProp'); + expect(elementFromGraph).not.toHaveProperty('anotherGraphProp'); + expect(Object.keys(elementFromGraph).sort()).toEqual( + ['id', 'x', 'y', 'width', 'height'].sort() + ); + }); + + it('should update existing properties from graph even if undefined in previous', () => { + const elementAsGraphJson = { + type: 'ReactElement', + position: { x: 10, y: 20 }, + size: { width: 100, height: 50 }, + id: 'element-1', + customProp: 'updated-value', + } as dia.Cell.JSON; + graph.syncCells([elementAsGraphJson], { remove: true }); + const cell = graph.getCell('element-1') as dia.Element; + + type ExtendedElement = GraphElement & { + customProp?: string; + }; + + const previous: ExtendedElement = { + id: 'element-1', + x: 5, + y: 15, + width: 80, + height: 40, + customProp: undefined, // Exists but undefined + }; + + const options: ElementFromGraphOptions = { + cell, + graph, + previous, + }; + + const elementFromGraph = defaultElementFromGraphSelector(options); + + // Should update customProp from graph + expect((elementFromGraph as ExtendedElement).customProp).toBe('updated-value'); + }); + }); + + describe('integration: links with syncCells', () => { + it('should handle link round-trip using syncCells', () => { + const link: GraphLink = { + id: 'link-1', + source: 'element-1', + target: 'element-2', + type: 'standard.Link', + z: 5, + }; + + // Convert link to graph JSON + const linkAsGraphJson = defaultLinkToGraphSelector({ + link, + graph, + }); + + // Store in graph using syncCells + graph.syncCells([linkAsGraphJson], { remove: true }); + + // Retrieve from graph + const graphLink = graph.getCell('link-1') as dia.Link; + expect(graphLink).toBeDefined(); + + // Convert back to link + const linkFromGraph = defaultLinkFromGraphSelector({ + cell: graphLink, + graph, + }); + + expect(linkFromGraph).toMatchObject({ + id: 'link-1', + source: { id: 'element-1' }, + target: { id: 'element-2' }, + type: 'standard.Link', + z: 5, + }); + }); + + it('should handle link with complex properties using syncCells', () => { + const link: GraphLink = { + id: 'link-1', + source: { id: 'element-1', port: 'port-1' }, + target: { id: 'element-2', port: 'port-2' }, + type: 'standard.Link', + z: 10, + markup: [{ tagName: 'path' }], + defaultLabel: { markup: [{ tagName: 'text' }] }, + attrs: { + line: { + stroke: 'blue', + strokeWidth: 2, + }, + }, + }; + + const linkAsGraphJson = defaultLinkToGraphSelector({ + link, + graph, + }); + + graph.syncCells([linkAsGraphJson], { remove: true }); + + const graphLink = graph.getCell('link-1') as dia.Link; + const linkFromGraph = defaultLinkFromGraphSelector({ + cell: graphLink, + graph, + }); + + expect(linkFromGraph).toMatchObject({ + id: 'link-1', + source: { id: 'element-1', port: 'port-1' }, + target: { id: 'element-2', port: 'port-2' }, + type: 'standard.Link', + z: 10, + }); + expect(linkFromGraph.markup).toEqual([{ tagName: 'path' }]); + expect(linkFromGraph.defaultLabel).toEqual({ markup: [{ tagName: 'text' }] }); + expect(linkFromGraph.attrs).toBeDefined(); + }); + + it('should filter link properties with previous state when using syncCells', () => { + type ExtendedLink = GraphLink & { + customProp?: string; + extraProp?: string; + anotherProp?: number; + }; + + const link: ExtendedLink = { + id: 'link-1', + source: 'element-1', + target: 'element-2', + type: 'standard.Link', + z: 5, + customProp: 'value-from-state', + extraProp: 'should-be-filtered', + anotherProp: 42, + }; + + const linkAsGraphJson = defaultLinkToGraphSelector({ + link, + graph, + }); + + // Add extra properties to graph JSON that don't exist in state + const linkWithExtraProps = { + ...linkAsGraphJson, + graphOnlyProp: 'should-not-appear', + anotherGraphProp: 'also-should-not-appear', + }; + + graph.syncCells([linkWithExtraProps], { remove: true }); + + const previous: ExtendedLink = { + id: 'link-1', + source: 'element-1', + target: 'element-2', + type: 'standard.Link', + z: 3, + customProp: undefined, + anotherProp: 0, + // extraProp is not in previous, so it should be filtered out + }; + + const graphLink = graph.getCell('link-1') as dia.Link; + const linkFromGraph = defaultLinkFromGraphSelector({ + cell: graphLink, + graph, + previous, + }); + + // Should only include properties that exist in previous state + expect(linkFromGraph).toMatchObject({ + id: 'link-1', + source: { id: 'element-1' }, + target: { id: 'element-2' }, + type: 'standard.Link', + z: 5, // Updated from graph + customProp: 'value-from-state', // From graph + anotherProp: 42, // Updated from graph + }); + + // Should NOT include properties that don't exist in previous state + expect(linkFromGraph).not.toHaveProperty('extraProp'); + expect(linkFromGraph).not.toHaveProperty('graphOnlyProp'); + expect(linkFromGraph).not.toHaveProperty('anotherGraphProp'); + }); + + it('should handle multiple links with syncCells and previous state filtering', () => { + type ExtendedLink = GraphLink & { + label?: string; + metadata?: Record; + }; + + const links: ExtendedLink[] = [ + { + id: 'link-1', + source: 'element-1', + target: 'element-2', + type: 'standard.Link', + z: 1, + label: 'Link 1', + }, + { + id: 'link-2', + source: 'element-2', + target: 'element-3', + type: 'standard.Link', + z: 2, + metadata: { key: 'value' }, + }, + ]; + + const linksAsGraphJson = links.map((link) => + defaultLinkToGraphSelector({ + link, + graph, + }) + ); + + graph.syncCells(linksAsGraphJson, { remove: true }); + + // Previous state only has specific properties for each link + const previousLinks: ExtendedLink[] = [ + { + id: 'link-1', + source: 'element-1', + target: 'element-2', + type: 'standard.Link', + z: 0, // Exists in previous + label: undefined, // Exists but undefined + }, + { + id: 'link-2', + source: 'element-2', + target: 'element-3', + type: 'standard.Link', + z: 0, // Exists in previous + metadata: undefined, // Exists but undefined + }, + ]; + + const retrievedLinks = graph.getLinks().map((graphLink) => { + const previous = previousLinks.find((l) => l.id === graphLink.id); + return defaultLinkFromGraphSelector({ + cell: graphLink, + graph, + previous, + }); + }); + + expect(retrievedLinks).toHaveLength(2); + + const link1 = retrievedLinks.find((l) => l.id === 'link-1'); + expect(link1).toMatchObject({ + id: 'link-1', + source: { id: 'element-1' }, + target: { id: 'element-2' }, + type: 'standard.Link', + z: 1, // Updated from graph + label: 'Link 1', // Updated from graph + }); + expect(link1).not.toHaveProperty('metadata'); + + const link2 = retrievedLinks.find((l) => l.id === 'link-2'); + expect(link2).toMatchObject({ + id: 'link-2', + source: { id: 'element-2' }, + target: { id: 'element-3' }, + type: 'standard.Link', + z: 2, // Updated from graph + metadata: { key: 'value' }, // Updated from graph + }); + expect(link2).not.toHaveProperty('label'); + }); + + it('should preserve undefined properties from previous state when using syncCells', () => { + type ExtendedLink = GraphLink & { + optionalProp?: string; + anotherOptionalProp?: number; + }; + + const link: ExtendedLink = { + id: 'link-1', + source: 'element-1', + target: 'element-2', + type: 'standard.Link', + optionalProp: 'has-value', + }; + + const linkAsGraphJson = defaultLinkToGraphSelector({ + link, + graph, + }); + + graph.syncCells([linkAsGraphJson], { remove: true }); + + const previous: ExtendedLink = { + id: 'link-1', + source: 'element-1', + target: 'element-2', + type: 'standard.Link', + optionalProp: undefined, // Explicitly undefined + anotherOptionalProp: undefined, // Explicitly undefined + }; + + const graphLink = graph.getCell('link-1') as dia.Link; + const linkFromGraph = defaultLinkFromGraphSelector({ + cell: graphLink, + graph, + previous, + }); + + // Should include optionalProp with value from graph + expect((linkFromGraph as ExtendedLink).optionalProp).toBe('has-value'); + // Should include anotherOptionalProp even though it's undefined in previous + expect(linkFromGraph).toHaveProperty('anotherOptionalProp'); + expect((linkFromGraph as ExtendedLink).anotherOptionalProp).toBeUndefined(); + }); + + it('should handle link updates with syncCells and previous state', () => { + type ExtendedLink = GraphLink & { + status?: string; + weight?: number; + }; + + // Initial link + const initialLink: ExtendedLink = { + id: 'link-1', + source: 'element-1', + target: 'element-2', + type: 'standard.Link', + status: 'active', + weight: 1, + }; + + const initialLinkAsGraphJson = defaultLinkToGraphSelector({ + link: initialLink, + graph, + }); + + graph.syncCells([initialLinkAsGraphJson], { remove: true }); + + // Update link with new values + const updatedLink: ExtendedLink = { + id: 'link-1', + source: 'element-1', + target: 'element-2', + type: 'standard.Link', + status: 'inactive', + weight: 2, + newProp: 'should-be-filtered', + }; + + const updatedLinkAsGraphJson = defaultLinkToGraphSelector({ + link: updatedLink, + graph, + }); + + graph.syncCells([updatedLinkAsGraphJson], { remove: true }); + + // Previous state only has status and weight + const previous: ExtendedLink = { + id: 'link-1', + source: 'element-1', + target: 'element-2', + type: 'standard.Link', + status: 'active', + weight: 1, + }; + + const graphLink = graph.getCell('link-1') as dia.Link; + const linkFromGraph = defaultLinkFromGraphSelector({ + cell: graphLink, + graph, + previous, + }); + + // Should only include properties from previous state + expect(linkFromGraph).toMatchObject({ + id: 'link-1', + source: { id: 'element-1' }, + target: { id: 'element-2' }, + type: 'standard.Link', + status: 'inactive', // Updated from graph + weight: 2, // Updated from graph + }); + + // Should NOT include newProp + expect(linkFromGraph).not.toHaveProperty('newProp'); + }); + + it('should handle link with attrs merging when using syncCells', () => { + const link: GraphLink = { + id: 'link-1', + source: 'element-1', + target: 'element-2', + type: 'standard.Link', + attrs: { + line: { + stroke: 'red', + strokeWidth: 3, + }, + }, + }; + + const linkAsGraphJson = defaultLinkToGraphSelector({ + link, + graph, + }); + + graph.syncCells([linkAsGraphJson], { remove: true }); + + const graphLink = graph.getCell('link-1') as dia.Link; + const linkFromGraph = defaultLinkFromGraphSelector({ + cell: graphLink, + graph, + }); + + expect(linkFromGraph.attrs).toBeDefined(); + expect(linkFromGraph.attrs).toMatchObject({ + line: { + stroke: 'red', + strokeWidth: 3, + }, + }); + }); + }); + + describe('integration: new properties defined in state type', () => { + it('should return new element property when it exists in previous state (even if undefined)', () => { + type ExtendedElement = GraphElement & { + newProperty?: string; + anotherNewProperty?: number; + }; + + // Create element in graph with new properties + const element: ExtendedElement = { + id: 'element-1', + x: 10, + y: 20, + width: 100, + height: 50, + newProperty: 'value-from-graph', + anotherNewProperty: 42, + }; + + const elementAsGraphJson = defaultElementToGraphSelector({ + element, + graph, + }); + + graph.syncCells([elementAsGraphJson], { remove: true }); + + // Previous state has newProperty defined (but undefined) and anotherNewProperty defined + const previous: ExtendedElement = { + id: 'element-1', + x: 5, + y: 15, + width: 80, + height: 40, + newProperty: undefined, // Defined in state type but undefined + anotherNewProperty: 0, // Defined in state type with initial value + }; + + const graphElement = graph.getCell('element-1') as dia.Element; + const elementFromGraph = defaultElementFromGraphSelector({ + cell: graphElement, + graph, + previous, + }); + + // Should return newProperty with value from graph + expect((elementFromGraph as ExtendedElement).newProperty).toBe('value-from-graph'); + // Should return anotherNewProperty with value from graph + expect((elementFromGraph as ExtendedElement).anotherNewProperty).toBe(42); + }); + + it('should return new link property when it exists in previous state (even if undefined)', () => { + type ExtendedLink = GraphLink & { + newLinkProperty?: string; + priority?: number; + metadata?: Record; + }; + + // Create link in graph with new properties + const link: ExtendedLink = { + id: 'link-1', + source: 'element-1', + target: 'element-2', + type: 'standard.Link', + newLinkProperty: 'value-from-graph', + // @ts-expect-error - priority is not defined in the state type + priority: 10, + metadata: { key: 'value' }, + }; + + const linkAsGraphJson = defaultLinkToGraphSelector({ + link, + graph, + }); + + graph.syncCells([linkAsGraphJson], { remove: true }); + + // Previous state has all properties defined (some undefined) + const previous: ExtendedLink = { + id: 'link-1', + source: 'element-1', + target: 'element-2', + type: 'standard.Link', + newLinkProperty: undefined, // Defined in state type but undefined + priority: undefined, // Defined in state type but undefined + metadata: undefined, // Defined in state type but undefined + }; + + const graphLink = graph.getCell('link-1') as dia.Link; + const linkFromGraph = defaultLinkFromGraphSelector({ + cell: graphLink, + graph, + previous, + }); + + // Should return all properties with values from graph + expect((linkFromGraph as ExtendedLink).newLinkProperty).toBe('value-from-graph'); + expect((linkFromGraph as ExtendedLink).priority).toBe(10); + expect((linkFromGraph as ExtendedLink).metadata).toEqual({ key: 'value' }); + }); + + it('should return multiple new element properties when all are defined in previous state', () => { + type ExtendedElement = GraphElement & { + status?: string; + category?: string; + tags?: string[]; + score?: number; + }; + + const element: ExtendedElement = { + id: 'element-1', + x: 10, + y: 20, + width: 100, + height: 50, + status: 'active', + category: 'type-a', + tags: ['tag1', 'tag2'], + score: 95, + }; + + const elementAsGraphJson = defaultElementToGraphSelector({ + element, + graph, + }); + + graph.syncCells([elementAsGraphJson], { remove: true }); + + // Previous state defines all properties (some undefined) + const previous: ExtendedElement = { + id: 'element-1', + x: 5, + y: 15, + width: 80, + height: 40, + status: undefined, + category: undefined, + tags: undefined, + score: undefined, + }; + + const graphElement = graph.getCell('element-1') as dia.Element; + const elementFromGraph = defaultElementFromGraphSelector({ + cell: graphElement, + graph, + previous, + }); + + // All properties should be returned with values from graph + expect((elementFromGraph as ExtendedElement).status).toBe('active'); + expect((elementFromGraph as ExtendedElement).category).toBe('type-a'); + expect((elementFromGraph as ExtendedElement).tags).toEqual(['tag1', 'tag2']); + expect((elementFromGraph as ExtendedElement).score).toBe(95); + }); + + it('should return new link properties with complex nested structures when defined in previous state', () => { + type ExtendedLink = GraphLink & { + config?: { + style?: string; + animation?: boolean; + }; + labels?: Array<{ text: string; position?: number }>; + customData?: Record; + }; + + const link: ExtendedLink = { + id: 'link-1', + source: 'element-1', + target: 'element-2', + type: 'standard.Link', + config: { + style: 'dashed', + animation: true, + }, + labels: [ + { text: 'Label 1', position: 0.3 }, + { text: 'Label 2', position: 0.7 }, + ], + customData: { + source: 'api', + timestamp: 1_234_567_890, + }, + }; + + const linkAsGraphJson = defaultLinkToGraphSelector({ + link, + graph, + }); + + graph.syncCells([linkAsGraphJson], { remove: true }); + + // Previous state defines all properties (all undefined) + const previous: ExtendedLink = { + id: 'link-1', + source: 'element-1', + target: 'element-2', + type: 'standard.Link', + config: undefined, + labels: undefined, + customData: undefined, + }; + + const graphLink = graph.getCell('link-1') as dia.Link; + const linkFromGraph = defaultLinkFromGraphSelector({ + cell: graphLink, + graph, + previous, + }); + + // All complex properties should be returned with values from graph + expect((linkFromGraph as ExtendedLink).config).toEqual({ + style: 'dashed', + animation: true, + }); + expect((linkFromGraph as ExtendedLink).labels).toEqual([ + { text: 'Label 1', position: 0.3 }, + { text: 'Label 2', position: 0.7 }, + ]); + expect((linkFromGraph as ExtendedLink).customData).toEqual({ + source: 'api', + timestamp: 1_234_567_890, + }); + }); + + it('should return new element properties when some are defined and some are not in previous state', () => { + type ExtendedElement = GraphElement & { + definedProperty?: string; + undefinedProperty?: number; + notInPreviousProperty?: boolean; + }; + + const element: ExtendedElement = { + id: 'element-1', + x: 10, + y: 20, + width: 100, + height: 50, + definedProperty: 'value-1', + undefinedProperty: 100, + notInPreviousProperty: true, + }; + + const elementAsGraphJson = defaultElementToGraphSelector({ + element, + graph, + }); + + graph.syncCells([elementAsGraphJson], { remove: true }); + + // Previous state only defines some properties + const previous: ExtendedElement = { + id: 'element-1', + x: 5, + y: 15, + width: 80, + height: 40, + definedProperty: undefined, // Defined in previous + undefinedProperty: undefined, // Defined in previous + // notInPreviousProperty is NOT in previous + }; + + const graphElement = graph.getCell('element-1') as dia.Element; + const elementFromGraph = defaultElementFromGraphSelector({ + cell: graphElement, + graph, + previous, + }); + + // Properties defined in previous should be returned + expect((elementFromGraph as ExtendedElement).definedProperty).toBe('value-1'); + expect((elementFromGraph as ExtendedElement).undefinedProperty).toBe(100); + + // Property not in previous should NOT be returned + expect(elementFromGraph).not.toHaveProperty('notInPreviousProperty'); + }); + + it('should return new link properties when mixed with existing GraphLink properties', () => { + type ExtendedLink = GraphLink & { + customLabel?: string; + weight?: number; + }; + + const link: ExtendedLink = { + id: 'link-1', + source: 'element-1', + target: 'element-2', + type: 'standard.Link', + z: 5, // Existing GraphLink property + markup: [{ tagName: 'path' }], // Existing GraphLink property + customLabel: 'Custom', // New property + weight: 10, // New property + }; + + const linkAsGraphJson = defaultLinkToGraphSelector({ + link, + graph, + }); + + graph.syncCells([linkAsGraphJson], { remove: true }); + + // Previous state has both existing and new properties + const previous: ExtendedLink = { + id: 'link-1', + source: 'element-1', + target: 'element-2', + type: 'standard.Link', + z: 3, // Existing property + customLabel: undefined, // New property defined + weight: undefined, // New property defined + }; + + const graphLink = graph.getCell('link-1') as dia.Link; + const linkFromGraph = defaultLinkFromGraphSelector({ + cell: graphLink, + graph, + previous, + }); + + // All properties should be returned + expect(linkFromGraph.z).toBe(5); // Updated from graph + expect((linkFromGraph as ExtendedLink).customLabel).toBe('Custom'); + expect((linkFromGraph as ExtendedLink).weight).toBe(10); + }); + + it('should properly return new properties after element update via syncCells', () => { + type ExtendedElement = GraphElement & { + version?: number; + lastModified?: string; + }; + + // Initial element without new properties + const initialElement: ExtendedElement = { + id: 'element-1', + x: 10, + y: 20, + width: 100, + height: 50, + }; + + const initialElementAsGraphJson = defaultElementToGraphSelector({ + element: initialElement, + graph, + }); + + graph.syncCells([initialElementAsGraphJson], { remove: true }); + + // Update element with new properties + const updatedElement: ExtendedElement = { + id: 'element-1', + x: 15, + y: 25, + width: 120, + height: 60, + version: 2, + lastModified: '2024-01-01', + }; + + const updatedElementAsGraphJson = defaultElementToGraphSelector({ + element: updatedElement, + graph, + }); + + graph.syncCells([updatedElementAsGraphJson], { remove: true }); + + // Previous state now includes the new properties + const previous: ExtendedElement = { + id: 'element-1', + x: 10, + y: 20, + width: 100, + height: 50, + version: undefined, + lastModified: undefined, + }; + + const graphElement = graph.getCell('element-1') as dia.Element; + const elementFromGraph = defaultElementFromGraphSelector({ + cell: graphElement, + graph, + previous, + }); + + // Should return all properties including new ones + expect(elementFromGraph.x).toBe(15); + expect(elementFromGraph.y).toBe(25); + expect(elementFromGraph.width).toBe(120); + expect(elementFromGraph.height).toBe(60); + expect((elementFromGraph as ExtendedElement).version).toBe(2); + expect((elementFromGraph as ExtendedElement).lastModified).toBe('2024-01-01'); + }); + }); +}); diff --git a/packages/joint-react/src/store/__tests__/graph-sync.test.ts b/packages/joint-react/src/state/__tests__/state-sync.test.ts similarity index 80% rename from packages/joint-react/src/store/__tests__/graph-sync.test.ts rename to packages/joint-react/src/state/__tests__/state-sync.test.ts index aa7d892368..73080d9d6c 100644 --- a/packages/joint-react/src/store/__tests__/graph-sync.test.ts +++ b/packages/joint-react/src/state/__tests__/state-sync.test.ts @@ -1,26 +1,48 @@ import { dia } from '@joint/core'; -import { DEFAULT_CELL_NAMESPACE, type GraphStoreSnapshot } from '../graph-store'; -import { graphSync } from '../graph-sync'; +import { + DEFAULT_CELL_NAMESPACE, + type GraphStoreSnapshot, + type GraphStoreDerivedSnapshot, +} from '../../store/graph-store'; +import { stateSync } from '../state-sync'; import { createState } from '../../utils/create-state'; import { createElements } from '../../utils/create'; import type { GraphElement } from '../../types/element-types'; import type { GraphLink } from '../../types/link-types'; -import { syncGraph } from '../../utils/cell/cell-utilities'; +import { + defaultElementToGraphSelector, + defaultLinkToGraphSelector, +} from '../graph-state-selectors'; + +// Helper to create getIdsSnapshot function +function createGetIdsSnapshot( + state: ReturnType>> +): () => GraphStoreDerivedSnapshot { + return () => { + const snapshot = state.getSnapshot(); + const elementIds: Record = {}; + const linkIds: Record = {}; + let areElementsMeasured = true; + + for (const [index, element] of snapshot.elements.entries()) { + elementIds[element.id] = index; + } + for (const element of snapshot.elements) { + const { width = 0, height = 0 } = element; + if (width <= 1 || height <= 1) { + areElementsMeasured = false; + break; + } + } + for (const [index, link] of snapshot.links.entries()) { + linkIds[link.id] = index; + } -jest.mock('../../utils/cell/cell-utilities', () => { - const actual = jest.requireActual('../../utils/cell/cell-utilities'); - return { - ...actual, - syncGraph: jest.fn().mockImplementation(actual.syncGraph), + return { elementIds, linkIds, areElementsMeasured }; }; -}); - -const mockedSyncGraph = syncGraph as jest.Mock; -describe('graphSync', () => { - beforeEach(() => { - mockedSyncGraph.mockClear(); - }); +} +describe('stateSync', () => { it('should sync dia.graph <-> state effectively', () => { const graph = new dia.Graph( {}, @@ -53,12 +75,13 @@ describe('graphSync', () => { const mockedSetState = jest.fn().mockImplementation(state.setState); state.setState = mockedSetState; + const getIdsSnapshot = createGetIdsSnapshot(state); + // Here we initially sync the graph with the state. // State should not be updated yet. - graphSync({ graph, store: state }); + stateSync({ graph, store: state, getIdsSnapshot }); expect(graph.getElements()).toHaveLength(2); expect(state.getSnapshot().elements).toHaveLength(2); - expect(mockedSyncGraph).toHaveBeenCalledTimes(1); expect(mockedSetState).toHaveBeenCalledTimes(0); // Here we update state via state API. // State should not be updated yet. @@ -68,23 +91,23 @@ describe('graphSync', () => { })); expect(graph.getElements()).toHaveLength(3); expect(state.getSnapshot().elements).toHaveLength(3); - expect(mockedSyncGraph).toHaveBeenCalledTimes(2); expect(mockedSetState).toHaveBeenCalledTimes(1); - // Here we update dia.graph itself via dia.graph API. + // Here we update dia.graph itself via graph.syncCells. // State should be updated now with 1 update call. const newElements = [ ...state.getSnapshot().elements, { id: '4', width: 100, height: 100, type: 'ReactElement' }, ]; - syncGraph({ - graph, - elements: newElements as Array, - links: [], - }); + const elementItems = newElements.map((element) => + defaultElementToGraphSelector({ + element, + graph, + }) + ); + graph.syncCells(elementItems, { remove: true }); expect(graph.getElements()).toHaveLength(4); expect(state.getSnapshot().elements).toHaveLength(4); - expect(mockedSyncGraph).toHaveBeenCalledTimes(3); expect(mockedSetState).toHaveBeenCalledTimes(2); }); @@ -120,9 +143,11 @@ describe('graphSync', () => { const mockedSetState = jest.fn().mockImplementation(state.setState); state.setState = mockedSetState; + const getIdsSnapshot = createGetIdsSnapshot(state); + // Here we initially sync the graph with the state. // State should not be updated yet. - graphSync({ graph, store: state }); + stateSync({ graph, store: state, getIdsSnapshot }); expect(graph.getElements()).toHaveLength(2); expect(state.getSnapshot().elements).toHaveLength(2); expect(mockedSetState).toHaveBeenCalledTimes(0); @@ -177,12 +202,14 @@ describe('graphSync', () => { }, ]); - // Add elements to graph using syncGraph - syncGraph({ - graph, - elements: existingElements as Array, - links: [], - }); + // Add elements to graph using syncCells + const elementItems = existingElements.map((element) => + defaultElementToGraphSelector({ + element, + graph, + }) + ); + graph.syncCells(elementItems, { remove: true }); // Create empty store const state = createState>({ @@ -194,12 +221,14 @@ describe('graphSync', () => { const mockedSetState = jest.fn().mockImplementation(state.setState); state.setState = mockedSetState; + const getIdsSnapshot = createGetIdsSnapshot(state); + // Graph has 2 elements, store is empty expect(graph.getElements()).toHaveLength(2); expect(state.getSnapshot().elements).toHaveLength(0); - // Initialize graphSync - it should sync existing graph cells to store - graphSync({ graph, store: state }); + // Initialize stateSync - it should sync existing graph cells to store + stateSync({ graph, store: state, getIdsSnapshot }); // Store should now have the 2 elements from the graph expect(state.getSnapshot().elements).toHaveLength(2); @@ -231,11 +260,13 @@ describe('graphSync', () => { }, ]); - syncGraph({ - graph, - elements: graphElements as Array, - links: [], - }); + const elementItems = graphElements.map((element) => + defaultElementToGraphSelector({ + element, + graph, + }) + ); + graph.syncCells(elementItems, { remove: true }); // Create store with different elements const storeElements = createElements([ @@ -256,13 +287,15 @@ describe('graphSync', () => { const mockedSetState = jest.fn().mockImplementation(state.setState); state.setState = mockedSetState; + const getIdsSnapshot = createGetIdsSnapshot(state); + // Graph has 1 element, store has 1 different element expect(graph.getElements()).toHaveLength(1); expect(state.getSnapshot().elements).toHaveLength(1); expect(state.getSnapshot().elements[0].id).toBe('2'); - // Initialize graphSync - it should NOT sync graph cells to store since store is not empty - graphSync({ graph, store: state }); + // Initialize stateSync - it should NOT sync graph cells to store since store is not empty + stateSync({ graph, store: state, getIdsSnapshot }); // Store should still have its original element expect(state.getSnapshot().elements).toHaveLength(1); @@ -301,17 +334,23 @@ describe('graphSync', () => { }, ]); - syncGraph({ - graph, - elements: existingElements as Array, - links: [ - { + const elementItems = existingElements.map((element) => + defaultElementToGraphSelector({ + element, + graph, + }) + ); + const linkItems = [ + defaultLinkToGraphSelector({ + link: { id: 'link1', source: '1', target: '2', }, - ], - }); + graph, + }), + ]; + graph.syncCells([...elementItems, ...linkItems], { remove: true }); // Create empty store const state = createState>({ @@ -323,14 +362,16 @@ describe('graphSync', () => { const mockedSetState = jest.fn().mockImplementation(state.setState); state.setState = mockedSetState; + const getIdsSnapshot = createGetIdsSnapshot(state); + // Graph has 2 elements and 1 link, store is empty expect(graph.getElements()).toHaveLength(2); expect(graph.getLinks()).toHaveLength(1); expect(state.getSnapshot().elements).toHaveLength(0); expect(state.getSnapshot().links).toHaveLength(0); - // Initialize graphSync - it should sync existing graph cells (elements and links) to store - graphSync({ graph, store: state }); + // Initialize stateSync - it should sync existing graph cells (elements and links) to store + stateSync({ graph, store: state, getIdsSnapshot }); // Store should now have the 2 elements and 1 link from the graph expect(state.getSnapshot().elements).toHaveLength(2); @@ -360,7 +401,8 @@ describe('graphSync', () => { const unsubscribeSpy = jest.fn(); state.subscribe = jest.fn(() => unsubscribeSpy); - const sync = graphSync({ graph, store: state }); + const getIdsSnapshot = createGetIdsSnapshot(state); + const sync = stateSync({ graph, store: state, getIdsSnapshot }); // Verify subscription was set up expect(state.subscribe).toHaveBeenCalledTimes(1); @@ -386,7 +428,8 @@ describe('graphSync', () => { name: 'elements', }); - const sync = graphSync({ graph, store: state }); + const getIdsSnapshot = createGetIdsSnapshot(state); + const sync = stateSync({ graph, store: state, getIdsSnapshot }); // eslint-disable-next-line unicorn/consistent-function-scoping const cellChangeCallback = jest.fn(() => () => {}); @@ -433,11 +476,13 @@ describe('graphSync', () => { }, ]); - syncGraph({ - graph, - elements: existingElements as Array, - links: [], - }); + const elementItems = existingElements.map((element) => + defaultElementToGraphSelector({ + element, + graph, + }) + ); + graph.syncCells(elementItems, { remove: true }); // Create store without setState const state = createState>({ @@ -450,13 +495,15 @@ describe('graphSync', () => { // @ts-expect-error Testing edge case where setState might be undefined state.setState = undefined; + const getIdsSnapshot = createGetIdsSnapshot(state); + // Graph has 1 element, store is empty expect(graph.getElements()).toHaveLength(1); expect(state.getSnapshot().elements).toHaveLength(0); - // Initialize graphSync - it should not crash and should not sync since setState is undefined + // Initialize stateSync - it should not crash and should not sync since setState is undefined expect(() => { - graphSync({ graph, store: state }); + stateSync({ graph, store: state, getIdsSnapshot }); }).not.toThrow(); // Store should still be empty since setState was undefined @@ -486,11 +533,13 @@ describe('graphSync', () => { }, ]); - syncGraph({ - graph, - elements: graphElements as Array, - links: [], - }); + const elementItems = graphElements.map((element) => + defaultElementToGraphSelector({ + element, + graph, + }) + ); + graph.syncCells(elementItems, { remove: true }); // Create store with initial elements (different from graph) const initialElements = createElements([ @@ -511,13 +560,15 @@ describe('graphSync', () => { const mockedSetState = jest.fn().mockImplementation(state.setState); state.setState = mockedSetState; + const getIdsSnapshot = createGetIdsSnapshot(state); + // Graph has 1 element, store has 1 different element expect(graph.getElements()).toHaveLength(1); expect(state.getSnapshot().elements).toHaveLength(1); expect(state.getSnapshot().elements[0].id).toBe('initial-element'); - // Initialize graphSync - graphSync({ graph, store: state }); + // Initialize stateSync + stateSync({ graph, store: state, getIdsSnapshot }); // Since store has initial elements, they should take precedence // The graph should be synced to match the store (initial elements) @@ -546,11 +597,13 @@ describe('graphSync', () => { }, ]); - syncGraph({ - graph, - elements: graphElements as Array, - links: [], - }); + const elementItems = graphElements.map((element) => + defaultElementToGraphSelector({ + element, + graph, + }) + ); + graph.syncCells(elementItems, { remove: true }); // Create external store with different elements const externalElements = createElements([ @@ -571,13 +624,15 @@ describe('graphSync', () => { const mockedSetState = jest.fn().mockImplementation(externalStore.setState); externalStore.setState = mockedSetState; + const getIdsSnapshot = createGetIdsSnapshot(externalStore); + // Graph has 1 element, external store has 1 different element expect(graph.getElements()).toHaveLength(1); expect(externalStore.getSnapshot().elements).toHaveLength(1); expect(externalStore.getSnapshot().elements[0].id).toBe('external-element'); - // Initialize graphSync with external store - graphSync({ graph, store: externalStore }); + // Initialize stateSync with external store + stateSync({ graph, store: externalStore, getIdsSnapshot }); // External store should take precedence - graph should be synced to match external store expect(graph.getElements()).toHaveLength(1); @@ -613,8 +668,10 @@ describe('graphSync', () => { name: 'elements', }); - // Initialize graphSync first - graphSync({ graph, store: state }); + const getIdsSnapshot = createGetIdsSnapshot(state); + + // Initialize stateSync first + stateSync({ graph, store: state, getIdsSnapshot }); // Verify initial state expect(graph.getElements()).toHaveLength(1); diff --git a/packages/joint-react/src/state/graph-state-selectors.ts b/packages/joint-react/src/state/graph-state-selectors.ts new file mode 100644 index 0000000000..52044e83c2 --- /dev/null +++ b/packages/joint-react/src/state/graph-state-selectors.ts @@ -0,0 +1,209 @@ +/* eslint-disable sonarjs/cognitive-complexity */ +import { util, type dia } from '@joint/core'; +import type { GraphElement } from '../types/element-types'; +import type { GraphLink } from '../types/link-types'; +import { getTargetOrSource } from '../utils/cell/get-link-targe-and-source-ids'; +import { REACT_TYPE } from '../models/react-element'; + +export interface ElementToGraphOptions { + readonly element: Element; + readonly graph: dia.Graph; +} + +export interface ElementFromGraphOptions { + readonly cell: dia.Element; + readonly previous?: Element; + readonly graph: dia.Graph; +} + +export interface LinkToGraphOptions { + readonly link: Link; + readonly graph: dia.Graph; +} + +export interface LinkFromGraphOptions { + readonly cell: dia.Link; + readonly previous?: Link; + readonly graph: dia.Graph; +} + +export type LinkFromGraphSelector = ( + options: LinkFromGraphOptions +) => Link; + +export interface GraphStateSelectors { + readonly elementToGraphSelector?: (options: ElementToGraphOptions) => dia.Cell.JSON; + readonly elementFromGraphSelector?: (options: ElementFromGraphOptions) => Element; + readonly linkToGraphSelector?: (options: LinkToGraphOptions) => dia.Cell.JSON; + readonly linkFromGraphSelector?: (options: LinkFromGraphOptions) => Link; +} + +/** + * Default selector that converts a Link to a JointJS Cell JSON representation. + * @param options - The options containing the link and graph. + * @param options.link - The link to convert. + * @param options.graph - The JointJS graph instance. + * @returns A JointJS Cell JSON representation of the link. + * @group state + * @description + * This selector extracts source and target information from the link and merges it with default attributes + * from the graph's cell namespace. It handles link type defaults and attribute merging. + */ +export function defaultLinkToGraphSelector( + options: LinkToGraphOptions +): dia.Cell.JSON { + const { graph, link } = options; + const source = getTargetOrSource(link.source); + const target = getTargetOrSource(link.target); + const { attrs, type = 'standard.Link', ...rest } = link; + + // TODO: this is not optimal solution + const defaults = util.result( + util.getByPath(graph.layerCollection.cellNamespace, type, '.').prototype, + 'defaults', + {} + ); + + const mergedLink = { + ...rest, + type, + attrs: util.defaultsDeep({}, attrs as never, defaults.attrs), + }; + + return { + ...mergedLink, + type: link.type ?? 'standard.Link', + source, + target, + } as unknown as dia.Cell.JSON; +} + +/** + * Default selector that converts an Element to a JointJS Cell JSON representation. + * @param options - The options containing the element and graph. + * @param options.element - The element to convert. + * @param options.graph - The JointJS graph instance. + * @returns A JointJS Cell JSON representation of the element. + * @group state + * @description + * This selector extracts position (x, y) and size (width, height) from the element and creates + * a JointJS cell JSON with the appropriate structure. It preserves all element properties. + */ +export function defaultElementToGraphSelector( + options: ElementToGraphOptions +): dia.Cell.JSON { + const { element } = options; + const { type = REACT_TYPE, x, y, width, height } = element; + + return { + type, + position: { x, y }, + size: { width, height }, + ...element, + } as dia.Cell.JSON; +} + +/** + * Default selector that converts a JointJS Link cell to a Link representation. + * @param options - The options containing the cell, previous state, and graph. + * @param options.cell - The JointJS Link cell to convert. + * @param options.previous - Optional previous link state to preserve shape. + * @param options.graph - The JointJS graph instance. + * @returns A Link representation extracted from the cell. + * @group state + * @description + * This selector extracts all properties from a JointJS Link cell. If a previous state is provided, + * it filters the result to only include properties that existed in the previous state, ensuring + * the state shape remains the source of truth. + */ +export function defaultLinkFromGraphSelector( + options: LinkFromGraphOptions +): Link { + const { cell, previous } = options; + + // Extract all properties from cell + const cellData: Record = { + ...cell.attributes, + id: cell.id, + source: cell.get('source') as dia.Cell.ID, + target: cell.get('target') as dia.Cell.ID, + type: cell.attributes.type, + z: cell.get('z'), + markup: cell.get('markup'), + defaultLabel: cell.get('defaultLabel'), + }; + + // If previous state exists, filter to only include properties that exist in previous state + // This ensures state shape is the source of truth + if (previous !== undefined) { + const filtered: Record = {}; + const previousRecord = previous as Record; + for (const key in previousRecord) { + if (Object.prototype.hasOwnProperty.call(previousRecord, key)) { + // Include property if it exists in previous (even if undefined) + // Get value from cellData if available, otherwise use previous value + filtered[key] = key in cellData ? cellData[key] : previousRecord[key]; + } + } + return filtered as Link; + } + + return cellData as Link; +} + +/** + * Default selector that converts a JointJS Element cell to a GraphElement representation. + * @param options - The options containing the cell, previous state, and graph. + * @param options.cell - The JointJS Element cell to convert. + * @param options.previous - Optional previous element state to preserve shape. + * @param options.graph - The JointJS graph instance. + * @returns A GraphElement representation extracted from the cell. + * @group state + * @description + * This selector extracts position and size from the cell's attributes and flattens them into x, y, width, height. + * If a previous state is provided, it filters the result to only include properties that existed in the + * previous state, ensuring the state shape remains the source of truth. + */ +export function defaultElementFromGraphSelector( + options: ElementFromGraphOptions +): GraphElement { + const { cell, previous } = options; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { size, position, data, attrs, type, ...attributes } = cell.attributes; + const cellData: Record = { + ...attributes, + ...position, + ...size, + id: cell.id, + ports: cell.get('ports'), + }; + if (type !== REACT_TYPE) { + cellData.type = type; + } + + // If previous state exists, filter to only include properties that exist in previous state + // This ensures state shape is the source of truth + // However, we always include core properties (x, y, width, height, id) from cellData + if (previous !== undefined) { + const filtered: Record = {}; + const previousRecord = previous as Record; + for (const key in previousRecord) { + if (Object.prototype.hasOwnProperty.call(previousRecord, key)) { + // Include property if it exists in previous (even if undefined) + // Get value from cellData if available, otherwise use previous value + filtered[key] = key in cellData ? cellData[key] : previousRecord[key]; + } + } + // Always include core properties from cellData, even if they weren't in previous state + // This ensures position and size changes are always reflected + if ('x' in cellData) filtered.x = cellData.x; + if ('y' in cellData) filtered.y = cellData.y; + if ('width' in cellData) filtered.width = cellData.width; + if ('height' in cellData) filtered.height = cellData.height; + if ('id' in cellData) filtered.id = cellData.id; + return filtered as Element; + } + + return cellData as Element; +} diff --git a/packages/joint-react/src/store/graph-sync.ts b/packages/joint-react/src/state/state-sync.ts similarity index 74% rename from packages/joint-react/src/store/graph-sync.ts rename to packages/joint-react/src/state/state-sync.ts index e6d3375134..77fc0f30c9 100644 --- a/packages/joint-react/src/store/graph-sync.ts +++ b/packages/joint-react/src/state/state-sync.ts @@ -1,32 +1,37 @@ /* eslint-disable sonarjs/cognitive-complexity */ -import type { GraphStoreSnapshot } from './graph-store'; +import type { GraphStoreDerivedSnapshot, GraphStoreSnapshot } from '../store/graph-store'; import { listenToCellChange, type OnChangeOptions } from '../utils/cell/listen-to-cell-change'; -import { elementFromGraph, linkFromGraph, syncGraph } from '../utils/cell/cell-utilities'; import { removeDeepReadOnly, type ExternalStoreLike } from '../utils/create-state'; import { util, type dia } from '@joint/core'; import type { GraphElement } from '../types/element-types'; import type { GraphLink } from '../types/link-types'; +import type { GraphStateSelectors } from './graph-state-selectors'; +import { + defaultElementToGraphSelector, + defaultElementFromGraphSelector, + defaultLinkToGraphSelector, + defaultLinkFromGraphSelector, +} from './graph-state-selectors'; /** - * Configuration options for graph synchronization. + * Configuration options for state synchronization. * @template Graph - The type of JointJS graph instance * @template Element - The type of elements in the graph * @template Link - The type of links in the graph */ -interface Options< - Graph extends dia.Graph, - Element extends dia.Element | GraphElement, - Link extends dia.Link | GraphLink, -> { +interface Options + extends GraphStateSelectors { /** The JointJS graph instance to synchronize */ readonly graph: Graph; /** The external store containing elements and links to sync with */ readonly store: ExternalStoreLike>; + readonly getIdsSnapshot: () => GraphStoreDerivedSnapshot; /** - * If true, the synchronization will be real-time. Otherwise, it will be based on batches. + * If true, batch updates are disabled and synchronization will be real-time. + * If false (default), batch updates are enabled for better performance. * @default false */ - readonly useRealtimeUpdated?: boolean; + readonly areBatchUpdatesDisabled?: boolean; } const BATCH_START_EVENT_NAME = 'batch:start'; const BATCH_STOP_EVENT_NAME = 'batch:stop'; @@ -48,15 +53,22 @@ const BATCH_STOP_EVENT_NAME = 'batch:stop'; * @template Graph - The type of JointJS graph instance * @template Element - The type of elements in the graph * @template Link - The type of links in the graph - * @param options - Configuration options for graph synchronization - * @returns GraphSync instance with subscription and cleanup methods + * @param options - Configuration options for state synchronization + * @returns StateSync instance with subscription and cleanup methods */ -export function graphSync< +export function stateSync< Graph extends dia.Graph, - Element extends dia.Element | GraphElement, - Link extends dia.Link | GraphLink, ->(options: Options): GraphSync { - const { graph, store, useRealtimeUpdated = false } = options; + Element extends GraphElement, + Link extends GraphLink, +>(options: Options): StateSync { + const { graph, store, areBatchUpdatesDisabled = false, getIdsSnapshot } = options; + + // Use provided selectors or fall back to defaults + const elementToGraph = options.elementToGraphSelector ?? defaultElementToGraphSelector; + const elementFromGraph = options.elementFromGraphSelector ?? defaultElementFromGraphSelector; + const linkToGraph = options.linkToGraphSelector ?? defaultLinkToGraphSelector; + const linkFromGraph = options.linkFromGraphSelector ?? defaultLinkFromGraphSelector; + // We need to ensure several things: // 1. Graph can update itself, via onCellChange or via onBatchStop - this change is internal and must update the external store - but only if the external store do not trigger the same change. // 2. External store can update the graph via new elements or links - this change is external and must update the internal graph @@ -90,8 +102,18 @@ export function graphSync< if (isReset) { // unfortunately this will create always new object references, so we need to compare them with more deeply - const graphElements = graph.getElements().map((element) => elementFromGraph(element)); - const graphLinks = graph.getLinks().map((link) => linkFromGraph(link)); + const graphElements = graph.getElements().map((element) => + elementFromGraph({ + cell: element, + graph, + }) + ); + const graphLinks = graph.getLinks().map((link) => + linkFromGraph({ + cell: link, + graph, + }) + ); const snapshot = store.getSnapshot(); const elements = removeDeepReadOnly(snapshot.elements); const links = removeDeepReadOnly(snapshot.links); @@ -108,17 +130,45 @@ export function graphSync< // Set flag to prevent this state update from triggering another graph sync updatingStateFromGraphCounter++; isUpdatingStateFromGraph = true; + + // Get IDs snapshot for O(1) lookups + const idsSnapshot = getIdsSnapshot(); + store.setState((previous: GraphStoreSnapshot) => { - const updates = new Map(); + const updates = new Map(); const removals = new Set(); for (const id of ids) { const cell = graph.getCell(id); if (cell) { if (cell.isLink()) { - updates.set(id, { type: 'link', data: linkFromGraph(cell) }); + // Get previous link using O(1) lookup + const linkIndex = idsSnapshot.linkIds[id]; + const previousLink = + linkIndex != null && linkIndex >= 0 && linkIndex < previous.links.length + ? previous.links[linkIndex] + : undefined; + + const updatedLink = linkFromGraph({ + cell: cell as dia.Link, + graph, + previous: previousLink, + }); + updates.set(id, { type: 'link', data: updatedLink as Link }); } else { - updates.set(id, { type: 'element', data: elementFromGraph(cell) }); + // Get previous element using O(1) lookup + const elementIndex = idsSnapshot.elementIds[id]; + const previousElement = + elementIndex != null && elementIndex >= 0 && elementIndex < previous.elements.length + ? previous.elements[elementIndex] + : undefined; + + const updatedElement = elementFromGraph({ + cell: cell as dia.Element, + graph, + previous: previousElement, + }); + updates.set(id, { type: 'element', data: updatedElement as Element }); } } else { removals.add(id); @@ -141,7 +191,7 @@ export function graphSync< if (util.isEqual(cellElement, update.data)) { nextElements.push(cellElement); } else { - nextElements.push(update.data); + nextElements.push(update.data as Element); elementsChanged = true; } updates.delete(id); @@ -156,7 +206,7 @@ export function graphSync< // Add new elements for (const [id, update] of updates) { if (update.type === 'element') { - nextElements.push(update.data); + nextElements.push(update.data as Element); elementsChanged = true; updates.delete(id); } @@ -178,7 +228,7 @@ export function graphSync< if (util.isEqual(link, update.data)) { nextLinks.push(link); } else { - nextLinks.push(update.data); + nextLinks.push(update.data as Link); linksChanged = true; } updates.delete(id); @@ -193,7 +243,7 @@ export function graphSync< // Add new links for (const [, update] of updates) { if (update.type === 'link') { - nextLinks.push(update.data); + nextLinks.push(update.data as Link); linksChanged = true; } } @@ -233,7 +283,7 @@ export function graphSync< // Skip if we're syncing from state to prevent circular updates if (isSyncingFromState) return; - if (!useRealtimeUpdated && graph.hasActiveBatch()) return; + if (!areBatchUpdatesDisabled && graph.hasActiveBatch()) return; onIncrementalChange(); }); @@ -250,7 +300,7 @@ export function graphSync< const onBatchStop = (_event: { batchName?: string }) => { batchCounter--; if (batchCounter > 0) return; // Still in a nested batch - if (!useRealtimeUpdated && graph.hasActiveBatch()) return; + if (!areBatchUpdatesDisabled && graph.hasActiveBatch()) return; // Reset the flag after batch completes const wasSyncingFromState = isSyncingFromState; @@ -286,8 +336,18 @@ export function graphSync< // Only sync if store is empty and graph has cells if (storeElements.length === 0 && storeLinks.length === 0) { - const existingElements = graph.getElements().map((element) => elementFromGraph(element)); - const existingLinks = graph.getLinks().map((link) => linkFromGraph(link)); + const existingElements = graph.getElements().map((element) => + elementFromGraph({ + cell: element, + graph, + }) + ) as Element[]; + const existingLinks = graph.getLinks().map((link) => + linkFromGraph({ + cell: link, + graph, + }) + ) as Link[]; if (existingElements.length > 0 || existingLinks.length > 0) { // Set flag to prevent syncing graph changes back to React during initialization @@ -296,8 +356,8 @@ export function graphSync< store.setState((previous) => ({ ...previous, - elements: existingElements as Element[], - links: existingLinks as Link[], + elements: existingElements, + links: existingLinks, })); updatingStateFromGraphCounter--; @@ -322,8 +382,18 @@ export function graphSync< // Compare current graph state with store state to avoid unnecessary syncs // This prevents syncing when graph and store are already in sync - const graphElements = graph.getElements().map((element) => elementFromGraph(element)); - const graphLinks = graph.getLinks().map((link) => linkFromGraph(link)); + const graphElements = graph.getElements().map((element) => + elementFromGraph({ + cell: element, + graph, + }) + ); + const graphLinks = graph.getLinks().map((link) => + linkFromGraph({ + cell: link, + graph, + }) + ); // Check if graph is already in sync with store if (util.isEqual(elements, graphElements) && util.isEqual(links, graphLinks)) { @@ -335,15 +405,26 @@ export function graphSync< syncFromStateCounter++; isSyncingFromState = true; - syncGraph({ - graph, - elements: elements as Array, - links: links as Array, - }); + // Build items array using selectors + const elementItems = elements.map((element) => + elementToGraph({ + element: element as Element, + graph, + }) + ); + const linkItems = links.map((link) => + linkToGraph({ + link: link as Link, + graph, + }) + ); + + // Use graph.syncCells directly instead of syncGraph + graph.syncCells([...elementItems, ...linkItems], { remove: true }); // Only reset the flag if there's no batch (events were processed synchronously) // If there's a batch, onBatchStop will handle resetting it - // We need to check after syncGraph because it might have started a batch + // We need to check after syncCells because it might have started a batch if (batchCounter === 0 && !graph.hasActiveBatch()) { // Decrement counter and reset flag if counter reaches 0 syncFromStateCounter--; @@ -394,10 +475,10 @@ export function graphSync< } /** - * Interface for graph synchronization instance. + * Interface for state synchronization instance. * Provides methods to subscribe to cell changes and clean up resources. */ -export interface GraphSync { +export interface StateSync { /** * Subscribes to cell change events in the graph. * The callback receives change information and should return a cleanup function. diff --git a/packages/joint-react/src/store/__tests__/graph-store.test.ts b/packages/joint-react/src/store/__tests__/graph-store.test.ts new file mode 100644 index 0000000000..526a28d975 --- /dev/null +++ b/packages/joint-react/src/store/__tests__/graph-store.test.ts @@ -0,0 +1,608 @@ +/* eslint-disable no-shadow */ +/* eslint-disable unicorn/consistent-function-scoping */ +import { dia, shapes } from '@joint/core'; +import { GraphStore } from '../graph-store'; +import { ReactElement } from '../../models/react-element'; +import type { GraphElement } from '../../types/element-types'; +import type { GraphLink } from '../../types/link-types'; +import { + defaultElementToGraphSelector, + defaultElementFromGraphSelector, + defaultLinkToGraphSelector, + defaultLinkFromGraphSelector, + type ElementToGraphOptions, + type ElementFromGraphOptions, + type LinkToGraphOptions, + type LinkFromGraphOptions, +} from '../../state/graph-state-selectors'; + +const DEFAULT_TEST_NAMESPACE = { ...shapes, ReactElement }; + +describe('GraphStore', () => { + describe('constructor', () => { + it('should create a GraphStore with default graph instance', () => { + const store = new GraphStore({}); + expect(store).toBeDefined(); + expect(store.graph).toBeInstanceOf(dia.Graph); + expect(store.publicState).toBeDefined(); + expect(store.internalState).toBeDefined(); + expect(store.derivedStore).toBeDefined(); + }); + + it('should create a GraphStore with provided graph instance', () => { + const graph = new dia.Graph({}, { cellNamespace: DEFAULT_TEST_NAMESPACE }); + const store = new GraphStore({ graph }); + expect(store.graph).toBe(graph); + }); + + it('should initialize with empty elements and links by default', () => { + const store = new GraphStore({}); + const snapshot = store.publicState.getSnapshot(); + expect(snapshot.elements).toEqual([]); + expect(snapshot.links).toEqual([]); + }); + + it('should initialize with initialElements', () => { + const initialElements: GraphElement[] = [ + { id: 'element-1', x: 10, y: 20, width: 100, height: 50, type: 'ReactElement' }, + { id: 'element-2', x: 30, y: 40, width: 80, height: 60, type: 'ReactElement' }, + ]; + const store = new GraphStore({ initialElements }); + const snapshot = store.publicState.getSnapshot(); + expect(snapshot.elements).toHaveLength(2); + expect(snapshot.elements[0].id).toBe('element-1'); + expect(snapshot.elements[1].id).toBe('element-2'); + }); + + it('should initialize with initialLinks', () => { + const initialLinks: GraphLink[] = [ + { id: 'link-1', source: 'element-1', target: 'element-2', type: 'standard.Link' }, + ]; + const store = new GraphStore({ initialLinks }); + const snapshot = store.publicState.getSnapshot(); + expect(snapshot.links).toHaveLength(1); + expect(snapshot.links[0].id).toBe('link-1'); + }); + + it('should initialize with both initialElements and initialLinks', () => { + const initialElements: GraphElement[] = [ + { id: 'element-1', x: 10, y: 20, width: 100, height: 50, type: 'ReactElement' }, + ]; + const initialLinks: GraphLink[] = [ + { id: 'link-1', source: 'element-1', target: 'element-2', type: 'standard.Link' }, + ]; + const store = new GraphStore({ initialElements, initialLinks }); + const snapshot = store.publicState.getSnapshot(); + expect(snapshot.elements).toHaveLength(1); + expect(snapshot.links).toHaveLength(1); + }); + + it('should merge custom cellNamespace with default namespace', () => { + const customNamespace = { CustomShape: class extends dia.Element {} }; + const store = new GraphStore({ cellNamespace: customNamespace }); + // The graph should have both default and custom namespaces + expect(store.graph).toBeDefined(); + }); + + it('should use external store when provided', () => { + function unsubscribe() { + // Empty unsubscribe function + } + const externalStore = { + getSnapshot: () => ({ elements: [], links: [] }), + subscribe: () => unsubscribe, + setState: () => {}, + }; + const store = new GraphStore({ externalStore }); + expect(store.publicState).toBe(externalStore); + }); + + it('should use custom selectors when provided', () => { + const customElementToGraph = jest.fn((options: ElementToGraphOptions) => { + return defaultElementToGraphSelector(options); + }); + const customElementFromGraph = jest.fn((options: ElementFromGraphOptions) => { + return defaultElementFromGraphSelector(options); + }); + const customLinkToGraph = jest.fn((options: LinkToGraphOptions) => { + return defaultLinkToGraphSelector(options); + }); + const customLinkFromGraph = jest.fn((options: LinkFromGraphOptions) => { + return defaultLinkFromGraphSelector(options); + }); + + const store = new GraphStore({ + elementToGraphSelector: customElementToGraph, + elementFromGraphSelector: customElementFromGraph, + linkToGraphSelector: customLinkToGraph, + linkFromGraphSelector: customLinkFromGraph, + }); + + // Add an element to trigger the selector + const element: GraphElement = { + id: 'test-element', + x: 10, + y: 20, + width: 100, + height: 50, + type: 'ReactElement', + }; + store.publicState.setState((previous) => ({ + ...previous, + elements: [...previous.elements, element], + })); + + // Wait a bit for sync to happen + setTimeout(() => { + expect(customElementToGraph).toHaveBeenCalled(); + }, 10); + }); + + it('should default areBatchUpdatesDisabled to false (batch updates enabled)', () => { + const store = new GraphStore({}); + // The store should be created successfully with default batch-based updates + expect(store).toBeDefined(); + expect(store.graph).toBeDefined(); + }); + + it('should use areBatchUpdatesDisabled when provided', () => { + const storeWithRealtime = new GraphStore({ areBatchUpdatesDisabled: true }); + expect(storeWithRealtime).toBeDefined(); + + const storeWithBatches = new GraphStore({ areBatchUpdatesDisabled: false }); + expect(storeWithBatches).toBeDefined(); + }); + + it('should handle graph with existing cells', () => { + const graph = new dia.Graph({}, { cellNamespace: DEFAULT_TEST_NAMESPACE }); + const existingElement = new dia.Element({ + id: 'existing-element', + type: 'ReactElement', + position: { x: 0, y: 0 }, + size: { width: 100, height: 50 }, + }); + graph.addCell(existingElement); + const cellCountBefore = graph.getCells().length; + + const initialElements: GraphElement[] = [ + { id: 'new-element', x: 10, y: 20, width: 100, height: 50, type: 'ReactElement' }, + ]; + const store = new GraphStore({ graph, initialElements }); + + // Store should be created successfully + expect(store).toBeDefined(); + // Graph should still have cells (at least the existing one, possibly more after sync) + expect(graph.getCells().length).toBeGreaterThanOrEqual(cellCountBefore); + }); + }); + + describe('destroy', () => { + it('should cleanup all resources when graph is internal', () => { + const store = new GraphStore({}); + const { graph } = store; + + store.destroy(false); + + // Graph should be cleared + expect(graph.getCells()).toHaveLength(0); + }); + + it('should not clear graph when graph is external', () => { + const graph = new dia.Graph({}, { cellNamespace: DEFAULT_TEST_NAMESPACE }); + const element = new dia.Element({ + id: 'test-element', + type: 'ReactElement', + position: { x: 0, y: 0 }, + size: { width: 100, height: 50 }, + }); + graph.addCell(element); + + const store = new GraphStore({ graph }); + const cellCountBefore = graph.getCells().length; + + store.destroy(true); + + // Graph should not be cleared + expect(graph.getCells()).toHaveLength(cellCountBefore); + expect(graph.getCell('test-element')).toBeDefined(); + }); + }); + + describe('updatePaperSnapshot', () => { + it('should update paper snapshot for given paperId', () => { + const store = new GraphStore({}); + const paperId = 'paper-1'; + + store.updatePaperSnapshot(paperId, () => ({ + paperElementViews: {}, + portsData: {}, + })); + + const internalSnapshot = store.internalState.getSnapshot(); + expect(internalSnapshot.papers[paperId]).toBeDefined(); + expect(internalSnapshot.papers[paperId].paperElementViews).toEqual({}); + }); + + it('should update existing paper snapshot', () => { + const store = new GraphStore({}); + const paperId = 'paper-1'; + + store.updatePaperSnapshot(paperId, () => ({ + paperElementViews: {}, + portsData: {}, + })); + + store.updatePaperSnapshot(paperId, (previous) => ({ + ...previous!, + paperElementViews: { 'element-1': {} as dia.ElementView }, + })); + + const internalSnapshot = store.internalState.getSnapshot(); + expect(internalSnapshot.papers[paperId].paperElementViews).toHaveProperty('element-1'); + }); + + it('should not update if snapshot is unchanged', () => { + const store = new GraphStore({}); + const paperId = 'paper-1'; + const snapshot = { paperElementViews: {}, portsData: {} }; + + store.updatePaperSnapshot(paperId, () => snapshot); + const firstUpdate = store.internalState.getSnapshot().papers[paperId]; + + store.updatePaperSnapshot(paperId, () => snapshot); + const secondUpdate = store.internalState.getSnapshot().papers[paperId]; + + // Should return same reference if unchanged + expect(firstUpdate).toBe(secondUpdate); + }); + }); + + describe('updatePaperElementView', () => { + it('should update element view for given paper and cell', () => { + const store = new GraphStore({}); + const paperId = 'paper-1'; + const cellId = 'element-1'; + const mockView = {} as dia.ElementView; + + store.updatePaperElementView(paperId, cellId, mockView); + + const internalSnapshot = store.internalState.getSnapshot(); + const paper = internalSnapshot.papers[paperId]; + expect(paper?.paperElementViews?.[cellId]).toBe(mockView); + }); + + it('should not update if view is unchanged', () => { + const store = new GraphStore({}); + const paperId = 'paper-1'; + const cellId = 'element-1'; + const mockView = {} as dia.ElementView; + + store.updatePaperElementView(paperId, cellId, mockView); + const snapshot1 = store.internalState.getSnapshot(); + const paper1 = snapshot1.papers[paperId]; + const firstUpdate = paper1?.paperElementViews?.[cellId]; + + store.updatePaperElementView(paperId, cellId, mockView); + const snapshot2 = store.internalState.getSnapshot(); + const paper2 = snapshot2.papers[paperId]; + const secondUpdate = paper2?.paperElementViews?.[cellId]; + + expect(firstUpdate).toBe(secondUpdate); + }); + }); + + describe('addPaper', () => { + it('should add a new paper and return cleanup function', () => { + const store = new GraphStore({}); + const paperId = 'paper-1'; + const paperElement = document.createElement('div'); + const cleanup = store.addPaper(paperId, { + paperOptions: { + model: store.graph, + width: 800, + height: 600, + }, + paperElement, + }); + + expect(store.getPaperStore(paperId)).toBeDefined(); + expect(typeof cleanup).toBe('function'); + }); + + it('should remove paper when cleanup is called', () => { + const store = new GraphStore({}); + const paperId = 'paper-1'; + const paperElement = document.createElement('div'); + const cleanup = store.addPaper(paperId, { + paperOptions: { + model: store.graph, + width: 800, + height: 600, + }, + paperElement, + }); + + expect(store.getPaperStore(paperId)).toBeDefined(); + + cleanup(); + + expect(store.getPaperStore(paperId)).toBeUndefined(); + }); + + it('should handle multiple papers', () => { + const store = new GraphStore({}); + const paper1Element = document.createElement('div'); + const paper2Element = document.createElement('div'); + const paper1 = store.addPaper('paper-1', { + paperOptions: { + model: store.graph, + width: 800, + height: 600, + }, + paperElement: paper1Element, + }); + const paper2 = store.addPaper('paper-2', { + paperOptions: { + model: store.graph, + width: 800, + height: 600, + }, + paperElement: paper2Element, + }); + + expect(store.getPaperStore('paper-1')).toBeDefined(); + expect(store.getPaperStore('paper-2')).toBeDefined(); + + paper1(); + expect(store.getPaperStore('paper-1')).toBeUndefined(); + expect(store.getPaperStore('paper-2')).toBeDefined(); + + paper2(); + expect(store.getPaperStore('paper-2')).toBeUndefined(); + }); + }); + + describe('hasMeasuredNode', () => { + it('should return false for non-measured nodes', () => { + const store = new GraphStore({}); + expect(store.hasMeasuredNode('non-existent')).toBe(false); + }); + + it('should return true for measured nodes', () => { + const store = new GraphStore({}); + const element: GraphElement = { + id: 'measured-element', + x: 10, + y: 20, + width: 100, + height: 50, + type: 'ReactElement', + }; + + store.publicState.setState((previous) => ({ + ...previous, + elements: [...previous.elements, element], + })); + + const domElement = document.createElement('div'); + store.setMeasuredNode({ + id: 'measured-element', + element: domElement, + }); + + expect(store.hasMeasuredNode('measured-element')).toBe(true); + }); + }); + + describe('setMeasuredNode', () => { + it('should register a node for measurement and return cleanup', () => { + const store = new GraphStore({}); + const element: GraphElement = { + id: 'measured-element', + x: 10, + y: 20, + width: 100, + height: 50, + type: 'ReactElement', + }; + + store.publicState.setState((previous) => ({ + ...previous, + elements: [...previous.elements, element], + })); + + const domElement = document.createElement('div'); + const setSize = jest.fn(); + const cleanup = store.setMeasuredNode({ + id: 'measured-element', + element: domElement, + setSize, + }); + + expect(typeof cleanup).toBe('function'); + expect(store.hasMeasuredNode('measured-element')).toBe(true); + + cleanup(); + expect(store.hasMeasuredNode('measured-element')).toBe(false); + }); + }); + + describe('getPaperStore', () => { + it('should return undefined for non-existent paper', () => { + const store = new GraphStore({}); + expect(store.getPaperStore('non-existent')).toBeUndefined(); + }); + + it('should return paper store for existing paper', () => { + const store = new GraphStore({}); + const paperElement = document.createElement('div'); + store.addPaper('paper-1', { + paperOptions: { + model: store.graph, + width: 800, + height: 600, + }, + paperElement, + }); + + const paperStore = store.getPaperStore('paper-1'); + expect(paperStore).toBeDefined(); + }); + }); + + describe('subscribeToCellChange', () => { + it('should subscribe to cell changes and return unsubscribe', () => { + const store = new GraphStore({}); + function unsubscribeCallback() { + // Empty unsubscribe callback + } + const callback = jest.fn(() => unsubscribeCallback); + const unsubscribe = store.subscribeToCellChange(callback); + + expect(typeof unsubscribe).toBe('function'); + + unsubscribe(); + }); + }); + + describe('updateExternalStore', () => { + it('should update the external store reference', () => { + const store = new GraphStore({}); + const originalStore = store.publicState; + + function unsubscribe() { + // Empty unsubscribe function + } + const newStore = { + getSnapshot: () => ({ elements: [], links: [] }), + subscribe: () => unsubscribe, + setState: () => {}, + }; + + store.updateExternalStore(newStore); + + expect(store.publicState).toBe(newStore); + expect(store.publicState).not.toBe(originalStore); + }); + }); + + describe('derivedStore', () => { + it('should create elementIds mapping', () => { + const store = new GraphStore({}); + const elements: GraphElement[] = [ + { id: 'element-1', x: 10, y: 20, width: 100, height: 50, type: 'ReactElement' }, + { id: 'element-2', x: 30, y: 40, width: 80, height: 60, type: 'ReactElement' }, + ]; + + store.publicState.setState((previous) => ({ + ...previous, + elements, + })); + + const derived = store.derivedStore.getSnapshot(); + expect(derived.elementIds['element-1']).toBe(0); + expect(derived.elementIds['element-2']).toBe(1); + }); + + it('should create linkIds mapping', () => { + const store = new GraphStore({}); + const links: GraphLink[] = [ + { id: 'link-1', source: 'element-1', target: 'element-2', type: 'standard.Link' }, + { id: 'link-2', source: 'element-2', target: 'element-3', type: 'standard.Link' }, + ]; + + store.publicState.setState((previous) => ({ + ...previous, + links, + })); + + const derived = store.derivedStore.getSnapshot(); + expect(derived.linkIds['link-1']).toBe(0); + expect(derived.linkIds['link-2']).toBe(1); + }); + + it('should track areElementsMeasured correctly', () => { + const store = new GraphStore({}); + + // Test with measured elements + const measuredElements: GraphElement[] = [ + { id: 'element-1', x: 10, y: 20, width: 100, height: 50, type: 'ReactElement' }, + ]; + + store.publicState.setState((previous) => ({ + ...previous, + elements: measuredElements, + })); + + let derived = store.derivedStore.getSnapshot(); + // After setting measured elements, should be true + expect(derived.areElementsMeasured).toBe(true); + + // Once measured, it stays true even if we add unmeasured elements + const mixedElements: GraphElement[] = [ + { id: 'element-1', x: 10, y: 20, width: 100, height: 50, type: 'ReactElement' }, + { id: 'element-2', x: 30, y: 40, width: 0, height: 0, type: 'ReactElement' }, + ]; + + store.publicState.setState((previous) => ({ + ...previous, + elements: mixedElements, + })); + + derived = store.derivedStore.getSnapshot(); + // Should remain true because wasElementsMeasuredBefore is true + expect(derived.areElementsMeasured).toBe(true); + }); + }); + + describe('state synchronization', () => { + it('should sync state changes to graph', (done) => { + const store = new GraphStore({}); + const element: GraphElement = { + id: 'sync-element', + x: 10, + y: 20, + width: 100, + height: 50, + type: 'ReactElement', + }; + + store.publicState.setState((previous) => ({ + ...previous, + elements: [...previous.elements, element], + })); + + // Wait for sync + setTimeout(() => { + const graphElement = store.graph.getCell('sync-element'); + expect(graphElement).toBeDefined(); + expect(graphElement?.isElement()).toBe(true); + done(); + }, 50); + }); + + it('should sync graph changes to state', (done) => { + const store = new GraphStore({}); + const { graph } = store; + + const element = new dia.Element({ + id: 'graph-element', + type: 'ReactElement', + position: { x: 0, y: 0 }, + size: { width: 100, height: 50 }, + }); + + graph.addCell(element); + + // Wait for sync + // eslint-disable-next-line @typescript-eslint/no-shadow + const findElementById = (element: GraphElement) => element.id === 'graph-element'; + setTimeout(() => { + const snapshot = store.publicState.getSnapshot(); + const stateElement = snapshot.elements.find(findElementById); + expect(stateElement).toBeDefined(); + done(); + }, 50); + }); + }); +}); diff --git a/packages/joint-react/src/store/graph-store.ts b/packages/joint-react/src/store/graph-store.ts index dd7c0fee21..11a87d026a 100644 --- a/packages/joint-react/src/store/graph-store.ts +++ b/packages/joint-react/src/store/graph-store.ts @@ -12,8 +12,9 @@ import { import { ReactElement } from '../models/react-element'; import type { ExternalStoreLike, State } from '../utils/create-state'; import { createState, derivedState, getValue } from '../utils/create-state'; -import { graphSync, type GraphSync } from './graph-sync'; +import { stateSync, type StateSync } from '../state/state-sync'; import type { OnChangeOptions } from '../utils/cell/listen-to-cell-change'; +import type { GraphStateSelectors } from '../state/graph-state-selectors'; export const DEFAULT_CELL_NAMESPACE: Record = { ...shapes, ReactElement }; @@ -36,8 +37,8 @@ export type GraphState = State; * @template Link - The type of links in the graph */ export interface GraphStoreSnapshot< - Element extends dia.Element | GraphElement = GraphElement, - Link extends dia.Link | GraphLink = GraphLink, + Element extends GraphElement = GraphElement, + Link extends GraphLink = GraphLink, > { /** Array of all elements (nodes) in the graph */ readonly elements: Element[]; @@ -68,8 +69,13 @@ export interface GraphStoreInternalSnapshot { /** * Configuration options for creating a GraphStore instance. + * @template Element - The type of elements in the graph + * @template Link - The type of links in the graph */ -export interface GraphStoreOptions { +export interface GraphStoreOptions< + Element extends GraphElement = GraphElement, + Link extends GraphLink = GraphLink, +> extends GraphStateSelectors { /** * Graph instance to use. If not provided, a new graph instance will be created. * Useful when you need to share a graph instance across multiple stores or integrate with existing JointJS code. @@ -112,6 +118,13 @@ export interface GraphStoreOptions { * Takes precedence over React-controlled mode (onElementsChange/onLinksChange). */ readonly externalStore?: ExternalGraphStore; + + /** + * If true, batch updates are disabled and synchronization will be real-time. + * If false (default), batch updates are enabled for better performance. + * @default false + */ + readonly areBatchUpdatesDisabled?: boolean; } /** @@ -148,7 +161,7 @@ export class GraphStore { private papers = new Map(); private observer: GraphStoreObserver; - private graphSync: GraphSync; + private stateSync: StateSync; private isControlled: boolean; constructor(config: GraphStoreOptions) { @@ -230,9 +243,14 @@ export class GraphStore { isEqual: util.isEqual, }); - this.graphSync = graphSync({ - useRealtimeUpdated: true, + this.stateSync = stateSync({ + areBatchUpdatesDisabled: config.areBatchUpdatesDisabled ?? false, graph: this.graph, + getIdsSnapshot: () => this.derivedStore.getSnapshot(), + elementToGraphSelector: config.elementToGraphSelector, + elementFromGraphSelector: config.elementFromGraphSelector, + linkToGraphSelector: config.linkToGraphSelector, + linkFromGraphSelector: config.linkFromGraphSelector, store: { getSnapshot: this.publicState.getSnapshot, subscribe: this.publicState.subscribe, @@ -270,7 +288,7 @@ export class GraphStore { // Initial sync: either from external store or from constructor elements/links // Only set initial elements/links if graph doesn't have existing cells - // (if graph has cells, syncExistingGraphCellsToStore in graphSync will handle it) + // (if graph has cells, syncExistingGraphCellsToStore in stateSync will handle it) const graphHasCells = this.graph.getElements().length > 0 || this.graph.getLinks().length > 0; if (!graphHasCells || initialElements.length > 0 || initialLinks.length > 0) { this.publicState.setState((previous) => ({ @@ -290,7 +308,7 @@ export class GraphStore { this.internalState.clean(); this.observer.clean(); this.unsubscribeFromExternal?.(); - this.graphSync.cleanup(); + this.stateSync.cleanup(); if (!isGraphExternal) { this.graph.clear(); } @@ -415,7 +433,7 @@ export class GraphStore { * @returns Unsubscribe function */ public subscribeToCellChange = (callback: (change: OnChangeOptions) => () => void) => { - return this.graphSync.subscribeToCellChange(callback); + return this.stateSync.subscribeToCellChange(callback); }; /** diff --git a/packages/joint-react/src/store/index.ts b/packages/joint-react/src/store/index.ts index 4023b87961..8e10a0eea4 100644 --- a/packages/joint-react/src/store/index.ts +++ b/packages/joint-react/src/store/index.ts @@ -1,3 +1,3 @@ export * from './graph-store'; export * from './paper-store'; -export type { OnSetSize } from './create-elements-size-observer'; +export * from './create-elements-size-observer'; diff --git a/packages/joint-react/src/stories/demos/flowchart/code.tsx b/packages/joint-react/src/stories/demos/flowchart/code.tsx index 450fa3d5e1..3063078b3a 100644 --- a/packages/joint-react/src/stories/demos/flowchart/code.tsx +++ b/packages/joint-react/src/stories/demos/flowchart/code.tsx @@ -9,13 +9,13 @@ import { createLinks, GraphProvider, Highlighter, - MeasuredNode, Paper, type InferElement, + useNodeSize, } from '@joint/react'; import { PAPER_CLASSNAME, PRIMARY, SECONDARY } from 'storybook-config/theme'; import { dia, linkTools } from '@joint/core'; -import { forwardRef, useState, type FC } from 'react'; +import { forwardRef, useRef, useState, type FC } from 'react'; const unit = 4; @@ -210,6 +210,9 @@ function DecisionNodeRaw( }); }; + const textRef = useRef(null); + useNodeSize(textRef, { setSize }); + return ( <> - - - {label} - - + + {label} + ); } @@ -255,6 +257,9 @@ function StepNodeRaw( }); }; + const textRef = useRef(null); + useNodeSize(textRef, { setSize }); + // discuss if (!width || !height) { return null; @@ -275,20 +280,19 @@ function StepNodeRaw( rx={unit} ry={unit} /> - - - {label} - - + + {label} + ); } diff --git a/packages/joint-react/src/stories/demos/introduction-demo/code.tsx b/packages/joint-react/src/stories/demos/introduction-demo/code.tsx index 5d9e50fa28..32f1a3cf22 100644 --- a/packages/joint-react/src/stories/demos/introduction-demo/code.tsx +++ b/packages/joint-react/src/stories/demos/introduction-demo/code.tsx @@ -4,6 +4,7 @@ /* eslint-disable sonarjs/no-small-switch */ /* eslint-disable react-perf/jsx-no-new-function-as-prop */ /* eslint-disable react-perf/jsx-no-new-object-as-prop */ +import React from 'react'; import { dia, linkTools, shapes } from '@joint/core'; import { PAPER_CLASSNAME, LIGHT } from 'storybook-config/theme'; import './index.css'; @@ -12,12 +13,12 @@ import { createLinks, GraphProvider, Highlighter, - MeasuredNode, Paper, Port, useCellId, useElements, useGraph, + useNodeSize, useLinks, type GraphElement, type PaperStore, @@ -164,6 +165,8 @@ function MessageComponent({ } const id = useCellId(); const { set } = useCellActions(); + const elementRef = React.useRef(null); + useNodeSize(elementRef); return ( - -
-
-
-
{iconContent}
-
- {titleText} -
{description}
-
+
+
+
+
{iconContent}
+
+ {titleText} +
{description}
- {/* Divider */} -
- { - set(id, (previous) => ({ ...previous, inputText: value })); - }} - />
+ {/* Divider */} +
+ { + set(id, (previous) => ({ ...previous, inputText: value })); + }} + />
- +
); diff --git a/packages/joint-react/src/stories/demos/pulsing-port/code.tsx b/packages/joint-react/src/stories/demos/pulsing-port/code.tsx index e9bbdf1f6a..5172a6cedd 100644 --- a/packages/joint-react/src/stories/demos/pulsing-port/code.tsx +++ b/packages/joint-react/src/stories/demos/pulsing-port/code.tsx @@ -1,5 +1,6 @@ /* eslint-disable react-perf/jsx-no-new-function-as-prop */ /* eslint-disable react-perf/jsx-no-new-object-as-prop */ +import { useRef } from 'react'; import { dia, highlighters, linkTools, V } from '@joint/core'; import { shapes } from '@joint/core'; import { createElements, type InferElement } from '../../../utils/create'; @@ -8,11 +9,11 @@ import { getCellId, GraphProvider, jsx, - MeasuredNode, Paper, Port, TextNode, useLinks, + useNodeSize, } from '@joint/react'; const NODE_WIDTH = 150; @@ -95,6 +96,9 @@ const elements = createElements([ type Element = InferElement; function NodeElement({ width, height, id }: Element) { + const rectRef = useRef(null); + useNodeSize(rectRef); + const isConnected = useLinks((links) => links .map((link) => { @@ -107,18 +111,17 @@ function NodeElement({ width, height, id }: Element) { return ( <> - - - + {id} diff --git a/packages/joint-react/src/stories/examples/with-auto-layout/code-with-build-in-shapes.tsx b/packages/joint-react/src/stories/examples/with-auto-layout/code-with-build-in-shapes.tsx index 31f0696ce5..a2ce3ef05d 100644 --- a/packages/joint-react/src/stories/examples/with-auto-layout/code-with-build-in-shapes.tsx +++ b/packages/joint-react/src/stories/examples/with-auto-layout/code-with-build-in-shapes.tsx @@ -2,13 +2,13 @@ import '../index.css'; import { createElements, GraphProvider, - MeasuredNode, Paper, + useNodeSize, type InferElement, type OnLoadOptions, type RenderElement, } from '@joint/react'; -import { useCallback } from 'react'; +import { useCallback, useRef } from 'react'; import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; const shape = { type: 'standard.Rectangle', @@ -76,11 +76,13 @@ const initialElements = createElements([ type BaseElementWithData = InferElement; function RenderedRect({ width, height, label }: BaseElementWithData) { + const elementRef = useRef(null); + useNodeSize(elementRef); return ( - -
{label}
-
+
+ {label} +
); } diff --git a/packages/joint-react/src/stories/examples/with-auto-layout/code.tsx b/packages/joint-react/src/stories/examples/with-auto-layout/code.tsx index 1bb62484a0..a14d2514dc 100644 --- a/packages/joint-react/src/stories/examples/with-auto-layout/code.tsx +++ b/packages/joint-react/src/stories/examples/with-auto-layout/code.tsx @@ -6,15 +6,15 @@ import '../index.css'; import { createElements, GraphProvider, - MeasuredNode, Paper, useElements, useGraph, + useNodeSize, type InferElement, type OnLoadOptions, type RenderElement, } from '@joint/react'; -import { useCallback, useState } from 'react'; +import { useCallback, useRef, useState } from 'react'; import type { dia } from '@joint/core'; import { PAPER_CLASSNAME } from 'storybook-config/theme'; import { useCellActions } from '../../../hooks/use-cell-actions'; @@ -36,11 +36,13 @@ type BaseElementWithData = InferElement; const INPUT_CLASSNAME = 'block w-15 mr-2 p-2 text-gray-900 border border-gray-300 rounded-lg bg-gray-50 text-xs focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500'; function RenderedRect({ width, height, label }: BaseElementWithData) { + const elementRef = useRef(null); + useNodeSize(elementRef); return ( - -
{label}
-
+
+ {label} +
); } diff --git a/packages/joint-react/src/stories/examples/with-card/code.tsx b/packages/joint-react/src/stories/examples/with-card/code.tsx index 6f576aac59..c609a37820 100644 --- a/packages/joint-react/src/stories/examples/with-card/code.tsx +++ b/packages/joint-react/src/stories/examples/with-card/code.tsx @@ -1,15 +1,12 @@ -/* eslint-disable react-perf/jsx-no-new-function-as-prop */ /* eslint-disable react-perf/jsx-no-new-object-as-prop */ import '../index.css'; -import { useCallback, type PropsWithChildren } from 'react'; +import React, { useCallback, type PropsWithChildren } from 'react'; import { createElements, createLinks, GraphProvider, - MeasuredNode, Paper, type InferElement, - type OnSetSize, type RenderElement, } from '@joint/react'; import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; @@ -41,30 +38,22 @@ function Card({ children, width, height }: PropsWithChildren { - const w = gap + imageWidth + gap + size.width + gap; - const h = gap + Math.max(size.height, imageWidth) + gap; - element.size(w, h); - }; - return ( <> - -
- {children} -
-
+
+ {children} +
); diff --git a/packages/joint-react/src/stories/examples/with-intersection/code.tsx b/packages/joint-react/src/stories/examples/with-intersection/code.tsx index c9e1b64f15..eee78b7824 100644 --- a/packages/joint-react/src/stories/examples/with-intersection/code.tsx +++ b/packages/joint-react/src/stories/examples/with-intersection/code.tsx @@ -2,10 +2,10 @@ import { createElements, GraphProvider, - MeasuredNode, useElements, useGraph, Paper, + useNodeSize, type InferElement, } from '@joint/react'; import '../index.css'; @@ -31,13 +31,13 @@ function ResizableNode({ id, label, width, height }: Readonly 0; }); + useNodeSize(nodeRef); + return ( - -
- {label} -
-
+
+ {label} +
); } diff --git a/packages/joint-react/src/stories/examples/with-json/code.tsx b/packages/joint-react/src/stories/examples/with-json/code.tsx index bdd378b016..5937da6679 100644 --- a/packages/joint-react/src/stories/examples/with-json/code.tsx +++ b/packages/joint-react/src/stories/examples/with-json/code.tsx @@ -4,13 +4,13 @@ import '../index.css'; import { createElements, GraphProvider, - MeasuredNode, Paper, useGraph, + useNodeSize, type InferElement, type RenderElement, } from '@joint/react'; -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -22,14 +22,18 @@ type BaseElementWithData = InferElement; function RenderElement(props: Readonly) { const { width, height, label, color } = props; + const elementRef = useRef(null); + useNodeSize(elementRef); return ( - -
- Example -
{label}
-
-
+
+ Example +
{label}
+
); } diff --git a/packages/joint-react/src/stories/examples/with-list-node/code.tsx b/packages/joint-react/src/stories/examples/with-list-node/code.tsx index df002b4c02..735de42b20 100644 --- a/packages/joint-react/src/stories/examples/with-list-node/code.tsx +++ b/packages/joint-react/src/stories/examples/with-list-node/code.tsx @@ -2,13 +2,13 @@ /* eslint-disable react-perf/jsx-no-new-function-as-prop */ import '../index.css'; -import { useCallback, type PropsWithChildren } from 'react'; +import React, { useCallback, useRef, type PropsWithChildren } from 'react'; import { createElements, createLinks, GraphProvider, - MeasuredNode, Paper, + useNodeSize, type InferElement, type OnSetSize, } from '@joint/react'; @@ -50,6 +50,7 @@ function ListElement({ }: PropsWithChildren) { const padding = 10; const headerHeight = 50; + const elementRef = useRef(null); const setListSize: OnSetSize = useCallback(({ element, size }) => { const w = padding + size.width + padding; @@ -57,6 +58,8 @@ function ListElement({ element.size(w, h, { async: false }); }, []); + useNodeSize(elementRef, { setSize: setListSize }); + const { set } = useCellActions(); const addInput = () => { @@ -82,39 +85,37 @@ function ListElement({ width={width - 2 * padding} height={height - headerHeight - padding} > - -
- -
    - {inputs.map((input, index) => ( -
  • - - { - const newInputs = [...inputs]; - newInputs[index] = event.target.value; - set(id, (previous) => ({ ...previous, inputs: newInputs })); - }} - /> -
  • - ))} -
- {inputs.length === 0 &&
No items
} -
-
+
+ +
    + {inputs.map((input, index) => ( +
  • + + { + const newInputs = [...inputs]; + newInputs[index] = event.target.value; + set(id, (previous) => ({ ...previous, inputs: newInputs })); + }} + /> +
  • + ))} +
+ {inputs.length === 0 &&
No items
} +
); diff --git a/packages/joint-react/src/stories/examples/with-minimap/code.tsx b/packages/joint-react/src/stories/examples/with-minimap/code.tsx index 8830679a53..52e614c5da 100644 --- a/packages/joint-react/src/stories/examples/with-minimap/code.tsx +++ b/packages/joint-react/src/stories/examples/with-minimap/code.tsx @@ -1,12 +1,12 @@ /* eslint-disable react-perf/jsx-no-new-object-as-prop */ import '../index.css'; -import { useCallback } from 'react'; +import { useCallback, useRef } from 'react'; import { createElements, createLinks, GraphProvider, - MeasuredNode, Paper, + useNodeSize, type InferElement, type RenderElement, } from '@joint/react'; @@ -55,14 +55,18 @@ function MiniMap() { } function RenderElement({ width, height, label, color }: Readonly) { + const elementRef = useRef(null); + useNodeSize(elementRef); return ( - -
- Example -
{label}
-
-
+
+ Example +
{label}
+
); } diff --git a/packages/joint-react/src/stories/examples/with-node-update/code-add-remove-node.tsx b/packages/joint-react/src/stories/examples/with-node-update/code-add-remove-node.tsx index af7c7ad4b6..e65a8447ad 100644 --- a/packages/joint-react/src/stories/examples/with-node-update/code-add-remove-node.tsx +++ b/packages/joint-react/src/stories/examples/with-node-update/code-add-remove-node.tsx @@ -4,14 +4,15 @@ import { createElements, createLinks, GraphProvider, - MeasuredNode, Paper, useCellId, useElements, useGraph, + useNodeSize, type InferElement, } from '@joint/react'; import '../index.css'; +import { useRef } from 'react'; import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; import { useCellActions } from '../../../hooks/use-cell-actions'; @@ -50,24 +51,24 @@ function ElementInput({ id, label }: BaseElementWithData) { function RenderElement({ label, width, height }: BaseElementWithData) { const graph = useGraph(); const id = useCellId(); + const elementRef = useRef(null); + useNodeSize(elementRef); return ( - -
-
- {label} - -
+
+
+ {label} +
- +
); } diff --git a/packages/joint-react/src/stories/examples/with-node-update/code.tsx b/packages/joint-react/src/stories/examples/with-node-update/code.tsx index 5bbc84b093..8e6d7100d5 100644 --- a/packages/joint-react/src/stories/examples/with-node-update/code.tsx +++ b/packages/joint-react/src/stories/examples/with-node-update/code.tsx @@ -4,12 +4,13 @@ import { createElements, createLinks, GraphProvider, - MeasuredNode, Paper, useElements, + useNodeSize, type InferElement, } from '@joint/react'; import '../index.css'; +import { useRef } from 'react'; import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; import { useCellActions } from '../../../hooks/use-cell-actions'; @@ -45,11 +46,13 @@ function ElementInput({ id, label }: BaseElementWithData) { } function RenderElement({ label, width, height }: BaseElementWithData) { + const elementRef = useRef(null); + useNodeSize(elementRef); return ( - -
{label}
-
+
+ {label} +
); } diff --git a/packages/joint-react/src/stories/examples/with-proximity-link/code.tsx b/packages/joint-react/src/stories/examples/with-proximity-link/code.tsx index 6196d5947a..5bcf69d357 100644 --- a/packages/joint-react/src/stories/examples/with-proximity-link/code.tsx +++ b/packages/joint-react/src/stories/examples/with-proximity-link/code.tsx @@ -1,11 +1,5 @@ /* eslint-disable react-perf/jsx-no-new-object-as-prop */ -import { - createElements, - GraphProvider, - MeasuredNode, - Paper, - type InferElement, -} from '@joint/react'; +import { createElements, GraphProvider, Paper, type InferElement, useNodeSize } from '@joint/react'; import '../index.css'; import { useRef } from 'react'; import { shapes, util } from '@joint/core'; @@ -153,11 +147,12 @@ function ResizableNode({ id, label, width, height }: Readonly - -
{label}
-
+
+ {label} +
); } diff --git a/packages/joint-react/src/stories/examples/with-resizable-node/code.tsx b/packages/joint-react/src/stories/examples/with-resizable-node/code.tsx index 6b93006fd1..b3632fc8a0 100644 --- a/packages/joint-react/src/stories/examples/with-resizable-node/code.tsx +++ b/packages/joint-react/src/stories/examples/with-resizable-node/code.tsx @@ -3,9 +3,9 @@ import { createElements, createLinks, GraphProvider, - MeasuredNode, Paper, useElements, + useNodeSize, type InferElement, } from '@joint/react'; import '../index.css'; @@ -53,16 +53,16 @@ function ResizableNode({ width, height, label }: Readonly) } }, []); + useNodeSize(nodeRef); return ( - -
- {label} -
-
+
+ {label} +
); } diff --git a/packages/joint-react/src/stories/examples/with-rotable-node/code.tsx b/packages/joint-react/src/stories/examples/with-rotable-node/code.tsx index 6337881d63..cc4c61bac4 100644 --- a/packages/joint-react/src/stories/examples/with-rotable-node/code.tsx +++ b/packages/joint-react/src/stories/examples/with-rotable-node/code.tsx @@ -3,14 +3,14 @@ import { createElements, createLinks, GraphProvider, - MeasuredNode, Paper, useElements, usePaper, + useNodeSize, type InferElement, } from '@joint/react'; import '../index.css'; -import { useCallback } from 'react'; +import { useCallback, useRef } from 'react'; import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; import { useCellActions } from '../../../hooks/use-cell-actions'; @@ -76,18 +76,18 @@ function RotatableNode({ label, id, width, height }: Readonly(null); + useNodeSize(elementRef); return ( - -
-
- {label} -
- +
+
+ {label} +
); } diff --git a/packages/joint-react/src/stories/examples/with-svg-node/code.tsx b/packages/joint-react/src/stories/examples/with-svg-node/code.tsx index 9b5db9400f..e9cb295aaf 100644 --- a/packages/joint-react/src/stories/examples/with-svg-node/code.tsx +++ b/packages/joint-react/src/stories/examples/with-svg-node/code.tsx @@ -5,12 +5,13 @@ import { createElements, createLinks, GraphProvider, - MeasuredNode, Paper, + useNodeSize, type InferElement, + type OnSetSize, type RenderElement, } from '@joint/react'; -import { useCallback } from 'react'; +import { useCallback, useRef } from 'react'; const initialEdges = createLinks([ { @@ -35,28 +36,32 @@ type BaseElementWithData = InferElement; function RenderedRect({ width, height, label }: BaseElementWithData) { const textMargin = 20; const cornerRadius = 5; + const textRef = useRef(null); + + const setSize: OnSetSize = useCallback( + ({ element, size: { width: sizeWidth, height: sizeHeight } }) => { + element.size(sizeWidth + textMargin, sizeHeight + textMargin); + }, + [textMargin] + ); + + useNodeSize(textRef, { setSize }); return ( <> - { - element.size(width + textMargin, height + textMargin); - }} + - - {label} - - + {label} + ); } diff --git a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-jotai.tsx b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-jotai.tsx index 14373a7d0a..19fe00d810 100644 --- a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-jotai.tsx +++ b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-jotai.tsx @@ -124,9 +124,7 @@ const linksAtom = atom(defaultLinks as GraphLink[]); * * The adapter automatically reads from the Jotai atoms, so no parameters * are needed. Just call this hook inside a component. - * * @returns An ExternalGraphStore compatible with GraphProvider - * * @example * ```tsx * @@ -159,7 +157,6 @@ function useJotaiAdapter(): ExternalGraphStore { * Subscribes to Jotai atom changes. * When the atoms change, the listener is called, which notifies * GraphStore to re-read the state and sync with JointJS. - * * @param listener - Callback function to call when state changes * @returns Unsubscribe function to remove the listener */ @@ -190,7 +187,6 @@ function useJotaiAdapter(): ExternalGraphStore { * The updater can be: * - A direct value: { elements: [...], links: [...] } * - A function: (previous) => ({ elements: [...], links: [...] }) - * * @param updater - The new state or a function to compute new state */ setState: (updater: Update) => { @@ -331,4 +327,3 @@ function Main(props: Readonly) { export default function App(props: Readonly) { return
; } - diff --git a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-peerjs.tsx b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-peerjs.tsx index 6723359cc0..60a2c15155 100644 --- a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-peerjs.tsx +++ b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-peerjs.tsx @@ -614,3 +614,5 @@ export default function App(props: Readonly) { return
; } + + diff --git a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-redux.tsx b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-redux.tsx index 94543d5cb5..218bcc3dba 100644 --- a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-redux.tsx +++ b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-redux.tsx @@ -1,3 +1,4 @@ +/* eslint-disable @eslint-react/hooks-extra/no-direct-set-state-in-use-effect */ /* eslint-disable sonarjs/pseudo-random */ /* eslint-disable react-perf/jsx-no-new-function-as-prop */ @@ -219,9 +220,7 @@ type UndoableGraphState = { * * The adapter automatically reads from the Redux store context, so no parameters * are needed. Just call this hook inside a component wrapped with Redux Provider. - * * @returns An ExternalGraphStore compatible with GraphProvider - * * @example * ```tsx * @@ -255,7 +254,6 @@ function useReduxAdapter(): ExternalGraphStore { * Subscribes to Redux store changes. * When the Redux state changes, the listener is called, which notifies * GraphStore to re-read the state and sync with JointJS. - * * @param listener - Callback function to call when state changes * @returns Unsubscribe function to remove the listener */ @@ -284,7 +282,6 @@ function useReduxAdapter(): ExternalGraphStore { * The updater can be: * - A direct value: { elements: [...], links: [...] } * - A function: (previous) => ({ elements: [...], links: [...] }) - * * @param updater - The new state or a function to compute new state */ setState: (updater: Update) => { @@ -388,8 +385,19 @@ function ReduxConnectedPaperApp() { const graphState = currentState.graph as UndoableGraphState; const newCanUndo = graphState.past.length > 0; const newCanRedo = graphState.future.length > 0; - setCanUndo(newCanUndo); - setCanRedo(newCanRedo); + // Update state only if values changed + setCanUndo((previous) => { + if (previous === newCanUndo) { + return previous; + } + return newCanUndo; + }); + setCanRedo((previous) => { + if (previous === newCanRedo) { + return previous; + } + return newCanRedo; + }); }; // Subscribe to store changes @@ -493,4 +501,3 @@ function ReduxConnectedPaperApp() { export default function App(props: Readonly) { return
; } - diff --git a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-zustand.tsx b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-zustand.tsx index e4bb1582a0..eee6c8c6b8 100644 --- a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-zustand.tsx +++ b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-zustand.tsx @@ -165,9 +165,7 @@ const useGraphStore = create((set) => ({ * * The adapter automatically reads from the Zustand store, so no parameters * are needed. Just call this hook inside a component. - * * @returns An ExternalGraphStore compatible with GraphProvider - * * @example * ```tsx * @@ -196,7 +194,6 @@ function useZustandAdapter(): ExternalGraphStore { * Subscribes to Zustand store changes. * When the Zustand state changes, the listener is called, which notifies * GraphStore to re-read the state and sync with JointJS. - * * @param listener - Callback function to call when state changes * @returns Unsubscribe function to remove the listener */ @@ -212,7 +209,6 @@ function useZustandAdapter(): ExternalGraphStore { * The updater can be: * - A direct value: { elements: [...], links: [...] } * - A function: (previous) => ({ elements: [...], links: [...] }) - * * @param updater - The new state or a function to compute new state */ setState: (updater: Update) => { @@ -322,4 +318,3 @@ function Main(props: Readonly) { export default function App(props: Readonly) { return
; } - diff --git a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode.tsx b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode.tsx index 250a52415d..3b0edfc0f9 100644 --- a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode.tsx +++ b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode.tsx @@ -122,7 +122,6 @@ type CustomLink = (typeof defaultLinks)[number]; * * In this example, we use SVG's to embed HTML content, * allowing us to use regular HTML/CSS for styling instead of SVG attributes. - * * @param props - The element properties (includes id, label, x, y, width, height, etc.) * @returns JSX to render inside the element */ diff --git a/packages/joint-react/src/stories/tutorials/step-by-step/code-html-renderer.tsx b/packages/joint-react/src/stories/tutorials/step-by-step/code-html-renderer.tsx index 6f7c5f9da9..e4a585cfc0 100644 --- a/packages/joint-react/src/stories/tutorials/step-by-step/code-html-renderer.tsx +++ b/packages/joint-react/src/stories/tutorials/step-by-step/code-html-renderer.tsx @@ -1,11 +1,11 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useRef, useState } from 'react'; import { createElements, createLinks, GraphProvider, - MeasuredNode, Paper, usePaper, + useNodeSize, type GraphProps, type InferElement, } from '@joint/react'; @@ -79,12 +79,14 @@ function Main() { const renderItem = useCallback( ({ data: { label }, width, height }: CustomElement) => { if (isHTMLEnabled) { + // eslint-disable-next-line react-hooks/rules-of-hooks + const elementRef = useRef(null); + // eslint-disable-next-line react-hooks/rules-of-hooks + useNodeSize(elementRef); return ( - -
-
{label}
-
-
+
+
{label}
+
); } return ; diff --git a/packages/joint-react/src/stories/tutorials/step-by-step/code-html.tsx b/packages/joint-react/src/stories/tutorials/step-by-step/code-html.tsx index b31a9c3510..fb3954f740 100644 --- a/packages/joint-react/src/stories/tutorials/step-by-step/code-html.tsx +++ b/packages/joint-react/src/stories/tutorials/step-by-step/code-html.tsx @@ -1,13 +1,13 @@ /* eslint-disable react-perf/jsx-no-new-object-as-prop */ - +import React from 'react'; import { createElements, createLinks, GraphProvider, - MeasuredNode, Paper, type GraphProps, type InferElement, + useNodeSize, } from '@joint/react'; import '../../examples/index.css'; import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; @@ -37,11 +37,13 @@ const initialEdges = createLinks([ type CustomElement = InferElement; function RenderItem(props: CustomElement) { const { label, width, height } = props; + const elementRef = React.useRef(null); + useNodeSize(elementRef); return ( - -
{label}
-
+
+ {label} +
); } diff --git a/packages/joint-react/src/utils/__tests__/get-cell.test.ts b/packages/joint-react/src/utils/__tests__/get-cell.test.ts index ead82624d2..9504233c68 100644 --- a/packages/joint-react/src/utils/__tests__/get-cell.test.ts +++ b/packages/joint-react/src/utils/__tests__/get-cell.test.ts @@ -1,4 +1,4 @@ -import { elementFromGraph, linkFromGraph } from '../cell/cell-utilities'; +import { mapElementFromGraph, mapLinkFromGraph } from '../cell/cell-utilities'; import type { dia } from '@joint/core'; describe('cell utilities', () => { @@ -30,7 +30,7 @@ describe('cell utilities', () => { describe('elementFromGraph', () => { it('should extract element attributes correctly', () => { - const element = elementFromGraph(mockCell); + const element = mapElementFromGraph(mockCell); expect(element).toMatchObject({ id: 'mock-id', type: 'mock-type', @@ -45,7 +45,7 @@ describe('cell utilities', () => { describe('linkFromGraph', () => { it('should extract link attributes correctly', () => { - const link = linkFromGraph(mockCell); + const link = mapLinkFromGraph(mockCell); expect(link).toMatchObject({ id: 'mock-id', source: 'source-id', diff --git a/packages/joint-react/src/utils/__tests__/is-react-element.test.ts b/packages/joint-react/src/utils/__tests__/is-react-element.test.ts index f1ce424489..208b49a3c7 100644 --- a/packages/joint-react/src/utils/__tests__/is-react-element.test.ts +++ b/packages/joint-react/src/utils/__tests__/is-react-element.test.ts @@ -52,3 +52,5 @@ describe('is-react-element', () => { + + diff --git a/packages/joint-react/src/utils/__tests__/noop-selector.test.ts b/packages/joint-react/src/utils/__tests__/noop-selector.test.ts index 68ce0ce3c4..458c90e34b 100644 --- a/packages/joint-react/src/utils/__tests__/noop-selector.test.ts +++ b/packages/joint-react/src/utils/__tests__/noop-selector.test.ts @@ -40,3 +40,5 @@ describe('noop-selector', () => { + + diff --git a/packages/joint-react/src/utils/cell/__tests__/get-link-targe-and-source-ids.test.ts b/packages/joint-react/src/utils/cell/__tests__/get-link-targe-and-source-ids.test.ts index 165fc290f1..7248aaff79 100644 --- a/packages/joint-react/src/utils/cell/__tests__/get-link-targe-and-source-ids.test.ts +++ b/packages/joint-react/src/utils/cell/__tests__/get-link-targe-and-source-ids.test.ts @@ -68,3 +68,5 @@ describe('get-link-targe-and-source-ids', () => { + + diff --git a/packages/joint-react/src/utils/cell/cell-utilities.ts b/packages/joint-react/src/utils/cell/cell-utilities.ts index 6c96060522..f38ae375f3 100644 --- a/packages/joint-react/src/utils/cell/cell-utilities.ts +++ b/packages/joint-react/src/utils/cell/cell-utilities.ts @@ -2,7 +2,6 @@ import { util, type dia } from '@joint/core'; import { REACT_TYPE } from '../../models/react-element'; import type { GraphLink } from '../../types/link-types'; import type { GraphElement } from '../../types/element-types'; -import { isCellInstance, isLinkInstance } from '../is'; import { getTargetOrSource } from './get-link-targe-and-source-ids'; export type CellOrJsonCell = dia.Cell | dia.Cell.JSON; @@ -13,19 +12,7 @@ export type CellOrJsonCell = dia.Cell | dia.Cell.JSON; * @param graph - The graph instance. * @returns The cell or JSON cell representation. */ -export function linkToGraph(link: dia.Link | GraphLink, graph: dia.Graph): CellOrJsonCell { - if (isLinkInstance(link)) { - const json = link.toJSON(); - - const source = getTargetOrSource(json.source); - const target = getTargetOrSource(json.target); - return { - ...json, - source, - target, - }; - } - +export function mapLinkToGraph(link: GraphLink, graph: dia.Graph): CellOrJsonCell { const source = getTargetOrSource(link.source); const target = getTargetOrSource(link.target); const { attrs, type = 'standard.Link', ...rest } = link; @@ -52,29 +39,23 @@ export function linkToGraph(link: dia.Link | GraphLink, graph: dia.Graph): CellO } /** - * Process an element: create a ReactElement if applicable, otherwise a standard Cell. + * Maps a GraphElement to a JointJS Cell or JSON representation. * @param element - The element to process. - * @param unsizedIds - A set of unsized element IDs. * @returns A standard JointJS element or a JSON representation of the element. * @group utils * @description * This function is used to process an element and convert it to a standard JointJS element if needed. - * It also checks if the element is a ReactElement and if it has a size. - * If the element is a ReactElement and has no size, it adds its ID to the unsizedIds set. + * It extracts position and size information from the element and creates the appropriate cell representation. * @private * @example * ```ts - * import { processElement } from '@joint/react'; + * import { mapElementToGraph } from '@joint/react'; * * const element = { id: '1', x: 10, y: 20, width: 100, height: 50 }; - * const unsizedIds = new Set(); - * const processed = processElement(element, unsizedIds); + * const processed = mapElementToGraph(element); * ``` */ -export function elementToGraph(element: T): CellOrJsonCell { - if (isCellInstance(element)) { - return element; - } +export function mapElementToGraph(element: T): CellOrJsonCell { const { type = REACT_TYPE, x, y, width, height } = element; return { @@ -108,7 +89,7 @@ export type GraphCell = Element | G * console.log(element); * ``` */ -export function elementFromGraph( +export function mapElementFromGraph( cell: dia.Cell ): Element { // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -143,7 +124,7 @@ export function elementFromGraph( +export function mapLinkFromGraph( cell: dia.Cell ): Link { return { @@ -160,8 +141,8 @@ export function linkFromGraph( export interface SyncGraphOptions { readonly graph: dia.Graph; - readonly elements?: Array; - readonly links?: Array; + readonly elements?: GraphElement[]; + readonly links?: GraphLink[]; } /** @@ -171,8 +152,8 @@ export interface SyncGraphOptions { export function syncGraph(options: SyncGraphOptions) { const { graph, elements = [], links = [] } = options; const items = [ - ...elements.map((element) => elementToGraph(element)), - ...links.map((link) => linkToGraph(link, graph)), + ...elements.map((element) => mapElementToGraph(element)), + ...links.map((link) => mapLinkToGraph(link, graph)), ]; // syncCells already wraps itself in a batch internally (see joint-core Graph.mjs:428-459) From efb99882912aa83057eb61022b75b6c945b0c814 Mon Sep 17 00:00:00 2001 From: samuelgja Date: Tue, 6 Jan 2026 12:02:00 +0700 Subject: [PATCH 13/24] chore(joint-react): add empty lines to multiple test files for consistency - Added empty lines at the end of various test files to maintain uniformity across the codebase. --- .../highlighters/__tests__/stroke.test.tsx | 3 ++ .../src/hooks/__tests__/use-element.test.tsx | 3 ++ .../src/hooks/__tests__/use-graph.test.ts | 3 ++ .../models/__tests__/react-element.test.ts | 3 ++ packages/joint-react/src/state/state-sync.ts | 37 ++++++++++--------- packages/joint-react/src/store/graph-store.ts | 20 ++++------ .../code-controlled-mode-jotai.tsx | 3 ++ .../code-controlled-mode-peerjs.tsx | 3 ++ .../code-controlled-mode-redux.tsx | 3 ++ .../code-controlled-mode-zustand.tsx | 3 ++ .../utils/__tests__/is-react-element.test.ts | 3 ++ .../src/utils/__tests__/noop-selector.test.ts | 3 ++ .../get-link-targe-and-source-ids.test.ts | 3 ++ 13 files changed, 61 insertions(+), 29 deletions(-) diff --git a/packages/joint-react/src/components/highlighters/__tests__/stroke.test.tsx b/packages/joint-react/src/components/highlighters/__tests__/stroke.test.tsx index 3b7c90376b..fef04ae6bd 100644 --- a/packages/joint-react/src/components/highlighters/__tests__/stroke.test.tsx +++ b/packages/joint-react/src/components/highlighters/__tests__/stroke.test.tsx @@ -89,3 +89,6 @@ describe('Stroke highlighter', () => { + + + diff --git a/packages/joint-react/src/hooks/__tests__/use-element.test.tsx b/packages/joint-react/src/hooks/__tests__/use-element.test.tsx index 8cc84dfa31..69b04b4b96 100644 --- a/packages/joint-react/src/hooks/__tests__/use-element.test.tsx +++ b/packages/joint-react/src/hooks/__tests__/use-element.test.tsx @@ -44,3 +44,6 @@ describe('use-element', () => { + + + diff --git a/packages/joint-react/src/hooks/__tests__/use-graph.test.ts b/packages/joint-react/src/hooks/__tests__/use-graph.test.ts index ed815bf76a..95a9a99594 100644 --- a/packages/joint-react/src/hooks/__tests__/use-graph.test.ts +++ b/packages/joint-react/src/hooks/__tests__/use-graph.test.ts @@ -49,3 +49,6 @@ describe('use-graph', () => { + + + diff --git a/packages/joint-react/src/models/__tests__/react-element.test.ts b/packages/joint-react/src/models/__tests__/react-element.test.ts index 7112eacad8..194854baf3 100644 --- a/packages/joint-react/src/models/__tests__/react-element.test.ts +++ b/packages/joint-react/src/models/__tests__/react-element.test.ts @@ -114,3 +114,6 @@ describe('react-element', () => { + + + diff --git a/packages/joint-react/src/state/state-sync.ts b/packages/joint-react/src/state/state-sync.ts index 77fc0f30c9..34c68a44aa 100644 --- a/packages/joint-react/src/state/state-sync.ts +++ b/packages/joint-react/src/state/state-sync.ts @@ -61,13 +61,16 @@ export function stateSync< Element extends GraphElement, Link extends GraphLink, >(options: Options): StateSync { - const { graph, store, areBatchUpdatesDisabled = false, getIdsSnapshot } = options; - - // Use provided selectors or fall back to defaults - const elementToGraph = options.elementToGraphSelector ?? defaultElementToGraphSelector; - const elementFromGraph = options.elementFromGraphSelector ?? defaultElementFromGraphSelector; - const linkToGraph = options.linkToGraphSelector ?? defaultLinkToGraphSelector; - const linkFromGraph = options.linkFromGraphSelector ?? defaultLinkFromGraphSelector; + const { + graph, + store, + areBatchUpdatesDisabled = false, + getIdsSnapshot, + elementFromGraphSelector = defaultElementFromGraphSelector, + linkFromGraphSelector = defaultLinkFromGraphSelector, + elementToGraphSelector = defaultElementToGraphSelector, + linkToGraphSelector = defaultLinkToGraphSelector, + } = options; // We need to ensure several things: // 1. Graph can update itself, via onCellChange or via onBatchStop - this change is internal and must update the external store - but only if the external store do not trigger the same change. @@ -103,13 +106,13 @@ export function stateSync< if (isReset) { // unfortunately this will create always new object references, so we need to compare them with more deeply const graphElements = graph.getElements().map((element) => - elementFromGraph({ + elementFromGraphSelector({ cell: element, graph, }) ); const graphLinks = graph.getLinks().map((link) => - linkFromGraph({ + linkFromGraphSelector({ cell: link, graph, }) @@ -149,7 +152,7 @@ export function stateSync< ? previous.links[linkIndex] : undefined; - const updatedLink = linkFromGraph({ + const updatedLink = linkFromGraphSelector({ cell: cell as dia.Link, graph, previous: previousLink, @@ -163,7 +166,7 @@ export function stateSync< ? previous.elements[elementIndex] : undefined; - const updatedElement = elementFromGraph({ + const updatedElement = elementFromGraphSelector({ cell: cell as dia.Element, graph, previous: previousElement, @@ -337,13 +340,13 @@ export function stateSync< // Only sync if store is empty and graph has cells if (storeElements.length === 0 && storeLinks.length === 0) { const existingElements = graph.getElements().map((element) => - elementFromGraph({ + elementFromGraphSelector({ cell: element, graph, }) ) as Element[]; const existingLinks = graph.getLinks().map((link) => - linkFromGraph({ + linkFromGraphSelector({ cell: link, graph, }) @@ -383,13 +386,13 @@ export function stateSync< // Compare current graph state with store state to avoid unnecessary syncs // This prevents syncing when graph and store are already in sync const graphElements = graph.getElements().map((element) => - elementFromGraph({ + elementFromGraphSelector({ cell: element, graph, }) ); const graphLinks = graph.getLinks().map((link) => - linkFromGraph({ + linkFromGraphSelector({ cell: link, graph, }) @@ -407,13 +410,13 @@ export function stateSync< // Build items array using selectors const elementItems = elements.map((element) => - elementToGraph({ + elementToGraphSelector({ element: element as Element, graph, }) ); const linkItems = links.map((link) => - linkToGraph({ + linkToGraphSelector({ link: link as Link, graph, }) diff --git a/packages/joint-react/src/store/graph-store.ts b/packages/joint-react/src/store/graph-store.ts index 11a87d026a..fb5c0c3b86 100644 --- a/packages/joint-react/src/store/graph-store.ts +++ b/packages/joint-react/src/store/graph-store.ts @@ -1,7 +1,6 @@ import { dia, shapes, util } from '@joint/core'; import type { GraphLink } from '../types/link-types'; import type { GraphElement } from '../types/element-types'; -import type { Dispatch, SetStateAction } from 'react'; import type { AddPaperOptions, PaperStoreSnapshot } from './paper-store'; import { PaperStore } from './paper-store'; import { @@ -156,13 +155,9 @@ export class GraphStore { private unsubscribeFromExternal?: () => void; - private onElementsChange?: Dispatch>; - private onLinksChange?: Dispatch>; - private papers = new Map(); private observer: GraphStoreObserver; private stateSync: StateSync; - private isControlled: boolean; constructor(config: GraphStoreOptions) { const { @@ -172,11 +167,12 @@ export class GraphStore { cellNamespace = DEFAULT_CELL_NAMESPACE, graph, externalStore: externalState, + elementFromGraphSelector, + elementToGraphSelector, + linkFromGraphSelector, + linkToGraphSelector, } = config; - const hasExternalState = typeof externalState === 'object'; - this.isControlled = hasExternalState; - this.graph = graph ?? new dia.Graph( @@ -247,10 +243,10 @@ export class GraphStore { areBatchUpdatesDisabled: config.areBatchUpdatesDisabled ?? false, graph: this.graph, getIdsSnapshot: () => this.derivedStore.getSnapshot(), - elementToGraphSelector: config.elementToGraphSelector, - elementFromGraphSelector: config.elementFromGraphSelector, - linkToGraphSelector: config.linkToGraphSelector, - linkFromGraphSelector: config.linkFromGraphSelector, + elementToGraphSelector, + elementFromGraphSelector, + linkToGraphSelector, + linkFromGraphSelector, store: { getSnapshot: this.publicState.getSnapshot, subscribe: this.publicState.subscribe, diff --git a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-jotai.tsx b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-jotai.tsx index 19fe00d810..dddefb3048 100644 --- a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-jotai.tsx +++ b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-jotai.tsx @@ -327,3 +327,6 @@ function Main(props: Readonly) { export default function App(props: Readonly) { return
; } + + + diff --git a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-peerjs.tsx b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-peerjs.tsx index 60a2c15155..4fa604e133 100644 --- a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-peerjs.tsx +++ b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-peerjs.tsx @@ -616,3 +616,6 @@ export default function App(props: Readonly) { + + + diff --git a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-redux.tsx b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-redux.tsx index 218bcc3dba..0edf365f09 100644 --- a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-redux.tsx +++ b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-redux.tsx @@ -501,3 +501,6 @@ function ReduxConnectedPaperApp() { export default function App(props: Readonly) { return
; } + + + diff --git a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-zustand.tsx b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-zustand.tsx index eee6c8c6b8..e28bef8213 100644 --- a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-zustand.tsx +++ b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-zustand.tsx @@ -318,3 +318,6 @@ function Main(props: Readonly) { export default function App(props: Readonly) { return
; } + + + diff --git a/packages/joint-react/src/utils/__tests__/is-react-element.test.ts b/packages/joint-react/src/utils/__tests__/is-react-element.test.ts index 208b49a3c7..66b18cf520 100644 --- a/packages/joint-react/src/utils/__tests__/is-react-element.test.ts +++ b/packages/joint-react/src/utils/__tests__/is-react-element.test.ts @@ -54,3 +54,6 @@ describe('is-react-element', () => { + + + diff --git a/packages/joint-react/src/utils/__tests__/noop-selector.test.ts b/packages/joint-react/src/utils/__tests__/noop-selector.test.ts index 458c90e34b..e71a22486d 100644 --- a/packages/joint-react/src/utils/__tests__/noop-selector.test.ts +++ b/packages/joint-react/src/utils/__tests__/noop-selector.test.ts @@ -42,3 +42,6 @@ describe('noop-selector', () => { + + + diff --git a/packages/joint-react/src/utils/cell/__tests__/get-link-targe-and-source-ids.test.ts b/packages/joint-react/src/utils/cell/__tests__/get-link-targe-and-source-ids.test.ts index 7248aaff79..d7314c7acc 100644 --- a/packages/joint-react/src/utils/cell/__tests__/get-link-targe-and-source-ids.test.ts +++ b/packages/joint-react/src/utils/cell/__tests__/get-link-targe-and-source-ids.test.ts @@ -70,3 +70,6 @@ describe('get-link-targe-and-source-ids', () => { + + + From 6f7667729b5867c70c9625df7f6393bdfe25ce7b Mon Sep 17 00:00:00 2001 From: samuelgja Date: Fri, 9 Jan 2026 16:57:47 +0700 Subject: [PATCH 14/24] feat(joint-react): enhance useNodeSize hook with transform functionality - Updated the useNodeSize hook to support a custom transform function for size measurement, allowing for more flexible size adjustments. - Refactored related types and interfaces to accommodate the new transform feature. - Improved documentation with examples demonstrating the use of the transform function for custom size handling. - Adjusted tests to reflect changes in the useNodeSize implementation. --- packages/joint-react/package.json | 1 + .../__tests__/use-measure-node-size.test.tsx | 243 ------------------ .../joint-react/src/hooks/use-node-size.tsx | 153 ++++++++--- packages/joint-react/src/index.ts | 2 +- .../models/__tests__/react-element.test.ts | 23 +- .../joint-react/src/models/react-element.tsx | 22 -- .../src/store/__tests__/graph-store.test.ts | 2 +- .../store/create-elements-size-observer.ts | 53 ++-- packages/joint-react/src/store/graph-store.ts | 11 +- packages/joint-react/src/store/index.ts | 2 +- .../src/stories/demos/flowchart/code.tsx | 50 ++-- .../src/stories/demos/pulsing-port/code.tsx | 4 +- .../src/stories/examples/with-card/code.tsx | 43 ++-- .../stories/examples/with-list-node/code.tsx | 28 +- .../stories/examples/with-svg-node/code.tsx | 15 +- .../code-controlled-mode-jotai.tsx | 5 +- .../code-controlled-mode-peerjs.tsx | 8 +- .../step-by-step/code-controlled-mode.tsx | 2 +- packages/joint-react/src/utils/create.ts | 4 +- yarn.lock | 8 + 20 files changed, 257 insertions(+), 422 deletions(-) delete mode 100644 packages/joint-react/src/hooks/__tests__/use-measure-node-size.test.tsx diff --git a/packages/joint-react/package.json b/packages/joint-react/package.json index 4830cc38ae..86c1c18e7e 100644 --- a/packages/joint-react/package.json +++ b/packages/joint-react/package.json @@ -94,6 +94,7 @@ "react-test-renderer": "^19.1.1", "redux": "^5.0.1", "redux-undo": "1.1.0", + "resize-observer-polyfill": "^1.5.1", "storybook": "^8.6.14", "storybook-addon-performance": "0.17.3", "storybook-multilevel-sort": "2.1.0", diff --git a/packages/joint-react/src/hooks/__tests__/use-measure-node-size.test.tsx b/packages/joint-react/src/hooks/__tests__/use-measure-node-size.test.tsx deleted file mode 100644 index 6ec7e5031a..0000000000 --- a/packages/joint-react/src/hooks/__tests__/use-measure-node-size.test.tsx +++ /dev/null @@ -1,243 +0,0 @@ -/* eslint-disable @eslint-react/hooks-extra/no-unnecessary-use-prefix */ -import React, { useRef } from 'react'; -import { render, act } from '@testing-library/react'; -import { useNodeSize } from '../use-node-size'; - -// Mocks for @joint/core and useGraphStore - -// This is a mock for a hook, but the linter wants no 'use' prefix if not a real hook -const mockHasMeasuredNode = jest.fn(() => false); -const mockSetMeasuredNode = jest.fn(() => jest.fn()); - -jest.mock('../use-graph-store', () => { - const graph = { - getCell: jest.fn((_id: string) => ({ - isElement: () => true, - set: jest.fn(), - })), - }; - return { - useGraphStore: () => ({ - graph, - setMeasuredNode: mockSetMeasuredNode, - hasMeasuredNode: mockHasMeasuredNode, - }), - }; -}); - -// This is a mock for a hook, but the linter wants no 'use' prefix if not a real hook -jest.mock('../use-cell-id', () => ({ - useCellId: () => 'cell-1', -})); - -jest.mock('../../store/create-elements-size-observer', () => ({ - createElementSizeObserver: ( - element: HTMLElement, - cb: (size: { width: number; height: number }) => void - ) => { - // Simulate initial measurement - setTimeout(() => { - const rect = element.getBoundingClientRect(); - cb({ width: rect.width, height: rect.height }); - }, 0); - return jest.fn(); - }, -})); - -describe('useNodeSize', () => { - beforeEach(() => { - // Reset mocks before each test - mockHasMeasuredNode.mockReturnValue(false); - mockSetMeasuredNode.mockReturnValue(jest.fn()); - }); - - interface TestComponentProps { - readonly style: React.CSSProperties; - readonly children?: React.ReactNode; - readonly setSize: (options: { - element: unknown; - size: { width: number; height: number }; - }) => void; - } - function TestComponent({ style, children, setSize }: TestComponentProps) { - const ref = useRef(null); - useNodeSize(ref, { setSize }); - return ( -
- {children} -
- ); - } - - const explicitStyle = { width: '123px', height: '45px' }; - const contentStyle = { padding: '10px', fontSize: '20px' }; - - it('measures element with explicit width and height', async () => { - const setSize = jest.fn(); - // Mock getBoundingClientRect for this test - const getBoundingClientRect = jest.fn(() => ({ width: 123, height: 45 })); - // Render and patch the ref after mount - const { getByTestId } = render( - - Explicit - - ); - const element = getByTestId('measured'); - // @ts-expect-error assigning mock getBoundingClientRect to element for test - element.getBoundingClientRect = getBoundingClientRect; - - // Wait for measurement - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 10)); - }); - - // Mock observer doesn't trigger resize callbacks, so setSize won't be called - // This test verifies the hook doesn't throw and setMeasuredNode is called - expect(mockSetMeasuredNode).toHaveBeenCalled(); - }); - - it('measures element with size from content/margin/padding', async () => { - const setSize = jest.fn(); - // Mock getBoundingClientRect for this test - const getBoundingClientRect = jest.fn(() => ({ width: 50, height: 30 })); - const { getByTestId } = render( - - Hello world - - ); - const element = getByTestId('measured'); - // @ts-expect-error assigning mock getBoundingClientRect to element for test - element.getBoundingClientRect = getBoundingClientRect; - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 10)); - }); - - // Mock observer doesn't trigger resize callbacks, so setSize won't be called - // This test verifies the hook doesn't throw and setMeasuredNode is called - expect(mockSetMeasuredNode).toHaveBeenCalled(); - }); - - describe('multiple useNodeSize hook error', () => { - it('should throw error when multiple useNodeSize hooks are used for the same element', () => { - // Mock that a measured node already exists - mockHasMeasuredNode.mockReturnValue(true); - - const setSize = jest.fn(); - const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); - - // The error will be thrown during render, so we need to catch it - let caughtError: Error | undefined; - try { - render(); - } catch (error) { - caughtError = error as Error; - } - - // Verify error was thrown - expect(caughtError).toBeDefined(); - expect(caughtError).toBeInstanceOf(Error); - expect(caughtError?.message).toContain('Multiple useNodeSize hooks detected'); - - consoleError.mockRestore(); - }); - - it('should throw detailed error message in development mode', () => { - // Mock that a measured node already exists - mockHasMeasuredNode.mockReturnValue(true); - - const setSize = jest.fn(); - const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); - - // Save original NODE_ENV - const originalEnv = process.env.NODE_ENV; - // Set to development mode - process.env.NODE_ENV = 'development'; - - let caughtError: Error | undefined; - try { - render(); - } catch (error) { - caughtError = error as Error; - } - - // Verify error was thrown with detailed message - expect(caughtError).toBeDefined(); - expect(caughtError).toBeInstanceOf(Error); - const errorMessage = caughtError?.message ?? ''; - expect(errorMessage).toContain( - 'Multiple useNodeSize hooks detected for element with id "cell-1"' - ); - expect(errorMessage).toContain('Only one useNodeSize hook can be used per element'); - expect(errorMessage).toContain('Solution:'); - expect(errorMessage).toContain('Use only one useNodeSize hook per element'); - expect(errorMessage).toContain('custom `setSize` handler'); - expect(errorMessage).toContain('Check your renderElement function'); - - // Restore original NODE_ENV - process.env.NODE_ENV = originalEnv; - consoleError.mockRestore(); - }); - - it('should throw concise error message in production mode', () => { - // Mock that a measured node already exists - mockHasMeasuredNode.mockReturnValue(true); - - const setSize = jest.fn(); - const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); - - // Save original NODE_ENV - const originalEnv = process.env.NODE_ENV; - // Set to production mode - process.env.NODE_ENV = 'production'; - - let caughtError: Error | undefined; - try { - render(); - } catch (error) { - caughtError = error as Error; - } - - // Verify error was thrown with concise message - expect(caughtError).toBeDefined(); - expect(caughtError).toBeInstanceOf(Error); - const errorMessage = caughtError?.message ?? ''; - expect(errorMessage).toBe( - 'Multiple useNodeSize hooks detected for element "cell-1". Only one useNodeSize hook can be used per element.' - ); - // Should not contain detailed solution in production - expect(errorMessage).not.toContain('Solution:'); - expect(errorMessage).not.toContain('Check your renderElement function'); - - // Restore original NODE_ENV - process.env.NODE_ENV = originalEnv; - consoleError.mockRestore(); - }); - - it('should not throw error when no useNodeSize hook exists for the element', () => { - // Mock that no measured node exists - mockHasMeasuredNode.mockReturnValue(false); - - const setSize = jest.fn(); - const getBoundingClientRect = jest.fn(() => ({ width: 123, height: 45 })); - - const { getByTestId } = render( - - Test - - ); - - const element = getByTestId('measured'); - // @ts-expect-error assigning mock getBoundingClientRect to element for test - element.getBoundingClientRect = getBoundingClientRect; - - // Should not throw and should call setMeasuredNode with the correct options - expect(mockSetMeasuredNode).toHaveBeenCalledWith( - expect.objectContaining({ - id: 'cell-1', - element: expect.any(HTMLElement), - }) - ); - }); - }); -}); diff --git a/packages/joint-react/src/hooks/use-node-size.tsx b/packages/joint-react/src/hooks/use-node-size.tsx index effcdb45c7..e38121125f 100644 --- a/packages/joint-react/src/hooks/use-node-size.tsx +++ b/packages/joint-react/src/hooks/use-node-size.tsx @@ -1,72 +1,152 @@ import { useLayoutEffect, type RefObject } from 'react'; import { useCellId } from './use-cell-id'; import { useGraphStore } from './use-graph-store'; -import type { OnSetSize } from '../store/create-elements-size-observer'; +import type { OnTransformElement, TransformResult } from '../store/create-elements-size-observer'; +import { useElement } from './use-element'; +/** + * Options for configuring how the node size is measured and applied. + */ export interface MeasureNodeOptions { /** - * Overwrite default node set function with custom handling. - * Useful for adding another padding, or just check element size. - * @default it sets element via cell.set('size', {width, height}) + * Custom transform function to modify the measured size before applying it to the graph element. + * + * This function receives the measured dimensions from the DOM element and the current graph element, + * allowing you to add padding, apply scaling, or perform other transformations. + * @param options - The measured size and the graph element instance + * @param options.width - The measured width of the DOM element in pixels + * @param options.height - The measured height of the DOM element in pixels + * @param options.x - The current x position of the graph element (optional) + * @param options.y - The current y position of the graph element (optional) + * @param options.element - The JointJS element instance that will be updated + * @returns The size values to apply to the graph element. Must include `width` and `height`. + * @default By default, the measured size is applied directly via `element.set('size', {width, height})` + * @example + * ```tsx + * const transform = ({ width, height }) => ({ + * width: width + 20, // Add 10px padding on each side + * height: height + 20, + * }); + * useNodeSize(elementRef, { transform }); + * ``` */ - readonly setSize?: OnSetSize; + readonly transform?: OnTransformElement; } const EMPTY_OBJECT: MeasureNodeOptions = {}; /** - * Custom hook to measure the size of a node and update its size in the graph. - * It uses the `createElementSizeObserver` utility to observe size changes. + * Custom hook to automatically measure the size of a DOM element and synchronize it with the graph element's size. + * + * This hook uses the ResizeObserver API to monitor size changes of the referenced DOM element + * and automatically updates the corresponding graph element's size. The returned values represent + * the current size of the graph element (which may differ from the measured DOM size if a custom + * `transform` function is provided). * - * **Important:** Only one `useNodeSize` hook can be used per element. - * Using multiple `useNodeSize` hooks for the same element will throw an error in development - * and cause unexpected behavior. If you need multiple measurements, use a single `useNodeSize` - * hook with a custom `setSize` handler. - * @param elementRef - A reference to the HTML or SVG element to measure. - * @param options - Options for measuring the node size. + * **How it works:** + * 1. Observes the DOM element referenced by `elementRef` for size changes + * 2. When the DOM element's size changes, applies the size (or transformed size) to the graph element + * 3. Returns the current graph element's dimensions, which are always defined + * + * **Important constraints:** + * - Only one `useNodeSize` hook can be used per element. Using multiple hooks for the same element + * will throw an error and cause unexpected behavior. + * - Must be used within a `renderElement` function or a component rendered from within it. + * - The returned values are always defined (width and height default to 0 if not set). + * + * @param elementRef - A reference to the HTML or SVG element to measure. The element must be rendered + * in the DOM when the hook runs. + * @param options - Optional configuration for measuring and transforming the node size. + * @returns An object containing the current graph element's dimensions: + * - `width`: The current width of the graph element in pixels (always defined, defaults to 0) + * - `height`: The current height of the graph element in pixels (always defined, defaults to 0) + * - `x`: The current x position of the graph element (optional, may be undefined) + * - `y`: The current y position of the graph element (optional, may be undefined) * @throws {Error} If multiple `useNodeSize` hooks are used for the same element. + * @throws {Error} If the cell is not a valid element. * @group Hooks * @example + * Basic usage with SVG element: * ```tsx * import { useNodeSize } from '@joint/react'; * import { useRef } from 'react'; * * function RenderElement() { - * const elementRef = useRef(null); - * useNodeSize(elementRef); - * return
Content
; + * const rectRef = useRef(null); + * const { width, height } = useNodeSize(rectRef); + * + * return ( + * + * ); * } * ``` + * * @example - * With custom size handler: + * Using returned values for calculations: * ```tsx - * import { useNodeSize } from '@joint/react'; - * import { useRef } from 'react'; - * import type { dia } from '@joint/core'; + * function Card() { + * const frameRef = useRef(null); + * const { width, height } = useNodeSize(frameRef); + * const gap = 10; + * const imageWidth = Math.max(width - gap * 2, 0); + * const imageHeight = Math.max(height - gap * 2, 0); * - * function RenderElement() { + * return ( + * <> + * + * + * + * ); + * } + * ``` + * + * @example + * With custom transform to add padding: + * ```tsx + * import { useNodeSize, type OnTransformElement } from '@joint/react'; + * import { useRef, useCallback } from 'react'; + * + * function ListElement() { * const elementRef = useRef(null); - * useNodeSize(elementRef, { - * setSize: ({ element, size }) => { - * // Custom size handling - * element.set('size', { width: size.width + 10, height: size.height + 10 }); + * const padding = 10; + * const headerHeight = 50; + * + * const transform: OnTransformElement = useCallback( + * ({ width: measuredWidth, height: measuredHeight }) => { + * return { + * width: padding + measuredWidth + padding, + * height: headerHeight + measuredHeight + padding, + * }; * }, - * }); - * return
Content
; + * [] + * ); + * + * const { width, height } = useNodeSize(elementRef, { transform }); + * + * return ( + * <> + * + * + *
Content
+ *
+ * + * ); * } * ``` */ export function useNodeSize( elementRef: RefObject, options?: MeasureNodeOptions -) { - const { setSize } = options ?? EMPTY_OBJECT; +): TransformResult { + const { transform } = options ?? EMPTY_OBJECT; const { graph, setMeasuredNode, hasMeasuredNode } = useGraphStore(); const id = useCellId(); + const { x, y, width = 0, height = 0 } = useElement(); + useLayoutEffect(() => { const element = elementRef.current; - if (!element) throw new Error('useNodeSize: elementRef.current must not be null'); + if (!element) return; const cell = graph.getCell(id); if (!cell?.isElement()) throw new Error('Cell not valid'); @@ -80,7 +160,7 @@ export function useNodeSize( `trying to set the size for the same element will cause conflicts and unexpected behavior.\n\n` + `Solution:\n` + `- Use only one useNodeSize hook per element\n` + - `- If you need multiple measurements, use a single useNodeSize hook with a custom \`setSize\` handler\n` + + `- If you need multiple measurements, use a single useNodeSize hook with a custom \`transform\` handler\n` + `- Check your renderElement function to ensure you're not using multiple useNodeSize hooks for the same element`; throw new Error(errorMessage); @@ -88,9 +168,14 @@ export function useNodeSize( if (!elementRef.current) { return; } - const clean = setMeasuredNode({ id, element: elementRef.current, setSize }); - return clean; - }, [elementRef, graph, hasMeasuredNode, id, setMeasuredNode, setSize]); + const clean = setMeasuredNode({ id, element: elementRef.current, transform }); + return () => { + clean(); + }; + // transform is not a dependency because it is a function + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [elementRef, graph, hasMeasuredNode, id, setMeasuredNode]); // This hook itself does not return anything. + return { x, y, width, height }; } diff --git a/packages/joint-react/src/index.ts b/packages/joint-react/src/index.ts index 1465200a42..56922ef1a2 100644 --- a/packages/joint-react/src/index.ts +++ b/packages/joint-react/src/index.ts @@ -25,4 +25,4 @@ export * from './types/cell.types'; export * from './types/event.types'; export * from './context'; -export * from './store'; +export * from './store'; \ No newline at end of file diff --git a/packages/joint-react/src/models/__tests__/react-element.test.ts b/packages/joint-react/src/models/__tests__/react-element.test.ts index 194854baf3..c336d113b7 100644 --- a/packages/joint-react/src/models/__tests__/react-element.test.ts +++ b/packages/joint-react/src/models/__tests__/react-element.test.ts @@ -1,6 +1,12 @@ -import { ReactElement, createElement, REACT_TYPE } from '../react-element'; +import { ReactElement, REACT_TYPE } from '../react-element'; import { dia } from '@joint/core'; +function createElement( + options?: Attributes & dia.Element.Attributes +) { + return new ReactElement(options); +} + describe('react-element', () => { describe('ReactElement', () => { it('should create a ReactElement instance', () => { @@ -102,18 +108,3 @@ describe('react-element', () => { }); }); }); - - - - - - - - - - - - - - - diff --git a/packages/joint-react/src/models/react-element.tsx b/packages/joint-react/src/models/react-element.tsx index 6a93f51721..5bdc2a7427 100644 --- a/packages/joint-react/src/models/react-element.tsx +++ b/packages/joint-react/src/models/react-element.tsx @@ -45,25 +45,3 @@ export class ReactElement extends dia.Eleme } markup: string | dia.MarkupJSON = elementMarkup; } - -/** - * Creates a new ReactElement instance. - * @param options - The attributes for the ReactElement. - * @returns A new ReactElement instance. - * @group Models - * @example - * ```ts - * import { createElement } from '@joint/react'; - * - * const element = createElement({ - * id: '1', - * position: { x: 10, y: 20 }, - * size: { width: 100, height: 50 }, - * }); - * ``` - */ -export function createElement( - options?: Attributes & dia.Element.Attributes -) { - return new ReactElement(options); -} diff --git a/packages/joint-react/src/store/__tests__/graph-store.test.ts b/packages/joint-react/src/store/__tests__/graph-store.test.ts index 526a28d975..8b0bc910bf 100644 --- a/packages/joint-react/src/store/__tests__/graph-store.test.ts +++ b/packages/joint-react/src/store/__tests__/graph-store.test.ts @@ -416,7 +416,7 @@ describe('GraphStore', () => { const cleanup = store.setMeasuredNode({ id: 'measured-element', element: domElement, - setSize, + transform: setSize, }); expect(typeof cleanup).toBe('function'); diff --git a/packages/joint-react/src/store/create-elements-size-observer.ts b/packages/joint-react/src/store/create-elements-size-observer.ts index ecbc4ac6b0..1f7bd1befd 100644 --- a/packages/joint-react/src/store/create-elements-size-observer.ts +++ b/packages/joint-react/src/store/create-elements-size-observer.ts @@ -11,28 +11,28 @@ const EPSILON = 0.5; /** * Size information for an observed element. */ -export interface SizeObserver { +export interface TransformResult { /** Width of the element in pixels */ readonly width: number; /** Height of the element in pixels */ readonly height: number; + readonly x?: number; + readonly y?: number; } /** * Options passed to the setSize callback when an element's size changes. */ -export interface OnSetOptions { +export interface TransformOptions extends TransformResult { /** The JointJS element instance */ readonly element: dia.Element; - /** The new size of the element */ - readonly size: SizeObserver; } /** * Callback function called when an element's size is measured. * Allows custom handling of size updates before they're applied to the graph. */ -export type OnSetSize = (options: OnSetOptions) => void; +export type OnTransformElement = (options: TransformOptions) => TransformResult; /** * Options for registering an element to be measured for size changes. @@ -41,16 +41,24 @@ export interface SetMeasuredNodeOptions { /** The DOM element (HTML or SVG) to observe for size changes */ readonly element: HTMLElement | SVGElement; /** Optional callback to handle size updates before they're applied */ - readonly setSize?: OnSetSize; + readonly transform?: OnTransformElement; /** The ID of the cell in the graph that corresponds to this DOM element */ readonly id: dia.Cell.ID; } interface ElementItem { readonly element: HTMLElement | SVGElement; - readonly setSize?: OnSetSize; + readonly transform?: OnTransformElement; } +// eslint-disable-next-line jsdoc/require-jsdoc +function defaultTransform(options: TransformOptions) { + const { width, height, x, y } = options; + return { width, height, x, y }; +} +const DEFAULT_OBJECT: Partial = { + transform: defaultTransform, +}; /** * Options for creating an elements size observer. */ @@ -58,7 +66,7 @@ interface Options { /** Options to pass to the ResizeObserver constructor */ readonly resizeObserverOptions?: ResizeObserverOptions; /** Function to get the current size of a cell from the graph */ - readonly getCellSize: (id: dia.Cell.ID) => SizeObserver; + readonly getCellTransform: (id: dia.Cell.ID) => TransformResult & { element: dia.Element }; /** Function to get the current IDs snapshot for efficient lookups */ readonly getIdsSnapshot: () => MarkDeepReadOnly; /** Function to get the current public snapshot containing all elements */ @@ -109,7 +117,7 @@ export interface GraphStoreObserver { export function createElementsSizeObserver(options: Options): GraphStoreObserver { const { resizeObserverOptions = DEFAULT_OBSERVER_OPTIONS, - getCellSize, + getCellTransform, getIdsSnapshot, onBatchUpdate, getPublicSnapshot, @@ -139,25 +147,40 @@ export function createElementsSizeObserver(options: Options): GraphStoreObserver const width = inlineSize; const height = blockSize; - const actualSize = getCellSize(id); + const cellTransform = getCellTransform(id); // Here we compare the actual size with the border box size const isChanged = - Math.abs(actualSize.width - width) > EPSILON || - Math.abs(actualSize.height - height) > EPSILON; + Math.abs(cellTransform.width - width) > EPSILON || + Math.abs(cellTransform.height - height) > EPSILON; if (!isChanged) { return; } + // we observe just width and height, not x and y + if (cellTransform.width === width && cellTransform.height === height) { + return; + } const elementIndex = idsSnapshot.elementIds[id]; if (elementIndex == undefined) { throw new Error(`Element with id ${id} not found in graph data ref`); } const element = newElements[elementIndex]; + const { transform = defaultTransform } = elements.get(id) ?? DEFAULT_OBJECT; if (!element) { throw new Error(`Element with id ${id} not found in graph data ref`); } - newElements[elementIndex] = { ...element, width, height }; + const { x, y, element: cell } = cellTransform; + newElements[elementIndex] = { + ...element, + ...transform({ + x, + y, + element: cell, + width, + height, + }), + }; hasChange = true; } @@ -169,9 +192,9 @@ export function createElementsSizeObserver(options: Options): GraphStoreObserver }); return { - add({ id, element, setSize }: SetMeasuredNodeOptions) { + add({ id, element, transform }: SetMeasuredNodeOptions) { observer.observe(element, resizeObserverOptions); - elements.set(id, { element, setSize }); + elements.set(id, { element, transform }); invertedIndex.set(element, id); return () => { observer.unobserve(element); diff --git a/packages/joint-react/src/store/graph-store.ts b/packages/joint-react/src/store/graph-store.ts index fb5c0c3b86..ca9f28a0f2 100644 --- a/packages/joint-react/src/store/graph-store.ts +++ b/packages/joint-react/src/store/graph-store.ts @@ -12,8 +12,8 @@ import { ReactElement } from '../models/react-element'; import type { ExternalStoreLike, State } from '../utils/create-state'; import { createState, derivedState, getValue } from '../utils/create-state'; import { stateSync, type StateSync } from '../state/state-sync'; -import type { OnChangeOptions } from '../utils/cell/listen-to-cell-change'; import type { GraphStateSelectors } from '../state/graph-state-selectors'; +import type { OnChangeOptions } from '../utils/cell/listen-to-cell-change'; export const DEFAULT_CELL_NAMESPACE: Record = { ...shapes, ReactElement }; @@ -207,6 +207,7 @@ export class GraphStore { isEqual: util.isEqual, }); + this.wasElementsMeasuredBefore = false; this.derivedStore = derivedState({ name: 'Jointjs/Derived', state: this.publicState, @@ -214,7 +215,7 @@ export class GraphStore { const elementIds: Record = {}; const linkIds: Record = {}; - let areElementsMeasured = true; + let areElementsMeasured = snapshot.elements.length > 0; for (const [index, element] of snapshot.elements.entries()) { elementIds[element.id] = index; } @@ -231,6 +232,7 @@ export class GraphStore { if (areElementsMeasured) { this.wasElementsMeasuredBefore = true; } + if (this.wasElementsMeasuredBefore) { areElementsMeasured = true; } @@ -270,14 +272,17 @@ export class GraphStore { elements: newElements, })); }, - getCellSize: (id) => { + getCellTransform: (id) => { const cell = this.graph.getCell(id); if (!cell?.isElement()) throw new Error('Cell not valid'); const size = cell.get('size'); + const position = cell.get('position'); if (!size) throw new Error('Size not found'); return { width: size.width, height: size.height, + element: cell, + ...position, }; }, }); diff --git a/packages/joint-react/src/store/index.ts b/packages/joint-react/src/store/index.ts index 8e10a0eea4..8f6c12a969 100644 --- a/packages/joint-react/src/store/index.ts +++ b/packages/joint-react/src/store/index.ts @@ -1,3 +1,3 @@ export * from './graph-store'; export * from './paper-store'; -export * from './create-elements-size-observer'; +export * from './create-elements-size-observer'; \ No newline at end of file diff --git a/packages/joint-react/src/stories/demos/flowchart/code.tsx b/packages/joint-react/src/stories/demos/flowchart/code.tsx index 3063078b3a..f7de49e82c 100644 --- a/packages/joint-react/src/stories/demos/flowchart/code.tsx +++ b/packages/joint-react/src/stories/demos/flowchart/code.tsx @@ -3,7 +3,7 @@ /* eslint-disable react-perf/jsx-no-new-function-as-prop */ /* eslint-disable react-perf/jsx-no-new-object-as-prop */ import './index.css'; -import type { GraphLink, OnSetSize } from '@joint/react'; +import type { GraphLink, OnTransformElement } from '@joint/react'; import { createElements, createLinks, @@ -194,25 +194,26 @@ interface PropsWithClick { type FlowchartNodeProps = InferElement & PropsWithClick; function DecisionNodeRaw( - { label, width, cx, cy, onMouseEnter, onMouseLeave }: FlowchartNodeProps, + { label, cx, cy, onMouseEnter, onMouseLeave }: FlowchartNodeProps, ref: React.ForwardedRef ) { // If we define custom size, not defined in initial nodes, we have to use measure node - const size = width; - const half = size / 2; - const padding = 20; - const setSize: OnSetSize = ({ element, size }) => { - const dimension = Math.max(size.width, size.height) + 2 * padding; - element.set({ - size: { width: dimension, height: dimension }, - position: { x: cx - dimension / 2, y: cy - dimension / 2 }, - }); + const transform: OnTransformElement = ({ width, height }) => { + const dimension = Math.max(width, height) + 2 * padding; + return { + width: dimension, + height: dimension, + x: cx, + y: cy, + }; }; const textRef = useRef(null); - useNodeSize(textRef, { setSize }); - + const { width } = useNodeSize(textRef, { transform }); + const size = width; + const half = size / 2; + const padding = 20; return ( <> ) { const padding = 20; - const setSize: OnSetSize = ({ element, size }) => { - const w = size.width + 2 * padding; - const h = size.height + 2 * padding; - element.set({ - size: { width: w, height: h }, - position: { x: cx - w / 2, y: cy - h / 2 }, - }); + const transform: OnTransformElement = ({ width, height }) => { + return { + width: width + 2 * padding, + height: height + 2 * padding, + x: cx, + y: cy, + }; }; const textRef = useRef(null); - useNodeSize(textRef, { setSize }); - - // discuss - if (!width || !height) { - return null; - } + const { width, height } = useNodeSize(textRef, { transform }); return ( <> diff --git a/packages/joint-react/src/stories/demos/pulsing-port/code.tsx b/packages/joint-react/src/stories/demos/pulsing-port/code.tsx index 5172a6cedd..86ff35c6bb 100644 --- a/packages/joint-react/src/stories/demos/pulsing-port/code.tsx +++ b/packages/joint-react/src/stories/demos/pulsing-port/code.tsx @@ -95,9 +95,9 @@ const elements = createElements([ type Element = InferElement; -function NodeElement({ width, height, id }: Element) { +function NodeElement({ id }: Element) { const rectRef = useRef(null); - useNodeSize(rectRef); + const { width, height } = useNodeSize(rectRef); const isConnected = useLinks((links) => links diff --git a/packages/joint-react/src/stories/examples/with-card/code.tsx b/packages/joint-react/src/stories/examples/with-card/code.tsx index c609a37820..02ba2364e3 100644 --- a/packages/joint-react/src/stories/examples/with-card/code.tsx +++ b/packages/joint-react/src/stories/examples/with-card/code.tsx @@ -1,11 +1,11 @@ -/* eslint-disable react-perf/jsx-no-new-object-as-prop */ import '../index.css'; -import React, { useCallback, type PropsWithChildren } from 'react'; +import { useCallback, useRef } from 'react'; import { createElements, createLinks, GraphProvider, Paper, + useNodeSize, type InferElement, type RenderElement, } from '@joint/react'; @@ -30,37 +30,34 @@ const initialEdges = createLinks([ type BaseElementWithData = InferElement; -function Card({ children, width, height }: PropsWithChildren) { +function Card() { + const frameRef = useRef(null); + const { width, height } = useNodeSize(frameRef); const gap = 10; - const imageWidth = 50; - const imageHeight = height - 2 * gap; + // avoid negative width and height + const imageWidth = Math.max(width - gap * 2, 0); + const imageHeight = Math.max(height - gap * 2, 0); const iconURL = `https://placehold.co/${imageWidth}x${imageHeight}`; - const foWidth = width - 2 * gap - imageWidth - gap; - const foHeight = height - 2 * gap; + const frameWidth = 80; + const frameHeight = 120; return ( <> - + - -
- {children} -
-
); } function Main() { - const renderElement: RenderElement = useCallback((element) => { - return {element.label}; + const renderElement: RenderElement = useCallback(() => { + return ; }, []); return ( diff --git a/packages/joint-react/src/stories/examples/with-list-node/code.tsx b/packages/joint-react/src/stories/examples/with-list-node/code.tsx index 735de42b20..8d7f105e5d 100644 --- a/packages/joint-react/src/stories/examples/with-list-node/code.tsx +++ b/packages/joint-react/src/stories/examples/with-list-node/code.tsx @@ -10,7 +10,7 @@ import { Paper, useNodeSize, type InferElement, - type OnSetSize, + type OnTransformElement, } from '@joint/react'; import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; import { useCellActions } from '../../../hooks/use-cell-actions'; @@ -41,24 +41,24 @@ const initialEdges = createLinks([ type BaseElementWithData = InferElement; -function ListElement({ - id, - children, - width, - height, - inputs, -}: PropsWithChildren) { +function ListElement({ id, children, inputs }: PropsWithChildren) { const padding = 10; const headerHeight = 50; const elementRef = useRef(null); - const setListSize: OnSetSize = useCallback(({ element, size }) => { - const w = padding + size.width + padding; - const h = headerHeight + size.height + padding; - element.size(w, h, { async: false }); - }, []); + const transform: OnTransformElement = useCallback( + ({ width: measuredWidth, height: measuredHeight }) => { + const w = padding + measuredWidth + padding; + const h = headerHeight + measuredHeight + padding; + return { + width: w, + height: h, + }; + }, + [] + ); - useNodeSize(elementRef, { setSize: setListSize }); + const { width, height } = useNodeSize(elementRef, { transform }); const { set } = useCellActions(); diff --git a/packages/joint-react/src/stories/examples/with-svg-node/code.tsx b/packages/joint-react/src/stories/examples/with-svg-node/code.tsx index e9cb295aaf..8f0baf57dd 100644 --- a/packages/joint-react/src/stories/examples/with-svg-node/code.tsx +++ b/packages/joint-react/src/stories/examples/with-svg-node/code.tsx @@ -8,7 +8,7 @@ import { Paper, useNodeSize, type InferElement, - type OnSetSize, + type OnTransformElement, type RenderElement, } from '@joint/react'; import { useCallback, useRef } from 'react'; @@ -33,19 +33,22 @@ const initialElements = createElements([ type BaseElementWithData = InferElement; -function RenderedRect({ width, height, label }: BaseElementWithData) { +function RenderedRect({ label }: BaseElementWithData) { const textMargin = 20; const cornerRadius = 5; const textRef = useRef(null); - const setSize: OnSetSize = useCallback( - ({ element, size: { width: sizeWidth, height: sizeHeight } }) => { - element.size(sizeWidth + textMargin, sizeHeight + textMargin); + const transform: OnTransformElement = useCallback( + ({ width: measuredWidth, height: measuredHeight }) => { + return { + width: measuredWidth + textMargin, + height: measuredHeight + textMargin, + }; }, [textMargin] ); - useNodeSize(textRef, { setSize }); + const { width, height } = useNodeSize(textRef, { transform }); return ( <> diff --git a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-jotai.tsx b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-jotai.tsx index dddefb3048..29db8ea1a5 100644 --- a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-jotai.tsx +++ b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-jotai.tsx @@ -216,7 +216,7 @@ interface PaperAppProps { readonly store: ExternalGraphStore; } -function PaperApp({ store }: PaperAppProps) { +function PaperApp({ store }: Readonly) { return (
@@ -327,6 +327,3 @@ function Main(props: Readonly) { export default function App(props: Readonly) { return
; } - - - diff --git a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-peerjs.tsx b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-peerjs.tsx index 4fa604e133..8a988779b6 100644 --- a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-peerjs.tsx +++ b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-peerjs.tsx @@ -385,7 +385,7 @@ interface PaperAppProps { readonly store: ExternalGraphStore; } -function PaperApp({ store }: PaperAppProps) { +function PaperApp({ store }: Readonly) { return (
@@ -613,9 +613,3 @@ function Main(props: Readonly) { export default function App(props: Readonly) { return
; } - - - - - - diff --git a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode.tsx b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode.tsx index 3b0edfc0f9..e3f2001a1b 100644 --- a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode.tsx +++ b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode.tsx @@ -162,7 +162,7 @@ interface PaperAppProps { * through JointJS APIs. Instead, always update React state, and GraphProvider * will automatically sync the changes to the graph. */ -function PaperApp({ onElementsChange, onLinksChange }: PaperAppProps) { +function PaperApp({ onElementsChange, onLinksChange }: Readonly) { return (
{/* diff --git a/packages/joint-react/src/utils/create.ts b/packages/joint-react/src/utils/create.ts index c1f068d5ef..03a37ab1ae 100644 --- a/packages/joint-react/src/utils/create.ts +++ b/packages/joint-react/src/utils/create.ts @@ -2,8 +2,8 @@ import type { GraphElement, StandardShapesTypeMapper } from '../types/element-ty import type { GraphLink, StandardLinkShapesType } from '../types/link-types'; type RequiredElementProps = { - width: number; - height: number; + width?: number; + height?: number; }; type ElementWithAttributes = diff --git a/yarn.lock b/yarn.lock index bd05c9ab4a..349584abf7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3501,6 +3501,7 @@ __metadata: react-test-renderer: "npm:^19.1.1" redux: "npm:^5.0.1" redux-undo: "npm:1.1.0" + resize-observer-polyfill: "npm:^1.5.1" scheduler: "npm:^0.27.0" storybook: "npm:^8.6.14" storybook-addon-performance: "npm:0.17.3" @@ -21218,6 +21219,13 @@ __metadata: languageName: node linkType: hard +"resize-observer-polyfill@npm:^1.5.1": + version: 1.5.1 + resolution: "resize-observer-polyfill@npm:1.5.1" + checksum: 10/e10ee50cd6cf558001de5c6fb03fee15debd011c2f694564b71f81742eef03fb30d6c2596d1d5bf946d9991cb692fcef529b7bd2e4057041377ecc9636c753ce + languageName: node + linkType: hard + "resolve-alpn@npm:^1.0.0": version: 1.2.1 resolution: "resolve-alpn@npm:1.2.1" From a0f940b672f490c18df4f46abd5bea60f98004ba Mon Sep 17 00:00:00 2001 From: samuelgja Date: Fri, 9 Jan 2026 17:13:06 +0700 Subject: [PATCH 15/24] feat(joint-react): improve size observer accuracy and performance - Updated the EPSILON value to reduce sub-pixel rendering jitter. - Introduced new functions to read entry sizes and snap to device pixels for better measurement accuracy. - Enhanced the ResizeObserver implementation to batch updates and prevent re-entrancy issues. - Improved documentation to clarify the observer's behavior and usage examples. --- .../store/create-elements-size-observer.ts | 199 +++++++++++++----- 1 file changed, 147 insertions(+), 52 deletions(-) diff --git a/packages/joint-react/src/store/create-elements-size-observer.ts b/packages/joint-react/src/store/create-elements-size-observer.ts index 1f7bd1befd..fca5df0466 100644 --- a/packages/joint-react/src/store/create-elements-size-observer.ts +++ b/packages/joint-react/src/store/create-elements-size-observer.ts @@ -4,9 +4,9 @@ import type { GraphStoreDerivedSnapshot, GraphStoreSnapshot } from './graph-stor import type { MarkDeepReadOnly } from '../utils/create-state'; const DEFAULT_OBSERVER_OPTIONS: ResizeObserverOptions = { box: 'border-box' }; -// Epsilon value to avoid jitter due to sub-pixel rendering -// especially on Safari -const EPSILON = 0.5; + +// Epsilon value to avoid jitter due to sub-pixel rendering (especially Safari) +const EPSILON = 0.9; /** * Size information for an observed element. @@ -56,9 +56,11 @@ function defaultTransform(options: TransformOptions) { const { width, height, x, y } = options; return { width, height, x, y }; } + const DEFAULT_OBJECT: Partial = { transform: defaultTransform, }; + /** * Options for creating an elements size observer. */ @@ -99,20 +101,69 @@ export interface GraphStoreObserver { readonly has: (id: dia.Cell.ID) => boolean; } +function readEntrySize(entry: ResizeObserverEntry): { width: number; height: number } | null { + // Prefer devicePixelContentBoxSize when available; it tends to be more stable. + const dpr = window.devicePixelRatio || 1; + + const devicePixel = ( + entry as unknown as { + devicePixelContentBoxSize?: Array<{ inlineSize: number; blockSize: number }>; + } + ).devicePixelContentBoxSize?.[0]; + + if (devicePixel) { + return { width: devicePixel.inlineSize / dpr, height: devicePixel.blockSize / dpr }; + } + + const border = ( + entry as unknown as { borderBoxSize?: Array<{ inlineSize: number; blockSize: number }> } + ).borderBoxSize?.[0]; + + if (border) { + return { width: border.inlineSize, height: border.blockSize }; + } + + const content = ( + entry as unknown as { contentBoxSize?: Array<{ inlineSize: number; blockSize: number }> } + ).contentBoxSize?.[0]; + + if (content) { + return { width: content.inlineSize, height: content.blockSize }; + } + + // Legacy fallback + if (entry.contentRect) { + return { width: entry.contentRect.width, height: entry.contentRect.height }; + } + + return null; +} + +function snapToDevicePixel(px: number): number { + const dpr = window.devicePixelRatio || 1; + return Math.round(px * dpr) / dpr; +} + /** * Creates an observer for element size changes using the ResizeObserver API. * - * This function sets up automatic size tracking for DOM elements that correspond to graph elements. - * When a DOM element's size changes (e.g., due to content changes or CSS updates), the observer - * automatically updates the corresponding graph element's size. - * - * **Features:** - * - Uses ResizeObserver for efficient size tracking - * - Batches multiple size changes together for performance - * - Compares sizes with epsilon to avoid jitter from sub-pixel rendering - * - Supports custom size update handlers - * @param options - The options for creating the size observer - * @returns A GraphStoreObserver instance with methods to add/remove observers + * Safari can throw/print "ResizeObserver loop completed with undelivered notifications" + * when the observer callback causes synchronous layout changes which trigger more resize + * notifications in the same frame. To prevent this we: + * - batch updates + * - apply them in requestAnimationFrame + * - guard against re-entrancy + * - snap sizes to device pixels to reduce sub-pixel jitter + * @param options - Options for the observer + * @returns GraphStoreObserver instance + * @example + * ```ts + * const observer = createElementsSizeObserver({ + * getCellTransform: (id) => { + * return { width: 100, height: 100 }; + * }, + * }); + * ``` */ export function createElementsSizeObserver(options: Options): GraphStoreObserver { const { @@ -122,73 +173,110 @@ export function createElementsSizeObserver(options: Options): GraphStoreObserver onBatchUpdate, getPublicSnapshot, } = options; + const elements = new Map(); const invertedIndex = new Map(); - const observer = new ResizeObserver((entries) => { - // we can consider this as single batch of - let hasChange = false; + + // Pending measured sizes to apply (batched). + const pending = new Map(); + + // Remembers last size we applied, so we can ignore immediate echoes. + const lastApplied = new Map(); + + let rafScheduled = false; + let suppress = false; + + const flush = () => { + rafScheduled = false; + if (pending.size === 0) return; + const idsSnapshot = getIdsSnapshot(); const publicSnapshot = getPublicSnapshot(); const newElements: GraphElement[] = [...publicSnapshot.elements] as GraphElement[]; - for (const entry of entries) { - // We must be careful to not mutate the snapshot data. - const { target, borderBoxSize } = entry; - const id = invertedIndex.get(target as HTMLElement | SVGElement); - if (!id) { - throw new Error(`Element with id ${id} not found in resize observer`); - } + let hasChange = false; - // If borderBoxSize is not available or empty, continue to the next entry. - if (!borderBoxSize || borderBoxSize.length === 0) continue; + for (const [id, measured] of pending) { + pending.delete(id); - const [size] = borderBoxSize; - const { inlineSize, blockSize } = size; + const elementIndex = idsSnapshot.elementIds[id]; + if (elementIndex == null) { + continue; + } - const width = inlineSize; - const height = blockSize; const cellTransform = getCellTransform(id); - // Here we compare the actual size with the border box size + const isChanged = - Math.abs(cellTransform.width - width) > EPSILON || - Math.abs(cellTransform.height - height) > EPSILON; + Math.abs(cellTransform.width - measured.width) > EPSILON || + Math.abs(cellTransform.height - measured.height) > EPSILON; - if (!isChanged) { - return; - } - // we observe just width and height, not x and y - if (cellTransform.width === width && cellTransform.height === height) { - return; - } + if (!isChanged) continue; - const elementIndex = idsSnapshot.elementIds[id]; - if (elementIndex == undefined) { - throw new Error(`Element with id ${id} not found in graph data ref`); - } const element = newElements[elementIndex]; + if (!element) continue; + const { transform = defaultTransform } = elements.get(id) ?? DEFAULT_OBJECT; - if (!element) { - throw new Error(`Element with id ${id} not found in graph data ref`); - } const { x, y, element: cell } = cellTransform; + newElements[elementIndex] = { ...element, ...transform({ x, y, element: cell, - width, - height, + width: measured.width, + height: measured.height, }), }; + + lastApplied.set(id, measured); hasChange = true; } - if (!hasChange) { - return; + if (!hasChange) return; + + suppress = true; + try { + onBatchUpdate(newElements); + } finally { + suppress = false; + } + }; + + const scheduleFlush = () => { + if (rafScheduled) return; + rafScheduled = true; + requestAnimationFrame(flush); + }; + + const observer = new ResizeObserver((entries) => { + if (suppress) return; + + for (const entry of entries) { + const target = entry.target as HTMLElement | SVGElement; + const id = invertedIndex.get(target); + if (!id) continue; + + const size = readEntrySize(entry); + if (!size) continue; + + const width = snapToDevicePixel(size.width); + const height = snapToDevicePixel(size.height); + + // Ignore the "echo" right after we applied the same size. + const previous = lastApplied.get(id); + if ( + previous && + Math.abs(previous.width - width) <= EPSILON && + Math.abs(previous.height - height) <= EPSILON + ) { + continue; + } + + pending.set(id, { width, height }); } - onBatchUpdate(newElements); + if (pending.size > 0) scheduleFlush(); }); return { @@ -196,20 +284,27 @@ export function createElementsSizeObserver(options: Options): GraphStoreObserver observer.observe(element, resizeObserverOptions); elements.set(id, { element, transform }); invertedIndex.set(element, id); + return () => { observer.unobserve(element); elements.delete(id); invertedIndex.delete(element); + pending.delete(id); + lastApplied.delete(id); }; }, + clean() { for (const [, { element }] of elements.entries()) { observer.unobserve(element); } elements.clear(); invertedIndex.clear(); + pending.clear(); + lastApplied.clear(); observer.disconnect(); }, + has(id: dia.Cell.ID) { return elements.has(id); }, From 86393e8bb95ef30494a056e295e5d853cafba085 Mon Sep 17 00:00:00 2001 From: samuelgja Date: Fri, 9 Jan 2026 18:55:40 +0700 Subject: [PATCH 16/24] fix(joint-react): refine size observer logic and update EPSILON value - Adjusted the EPSILON value to 0.5 to better handle sub-pixel rendering issues. - Simplified the ResizeObserver implementation by removing unnecessary functions and improving size comparison logic. - Enhanced documentation to clarify the observer's functionality and usage. - Added console logging for debugging purposes in the introduction demo. --- .../store/create-elements-size-observer.ts | 199 +++++------------- .../stories/demos/introduction-demo/code.tsx | 3 +- 2 files changed, 54 insertions(+), 148 deletions(-) diff --git a/packages/joint-react/src/store/create-elements-size-observer.ts b/packages/joint-react/src/store/create-elements-size-observer.ts index fca5df0466..1f7bd1befd 100644 --- a/packages/joint-react/src/store/create-elements-size-observer.ts +++ b/packages/joint-react/src/store/create-elements-size-observer.ts @@ -4,9 +4,9 @@ import type { GraphStoreDerivedSnapshot, GraphStoreSnapshot } from './graph-stor import type { MarkDeepReadOnly } from '../utils/create-state'; const DEFAULT_OBSERVER_OPTIONS: ResizeObserverOptions = { box: 'border-box' }; - -// Epsilon value to avoid jitter due to sub-pixel rendering (especially Safari) -const EPSILON = 0.9; +// Epsilon value to avoid jitter due to sub-pixel rendering +// especially on Safari +const EPSILON = 0.5; /** * Size information for an observed element. @@ -56,11 +56,9 @@ function defaultTransform(options: TransformOptions) { const { width, height, x, y } = options; return { width, height, x, y }; } - const DEFAULT_OBJECT: Partial = { transform: defaultTransform, }; - /** * Options for creating an elements size observer. */ @@ -101,69 +99,20 @@ export interface GraphStoreObserver { readonly has: (id: dia.Cell.ID) => boolean; } -function readEntrySize(entry: ResizeObserverEntry): { width: number; height: number } | null { - // Prefer devicePixelContentBoxSize when available; it tends to be more stable. - const dpr = window.devicePixelRatio || 1; - - const devicePixel = ( - entry as unknown as { - devicePixelContentBoxSize?: Array<{ inlineSize: number; blockSize: number }>; - } - ).devicePixelContentBoxSize?.[0]; - - if (devicePixel) { - return { width: devicePixel.inlineSize / dpr, height: devicePixel.blockSize / dpr }; - } - - const border = ( - entry as unknown as { borderBoxSize?: Array<{ inlineSize: number; blockSize: number }> } - ).borderBoxSize?.[0]; - - if (border) { - return { width: border.inlineSize, height: border.blockSize }; - } - - const content = ( - entry as unknown as { contentBoxSize?: Array<{ inlineSize: number; blockSize: number }> } - ).contentBoxSize?.[0]; - - if (content) { - return { width: content.inlineSize, height: content.blockSize }; - } - - // Legacy fallback - if (entry.contentRect) { - return { width: entry.contentRect.width, height: entry.contentRect.height }; - } - - return null; -} - -function snapToDevicePixel(px: number): number { - const dpr = window.devicePixelRatio || 1; - return Math.round(px * dpr) / dpr; -} - /** * Creates an observer for element size changes using the ResizeObserver API. * - * Safari can throw/print "ResizeObserver loop completed with undelivered notifications" - * when the observer callback causes synchronous layout changes which trigger more resize - * notifications in the same frame. To prevent this we: - * - batch updates - * - apply them in requestAnimationFrame - * - guard against re-entrancy - * - snap sizes to device pixels to reduce sub-pixel jitter - * @param options - Options for the observer - * @returns GraphStoreObserver instance - * @example - * ```ts - * const observer = createElementsSizeObserver({ - * getCellTransform: (id) => { - * return { width: 100, height: 100 }; - * }, - * }); - * ``` + * This function sets up automatic size tracking for DOM elements that correspond to graph elements. + * When a DOM element's size changes (e.g., due to content changes or CSS updates), the observer + * automatically updates the corresponding graph element's size. + * + * **Features:** + * - Uses ResizeObserver for efficient size tracking + * - Batches multiple size changes together for performance + * - Compares sizes with epsilon to avoid jitter from sub-pixel rendering + * - Supports custom size update handlers + * @param options - The options for creating the size observer + * @returns A GraphStoreObserver instance with methods to add/remove observers */ export function createElementsSizeObserver(options: Options): GraphStoreObserver { const { @@ -173,110 +122,73 @@ export function createElementsSizeObserver(options: Options): GraphStoreObserver onBatchUpdate, getPublicSnapshot, } = options; - const elements = new Map(); const invertedIndex = new Map(); - - // Pending measured sizes to apply (batched). - const pending = new Map(); - - // Remembers last size we applied, so we can ignore immediate echoes. - const lastApplied = new Map(); - - let rafScheduled = false; - let suppress = false; - - const flush = () => { - rafScheduled = false; - if (pending.size === 0) return; - + const observer = new ResizeObserver((entries) => { + // we can consider this as single batch of + let hasChange = false; const idsSnapshot = getIdsSnapshot(); const publicSnapshot = getPublicSnapshot(); const newElements: GraphElement[] = [...publicSnapshot.elements] as GraphElement[]; + for (const entry of entries) { + // We must be careful to not mutate the snapshot data. + const { target, borderBoxSize } = entry; - let hasChange = false; + const id = invertedIndex.get(target as HTMLElement | SVGElement); + if (!id) { + throw new Error(`Element with id ${id} not found in resize observer`); + } - for (const [id, measured] of pending) { - pending.delete(id); + // If borderBoxSize is not available or empty, continue to the next entry. + if (!borderBoxSize || borderBoxSize.length === 0) continue; - const elementIndex = idsSnapshot.elementIds[id]; - if (elementIndex == null) { - continue; - } + const [size] = borderBoxSize; + const { inlineSize, blockSize } = size; + const width = inlineSize; + const height = blockSize; const cellTransform = getCellTransform(id); - + // Here we compare the actual size with the border box size const isChanged = - Math.abs(cellTransform.width - measured.width) > EPSILON || - Math.abs(cellTransform.height - measured.height) > EPSILON; + Math.abs(cellTransform.width - width) > EPSILON || + Math.abs(cellTransform.height - height) > EPSILON; - if (!isChanged) continue; + if (!isChanged) { + return; + } + // we observe just width and height, not x and y + if (cellTransform.width === width && cellTransform.height === height) { + return; + } + const elementIndex = idsSnapshot.elementIds[id]; + if (elementIndex == undefined) { + throw new Error(`Element with id ${id} not found in graph data ref`); + } const element = newElements[elementIndex]; - if (!element) continue; - const { transform = defaultTransform } = elements.get(id) ?? DEFAULT_OBJECT; + if (!element) { + throw new Error(`Element with id ${id} not found in graph data ref`); + } const { x, y, element: cell } = cellTransform; - newElements[elementIndex] = { ...element, ...transform({ x, y, element: cell, - width: measured.width, - height: measured.height, + width, + height, }), }; - - lastApplied.set(id, measured); hasChange = true; } - if (!hasChange) return; - - suppress = true; - try { - onBatchUpdate(newElements); - } finally { - suppress = false; - } - }; - - const scheduleFlush = () => { - if (rafScheduled) return; - rafScheduled = true; - requestAnimationFrame(flush); - }; - - const observer = new ResizeObserver((entries) => { - if (suppress) return; - - for (const entry of entries) { - const target = entry.target as HTMLElement | SVGElement; - const id = invertedIndex.get(target); - if (!id) continue; - - const size = readEntrySize(entry); - if (!size) continue; - - const width = snapToDevicePixel(size.width); - const height = snapToDevicePixel(size.height); - - // Ignore the "echo" right after we applied the same size. - const previous = lastApplied.get(id); - if ( - previous && - Math.abs(previous.width - width) <= EPSILON && - Math.abs(previous.height - height) <= EPSILON - ) { - continue; - } - - pending.set(id, { width, height }); + if (!hasChange) { + return; } - if (pending.size > 0) scheduleFlush(); + onBatchUpdate(newElements); }); return { @@ -284,27 +196,20 @@ export function createElementsSizeObserver(options: Options): GraphStoreObserver observer.observe(element, resizeObserverOptions); elements.set(id, { element, transform }); invertedIndex.set(element, id); - return () => { observer.unobserve(element); elements.delete(id); invertedIndex.delete(element); - pending.delete(id); - lastApplied.delete(id); }; }, - clean() { for (const [, { element }] of elements.entries()) { observer.unobserve(element); } elements.clear(); invertedIndex.clear(); - pending.clear(); - lastApplied.clear(); observer.disconnect(); }, - has(id: dia.Cell.ID) { return elements.has(id); }, diff --git a/packages/joint-react/src/stories/demos/introduction-demo/code.tsx b/packages/joint-react/src/stories/demos/introduction-demo/code.tsx index 32f1a3cf22..020abc5225 100644 --- a/packages/joint-react/src/stories/demos/introduction-demo/code.tsx +++ b/packages/joint-react/src/stories/demos/introduction-demo/code.tsx @@ -495,7 +495,8 @@ function Main() { new shapes.standard.Link({ ...links[0], id: Math.random() })} width="100%" renderElement={renderElement} className={PAPER_CLASSNAME} From 5d51044728b8975598a55ea0a3989f36d9b99569 Mon Sep 17 00:00:00 2001 From: samuelgja Date: Wed, 14 Jan 2026 12:51:59 +0700 Subject: [PATCH 17/24] fix(joint-react): update paper component opacity and enhance size observer logic - Adjusted the opacity of the paper component based on element measurement status. - Refactored size observer logic to improve clarity and performance, including renaming interfaces for better understanding. - Enhanced the handling of size changes in the ResizeObserver to ensure accurate updates and prevent unnecessary re-renders. - Updated transform functions in flowchart and card components for improved size calculations. --- .../__snapshots__/custom.test.tsx.snap | 6 +- .../__snapshots__/mask.test.tsx.snap | 6 +- .../__snapshots__/opacity.test.tsx.snap | 2 +- .../__snapshots__/store.test.tsx.snap | 2 +- .../src/components/paper/paper.stories.tsx | 79 +++++++++- .../src/components/paper/paper.tsx | 4 +- .../render-element/paper-element-item.tsx | 1 - .../__snapshots__/port-group.test.tsx.snap | 2 +- .../__snapshots__/port-item.test.tsx.snap | 2 +- .../__snapshots__/text-node.test.tsx.snap | 8 +- packages/joint-react/src/index.ts | 8 +- .../src/state/graph-state-selectors.ts | 14 +- .../store/create-elements-size-observer.ts | 143 ++++++++++++------ .../src/stories/demos/flowchart/code.tsx | 51 +++---- .../src/stories/examples/with-card/code.tsx | 57 ++++--- .../src/utils/cell/cell-utilities.ts | 4 - 16 files changed, 259 insertions(+), 130 deletions(-) diff --git a/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/custom.test.tsx.snap b/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/custom.test.tsx.snap index b70fc4d456..d54654e922 100644 --- a/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/custom.test.tsx.snap +++ b/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/custom.test.tsx.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`Highlighters/Custom "CustomWithMask": Highlighters/Custom-CustomWithMask 1`] = `"
"`; +exports[`Highlighters/Custom "CustomWithMask": Highlighters/Custom-CustomWithMask 1`] = `"
"`; -exports[`Highlighters/Custom "CustomWithOpacity": Highlighters/Custom-CustomWithOpacity 1`] = `"
"`; +exports[`Highlighters/Custom "CustomWithOpacity": Highlighters/Custom-CustomWithOpacity 1`] = `"
"`; -exports[`Highlighters/Custom "CustomWithStroke": Highlighters/Custom-CustomWithStroke 1`] = `"
"`; +exports[`Highlighters/Custom "CustomWithStroke": Highlighters/Custom-CustomWithStroke 1`] = `"
"`; diff --git a/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/mask.test.tsx.snap b/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/mask.test.tsx.snap index 7c877b4d3b..254bdcb11c 100644 --- a/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/mask.test.tsx.snap +++ b/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/mask.test.tsx.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`Highlighters/Mask "Default": Highlighters/Mask-Default 1`] = `"
"`; +exports[`Highlighters/Mask "Default": Highlighters/Mask-Default 1`] = `"
"`; -exports[`Highlighters/Mask "WithPadding": Highlighters/Mask-WithPadding 1`] = `"
"`; +exports[`Highlighters/Mask "WithPadding": Highlighters/Mask-WithPadding 1`] = `"
"`; -exports[`Highlighters/Mask "WithSVGProps": Highlighters/Mask-WithSVGProps 1`] = `"
"`; +exports[`Highlighters/Mask "WithSVGProps": Highlighters/Mask-WithSVGProps 1`] = `"
"`; diff --git a/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/opacity.test.tsx.snap b/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/opacity.test.tsx.snap index 20c148b7f8..bd039ab1fc 100644 --- a/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/opacity.test.tsx.snap +++ b/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/opacity.test.tsx.snap @@ -1,3 +1,3 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`Highlighters/Opacity "Default": Highlighters/Opacity-Default 1`] = `"
"`; +exports[`Highlighters/Opacity "Default": Highlighters/Opacity-Default 1`] = `"
"`; diff --git a/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/store.test.tsx.snap b/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/store.test.tsx.snap index 44c5882544..87822f46bf 100644 --- a/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/store.test.tsx.snap +++ b/packages/joint-react/src/components/highlighters/__tests__/__snapshots__/store.test.tsx.snap @@ -1,3 +1,3 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`Highlighters/Stroke "Default": Highlighters/Stroke-Default 1`] = `"
"`; +exports[`Highlighters/Stroke "Default": Highlighters/Stroke-Default 1`] = `"
"`; diff --git a/packages/joint-react/src/components/paper/paper.stories.tsx b/packages/joint-react/src/components/paper/paper.stories.tsx index fb8989c120..750f041eef 100644 --- a/packages/joint-react/src/components/paper/paper.stories.tsx +++ b/packages/joint-react/src/components/paper/paper.stories.tsx @@ -1,9 +1,10 @@ +/* eslint-disable sonarjs/no-identical-functions */ /* eslint-disable react-hooks/rules-of-hooks */ /* eslint-disable react-perf/jsx-no-new-array-as-prop */ /* eslint-disable react-perf/jsx-no-new-function-as-prop */ /* eslint-disable react-perf/jsx-no-new-object-as-prop */ -import React from 'react'; +import React, { useRef } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; import { SimpleGraphDecorator, @@ -359,6 +360,7 @@ export const WithOnClickColorChange: Story = { }; return ( { + const renderElement: RenderElement = ({ hoverColor }) => { + const ref = useRef(null); + useNodeSize(ref, { + transform: ({ x, y, width, height, id }) => { + if (id === '1') { + return { + width, + height, + x: x + 200, + y: y + 200, + }; + } + return { + width, + height, + }; + }, + }); + return ( + <> +
+ ; + + ); + }; + return ( + + + + ); + }, +}; diff --git a/packages/joint-react/src/components/paper/paper.tsx b/packages/joint-react/src/components/paper/paper.tsx index 7bfc3cef5f..a1bd1e7cbe 100644 --- a/packages/joint-react/src/components/paper/paper.tsx +++ b/packages/joint-react/src/components/paper/paper.tsx @@ -326,11 +326,11 @@ function PaperBase( const paperContainerStyle = useMemo( (): CSSProperties => ({ - opacity: 1, + opacity: areElementsMeasured ? 1 : 0, position: 'relative', ...defaultStyle, }), - [defaultStyle] + [areElementsMeasured, defaultStyle] ); return ( diff --git a/packages/joint-react/src/components/paper/render-element/paper-element-item.tsx b/packages/joint-react/src/components/paper/render-element/paper-element-item.tsx index 18198a9eec..337285d976 100644 --- a/packages/joint-react/src/components/paper/render-element/paper-element-item.tsx +++ b/packages/joint-react/src/components/paper/render-element/paper-element-item.tsx @@ -27,7 +27,6 @@ function SVGElementItemComponent( } const element = renderElement(cell); - return createPortal(element, portalElement); } diff --git a/packages/joint-react/src/components/port/__tests__/__snapshots__/port-group.test.tsx.snap b/packages/joint-react/src/components/port/__tests__/__snapshots__/port-group.test.tsx.snap index fb0e8241a4..d83bac351c 100644 --- a/packages/joint-react/src/components/port/__tests__/__snapshots__/port-group.test.tsx.snap +++ b/packages/joint-react/src/components/port/__tests__/__snapshots__/port-group.test.tsx.snap @@ -1,3 +1,3 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`Port/Item "Default": Port/Item-Default 1`] = `"
"`; +exports[`Port/Item "Default": Port/Item-Default 1`] = `"
"`; diff --git a/packages/joint-react/src/components/port/__tests__/__snapshots__/port-item.test.tsx.snap b/packages/joint-react/src/components/port/__tests__/__snapshots__/port-item.test.tsx.snap index fb0e8241a4..d83bac351c 100644 --- a/packages/joint-react/src/components/port/__tests__/__snapshots__/port-item.test.tsx.snap +++ b/packages/joint-react/src/components/port/__tests__/__snapshots__/port-item.test.tsx.snap @@ -1,3 +1,3 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`Port/Item "Default": Port/Item-Default 1`] = `"
"`; +exports[`Port/Item "Default": Port/Item-Default 1`] = `"
"`; diff --git a/packages/joint-react/src/components/text-node/__tests__/__snapshots__/text-node.test.tsx.snap b/packages/joint-react/src/components/text-node/__tests__/__snapshots__/text-node.test.tsx.snap index 8bf367b2b3..3d26f02b5e 100644 --- a/packages/joint-react/src/components/text-node/__tests__/__snapshots__/text-node.test.tsx.snap +++ b/packages/joint-react/src/components/text-node/__tests__/__snapshots__/text-node.test.tsx.snap @@ -1,9 +1,9 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`TextNode "Default": TextNode-Default 1`] = `"
"`; +exports[`TextNode "Default": TextNode-Default 1`] = `"
"`; -exports[`TextNode "WithBreakText": TextNode-WithBreakText 1`] = `"
"`; +exports[`TextNode "WithBreakText": TextNode-WithBreakText 1`] = `"
"`; -exports[`TextNode "WithBreakTextWithoutDefinedWith": TextNode-WithBreakTextWithoutDefinedWith 1`] = `"
"`; +exports[`TextNode "WithBreakTextWithoutDefinedWith": TextNode-WithBreakTextWithoutDefinedWith 1`] = `"
"`; -exports[`TextNode "WithEllipsis": TextNode-WithEllipsis 1`] = `"
"`; +exports[`TextNode "WithEllipsis": TextNode-WithEllipsis 1`] = `"
"`; diff --git a/packages/joint-react/src/index.ts b/packages/joint-react/src/index.ts index 56922ef1a2..27e758321c 100644 --- a/packages/joint-react/src/index.ts +++ b/packages/joint-react/src/index.ts @@ -6,13 +6,7 @@ export * from './components'; export * from './hooks'; export * from './utils/create'; -export { - mapElementFromGraph as elementFromGraph, - mapLinkFromGraph as linkFromGraph, - mapLinkToGraph as linkToGraph, - syncGraph, - type CellOrJsonCell, -} from './utils/cell/cell-utilities'; +export * from './utils/cell/cell-utilities'; export * from './utils/joint-jsx/jsx-to-markup'; export * from './utils/link-utilities'; export * from './utils/object-utilities'; diff --git a/packages/joint-react/src/state/graph-state-selectors.ts b/packages/joint-react/src/state/graph-state-selectors.ts index 52044e83c2..ad3aa492b6 100644 --- a/packages/joint-react/src/state/graph-state-selectors.ts +++ b/packages/joint-react/src/state/graph-state-selectors.ts @@ -94,13 +94,17 @@ export function defaultElementToGraphSelector( ): dia.Cell.JSON { const { element } = options; const { type = REACT_TYPE, x, y, width, height } = element; - - return { + const model :dia.Cell.JSON= { type, - position: { x, y }, - size: { width, height }, ...element, - } as dia.Cell.JSON; + } + if (x !== undefined && y !== undefined) { + model.position = { x , y }; + } + if (width !== undefined && height !== undefined) { + model.size = { width, height }; + } + return model } /** diff --git a/packages/joint-react/src/store/create-elements-size-observer.ts b/packages/joint-react/src/store/create-elements-size-observer.ts index 1f7bd1befd..6234e1bc24 100644 --- a/packages/joint-react/src/store/create-elements-size-observer.ts +++ b/packages/joint-react/src/store/create-elements-size-observer.ts @@ -1,3 +1,4 @@ +/* eslint-disable sonarjs/cognitive-complexity */ import type { dia } from '@joint/core'; import type { GraphElement } from '../types/element-types'; import type { GraphStoreDerivedSnapshot, GraphStoreSnapshot } from './graph-store'; @@ -23,9 +24,10 @@ export interface TransformResult { /** * Options passed to the setSize callback when an element's size changes. */ -export interface TransformOptions extends TransformResult { +export interface TransformOptions extends Required { /** The JointJS element instance */ readonly element: dia.Element; + readonly id: dia.Cell.ID; } /** @@ -46,9 +48,11 @@ export interface SetMeasuredNodeOptions { readonly id: dia.Cell.ID; } -interface ElementItem { +interface ObservedElement { readonly element: HTMLElement | SVGElement; readonly transform?: OnTransformElement; + lastWidth?: number; + lastHeight?: number; } // eslint-disable-next-line jsdoc/require-jsdoc @@ -56,7 +60,7 @@ function defaultTransform(options: TransformOptions) { const { width, height, x, y } = options; return { width, height, x, y }; } -const DEFAULT_OBJECT: Partial = { +const DEFAULT_OBSERVED_ELEMENT: Partial = { transform: defaultTransform, }; /** @@ -99,6 +103,15 @@ export interface GraphStoreObserver { readonly has: (id: dia.Cell.ID) => boolean; } +/** + * Rounds a number to two decimals. + * @param value - The value to round to two decimals + * @returns The rounded value + */ +function roundToTwoDecimals(value: number) { + return Math.round(value * 100) / 100; +} + /** * Creates an observer for element size changes using the ResizeObserver API. * @@ -122,96 +135,126 @@ export function createElementsSizeObserver(options: Options): GraphStoreObserver onBatchUpdate, getPublicSnapshot, } = options; - const elements = new Map(); - const invertedIndex = new Map(); + const observedElementsByCellId = new Map(); + const cellIdByDomElement = new Map(); const observer = new ResizeObserver((entries) => { - // we can consider this as single batch of - let hasChange = false; + // Process all entries as a single batch + let hasAnySizeChange = false; const idsSnapshot = getIdsSnapshot(); const publicSnapshot = getPublicSnapshot(); - const newElements: GraphElement[] = [...publicSnapshot.elements] as GraphElement[]; + const updatedElements: GraphElement[] = [...publicSnapshot.elements] as GraphElement[]; + for (const entry of entries) { // We must be careful to not mutate the snapshot data. const { target, borderBoxSize } = entry; - const id = invertedIndex.get(target as HTMLElement | SVGElement); - if (!id) { - throw new Error(`Element with id ${id} not found in resize observer`); + const cellId = cellIdByDomElement.get(target as HTMLElement | SVGElement); + if (!cellId) { + throw new Error(`DOM element not found in resize observer`); } // If borderBoxSize is not available or empty, continue to the next entry. - if (!borderBoxSize || borderBoxSize.length === 0) continue; + if (!borderBoxSize || borderBoxSize.length === 0) { + continue; + } const [size] = borderBoxSize; const { inlineSize, blockSize } = size; - const width = inlineSize; - const height = blockSize; - const cellTransform = getCellTransform(id); - // Here we compare the actual size with the border box size - const isChanged = - Math.abs(cellTransform.width - width) > EPSILON || - Math.abs(cellTransform.height - height) > EPSILON; + const measuredWidth = roundToTwoDecimals(inlineSize); + const measuredHeight = roundToTwoDecimals(blockSize); + const currentCellTransform = getCellTransform(cellId); - if (!isChanged) { - return; + // Compare the measured size with the current cell size using epsilon to avoid jitter + const hasSizeChanged = + Math.abs(currentCellTransform.width - measuredWidth) > EPSILON || + Math.abs(currentCellTransform.height - measuredHeight) > EPSILON; + + if (!hasSizeChanged) { + continue; } - // we observe just width and height, not x and y - if (cellTransform.width === width && cellTransform.height === height) { - return; + + // We observe just width and height, not x and y + if ( + currentCellTransform.width === measuredWidth && + currentCellTransform.height === measuredHeight + ) { + continue; } - const elementIndex = idsSnapshot.elementIds[id]; - if (elementIndex == undefined) { - throw new Error(`Element with id ${id} not found in graph data ref`); + const elementArrayIndex = idsSnapshot.elementIds[cellId]; + if (elementArrayIndex == undefined) { + throw new Error(`Element with id ${cellId} not found in graph data ref`); } - const element = newElements[elementIndex]; - const { transform = defaultTransform } = elements.get(id) ?? DEFAULT_OBJECT; - if (!element) { - throw new Error(`Element with id ${id} not found in graph data ref`); + + const graphElement = updatedElements[elementArrayIndex]; + const observedElement = observedElementsByCellId.get(cellId) ?? DEFAULT_OBSERVED_ELEMENT; + const { + transform: sizeTransformFunction = defaultTransform, + } = observedElement; + + const lastWidth = roundToTwoDecimals(observedElement.lastWidth ?? 0); + const lastHeight = roundToTwoDecimals(observedElement.lastHeight ?? 0); + + // Check if the change is significant compared to the last observed size + const widthDifference = Math.abs(lastWidth - measuredWidth); + const heightDifference = Math.abs(lastHeight - measuredHeight); + if (widthDifference <= EPSILON && heightDifference <= EPSILON) { + continue; + } + + // Update cached size values + observedElement.lastWidth = measuredWidth; + observedElement.lastHeight = measuredHeight; + + if (!graphElement) { + throw new Error(`Element with id ${cellId} not found in graph data ref`); } - const { x, y, element: cell } = cellTransform; - newElements[elementIndex] = { - ...element, - ...transform({ - x, - y, + + const { x, y, element: cell } = currentCellTransform; + updatedElements[elementArrayIndex] = { + ...graphElement, + ...sizeTransformFunction({ + x: x ?? 0, + y: y ?? 0, element: cell, - width, - height, + width: measuredWidth, + height: measuredHeight, + id: cellId, }), }; - hasChange = true; + + hasAnySizeChange = true; } - if (!hasChange) { + if (!hasAnySizeChange) { return; } - onBatchUpdate(newElements); + onBatchUpdate(updatedElements); }); return { add({ id, element, transform }: SetMeasuredNodeOptions) { observer.observe(element, resizeObserverOptions); - elements.set(id, { element, transform }); - invertedIndex.set(element, id); + observedElementsByCellId.set(id, { element, transform }); + cellIdByDomElement.set(element, id); return () => { observer.unobserve(element); - elements.delete(id); - invertedIndex.delete(element); + observedElementsByCellId.delete(id); + cellIdByDomElement.delete(element); }; }, clean() { - for (const [, { element }] of elements.entries()) { + for (const [, { element }] of observedElementsByCellId.entries()) { observer.unobserve(element); } - elements.clear(); - invertedIndex.clear(); + observedElementsByCellId.clear(); + cellIdByDomElement.clear(); observer.disconnect(); }, has(id: dia.Cell.ID) { - return elements.has(id); + return observedElementsByCellId.has(id); }, }; } diff --git a/packages/joint-react/src/stories/demos/flowchart/code.tsx b/packages/joint-react/src/stories/demos/flowchart/code.tsx index f7de49e82c..a42fc7a216 100644 --- a/packages/joint-react/src/stories/demos/flowchart/code.tsx +++ b/packages/joint-react/src/stories/demos/flowchart/code.tsx @@ -3,7 +3,7 @@ /* eslint-disable react-perf/jsx-no-new-function-as-prop */ /* eslint-disable react-perf/jsx-no-new-object-as-prop */ import './index.css'; -import type { GraphLink, OnTransformElement } from '@joint/react'; +import type { GraphLink, TransformOptions } from '@joint/react'; import { createElements, createLinks, @@ -193,34 +193,36 @@ interface PropsWithClick { } type FlowchartNodeProps = InferElement & PropsWithClick; +function transform(options: TransformOptions & { padding: number; cx: number; cy: number }) { + const { width: nodeWidth, height: nodeHeight, padding, cx, cy } = options; + const modelWidth = nodeWidth + 2 * padding; + const modelHeight = nodeHeight + 2 * padding; + return { + width: modelWidth, + height: modelHeight, + x: cx - modelWidth / 2, + y: cy - modelHeight / 2, + }; +} function DecisionNodeRaw( { label, cx, cy, onMouseEnter, onMouseLeave }: FlowchartNodeProps, ref: React.ForwardedRef ) { // If we define custom size, not defined in initial nodes, we have to use measure node - - const transform: OnTransformElement = ({ width, height }) => { - const dimension = Math.max(width, height) + 2 * padding; - return { - width: dimension, - height: dimension, - x: cx, - y: cy, - }; - }; + const padding = 30; const textRef = useRef(null); - const { width } = useNodeSize(textRef, { transform }); - const size = width; - const half = size / 2; - const padding = 20; + const { width, height } = useNodeSize(textRef, { + transform: (options) => transform({ ...options, padding, cx, cy }), + }); + return ( <> { - return { - width: width + 2 * padding, - height: height + 2 * padding, - x: cx, - y: cy, - }; - }; - const textRef = useRef(null); - const { width, height } = useNodeSize(textRef, { transform }); + const { width, height } = useNodeSize(textRef, { + transform: (options) => transform({ ...options, padding, cx, cy }), + }); return ( <> diff --git a/packages/joint-react/src/stories/examples/with-card/code.tsx b/packages/joint-react/src/stories/examples/with-card/code.tsx index 02ba2364e3..5481726499 100644 --- a/packages/joint-react/src/stories/examples/with-card/code.tsx +++ b/packages/joint-react/src/stories/examples/with-card/code.tsx @@ -1,5 +1,7 @@ +/* eslint-disable react-perf/jsx-no-new-object-as-prop */ import '../index.css'; import { useCallback, useRef } from 'react'; +import type { OnTransformElement } from '@joint/react'; import { createElements, createLinks, @@ -30,34 +32,53 @@ const initialEdges = createLinks([ type BaseElementWithData = InferElement; -function Card() { - const frameRef = useRef(null); - const { width, height } = useNodeSize(frameRef); +function Card({ label }: Readonly>) { + const contentRef = useRef(null); const gap = 10; - // avoid negative width and height - const imageWidth = Math.max(width - gap * 2, 0); - const imageHeight = Math.max(height - gap * 2, 0); + const imageWidth = 50; + const transformSize: OnTransformElement = useCallback( + ({ width: measuredWidth, height: measuredHeight }) => { + return { + width: gap + imageWidth + gap + measuredWidth + gap, + height: gap + Math.max(measuredHeight, imageWidth) + gap, + }; + }, + [] + ); + const { width, height } = useNodeSize(contentRef, { + transform: transformSize, + }); + + const imageHeight = height - 2 * gap; const iconURL = `https://placehold.co/${imageWidth}x${imageHeight}`; - const frameWidth = 80; - const frameHeight = 120; + const foWidth = width - 2 * gap - imageWidth - gap; + const foHeight = height - 2 * gap; return ( <> - + + +
+ {label} +
+
); } + function Main() { - const renderElement: RenderElement = useCallback(() => { - return ; + const renderElement: RenderElement = useCallback((data) => { + return ; }, []); return ( diff --git a/packages/joint-react/src/utils/cell/cell-utilities.ts b/packages/joint-react/src/utils/cell/cell-utilities.ts index f38ae375f3..2d4faaf6e7 100644 --- a/packages/joint-react/src/utils/cell/cell-utilities.ts +++ b/packages/joint-react/src/utils/cell/cell-utilities.ts @@ -66,10 +66,6 @@ export function mapElementToGraph(element: T): CellOrJso } as dia.Cell.JSON; } -export interface Ports { - readonly groups?: Record; - readonly items?: dia.Element.Port[]; -} export type GraphCell = Element | GraphLink; From ee2c6d35ff931198cce53092f6eb15bf60f78cc5 Mon Sep 17 00:00:00 2001 From: samuelgja Date: Fri, 16 Jan 2026 13:28:43 +0700 Subject: [PATCH 18/24] feat(joint-react): enhance App component with detailed documentation and improved structure - Added comprehensive documentation comments throughout the App component to clarify functionality and usage of key features. - Organized code into distinct sections for better readability, including constants, type definitions, and component descriptions. - Improved element rendering logic and integrated the useNodeSize hook for dynamic sizing of message elements. - Enhanced the toolbar component with clear action descriptions and improved button functionality for graph manipulation. --- examples/joint-react/src/App.tsx | 352 ++++++++++++++---- .../src/components/paper/paper.stories.tsx | 8 +- .../src/components/paper/paper.tsx | 4 +- .../src/components/paper/paper.types.ts | 4 + .../render-element/paper-element-item.tsx | 1 + .../src/components/port/port-item.tsx | 2 +- .../src/hooks/use-graph-store-selector.ts | 10 + .../joint-react/src/models/react-element.tsx | 1 - .../src/state/__tests__/state-sync.test.ts | 10 +- .../src/state/graph-state-selectors.ts | 16 +- packages/joint-react/src/state/state-sync.ts | 179 ++++++--- .../src/store/__tests__/graph-store.test.ts | 40 +- packages/joint-react/src/store/graph-store.ts | 150 ++++++-- .../src/utils/cell/cell-utilities.ts | 23 -- 14 files changed, 598 insertions(+), 202 deletions(-) diff --git a/examples/joint-react/src/App.tsx b/examples/joint-react/src/App.tsx index 31a163b345..8c4bdd2ed0 100644 --- a/examples/joint-react/src/App.tsx +++ b/examples/joint-react/src/App.tsx @@ -1,3 +1,16 @@ +/** + * Joint React Demo Application + * + * This demo showcases the key features of @joint/react: + * - Custom element rendering with HTML content + * - Dynamic node sizing using useNodeSize hook + * - Interactive selection and highlighting + * - Port-based connections + * - Minimap navigation + * - Link tools and interactions + * - Graph manipulation (duplicate, delete, zoom to fit) + */ + import { dia, linkTools, shapes } from '@joint/core'; import './index.css'; import { @@ -5,31 +18,55 @@ import { createLinks, GraphProvider, Highlighter, - MeasuredNode, Paper, Port, useCellId, useGraph, usePaper, useCellActions, + useNodeSize, type GraphElement, type PaperProps, type RenderElement, } from '@joint/react'; -import { useCallback, useState } from 'react'; +import React, { useCallback, useRef, useState } from 'react'; + +// ============================================================================ +// Constants +// ============================================================================ +/** CSS class name for Paper components (main view and minimap) */ const PAPER_CLASSNAME = 'border-1 border-gray-300 rounded-lg shadow-md overflow-hidden p-2 mr-2'; + +/** Light color used for links and highlights */ const LIGHT = '#DDE6ED'; -// Define the class name for the paper + +/** CSS class name for toolbar buttons */ const BUTTON_CLASSNAME = 'bg-blue-500 cursor-pointer hover:bg-blue-700 text-white font-bold py-2 px-4 rounded text-sm flex items-center'; -// Define types for the elements +/** Height of each table row for port positioning */ +const ROW_HEIGHT = 45; + +/** Starting Y position for table row ports */ +const ROW_START = 65; + +// ============================================================================ +// Type Definitions +// ============================================================================ + +/** + * Base interface for all graph elements in this demo. + * Extends GraphElement with a discriminator field for element type. + */ interface ElementBase extends GraphElement { readonly elementType: 'alert' | 'info' | 'table'; } +/** + * Message element type - displays alert or info messages with editable text. + */ interface MessageElement extends ElementBase { readonly elementType: 'alert' | 'info'; readonly title: string; @@ -37,17 +74,34 @@ interface MessageElement extends ElementBase { readonly inputText: string; } +/** + * Table element type - displays tabular data with ports for each row. + */ interface TableElement extends ElementBase { readonly elementType: 'table'; readonly columnNames: string[]; readonly rows: string[][]; } +/** Union type of all element types in this demo */ type Element = MessageElement | TableElement; +/** Helper type that adds selection state to an element */ type ElementWithSelected = { readonly isSelected: boolean } & T; -// Define static properties for the paper - used by minimap and main paper +// ============================================================================ +// Paper Configuration +// ============================================================================ + +/** + * Shared Paper configuration used by both the main view and minimap. + * + * Key features: + * - Right-angle router for links (creates L-shaped connections) + * - Link snapping for easier connection creation + * - Link tools appear on hover + * - Magnet validation to prevent connections to passive magnets + */ const PAPER_PROPS: PaperProps = { defaultRouter: { name: 'rightAngle', @@ -61,15 +115,26 @@ const PAPER_PROPS: PaperProps = { }, snapLinks: { radius: 25 }, validateMagnet: (_cellView, magnet) => { + // Only allow connections to active magnets (not passive ones) return magnet.getAttribute('magnet') !== 'passive'; }, sorting: dia.Paper.sorting.APPROX, linkPinning: false, + // Show link tools when hovering over links onLinkMouseEnter: ({ linkView }) => linkView.addTools(toolsView), onLinkMouseLeave: ({ linkView }) => linkView.removeTools(), }; -// Create initial elements and links with typing support +// ============================================================================ +// Initial Graph Data +// ============================================================================ + +/** + * Initial elements in the graph. + * + * Note: Message elements don't specify width/height - they use useNodeSize + * to automatically measure their content size. + */ const elements = createElements([ { id: '1', @@ -102,7 +167,6 @@ const elements = createElements([ ['Row 4', 'Row 5', 'Row 6'], ['Row 7', 'Row 8', 'Row 9'], ], - inputText: '', width: 400, height: 200, attrs: { @@ -113,39 +177,66 @@ const elements = createElements([ }, ]); -// Create initial links from table element port to another element +/** + * Initial links connecting elements in the graph. + * + * This link connects from a port on the table element (row 0) to the alert element. + * Ports are created dynamically based on table rows. + */ const links = createLinks([ { id: 'link2', - source: { id: '3', port: 'out-3-0' }, // Port from table element + source: { id: '3', port: 'out-3-0' }, // Port from table element, row 0 target: { id: '1' }, attrs: { line: { stroke: LIGHT, class: 'link', strokeWidth: 2, - strokeDasharray: '5,5', + strokeDasharray: '5,5', // Dashed line style targetMarker: { - d: 'M 0 0 L 8 4 L 8 -4 Z', // Larger arrowhead + d: 'M 0 0 L 8 4 L 8 -4 Z', // Custom arrowhead }, }, }, }, ]); -// Define the message component +// ============================================================================ +// Element Components +// ============================================================================ + +/** + * Message Component - Renders alert or info message elements. + * + * Features: + * - Uses useNodeSize hook to automatically measure and update element size + * - Editable text input that updates the graph element + * - Visual highlighting when selected + * - Different styling for alert vs info types + * + * @param props - Message element properties with selection state + */ function MessageComponent({ elementType, title, description, inputText, - width, - height, isSelected, id, }: ElementWithSelected) { - let iconContent; - let titleText; + // Create a ref to the DOM element we want to measure + const contentRef = useRef(null); + + + // useNodeSize automatically measures the contentRef element and updates the graph element size + // It returns the current element dimensions (which may differ from measured if transform is used) + const { width, height } = useNodeSize(contentRef); + + // Determine icon and title styling based on element type + let iconContent: React.ReactNode; + let titleText: React.ReactNode; + switch (elementType) { case 'alert': { iconContent = ( @@ -160,8 +251,10 @@ function MessageComponent({ break; } } + + // Get cell actions to update the element const { set } = useCellActions(); - // const setMessage = useUpdateElement(id, 'inputText'); + return ( - -
-
-
-
{iconContent}
-
- {titleText} -
{description}
-
+
+
+
+
{iconContent}
+
+ {titleText} +
{description}
- {/* Divider */} -
- { - set(id, (previous: MessageElement) => ({ ...previous, inputText: value })); - }} - />
+ {/* Divider */} +
+ { + // Update the element's inputText property using the set action + set(id, (previous: MessageElement) => ({ + ...previous, + inputText: value, + })); + }} + />
- +
); } -const ROW_HEIGHT = 45; -const ROW_START = 65; -// Define the table element +/** + * Table Element Component - Renders a table with ports for each row. + * + * Features: + * - Fixed size (width/height specified in element data) + * - Dynamic ports created for each table row + * - Visual highlighting when selected + * - Ports positioned to align with table rows + * + * @param props - Table element properties with selection state + */ function TableElement({ columnNames, rows, @@ -211,9 +317,12 @@ function TableElement({ height, isSelected, }: ElementWithSelected) { + // Get the current cell ID to create unique port IDs const cellId = useCellId(); + return ( <> + {/* Selection highlight - appears when element is selected */} + + {/* Port Group - Creates connection points on the right side */} {rows.map((_, index) => ( @@ -274,19 +388,40 @@ function TableElement({ ); } -// Minimap component +// ============================================================================ +// Minimap Component +// ============================================================================ + +/** + * Minimap Component - Provides an overview of the entire graph. + * + * Features: + * - Simplified element rendering (just rectangles) + * - Non-interactive (view-only) + * - Automatically fits content when rendered + * - Positioned in bottom-right corner + */ function MiniMap() { + // Simple render function for minimap - just shows rectangles const renderElement: RenderElement = useCallback( ({ width, height }) => ( ), - [], + [] ); - // On change, the minimap will be resized to fit the content automatically - const onElementReady = useCallback(({ paper }: { paper: dia.Paper }) => { + + // Fit content to view when minimap is ready + // This ensures all elements are visible in the minimap viewport + const onElementsSizeReady = useCallback(({ paper }: { paper: dia.Paper }) => { const { model: graph } = paper; + const contentArea = graph.getCellsBBox(graph.getElements()); + + if (!contentArea) { + return; + } + paper.transformToFitContent({ - contentArea: graph.getCellsBBox(graph.getElements()) ?? undefined, + contentArea, verticalAlign: 'middle', horizontalAlign: 'middle', padding: 20, @@ -302,38 +437,61 @@ function MiniMap() { className={PAPER_CLASSNAME} height={'100%'} renderElement={renderElement} - onElementsSizeReady={onElementReady} - onRenderDone={onElementReady} + onElementsSizeReady={onElementsSizeReady} />
); } -// Define the remove tool for the link +// ============================================================================ +// Link Tools Configuration +// ============================================================================ + +/** + * Link tools that appear when hovering over links. + * + * Currently includes: + * - Remove tool: Allows deleting links by clicking the tool + */ const removeTool = new linkTools.Remove({ scale: 1.5, style: { stroke: '#999' }, }); -// Define the tools view for the link - so we can remove the link when hovered const toolsView = new dia.ToolsView({ tools: [removeTool], }); +// ============================================================================ +// Toolbar Component +// ============================================================================ + interface ToolbarProps { - readonly onToggleMinimap: (visible: boolean) => void; - readonly isMinimapVisible: boolean; - readonly selectedId: dia.Cell.ID | null; - readonly setSelectedId: (id: dia.Cell.ID | null) => void; + readonly onToggleMinimap: (visible: boolean) => void; + readonly isMinimapVisible: boolean; + readonly selectedId: dia.Cell.ID | null; + readonly setSelectedId: (id: dia.Cell.ID | null) => void; } -// Toolbar component with some actions + +/** + * Toolbar Component - Provides graph manipulation actions. + * + * Actions: + * - Toggle minimap visibility + * - Duplicate selected element + * - Remove selected element + * - Zoom to fit all content + */ function ToolBar(props: ToolbarProps) { - const { onToggleMinimap, isMinimapVisible, selectedId, setSelectedId } = - props; + const { onToggleMinimap, isMinimapVisible, selectedId, setSelectedId } = props; + + // Get graph and paper instances for direct manipulation const graph = useGraph(); const paper = usePaper(); + return ( -
+
+ {/* Toggle Minimap Button */} + + {/* Duplicate Button - Only enabled when an element is selected */} + + {/* Remove Button - Only enabled when an element is selected */} + + {/* Zoom to Fit Button - Always enabled */} +
+
+ ); +} + +export default function App() { + const [elements, setElements] = useState(initialNodes); + const [links, setLinks] = useState(initialEdges); + + return ( + +
+ + ); +} diff --git a/packages/joint-react/src/stories/examples/with-json/story.tsx b/packages/joint-react/src/stories/examples/stress/story.tsx similarity index 93% rename from packages/joint-react/src/stories/examples/with-json/story.tsx rename to packages/joint-react/src/stories/examples/stress/story.tsx index 78f6268266..6e16468f23 100644 --- a/packages/joint-react/src/stories/examples/with-json/story.tsx +++ b/packages/joint-react/src/stories/examples/stress/story.tsx @@ -4,10 +4,11 @@ import Code from './code'; import { makeRootDocumentation } from '../../utils/make-story'; import CodeRaw from './code?raw'; + export type Story = StoryObj; export default { - title: 'Examples/Json', + title: 'Examples/Stress', component: Code, tags: ['example'], parameters: makeRootDocumentation({ diff --git a/packages/joint-react/src/stories/examples/with-auto-layout/code-with-build-in-shapes.tsx b/packages/joint-react/src/stories/examples/with-auto-layout/code-with-build-in-shapes.tsx deleted file mode 100644 index a2ce3ef05d..0000000000 --- a/packages/joint-react/src/stories/examples/with-auto-layout/code-with-build-in-shapes.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import '../index.css'; -import { - createElements, - GraphProvider, - Paper, - useNodeSize, - type InferElement, - type OnLoadOptions, - type RenderElement, -} from '@joint/react'; -import { useCallback, useRef } from 'react'; -import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; -const shape = { - type: 'standard.Rectangle', - width: 100, - height: 50, - attrs: { - body: { - fill: PRIMARY, - }, - label: { - text: 'Rectangle1', - fill: 'white', - }, - }, -} as const; - -const initialElements = createElements([ - { - id: '1', - label: 'Node 1', - ...shape, - }, - { - id: '2', - label: 'Node 2', - ...shape, - }, - { - id: '3', - label: 'Node 1', - ...shape, - }, - { - id: '4', - label: 'Node 2', - ...shape, - }, - { - id: '5', - label: 'Node 1', - ...shape, - }, - { - id: '6', - label: 'Node 2', - ...shape, - }, - { - id: '7', - label: 'Node 1', - ...shape, - }, - { - id: '8', - label: 'Node 2', - ...shape, - }, - { - id: '9', - label: 'Node 2', - ...shape, - }, -]); - -type BaseElementWithData = InferElement; - -function RenderedRect({ width, height, label }: BaseElementWithData) { - const elementRef = useRef(null); - useNodeSize(elementRef); - return ( - -
- {label} -
-
- ); -} - -const PAPER_WIDTH = 400; -function Main() { - const renderElement: RenderElement = useCallback( - (props) => , - [] - ); - - // eslint-disable-next-line unicorn/consistent-function-scoping - function makeLayout({ graph }: OnLoadOptions) { - const gap = 20; - let currentX = 0; - let currentY = 0; - const elements = graph.getElements(); - for (const element of elements) { - const { width, height } = element.size(); - if (currentX + width > PAPER_WIDTH) { - currentX = 0; - currentY += height + gap; - } - element.position(currentX, currentY); - currentX += width + gap; - } - } - return ( - - ); -} - -export default function App() { - return ( - -
- - ); -} diff --git a/packages/joint-react/src/stories/examples/with-auto-layout/docs.mdx b/packages/joint-react/src/stories/examples/with-auto-layout/docs.mdx index 79118b87d0..ffd689f8ac 100644 --- a/packages/joint-react/src/stories/examples/with-auto-layout/docs.mdx +++ b/packages/joint-react/src/stories/examples/with-auto-layout/docs.mdx @@ -1,7 +1,6 @@ import { Meta, Story, Canvas, Controls, Markdown } from '@storybook/blocks'; import * as Stories from './story'; import Code from './code?raw'; -import CodeWithBuildInShapes from './code-with-build-in-shapes?raw'; import {getAPIPropsLink} from '../../utils/get-api-documentation-link' @@ -43,19 +42,7 @@ ${Code} - **Layout Algorithm**: Uses \`@joint/layout-directed-graph\` to position elements - **Async Layout**: Layout runs after React rendering completes -## Demo with Built-in Shapes -This variant shows how automatic layout works with JointJS built-in shapes: - - - -## Code with Built-in Shapes - - -{`\`\`\`tsx -${CodeWithBuildInShapes} -\`\`\``} - ## Best Practices diff --git a/packages/joint-react/src/stories/examples/with-auto-layout/story.tsx b/packages/joint-react/src/stories/examples/with-auto-layout/story.tsx index 27210b44f6..6afa609666 100644 --- a/packages/joint-react/src/stories/examples/with-auto-layout/story.tsx +++ b/packages/joint-react/src/stories/examples/with-auto-layout/story.tsx @@ -1,14 +1,11 @@ import type { Meta, StoryObj } from '@storybook/react'; import '../index.css'; import Code from './code'; -import CodeWithBuildInShapes from './code-with-build-in-shapes'; export type Story = StoryObj; import { makeRootDocumentation } from '../../utils/make-story'; import CodeRaw from './code?raw'; -import CodeWithBuildInShapesRaw from './code-with-build-in-shapes?raw'; - export default { title: 'Examples/Automatic layout', component: Code, @@ -19,9 +16,3 @@ export default { } satisfies Meta; export const Default: Story = {}; -export const WithBuildInShapes: Story = { - render: () => , - parameters: makeRootDocumentation({ - code: CodeWithBuildInShapesRaw, - }), -}; diff --git a/packages/joint-react/src/stories/examples/with-intersection/code.tsx b/packages/joint-react/src/stories/examples/with-intersection/code.tsx index eee78b7824..dde5cdd3ad 100644 --- a/packages/joint-react/src/stories/examples/with-intersection/code.tsx +++ b/packages/joint-react/src/stories/examples/with-intersection/code.tsx @@ -22,7 +22,7 @@ const initialElements = createElements([ type BaseElementWithData = InferElement; -function ResizableNode({ id, label, width, height }: Readonly) { +function ResizableNode({ id, label }: Readonly) { const nodeRef = useRef(null); const graph = useGraph(); const element = graph.getCell(id) as dia.Element; @@ -31,7 +31,7 @@ function ResizableNode({ id, label, width, height }: Readonly 0; }); - useNodeSize(nodeRef); + const { width, height } = useNodeSize(nodeRef); return ( diff --git a/packages/joint-react/src/stories/examples/with-json/code.tsx b/packages/joint-react/src/stories/examples/with-json/code.tsx deleted file mode 100644 index 5937da6679..0000000000 --- a/packages/joint-react/src/stories/examples/with-json/code.tsx +++ /dev/null @@ -1,71 +0,0 @@ -/* eslint-disable react-perf/jsx-no-new-object-as-prop */ -import '../index.css'; - -import { - createElements, - GraphProvider, - Paper, - useGraph, - useNodeSize, - type InferElement, - type RenderElement, -} from '@joint/react'; -import { useEffect, useRef } from 'react'; -import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const initialElements = createElements([ - { id: '1', label: 'hello', color: PRIMARY, x: 100, y: 10, width: 100, height: 50 }, -]); - -type BaseElementWithData = InferElement; - -function RenderElement(props: Readonly) { - const { width, height, label, color } = props; - const elementRef = useRef(null); - useNodeSize(elementRef); - return ( - -
- Example -
{label}
-
-
- ); -} -function Main() { - const graph = useGraph(); - useEffect(() => { - graph.fromJSON({ - cells: [ - { - id: 1, - type: 'standard.Rectangle', - position: { - x: 100, - y: 100, - }, - size: { - width: 100, - height: 100, - }, - }, - ], - }); - }, [graph]); - return ( - - ); -} - -export default function App() { - return ( - -
- - ); -} diff --git a/packages/joint-react/src/stories/examples/with-json/docs.mdx b/packages/joint-react/src/stories/examples/with-json/docs.mdx deleted file mode 100644 index 2c4419c981..0000000000 --- a/packages/joint-react/src/stories/examples/with-json/docs.mdx +++ /dev/null @@ -1,27 +0,0 @@ -import { Meta, Story, Canvas, Controls, Markdown } from '@storybook/blocks'; -import * as Stories from './story'; -import Code from './code?raw'; - - - -# Example: Rendering with JSON -This example demonstrates how to render a graph using JSON data. The graph structure, including its elements and connections, is defined in JSON format and then loaded into the `GraphProvider` component. - -### Demo -Below is a live demo of the graph rendered from JSON data: - - -### Code -The graph is created by parsing JSON data and rendering it within the `GraphProvider` component. This approach allows you to define and manage graph structures programmatically. - - -{`\`\`\`tsx -${Code} -\`\`\``} - - - - - - - diff --git a/packages/joint-react/src/stories/examples/with-node-update/code-add-remove-node.tsx b/packages/joint-react/src/stories/examples/with-node-update/code-add-remove-node.tsx index e65a8447ad..7cbf09479b 100644 --- a/packages/joint-react/src/stories/examples/with-node-update/code-add-remove-node.tsx +++ b/packages/joint-react/src/stories/examples/with-node-update/code-add-remove-node.tsx @@ -48,11 +48,11 @@ function ElementInput({ id, label }: BaseElementWithData) { ); } -function RenderElement({ label, width, height }: BaseElementWithData) { +function RenderElement({ label }: BaseElementWithData) { const graph = useGraph(); const id = useCellId(); const elementRef = useRef(null); - useNodeSize(elementRef); + const { width, height } = useNodeSize(elementRef); return (
diff --git a/packages/joint-react/src/stories/examples/with-node-update/code.tsx b/packages/joint-react/src/stories/examples/with-node-update/code.tsx index 8e6d7100d5..dd984e571a 100644 --- a/packages/joint-react/src/stories/examples/with-node-update/code.tsx +++ b/packages/joint-react/src/stories/examples/with-node-update/code.tsx @@ -45,9 +45,9 @@ function ElementInput({ id, label }: BaseElementWithData) { ); } -function RenderElement({ label, width, height }: BaseElementWithData) { +function RenderElement({ label }: BaseElementWithData) { const elementRef = useRef(null); - useNodeSize(elementRef); + const { width, height } = useNodeSize(elementRef); return (
diff --git a/packages/joint-react/src/stories/examples/with-proximity-link/code.tsx b/packages/joint-react/src/stories/examples/with-proximity-link/code.tsx index 5bcf69d357..24c96b935e 100644 --- a/packages/joint-react/src/stories/examples/with-proximity-link/code.tsx +++ b/packages/joint-react/src/stories/examples/with-proximity-link/code.tsx @@ -106,7 +106,7 @@ function createProximityLinks( } } -function ResizableNode({ id, label, width, height }: Readonly) { +function ResizableNode({ id, label }: Readonly) { const nodeRef = useRef(null); const managedLinksRef = useRef>(new Set()); @@ -147,7 +147,7 @@ function ResizableNode({ id, label, width, height }: Readonly
diff --git a/packages/joint-react/src/stories/examples/with-resizable-node/code.tsx b/packages/joint-react/src/stories/examples/with-resizable-node/code.tsx index b3632fc8a0..b0c193e858 100644 --- a/packages/joint-react/src/stories/examples/with-resizable-node/code.tsx +++ b/packages/joint-react/src/stories/examples/with-resizable-node/code.tsx @@ -32,7 +32,7 @@ const initialEdges = createLinks([ type BaseElementWithData = InferElement; -function ResizableNode({ width, height, label }: Readonly) { +function ResizableNode({ label }: Readonly) { const nodeRef = useRef(null); const handleMouseDown = useCallback((event: React.MouseEvent) => { const node = nodeRef.current; @@ -53,7 +53,7 @@ function ResizableNode({ width, height, label }: Readonly) } }, []); - useNodeSize(nodeRef); + const { width, height } = useNodeSize(nodeRef); return (
; -function RotatableNode({ label, id, width, height }: Readonly) { +function RotatableNode({ label, id }: Readonly) { const paper = usePaper(); const { set } = useCellActions(); @@ -77,7 +77,7 @@ function RotatableNode({ label, id, width, height }: Readonly(null); - useNodeSize(elementRef); + const { width, height } = useNodeSize(elementRef); return (
diff --git a/packages/joint-react/src/types/index.ts b/packages/joint-react/src/types/index.ts index e15ff0a27b..ae1885860a 100644 --- a/packages/joint-react/src/types/index.ts +++ b/packages/joint-react/src/types/index.ts @@ -5,3 +5,6 @@ export type RemoveIndexSignature = { export type OmitWithoutIndexSignature = Omit, K>; export * from './event.types'; + +// Ensure SVG type augmentations are loaded +import './svg'; diff --git a/packages/joint-react/src/types/svg.d.ts b/packages/joint-react/src/types/svg.d.ts new file mode 100644 index 0000000000..f8f9e830d6 --- /dev/null +++ b/packages/joint-react/src/types/svg.d.ts @@ -0,0 +1,18 @@ +import 'react'; + +/** + * Extends React's SVG types to include JointJS-specific attributes like 'magnet'. + * This allows TypeScript to recognize custom attributes used by JointJS. + */ +declare module 'react' { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface SVGProps { + /** + * JointJS-specific attribute that controls whether an element can be used as a connection point. + * - 'passive': Element cannot be used as a connection point + * - 'true': Element can be used as a connection point (default) + * - 'false': Element cannot be used as a connection point + */ + magnet?: 'passive'; + } +} diff --git a/packages/joint-react/src/utils/clear-view.ts b/packages/joint-react/src/utils/clear-view.ts new file mode 100644 index 0000000000..ad87161360 --- /dev/null +++ b/packages/joint-react/src/utils/clear-view.ts @@ -0,0 +1,45 @@ +import type { dia } from '@joint/core'; + +interface Options { + readonly graph: dia.Graph; + readonly paper: dia.Paper; + readonly cellId: dia.Cell.ID; + readonly onValidateLink?: (link: dia.Link) => boolean; +} +const DEFAULT_ON_VALIDATE_LINK = () => true; + +/** + * Clear the view of the cell and the links connected to it. + * @internal + * @group Utils + * @description + * This function is used to clear the view of the cell and the links connected to it. + * It is used to ensure that the view is recalculated and the links are updated. + * @param options - The options for the clear view. + */ +export function clearView(options: Options) { + const { graph, paper, cellId, onValidateLink = DEFAULT_ON_VALIDATE_LINK } = options; + const elementView = paper.findViewByModel(cellId); + elementView.cleanNodesCache(); + for (const link of graph.getConnectedLinks(elementView.model)) { + const target = link.target(); + const source = link.source(); + const isElementLink = target.id === cellId || source.id === cellId; + if (!isElementLink) { + continue; + } + + const isValid = onValidateLink(link); + if (!isValid) { + continue; + } + + const linkView = link.findView(paper); + // @ts-expect-error we use private jointjs api method, it throw error here. + linkView._sourceMagnet = null; + // @ts-expect-error we use private jointjs api method, it throw error here. + linkView._targetMagnet = null; + // @ts-expect-error we use private jointjs api method, it throw error here. + linkView.requestConnectionUpdate({ async: false }); + } +} diff --git a/packages/joint-react/src/utils/create-state.ts b/packages/joint-react/src/utils/create-state.ts index ce425c3016..0bc29d009e 100644 --- a/packages/joint-react/src/utils/create-state.ts +++ b/packages/joint-react/src/utils/create-state.ts @@ -1,4 +1,5 @@ import ReactDOM from 'react-dom'; +import { startTransition } from 'react'; import { sendToDevTool } from './dev-tools'; import { util } from '@joint/core'; import { isUpdater } from './is'; @@ -92,6 +93,12 @@ export interface State extends ExternalStoreLike { * Subscribers will be notified if the state actually changed. */ setState: (updater: Update) => void; + /** + * Updates the state with a new value or updater function wrapped in startTransition. + * Use this for non-urgent updates that can be deferred to keep the UI responsive. + * Subscribers will be notified if the state actually changed. + */ + setStateTransition: (updater: Update) => void; } /** * Options for creating a new state instance. @@ -153,6 +160,12 @@ export function createState(options: Options): State { notifySubscribers(); }, + setStateTransition: (updater: Update) => { + startTransition(() => { + state.setState(updater); + }); + }, + select: ( selectName: string, selector: (state: T) => S, diff --git a/packages/joint-react/src/utils/fast-equality.ts b/packages/joint-react/src/utils/fast-equality.ts new file mode 100644 index 0000000000..985fcbdab3 --- /dev/null +++ b/packages/joint-react/src/utils/fast-equality.ts @@ -0,0 +1,118 @@ +import { util } from '@joint/core'; +import type { GraphElement } from '../types/element-types'; + +/** + * Fast equality check for arrays of graph elements. + * Optimized for position-only updates by checking IDs first, then only comparing changed elements. + * @template T - The type of elements (must have an id property) + * @param a - First array to compare + * @param b - Second array to compare + * @param compareFunction - Optional deep comparison function. Defaults to util.isEqual + * @returns True if arrays are equal, false otherwise + */ +export function fastElementArrayEqual( + a: T[], + b: T[], + compareFunction: (a: T, b: T) => boolean = util.isEqual +): boolean { + if (a.length !== b.length) { + return false; + } + if (a === b) { + return true; + } + + // Fast path: check IDs first (O(n) instead of O(n²) deep comparison) + for (const [index, element] of a.entries()) { + if (element.id !== b[index].id) { + return false; + } + } + + // Only deep compare if IDs match (most common case: only positions changed) + for (const [index, element] of a.entries()) { + if (!compareFunction(element, b[index])) { + return false; + } + } + return true; +} + +/** + * Extracts all properties except x and y from an element. + * @param element - The element to extract properties from + * @returns Record of properties excluding x and y + */ +function extractNonPositionProperties(element: GraphElement): Record { + const rest: Record = {}; + for (const key in element) { + if (key !== 'x' && key !== 'y') { + rest[key] = element[key as keyof GraphElement]; + } + } + return rest; +} + +/** + * Checks if an update only changed element positions (x, y) without changing other properties. + * This is a fast path optimization for position-only updates. + * @param previous - Previous array of elements + * @param next - Next array of elements + * @returns True if only positions changed, false otherwise + */ +export function isPositionOnlyUpdate(previous: GraphElement[], next: GraphElement[]): boolean { + if (previous.length !== next.length) { + return false; + } + + for (const [index, previousElement] of previous.entries()) { + const nextElement = next[index]; + + // Check IDs match + if (previousElement.id !== nextElement.id) { + return false; + } + + // Check if position changed + const positionChanged = + previousElement.x !== nextElement.x || previousElement.y !== nextElement.y; + + if (!positionChanged) { + continue; + } + + // Position changed - check if other properties are unchanged + const previousRest = extractNonPositionProperties(previousElement); + const nextRest = extractNonPositionProperties(nextElement); + + // If other properties changed, this is not a position-only update + if (!util.isEqual(previousRest, nextRest)) { + return false; + } + } + + return true; +} + +/** + * Shallow equality check for arrays. + * Only compares array length and reference equality of elements. + * @template T - The type of array elements + * @param a - First array to compare + * @param b - Second array to compare + * @returns True if arrays are shallowly equal, false otherwise + */ +export function shallowArrayEqual(a: T[], b: T[]): boolean { + if (a.length !== b.length) { + return false; + } + if (a === b) { + return true; + } + for (const [index, element] of a.entries()) { + if (element !== b[index]) { + return false; + } + } + return true; +} From 351d9c1df642f1b26e8f20ae3f9a25b27e8570d3 Mon Sep 17 00:00:00 2001 From: samuelgja Date: Sat, 17 Jan 2026 21:07:42 +0700 Subject: [PATCH 21/24] feat(joint-react): introduce BaseLink and LinkLabel components for custom link rendering - Added BaseLink component to set link properties when rendering custom links, including support for custom markers. - Introduced LinkLabel component to render content at specific positions along links, utilizing React portals for dynamic positioning. - Implemented SimpleRenderLinkDecorator for Storybook to facilitate testing and demonstration of link rendering. - Updated related types and interfaces to support new link functionalities. - Enhanced documentation with examples for both BaseLink and LinkLabel components, showcasing their usage in custom link rendering. --- .../decorators/with-simple-data.tsx | 16 + .../decorators/with-strict-mode.tsx | 2 +- packages/joint-react/__mocks__/jest-setup.ts | 9 + packages/joint-react/src/components/index.ts | 1 + .../link/__tests__/base-link.test.tsx | 224 +++++++ .../link/__tests__/link-label.test.tsx | 511 ++++++++++++++++ .../src/components/link/base-link.stories.tsx | 326 ++++++++++ .../src/components/link/base-link.tsx | 169 ++++++ .../src/components/link/base-link.types.ts | 20 + .../joint-react/src/components/link/index.ts | 61 ++ .../components/link/link-label.stories.tsx | 101 ++++ .../src/components/link/link-label.tsx | 181 ++++++ .../src/components/link/link-label.types.ts | 53 ++ .../src/components/link/link.arrows.tsx | 121 ++++ .../graph-provider-controlled-mode.test.tsx | 78 +-- .../paper/__tests__/graph-provider.test.tsx | 183 +++++- .../components/paper/__tests__/paper.test.tsx | 15 +- .../src/components/paper/paper.stories.tsx | 1 + .../src/components/paper/paper.tsx | 117 +++- .../src/components/paper/paper.types.ts | 42 ++ .../src/components/port/port-group.tsx | 39 +- .../src/components/port/port-item.tsx | 18 +- .../joint-react/src/hooks/use-node-size.tsx | 2 +- .../src/hooks/use-state-to-external-store.ts | 6 - packages/joint-react/src/models/react-link.ts | 41 ++ .../__tests__/graph-state-selectors.test.ts | 104 ++-- .../src/state/graph-state-selectors.ts | 39 +- packages/joint-react/src/state/state-sync.ts | 132 ++-- .../src/store/__tests__/graph-store.test.ts | 48 +- .../store/create-elements-size-observer.ts | 5 +- packages/joint-react/src/store/graph-store.ts | 567 +++++++++++++++++- packages/joint-react/src/store/index.ts | 2 +- packages/joint-react/src/store/paper-store.ts | 164 ++++- .../src/stories/demos/flowchart/code.tsx | 48 +- .../src/stories/examples/stress/code.tsx | 39 +- .../examples/with-intersection/code.tsx | 2 +- .../examples/with-render-link/code.tsx | 76 +++ packages/joint-react/src/types/index.ts | 3 - packages/joint-react/src/types/link-types.ts | 2 +- .../src/utils/run-storybook-snapshot.tsx | 12 +- .../joint-react/src/utils/test-wrappers.tsx | 37 ++ 41 files changed, 3306 insertions(+), 311 deletions(-) create mode 100644 packages/joint-react/src/components/link/__tests__/base-link.test.tsx create mode 100644 packages/joint-react/src/components/link/__tests__/link-label.test.tsx create mode 100644 packages/joint-react/src/components/link/base-link.stories.tsx create mode 100644 packages/joint-react/src/components/link/base-link.tsx create mode 100644 packages/joint-react/src/components/link/base-link.types.ts create mode 100644 packages/joint-react/src/components/link/index.ts create mode 100644 packages/joint-react/src/components/link/link-label.stories.tsx create mode 100644 packages/joint-react/src/components/link/link-label.tsx create mode 100644 packages/joint-react/src/components/link/link-label.types.ts create mode 100644 packages/joint-react/src/components/link/link.arrows.tsx create mode 100644 packages/joint-react/src/models/react-link.ts create mode 100644 packages/joint-react/src/stories/examples/with-render-link/code.tsx diff --git a/packages/joint-react/.storybook/decorators/with-simple-data.tsx b/packages/joint-react/.storybook/decorators/with-simple-data.tsx index a215a891e6..bf87b947b0 100644 --- a/packages/joint-react/.storybook/decorators/with-simple-data.tsx +++ b/packages/joint-react/.storybook/decorators/with-simple-data.tsx @@ -76,6 +76,7 @@ export function SimpleGraphDecorator(Story: StoryFunction, { args }: StoryCtx) { export function RenderItemDecorator( properties: Readonly<{ renderElement: (element: SimpleElement) => JSX.Element; + renderLink?: (link: any) => JSX.Element; }> ) { return ( @@ -86,6 +87,7 @@ export function RenderItemDecorator( height={450} className={PAPER_CLASSNAME} renderElement={properties.renderElement} + renderLink={properties.renderLink} linkPinning={false} /> @@ -123,6 +125,20 @@ export function SimpleRenderItemDecorator(Story: StoryFunction, { args }: StoryC return ; } +export function SimpleRenderLinkDecorator(Story: StoryFunction, { args }: StoryCtx) { + const component = useCallback( + (element: SimpleElement) => , + [Story, args] + ); + return ( + {id}} + /> + ); +} + export function HTMLNode(props: PropsWithChildren>) { const elementRef = useRef(null); const { width, height } = useNodeSize(elementRef); diff --git a/packages/joint-react/.storybook/decorators/with-strict-mode.tsx b/packages/joint-react/.storybook/decorators/with-strict-mode.tsx index bacc3a843d..02068bd7fd 100644 --- a/packages/joint-react/.storybook/decorators/with-strict-mode.tsx +++ b/packages/joint-react/.storybook/decorators/with-strict-mode.tsx @@ -3,7 +3,7 @@ import React from 'react'; export function withStrictMode(Story: any) { return ( - // + // // ); diff --git a/packages/joint-react/__mocks__/jest-setup.ts b/packages/joint-react/__mocks__/jest-setup.ts index 2ede80602e..617f330597 100644 --- a/packages/joint-react/__mocks__/jest-setup.ts +++ b/packages/joint-react/__mocks__/jest-setup.ts @@ -83,6 +83,15 @@ beforeEach(() => { writable: true, value: jest.fn().mockImplementation(() => SVGRect), }); + + /** + * @description Mock checkVisibility method which is not implemented in JSDOM + * @see https://developer.mozilla.org/en-US/docs/Web/API/Element/checkVisibility + */ + Object.defineProperty(globalThis.Element.prototype, 'checkVisibility', { + writable: true, + value: jest.fn().mockImplementation(() => true), + }); }); // Interfaces diff --git a/packages/joint-react/src/components/index.ts b/packages/joint-react/src/components/index.ts index 193a235f8b..6c6a6c7924 100644 --- a/packages/joint-react/src/components/index.ts +++ b/packages/joint-react/src/components/index.ts @@ -2,4 +2,5 @@ export * from './graph/graph-provider'; export * from './paper'; export * from './highlighters'; export * from './port'; +export * from './link'; export * from './text-node/text-node'; diff --git a/packages/joint-react/src/components/link/__tests__/base-link.test.tsx b/packages/joint-react/src/components/link/__tests__/base-link.test.tsx new file mode 100644 index 0000000000..7c00501c9e --- /dev/null +++ b/packages/joint-react/src/components/link/__tests__/base-link.test.tsx @@ -0,0 +1,224 @@ +/* eslint-disable unicorn/consistent-function-scoping */ +/* eslint-disable react-perf/jsx-no-new-object-as-prop */ +import { render, waitFor } from '@testing-library/react'; +import { getTestGraph, paperRenderLinkWrapper } from '../../../utils/test-wrappers'; +import { dia } from '@joint/core'; +import { BaseLink } from '../base-link'; + +describe('BaseLink', () => { + const getTestWrapper = () => { + const graph = getTestGraph(); + return { + graph, + wrapper: paperRenderLinkWrapper({ + graphProviderProps: { + graph, + elements: [ + { + id: 'element-1', + x: 0, + y: 0, + width: 100, + height: 100, + }, + { + id: 'element-2', + x: 200, + y: 200, + width: 100, + height: 100, + }, + ], + links: [ + { + id: 'link-1', + source: 'element-1', + target: 'element-2', + }, + ], + }, + }), + }; + }; + + it('should set stroke attribute correctly', async () => { + const { graph, wrapper } = getTestWrapper(); + const { unmount } = render(, { wrapper }); + await waitFor(() => { + const link = graph.getCell('link-1'); + if (!link) { + throw new Error('Link not found in graph'); + } + expect(link).toBeInstanceOf(dia.Link); + const line = link.attr('line'); + expect(line?.stroke).toBe('blue'); + }); + unmount(); + }); + + it('should set strokeWidth attribute correctly', async () => { + const { graph, wrapper } = getTestWrapper(); + const { unmount } = render(, { wrapper }); + await waitFor(() => { + const link = graph.getCell('link-1'); + if (!link) { + throw new Error('Link not found in graph'); + } + const line = link.attr('line'); + expect(line?.strokeWidth).toBe(3); + }); + unmount(); + }); + + it('should set strokeDasharray attribute correctly', async () => { + const { graph, wrapper } = getTestWrapper(); + const { unmount } = render(, { wrapper }); + await waitFor(() => { + const link = graph.getCell('link-1'); + if (!link) { + throw new Error('Link not found in graph'); + } + const line = link.attr('line'); + expect(line?.strokeDasharray).toBe('5,5'); + }); + unmount(); + }); + + it('should set strokeDashoffset attribute correctly', async () => { + const { graph, wrapper } = getTestWrapper(); + const { unmount } = render(, { wrapper }); + await waitFor(() => { + const link = graph.getCell('link-1'); + if (!link) { + throw new Error('Link not found in graph'); + } + const line = link.attr('line'); + expect(line?.strokeDashoffset).toBe(10); + }); + unmount(); + }); + + it('should set strokeLinecap attribute correctly', async () => { + const { graph, wrapper } = getTestWrapper(); + const { unmount } = render(, { wrapper }); + await waitFor(() => { + const link = graph.getCell('link-1'); + if (!link) { + throw new Error('Link not found in graph'); + } + const line = link.attr('line'); + expect(line?.strokeLinecap).toBe('round'); + }); + unmount(); + }); + + it('should set strokeLinejoin attribute correctly', async () => { + const { graph, wrapper } = getTestWrapper(); + const { unmount } = render(, { wrapper }); + await waitFor(() => { + const link = graph.getCell('link-1'); + if (!link) { + throw new Error('Link not found in graph'); + } + const line = link.attr('line'); + expect(line?.strokeLinejoin).toBe('bevel'); + }); + unmount(); + }); + + it('should set fill attribute correctly', async () => { + const { graph, wrapper } = getTestWrapper(); + const { unmount } = render(, { wrapper }); + await waitFor(() => { + const link = graph.getCell('link-1'); + if (!link) { + throw new Error('Link not found in graph'); + } + const line = link.attr('line'); + expect(line?.fill).toBe('red'); + }); + unmount(); + }); + + it('should set opacity attribute correctly', async () => { + const { graph, wrapper } = getTestWrapper(); + const { unmount } = render(, { wrapper }); + await waitFor(() => { + const link = graph.getCell('link-1'); + if (!link) { + throw new Error('Link not found in graph'); + } + const line = link.attr('line'); + expect(line?.opacity).toBe(0.5); + }); + unmount(); + }); + + it('should set multiple attributes correctly', async () => { + const { graph, wrapper } = getTestWrapper(); + const { unmount } = render( + , + { wrapper } + ); + await waitFor(() => { + const link = graph.getCell('link-1'); + if (!link) { + throw new Error('Link not found in graph'); + } + const line = link.attr('line'); + expect(line?.stroke).toBe('green'); + expect(line?.strokeWidth).toBe(4); + expect(line?.strokeDasharray).toBe('10,5'); + expect(line?.opacity).toBe(0.8); + }); + unmount(); + }); + + it('should restore default attributes on unmount', async () => { + const { graph, wrapper } = getTestWrapper(); + const { unmount } = render(, { wrapper }); + await waitFor(() => { + const link = graph.getCell('link-1'); + if (!link) { + throw new Error('Link not found in graph'); + } + const line = link.attr('line'); + expect(line?.stroke).toBe('blue'); + expect(line?.strokeWidth).toBe(5); + }); + unmount(); + + await waitFor(() => { + const link = graph.getCell('link-1'); + if (!link) { + throw new Error('Link not found in graph'); + } + const line = link.attr('line'); + expect(line?.stroke).toBe('#333333'); + }); + }); + + it('should update attributes when props change', async () => { + const { graph, wrapper } = getTestWrapper(); + const { rerender } = render(, { wrapper }); + await waitFor(() => { + const link = graph.getCell('link-1'); + if (!link) { + throw new Error('Link not found in graph'); + } + const line = link.attr('line'); + expect(line?.stroke).toBe('red'); + }); + + rerender(); + await waitFor(() => { + const link = graph.getCell('link-1'); + if (!link) { + throw new Error('Link not found in graph'); + } + const line = link.attr('line'); + expect(line?.stroke).toBe('purple'); + expect(line?.strokeWidth).toBe(6); + }); + }); +}); diff --git a/packages/joint-react/src/components/link/__tests__/link-label.test.tsx b/packages/joint-react/src/components/link/__tests__/link-label.test.tsx new file mode 100644 index 0000000000..c9c5375100 --- /dev/null +++ b/packages/joint-react/src/components/link/__tests__/link-label.test.tsx @@ -0,0 +1,511 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable @typescript-eslint/no-shadow */ +/* eslint-disable no-shadow */ +/* eslint-disable @typescript-eslint/prefer-optional-chain */ +/* eslint-disable unicorn/consistent-function-scoping */ +/* eslint-disable react-perf/jsx-no-new-object-as-prop */ +import { render, waitFor, act } from '@testing-library/react'; +import { getTestGraph, paperRenderLinkWrapper } from '../../../utils/test-wrappers'; +import type { dia } from '@joint/core'; +import { LinkLabel } from '../link-label'; + +interface LinkLabelWithId extends dia.Link.Label { + readonly labelId: string; +} + +function isLabelPosition( + position: number | dia.LinkView.LabelOptions | undefined +): position is dia.LinkView.LabelOptions { + // eslint-disable-next-line sonarjs/different-types-comparison + return typeof position === 'object' && position !== null; +} + +function getLabelPosition(label: LinkLabelWithId): dia.LinkView.LabelOptions { + const { position } = label; + if (!isLabelPosition(position)) { + throw new Error('Expected label position to be an object'); + } + return position; +} + +describe('LinkLabel', () => { + const getTestWrapper = () => { + const graph = getTestGraph(); + return { + graph, + wrapper: paperRenderLinkWrapper({ + graphProviderProps: { + graph, + elements: [ + { + id: 'element-1', + x: 0, + y: 0, + width: 100, + height: 100, + }, + { + id: 'element-2', + x: 200, + y: 200, + width: 100, + height: 100, + }, + ], + links: [ + { + id: 'link-1', + source: 'element-1', + target: 'element-2', + }, + ], + }, + }), + }; + }; + + describe('mount and unmount', () => { + it('should create label on mount', async () => { + const { graph, wrapper } = getTestWrapper(); + render( + + Test Label + , + { wrapper } + ); + + await waitFor(() => { + const link = graph.getCell('link-1'); + if (!link || !link.isLink()) { + throw new Error('Link not found'); + } + const labels = link.labels(); + expect(labels.length).toBe(1); + const label = labels[0] as LinkLabelWithId; + expect(getLabelPosition(label).distance).toBe(0.5); + expect(label.labelId).toBeDefined(); + }); + }); + + it('should remove label on unmount', async () => { + const { graph, wrapper } = getTestWrapper(); + const { unmount } = render( + + Test Label + , + { wrapper } + ); + + await waitFor(() => { + const link = graph.getCell('link-1'); + if (!link || !link.isLink()) { + throw new Error('Link not found'); + } + expect(link.labels().length).toBe(1); + }); + + unmount(); + + await waitFor(() => { + const link = graph.getCell('link-1'); + if (!link || !link.isLink()) { + throw new Error('Link not found'); + } + expect(link.labels().length).toBe(0); + }); + }); + }); + + describe('prop updates', () => { + it('should update distance when prop changes', async () => { + const { graph, wrapper } = getTestWrapper(); + const { rerender } = render(, { wrapper }); + + await waitFor(() => { + const link = graph.getCell('link-1'); + if (!link || !link.isLink()) { + throw new Error('Link not found'); + } + const labels = link.labels(); + expect(labels.length).toBe(1); + const label = labels[0] as LinkLabelWithId; + expect(getLabelPosition(label).distance).toBe(0.3); + }); + + rerender(); + + await waitFor(() => { + const link = graph.getCell('link-1'); + if (!link || !link.isLink()) { + throw new Error('Link not found'); + } + const labels = link.labels(); + expect(labels.length).toBe(1); + const label = labels[0] as LinkLabelWithId; + expect(getLabelPosition(label).distance).toBe(0.7); + }); + }); + + it('should update offset when prop changes', async () => { + const { graph, wrapper } = getTestWrapper(); + const { rerender } = render(, { wrapper }); + + await waitFor(() => { + const link = graph.getCell('link-1'); + if (!link || !link.isLink()) { + throw new Error('Link not found'); + } + const labels = link.labels(); + const label = labels[0] as LinkLabelWithId; + expect(getLabelPosition(label).offset).toBe(10); + }); + + rerender(); + + await waitFor(() => { + const link = graph.getCell('link-1'); + if (!link || !link.isLink()) { + throw new Error('Link not found'); + } + const labels = link.labels(); + const label = labels[0] as LinkLabelWithId; + expect(getLabelPosition(label).offset).toEqual({ x: 20, y: 30 }); + }); + }); + + it('should update angle when prop changes', async () => { + const { graph, wrapper } = getTestWrapper(); + const { rerender } = render(, { wrapper }); + + await waitFor(() => { + const link = graph.getCell('link-1'); + if (!link || !link.isLink()) { + throw new Error('Link not found'); + } + const labels = link.labels(); + const label = labels[0] as LinkLabelWithId; + expect(getLabelPosition(label).angle).toBe(0); + }); + + rerender(); + + await waitFor(() => { + const link = graph.getCell('link-1'); + if (!link || !link.isLink()) { + throw new Error('Link not found'); + } + const labels = link.labels(); + const label = labels[0] as LinkLabelWithId; + expect(getLabelPosition(label).angle).toBe(45); + }); + }); + + it('should update attrs when prop changes', async () => { + const { graph, wrapper } = getTestWrapper(); + const { rerender } = render( + , + { wrapper } + ); + + await waitFor(() => { + const link = graph.getCell('link-1'); + if (!link || !link.isLink()) { + throw new Error('Link not found'); + } + const labels = link.labels(); + const label = labels[0] as LinkLabelWithId; + expect(label.attrs?.text?.text).toBe('Label 1'); + }); + + rerender(); + + await waitFor(() => { + const link = graph.getCell('link-1'); + if (!link || !link.isLink()) { + throw new Error('Link not found'); + } + const labels = link.labels(); + const label = labels[0] as LinkLabelWithId; + expect(label.attrs?.text?.text).toBe('Label 2'); + }); + }); + + it('should update size when prop changes', async () => { + const { graph, wrapper } = getTestWrapper(); + const { rerender } = render(, { + wrapper, + }); + + await waitFor(() => { + const link = graph.getCell('link-1'); + if (!link || !link.isLink()) { + throw new Error('Link not found'); + } + const labels = link.labels(); + const label = labels[0] as LinkLabelWithId; + expect(label.size?.width).toBe(50); + expect(label.size?.height).toBe(20); + }); + + rerender(); + + await waitFor(() => { + const link = graph.getCell('link-1'); + if (!link || !link.isLink()) { + throw new Error('Link not found'); + } + const labels = link.labels(); + const label = labels[0] as LinkLabelWithId; + expect(label.size?.width).toBe(100); + expect(label.size?.height).toBe(40); + }); + }); + + it('should update args when prop changes', async () => { + const { graph, wrapper } = getTestWrapper(); + const { rerender } = render(, { + wrapper, + }); + + await waitFor(() => { + const link = graph.getCell('link-1'); + if (!link || !link.isLink()) { + throw new Error('Link not found'); + } + const labels = link.labels(); + const label = labels[0] as LinkLabelWithId; + expect(getLabelPosition(label).args?.absoluteDistance).toBe(true); + }); + + rerender(); + + await waitFor(() => { + const link = graph.getCell('link-1'); + if (!link || !link.isLink()) { + throw new Error('Link not found'); + } + const labels = link.labels(); + const label = labels[0] as LinkLabelWithId; + expect(getLabelPosition(label).args?.absoluteDistance).toBe(false); + }); + }); + + it('should update multiple props together', async () => { + const { graph, wrapper } = getTestWrapper(); + const { rerender } = render( + , + { wrapper } + ); + + await waitFor(() => { + const link = graph.getCell('link-1'); + if (!link || !link.isLink()) { + throw new Error('Link not found'); + } + const labels = link.labels(); + const label = labels[0] as LinkLabelWithId; + const position = getLabelPosition(label); + expect(position.distance).toBe(0.3); + expect(position.offset).toBe(10); + expect(position.angle).toBe(0); + expect(label.attrs?.text?.text).toBe('A'); + }); + + rerender(); + + await waitFor(() => { + const link = graph.getCell('link-1'); + if (!link || !link.isLink()) { + throw new Error('Link not found'); + } + const labels = link.labels(); + const label = labels[0] as LinkLabelWithId; + const position = getLabelPosition(label); + expect(position.distance).toBe(0.7); + expect(position.offset).toBe(20); + expect(position.angle).toBe(90); + expect(label.attrs?.text?.text).toBe('B'); + }); + }); + }); + + describe('render optimization', () => { + it('should not create duplicate labels when props change', async () => { + const { graph, wrapper } = getTestWrapper(); + const { rerender } = render(, { wrapper }); + + await waitFor(() => { + const link = graph.getCell('link-1'); + if (!link || !link.isLink()) { + throw new Error('Link not found'); + } + expect(link.labels().length).toBe(1); + }); + + // Update props multiple times + rerender(); + rerender(); + rerender(); + + await waitFor(() => { + const link = graph.getCell('link-1'); + if (!link || !link.isLink()) { + throw new Error('Link not found'); + } + // Should still have only one label + expect(link.labels().length).toBe(1); + const label = link.labels()[0] as LinkLabelWithId; + expect(getLabelPosition(label).distance).toBe(0.8); + }); + }); + + it('should not re-create label when stable props change', async () => { + const { graph, wrapper } = getTestWrapper(); + let renderCount = 0; + const TestComponent = () => { + renderCount++; + return Test; + }; + + const { rerender } = render(, { wrapper }); + + await waitFor(() => { + const link = graph.getCell('link-1'); + if (!link || !link.isLink()) { + throw new Error('Link not found'); + } + expect(link.labels().length).toBe(1); + }); + + // Get the initial labelId + const link = graph.getCell('link-1'); + if (!link || !link.isLink()) { + throw new Error('Link not found'); + } + const initialLabelId = (link.labels()[0] as LinkLabelWithId).labelId; + + // Update props that should trigger update effect, not create effect + act(() => { + rerender(); + }); + + await waitFor(() => { + const link = graph.getCell('link-1'); + if (!link || !link.isLink()) { + throw new Error('Link not found'); + } + // Label should still exist with same labelId + const labels = link.labels(); + expect(labels.length).toBe(1); + const label = labels[0] as LinkLabelWithId; + expect(label.labelId).toBe(initialLabelId); + }); + + // Component may re-render, but label should not be recreated + expect(link.labels().length).toBe(1); + }); + + it('should only create label once on mount, then update on prop changes', async () => { + const { graph, wrapper } = getTestWrapper(); + const { rerender } = render(, { wrapper }); + + // Wait for link to be available and label to be created + await waitFor(() => { + const foundLink = graph.getCell('link-1'); + if (!foundLink || !foundLink.isLink()) { + throw new Error('Link not found'); + } + expect(foundLink.labels().length).toBe(1); + }); + + const link = graph.getCell('link-1'); + if (!link || !link.isLink()) { + throw new Error('Link not found'); + } + + // Get the initial labelId to verify it's the same label being updated + const initialLabelId = (link.labels()[0] as LinkLabelWithId).labelId; + + // Update props - should trigger update effect, not create effect + rerender(); + await waitFor(() => { + const labels = link!.labels(); + expect(labels.length).toBe(1); + const label = labels[0] as LinkLabelWithId; + expect(label.labelId).toBe(initialLabelId); // Same label, not a new one + expect(getLabelPosition(label).distance).toBe(0.6); + }); + + // Update again + rerender(); + await waitFor(() => { + const labels = link!.labels(); + expect(labels.length).toBe(1); + const label = labels[0] as LinkLabelWithId; + expect(label.labelId).toBe(initialLabelId); // Still the same label + expect(getLabelPosition(label).distance).toBe(0.7); + }); + + // Should still have only one label + const finalLink = graph.getCell('link-1'); + if (!finalLink || !finalLink.isLink()) { + throw new Error('Link not found'); + } + expect(finalLink.labels().length).toBe(1); + const finalLabel = finalLink.labels()[0] as LinkLabelWithId; + expect(finalLabel.labelId).toBe(initialLabelId); // Same label throughout + }); + }); + + describe('edge cases', () => { + it('should handle ensureLegibility prop', async () => { + const { graph, wrapper } = getTestWrapper(); + render(, { wrapper }); + + await waitFor(() => { + const link = graph.getCell('link-1'); + if (!link || !link.isLink()) { + throw new Error('Link not found'); + } + const labels = link.labels(); + const label = labels[0] as LinkLabelWithId; + expect(getLabelPosition(label).args?.ensureLegibility).toBe(true); + }); + }); + + it('should handle keepGradient prop', async () => { + const { graph, wrapper } = getTestWrapper(); + render(, { wrapper }); + + await waitFor(() => { + const link = graph.getCell('link-1'); + if (!link || !link.isLink()) { + throw new Error('Link not found'); + } + const labels = link.labels(); + const label = labels[0] as LinkLabelWithId; + expect(getLabelPosition(label).args?.keepGradient).toBe(true); + }); + }); + + it('should handle undefined optional props', async () => { + const { graph, wrapper } = getTestWrapper(); + render(, { wrapper }); + + await waitFor(() => { + const link = graph.getCell('link-1'); + if (!link || !link.isLink()) { + throw new Error('Link not found'); + } + const labels = link.labels(); + expect(labels.length).toBe(1); + const label = labels[0] as LinkLabelWithId; + const position = getLabelPosition(label); + expect(position.distance).toBe(0.5); + // Optional props should be undefined or have defaults + expect(position.offset).toBeUndefined(); + expect(position.angle).toBeUndefined(); + }); + }); + }); +}); diff --git a/packages/joint-react/src/components/link/base-link.stories.tsx b/packages/joint-react/src/components/link/base-link.stories.tsx new file mode 100644 index 0000000000..66cf599462 --- /dev/null +++ b/packages/joint-react/src/components/link/base-link.stories.tsx @@ -0,0 +1,326 @@ +/* eslint-disable react-perf/jsx-no-new-function-as-prop */ +import type { Meta, StoryObj } from '@storybook/react'; +import '../../stories/examples/index.css'; +import { getAPILink } from '../../stories/utils/get-api-documentation-link'; +import { makeRootDocumentation, makeStory } from '../../stories/utils/make-story'; +import { SimpleRenderLinkDecorator } from 'storybook-config/decorators/with-simple-data'; +import { BaseLink } from './base-link'; + +export type Story = StoryObj; +const API_URL = getAPILink('Link.BaseLink', 'variables'); + +const meta: Meta = { + title: 'Components/Link/BaseLink', + component: BaseLink, + decorators: [SimpleRenderLinkDecorator], + tags: ['component'], + parameters: makeRootDocumentation({ + apiURL: API_URL, + description: ` +The **Link.BaseLink** component sets link properties when rendering custom links. It must be used inside the \`renderLink\` function. + +**Key Features:** +- Sets link attributes (stroke, strokeWidth, etc.) +- Sets link markup +- Sets other link properties +- Must be used inside renderLink context + `, + usage: ` +\`\`\`tsx +import { Link } from '@joint/react'; + +function RenderLink({ id }) { + return ( + <> + + + ); +} +\`\`\` + `, + props: ` +- **attrs**: Link attributes to apply +- **markup**: Link markup to use for rendering +- **...rest**: Additional link properties + `, + code: `import { Link } from '@joint/react'; + + + `, + }), +}; + +export default meta; + +export const Default = makeStory({ + args: { + stroke: 'blue', + }, + apiURL: API_URL, + name: 'Basic link', +}); + +export const WithArrowMarkers = makeStory({ + args: { + stroke: '#0075f2', + strokeWidth: 2, + startMarker: 'arrow', + endMarker: 'arrow', + }, + apiURL: API_URL, + name: 'Arrow markers', +}); + +export const WithArrowRoundedMarkers = makeStory({ + args: { + stroke: '#ed2637', + strokeWidth: 2, + startMarker: 'arrow-rounded', + endMarker: 'arrow-rounded', + }, + apiURL: API_URL, + name: 'Rounded arrow markers', +}); + +export const WithTriangleMarkers = makeStory({ + args: { + stroke: '#28a745', + strokeWidth: 2, + startMarker: 'triangle', + endMarker: 'triangle', + }, + apiURL: API_URL, + name: 'Triangle markers', +}); + +export const WithDiamondMarkers = makeStory({ + args: { + stroke: '#ffc107', + strokeWidth: 2, + startMarker: 'diamond', + endMarker: 'diamond', + }, + apiURL: API_URL, + name: 'Diamond markers', +}); + +export const WithCircleMarkers = makeStory({ + args: { + stroke: '#6f42c1', + strokeWidth: 2, + startMarker: 'circle', + endMarker: 'circle', + }, + apiURL: API_URL, + name: 'Circle markers', +}); + +export const WithDifferentStartEndMarkers = makeStory({ + args: { + stroke: '#17a2b8', + strokeWidth: 2, + startMarker: 'arrow', + endMarker: 'circle', + }, + apiURL: API_URL, + name: 'Different start and end markers', +}); + +export const WithCrossMarkers = makeStory({ + args: { + stroke: '#dc3545', + strokeWidth: 2, + startMarker: 'cross', + endMarker: 'cross', + }, + apiURL: API_URL, + name: 'Cross markers', +}); + +export const WithLineMarkers = makeStory({ + args: { + stroke: '#20c997', + strokeWidth: 2, + startMarker: 'line', + endMarker: 'line', + }, + apiURL: API_URL, + name: 'Line markers', +}); + +export const WithOpenMarkers = makeStory({ + args: { + stroke: '#fd7e14', + strokeWidth: 2, + startMarker: 'circle-open', + endMarker: 'triangle-open', + }, + apiURL: API_URL, + name: 'Open markers', +}); + +export const WithRoundedDiamondMarkers = makeStory({ + args: { + stroke: '#e83e8c', + strokeWidth: 2, + startMarker: 'diamond-rounded', + endMarker: 'diamond-rounded', + }, + apiURL: API_URL, + name: 'Rounded diamond markers', +}); + +export const NoMarkers = makeStory({ + args: { + stroke: '#6c757d', + strokeWidth: 2, + startMarker: 'none', + endMarker: 'none', + }, + apiURL: API_URL, + name: 'No markers', +}); + +// Custom marker function stories +function CustomStarMarkerStory() { + return ( + ( + + )} + endMarker={(props) => ( + + )} + /> + ); +} + +export const WithCustomStarMarkers = makeStory({ + component: CustomStarMarkerStory, + apiURL: API_URL, + name: 'Custom star markers', +}); + +function CustomSquareMarkerStory() { + return ( + ( + + )} + endMarker={(props) => ( + + )} + /> + ); +} + +export const WithCustomSquareMarkers = makeStory({ + component: CustomSquareMarkerStory, + apiURL: API_URL, + name: 'Custom square markers', +}); + +function CustomHeartMarkerStory() { + return ( + ( + + )} + endMarker={(props) => ( + + )} + /> + ); +} + +export const WithCustomHeartMarkers = makeStory({ + component: CustomHeartMarkerStory, + apiURL: API_URL, + name: 'Custom heart markers', +}); + +function CustomMixedMarkersStory() { + return ( + ( + + )} + /> + ); +} + +export const WithMixedPredefinedAndCustomMarkers = makeStory({ + component: CustomMixedMarkersStory, + apiURL: API_URL, + name: 'Mixed predefined and custom markers', +}); + +function CustomComplexMarkerStory() { + return ( + ( + + + + + )} + endMarker={(props) => ( + + + + + )} + /> + ); +} + +export const WithCustomComplexMarkers = makeStory({ + component: CustomComplexMarkerStory, + apiURL: API_URL, + name: 'Custom complex markers', +}); diff --git a/packages/joint-react/src/components/link/base-link.tsx b/packages/joint-react/src/components/link/base-link.tsx new file mode 100644 index 0000000000..03070ea3be --- /dev/null +++ b/packages/joint-react/src/components/link/base-link.tsx @@ -0,0 +1,169 @@ +import { memo, useLayoutEffect, useMemo } from 'react'; +import { useGraphStore } from '../../hooks/use-graph-store'; +import { useCellId, usePaperStoreContext } from '../../hooks'; +import type { StandardLinkShapesTypeMapper } from '../../types/link-types'; +import { getLinkArrow, type LinkArrowName, type MarkerProps } from './link.arrows'; +import { jsx } from '../../utils/joint-jsx/jsx-to-markup'; +import type React from 'react'; +import type { OmitWithoutIndexSignature } from '../../types'; + +type StandardLinkAttributes = Required; +type LineAttributes = StandardLinkAttributes['line']; + +/** + * Marker configuration - either a predefined arrow name or a direct component function. + */ +export type MarkerConfig = LinkArrowName | ((props: MarkerProps) => React.JSX.Element); + +export interface BaseLinkProps + extends OmitWithoutIndexSignature { + /** + * Arrow marker for the start of the link. + * Can be a predefined arrow name from LINK_ARROWS or a direct component function. + */ + readonly startMarker?: MarkerConfig; + /** + * Arrow marker for the end of the link. + * Can be a predefined arrow name from LINK_ARROWS or a direct component function. + */ + readonly endMarker?: MarkerConfig; +} + +// eslint-disable-next-line jsdoc/require-jsdoc +function Component(props: BaseLinkProps) { + const { startMarker, endMarker, ...lineAttributes } = props; + const linkId = useCellId(); + const graphStore = useGraphStore(); + const { graph } = graphStore; + const { paper } = usePaperStoreContext(); + + const resolvedLineAttributes = useMemo(() => { + const resolved: typeof lineAttributes & { + sourceMarker?: { markup: ReturnType }; + targetMarker?: { markup: ReturnType }; + } = { ...lineAttributes }; + + // Get the link to check for color attribute + const link = graph.getCell(linkId); + const linkColor = link?.attr('color') as string | undefined; + + // Determine marker color: use stroke if provided, otherwise inherit from link color, fallback to black + const markerColor = (lineAttributes.stroke as string | undefined) ?? linkColor ?? '#000000'; + + if (startMarker) { + let markerComponent: ((props: MarkerProps) => React.JSX.Element) | undefined; + + // Check if it's a predefined marker name or direct function + if (typeof startMarker === 'string') { + const marker = getLinkArrow(startMarker); + markerComponent = marker?.component; + } else { + markerComponent = startMarker; + } + + if (markerComponent) { + // Convert React component to JointJS markup + const componentResult = markerComponent({ color: markerColor }); + resolved.sourceMarker = { markup: jsx(componentResult) }; + } + } + + if (endMarker) { + let markerComponent: ((props: MarkerProps) => React.JSX.Element) | undefined; + + // Check if it's a predefined marker name or direct function + if (typeof endMarker === 'string') { + const marker = getLinkArrow(endMarker); + markerComponent = marker?.component; + } else { + markerComponent = endMarker; + } + + if (markerComponent) { + // Convert React component to JointJS markup + const componentResult = markerComponent({ color: markerColor }); + resolved.targetMarker = { markup: jsx(componentResult) }; + } + } + + return resolved; + }, [graph, linkId, lineAttributes, startMarker, endMarker]); + + // Effect 1: Capture default attributes on mount, restore on unmount + // Only depends on paper and graph (stable references) - runs on mount/unmount + useLayoutEffect(() => { + const link = graph.getCell(linkId); + + if (!link) { + throw new Error(`Link with id ${linkId} not found`); + } + if (!paper) { + return; + } + if (!link.isLink()) { + throw new Error(`Cell with id ${linkId} is not a link`); + } + + // Capture default attributes for cleanup + const defaultAttributes = link.attr(); + + return () => { + // Restore default attributes via graphStore for batching + graphStore.setLink(linkId, defaultAttributes); + }; + }, [graph, graphStore, paper, linkId]); // Only stable dependencies - captures defaults on mount, restores on unmount + + // Effect 2: Update attributes when props change or when link is updated + useLayoutEffect(() => { + const link = graph.getCell(linkId); + + if (!link) { + return; + } + if (!paper) { + return; + } + if (!link.isLink()) { + return; + } + + // Always re-apply current attributes via graphStore for batching + graphStore.setLink(linkId, { + line: resolvedLineAttributes, + }); + graphStore.flushPendingUpdates(); + }, [graph, graphStore, resolvedLineAttributes, linkId, paper]); + + return null; +} + +/** + * BaseLink component sets link properties when rendering custom links. + * Must be used inside `renderLink` function. + * @group Components + * @category Link + * @example + * ```tsx + * function RenderLink({ id }) { + * return ( + * <> + * + * + * ); + * } + * ``` + * @example + * ```tsx + * function RenderLink({ id }) { + * return ( + * <> + * } + * /> + * + * ); + * } + * ``` + */ +export const BaseLink = memo(Component); diff --git a/packages/joint-react/src/components/link/base-link.types.ts b/packages/joint-react/src/components/link/base-link.types.ts new file mode 100644 index 0000000000..372699d712 --- /dev/null +++ b/packages/joint-react/src/components/link/base-link.types.ts @@ -0,0 +1,20 @@ +import type { dia } from '@joint/core'; + +/** + * Props for the BaseLink component. + * BaseLink is used to set link properties when rendering custom links. + */ +export interface BaseLinkProps { + /** + * Link attributes to apply to the link. + */ + readonly attrs?: dia.Link.Attributes; + /** + * Link markup to use for rendering. + */ + readonly markup?: dia.MarkupJSON; + /** + * Additional link properties. + */ + readonly [key: string]: unknown; +} diff --git a/packages/joint-react/src/components/link/index.ts b/packages/joint-react/src/components/link/index.ts new file mode 100644 index 0000000000..6da1ed679f --- /dev/null +++ b/packages/joint-react/src/components/link/index.ts @@ -0,0 +1,61 @@ +/* eslint-disable @typescript-eslint/no-namespace */ + +export * from './base-link.types'; +export * from './link-label.types'; +import { BaseLink } from './base-link'; +import { LinkLabel } from './link-label'; +export type { BaseLinkProps, MarkerConfig } from './base-link'; +export type { LinkLabelProps, LinkLabelPosition } from './link-label.types'; +export { LINK_ARROWS, getLinkArrow, type LinkArrowName, type LinkArrowMarker, type MarkerProps } from './link.arrows'; + +// Direct exports for convenience + +const Component = { + Base: BaseLink, + Label: LinkLabel, +}; + +/** + * Joint js Links in react. + * Links are used to connect elements together. + * BaseLink is used to set link properties, and LinkLabel is used to render labels at specific positions along links. + * @group Components + * @experimental This feature is experimental and may change in the future. + * @example + * ```tsx + * import { Link } from '@joint/react'; + * + * function RenderLink({ id }) { + * return ( + * <> + * + * + * Label + * + * + * ); + * } + * ``` + */ +export namespace Link { + /** + * BaseLink component sets link properties when rendering custom links. + * Must be used inside `renderLink` function. + * @experimental This feature is experimental and may change in the future. + * @group Components + * @category Link + */ + // eslint-disable-next-line no-shadow, @typescript-eslint/no-shadow + export const { Base } = Component; + /** + * LinkLabel component renders content at a specific position along a link. + * Must be used inside `renderLink` function. + * @experimental This feature is experimental and may change in the future. + * @group Components + * @category Link + */ + export const { Label } = Component; +} + +export { BaseLink } from './base-link'; +export { LinkLabel } from './link-label'; diff --git a/packages/joint-react/src/components/link/link-label.stories.tsx b/packages/joint-react/src/components/link/link-label.stories.tsx new file mode 100644 index 0000000000..be8a53e8d8 --- /dev/null +++ b/packages/joint-react/src/components/link/link-label.stories.tsx @@ -0,0 +1,101 @@ +/* eslint-disable react-perf/jsx-no-new-object-as-prop */ +import type { Meta, StoryObj } from '@storybook/react'; +import '../../stories/examples/index.css'; +import { Link } from '@joint/react'; +import { getAPILink } from '../../stories/utils/get-api-documentation-link'; +import { makeRootDocumentation, makeStory } from '../../stories/utils/make-story'; +import { SimpleRenderLinkDecorator } from 'storybook-config/decorators/with-simple-data'; + +export type Story = StoryObj; +const API_URL = getAPILink('Link.Label', 'variables'); + +const meta: Meta = { + title: 'Components/Link/Label', + component: Link.Label, + decorators: [SimpleRenderLinkDecorator], + tags: ['component'], + parameters: makeRootDocumentation({ + apiURL: API_URL, + description: ` +The **Link.Label** component renders content at a specific position along a link. It uses React portals to render children into the link label node. + +**Key Features:** +- Renders content at specific positions along links (start, middle, end, custom) +- Supports custom positioning with distance, offset, and angle +- Uses React portals for rendering +- Must be used inside renderLink context + `, + usage: ` +\`\`\`tsx +import { Link } from '@joint/react'; + +function RenderLink({ id }) { + return ( + <> + + + Label + + + ); +} +\`\`\` + `, + props: ` +- **position**: Position of the label along the link (required) + - **distance**: 0-1 (0 = start, 0.5 = middle, 1 = end) + - **offset**: number (perpendicular) or {x, y} (absolute) + - **angle**: rotation angle in degrees +- **children**: Content to render inside the label portal +- **attrs**: Label attributes +- **size**: Label size + `, + code: `import { Link } from '@joint/react'; + + + Label + + `, + }), +}; + +export default meta; + +function Component() { + const labelWidth = 100; + const labelHeight = 20; + // Center the label by offsetting by negative half-width and half-height + const offsetX = -labelWidth / 2; + const offsetY = -labelHeight / 2; + + return ( + <> + + +
+ Start +
+
+
+ + +
+ Middle +
+
+
+ + +
+ End +
+
+
+ + ); +} +export const Default = makeStory({ + component: Component, + apiURL: API_URL, + name: 'Label at middle', +}); diff --git a/packages/joint-react/src/components/link/link-label.tsx b/packages/joint-react/src/components/link/link-label.tsx new file mode 100644 index 0000000000..09908334ac --- /dev/null +++ b/packages/joint-react/src/components/link/link-label.tsx @@ -0,0 +1,181 @@ +import type { dia } from '@joint/core'; +import { memo, useId, useLayoutEffect, useMemo, useRef } from 'react'; +import { createPortal } from 'react-dom'; +import { useGraphStore } from '../../hooks/use-graph-store'; +import { useGraphInternalStoreSelector } from '../../hooks/use-graph-store-selector'; +import { useCellId, usePaperStoreContext } from '../../hooks'; + +interface LinkLabelWithId extends dia.Link.Label { + readonly labelId: string; +} + +export interface LinkLabelPosition extends dia.LinkView.LabelOptions { + /** + * Distance along the link (0-1 for relative, or absolute pixels with absoluteDistance: true). + * 0 = start, 0.5 = middle, 1 = end + */ + readonly distance?: number; + /** + * Offset from the link path. + * Can be a number (perpendicular offset) or an object with x and y (absolute offset). + */ + readonly offset?: number | { readonly x: number; readonly y: number }; + /** + * Rotation angle in degrees. + */ + readonly angle?: number; + /** + * Additional position arguments (e.g., absoluteDistance, reverseDistance, absoluteOffset). + */ + readonly args?: Record; +} + +export interface LinkLabelProps extends LinkLabelPosition { + /** + * Children to render inside the label portal. + */ + readonly children?: React.ReactNode; + /** + * Label attributes. + */ + readonly attrs?: dia.Link.Label['attrs']; + /** + * Label size. + */ + readonly size?: dia.Link.Label['size']; +} + +// eslint-disable-next-line jsdoc/require-jsdoc +function Component(props: LinkLabelProps) { + const { children, attrs, size, angle, args, distance, offset, ensureLegibility, keepGradient } = + props; + const linkId = useCellId(); + const graphStore = useGraphStore(); + const { graph } = graphStore; + const paperStore = usePaperStoreContext(); + const { paper, paperId } = paperStore; + const labelsRef = useRef([]); + const labelId = useId(); + + // Prepare label data during render (synchronous, runs before useLayoutEffect) + const labelData = useMemo(() => { + if (!paper) { + return null; + } + const position: dia.Link.LabelPosition = { + distance, + offset, + angle, + args: { + ...args, + ensureLegibility, + keepGradient, + }, + }; + + return { + position, + attrs, + size, + labelId, + }; + }, [paper, distance, offset, angle, args, ensureLegibility, keepGradient, attrs, size, labelId]); + + // Effect 1: Create label on mount, remove on unmount + // Only depends on paper and graph (stable references) - runs on mount/unmount + useLayoutEffect(() => { + if (!labelData) { + return; + } + + const link = graph.getCell(linkId); + if (!link) { + throw new Error(`Link with id ${linkId} not found`); + } + if (!link.isLink()) { + throw new Error(`Cell with id ${linkId} is not a link`); + } + + // Apply pre-computed label data (faster than computing in effect) + graphStore.setLinkLabel(linkId, labelId, labelData); + graphStore.flushPendingUpdates(); + labelsRef.current = link.labels(); + return () => { + // Remove label via graphStore for batching + graphStore.removeLinkLabel(linkId, labelId); + }; + }, [graph, graphStore, linkId, labelId, labelData]); // labelData is pre-computed, so effect is faster + + // Effect 2: Update label when props change + // Uses pre-computed labelData from useMemo (faster than computing in effect) + useLayoutEffect(() => { + if (!labelData) { + return; + } + + const link = graph.getCell(linkId); + if (!link) { + return; + } + if (!link.isLink()) { + return; + } + + const currentLabels = link.labels(); + const existingLabelIndex = currentLabels.findIndex( + (label) => (label as LinkLabelWithId).labelId === labelId + ); + + if (existingLabelIndex === -1) { + return; + } + + // Apply pre-computed label data (faster than computing in effect) + graphStore.setLinkLabel(linkId, labelId, labelData); + labelsRef.current = link.labels(); + }, [graph, graphStore, linkId, labelId, labelData]); // labelData is pre-computed, so effect is faster + + const portalNode = useGraphInternalStoreSelector((state) => { + // Read labels directly from the link model to ensure we have the latest state + const link = graph.getCell(linkId); + if (!link?.isLink()) { + return null; + } + const labels = link.labels(); + const labelIndex = labels.findIndex((l) => (l as LinkLabelWithId).labelId === labelId); + if (labelIndex === -1) { + return null; + } + const linkLabelId = paperStore.getLinkLabelId(linkId, labelIndex); + return state.papers[paperId]?.linksData?.[linkLabelId]; + }); + + // Component always mounts, useLayoutEffect runs immediately to add label to graph + // Portal rendering waits until portalNode is available (createPortal requires valid DOM node) + if (!portalNode) { + return null; + } + + return createPortal(children, portalNode); +} + +/** + * LinkLabel component renders content at a specific position along a link. + * Must be used inside `renderLink` function. + * @group Components + * @category Link + * @example + * ```tsx + * function RenderLink({ id }) { + * return ( + * <> + * + * + * Label + * + * + * ); + * } + * ``` + */ +export const LinkLabel = memo(Component); diff --git a/packages/joint-react/src/components/link/link-label.types.ts b/packages/joint-react/src/components/link/link-label.types.ts new file mode 100644 index 0000000000..a51d01e721 --- /dev/null +++ b/packages/joint-react/src/components/link/link-label.types.ts @@ -0,0 +1,53 @@ +import type { dia } from '@joint/core'; + +/** + * Position options for link labels. + * Similar to Joint.js label position system. + */ +export interface LinkLabelPosition { + /** + * Distance along the link (0-1 for relative, or absolute pixels with absoluteDistance: true). + * 0 = start, 0.5 = middle, 1 = end + */ + readonly distance?: number; + /** + * Offset from the link path. + * Can be a number (perpendicular offset) or an object with x and y (absolute offset). + */ + readonly offset?: number | { readonly x: number; readonly y: number }; + /** + * Rotation angle in degrees. + */ + readonly angle?: number; + /** + * Additional position arguments (e.g., absoluteDistance, reverseDistance, absoluteOffset). + */ + readonly args?: Record; +} + +/** + * Props for the LinkLabel component. + */ +export interface LinkLabelProps { + /** + * Position of the label along the link. + */ + readonly position: LinkLabelPosition; + /** + * Optional unique identifier for the label. + * If not provided, the label will be identified by its index in the labels array. + */ + readonly id?: string; + /** + * Children to render inside the label portal. + */ + readonly children?: React.ReactNode; + /** + * Label attributes. + */ + readonly attrs?: dia.Link.Label['attrs']; + /** + * Label size. + */ + readonly size?: dia.Link.Label['size']; +} diff --git a/packages/joint-react/src/components/link/link.arrows.tsx b/packages/joint-react/src/components/link/link.arrows.tsx new file mode 100644 index 0000000000..f3d7139e16 --- /dev/null +++ b/packages/joint-react/src/components/link/link.arrows.tsx @@ -0,0 +1,121 @@ +import React from 'react'; + +export interface MarkerProps { + readonly color: string; + readonly strokeWidth?: string; + readonly strokeLinejoin?: 'inherit' | 'round' | 'bevel' | 'miter'; +} + +export interface LinkArrowMarker { + readonly name: string; + readonly component: (props: MarkerProps) => React.JSX.Element; +} + +/** + * Predefined arrow markers for links. + * Use these with BaseLink's startMarker and endMarker props. + * @group Components + * @category Link + */ +export const LINK_ARROWS = { + arrow: { + name: 'arrow', + component: (props: MarkerProps) => ( + + ), + }, + 'arrow-rounded': { + name: 'arrow-rounded', + component: (props: MarkerProps) => ( + + ), + }, + triangle: { + name: 'triangle', + component: (props: MarkerProps) => ( + + ), + }, + 'triangle-open': { + name: 'triangle-open', + component: (props: MarkerProps) => ( + + ), + }, + diamond: { + name: 'diamond', + component: (props: MarkerProps) => ( + + ), + }, + 'diamond-rounded': { + name: 'diamond-rounded', + component: (props: MarkerProps) => ( + + ), + }, + circle: { + name: 'circle', + component: (props: MarkerProps) => ( + + ), + }, + 'circle-open': { + name: 'circle-open', + component: (props: MarkerProps) => ( + + ), + }, + line: { + name: 'line', + component: (props: MarkerProps) => ( + + ), + }, + cross: { + name: 'cross', + component: (props: MarkerProps) => ( + + ), + }, + none: { + name: 'none', + component: () => , + }, +} as const; + +/** + * Arrow marker names that can be used with BaseLink. + * @group Components + * @category Link + */ +export type LinkArrowName = keyof typeof LINK_ARROWS; + +/** + * Get an arrow marker by name. + * @param name - The name of the arrow marker. + * @returns The arrow marker or undefined if not found. + * @group Components + * @category Link + */ +export function getLinkArrow(name: LinkArrowName): LinkArrowMarker | undefined { + return LINK_ARROWS[name]; +} diff --git a/packages/joint-react/src/components/paper/__tests__/graph-provider-controlled-mode.test.tsx b/packages/joint-react/src/components/paper/__tests__/graph-provider-controlled-mode.test.tsx index a8553c5c55..09c6bb29f1 100644 --- a/packages/joint-react/src/components/paper/__tests__/graph-provider-controlled-mode.test.tsx +++ b/packages/joint-react/src/components/paper/__tests__/graph-provider-controlled-mode.test.tsx @@ -14,8 +14,8 @@ describe('GraphProvider Controlled Mode', () => { describe('Basic useState integration', () => { it('should sync React state to store and graph on initial mount', async () => { const initialElements = createElements([ - { id: '1', width: 100, height: 100, type: 'ReactElement' }, - { id: '2', width: 200, height: 200, type: 'ReactElement' }, + { id: '1', width: 100, height: 100 }, + { id: '2', width: 200, height: 200 }, ]); let elementCount = 0; @@ -46,9 +46,7 @@ describe('GraphProvider Controlled Mode', () => { }); it('should update store when React state changes via useState', async () => { - const initialElements = createElements([ - { id: '1', width: 100, height: 100, type: 'ReactElement' }, - ]); + const initialElements = createElements([{ id: '1', width: 100, height: 100 }]); let elementCount = 0; let elementIds: string[] = []; @@ -82,9 +80,9 @@ describe('GraphProvider Controlled Mode', () => { act(() => { setElementsExternal?.( createElements([ - { id: '1', width: 100, height: 100, type: 'ReactElement' }, - { id: '2', width: 200, height: 200, type: 'ReactElement' }, - { id: '3', width: 300, height: 300, type: 'ReactElement' }, + { id: '1', width: 100, height: 100 }, + { id: '2', width: 200, height: 200 }, + { id: '3', width: 300, height: 300 }, ]) ); }); @@ -96,9 +94,7 @@ describe('GraphProvider Controlled Mode', () => { }); it('should handle both elements and links in controlled mode', async () => { - const initialElements = createElements([ - { id: '1', width: 100, height: 100, type: 'ReactElement' }, - ]); + const initialElements = createElements([{ id: '1', width: 100, height: 100 }]); const initialLink = new dia.Link({ id: 'link1', type: 'standard.Link', @@ -146,8 +142,8 @@ describe('GraphProvider Controlled Mode', () => { act(() => { setElementsExternal?.( createElements([ - { id: '1', width: 100, height: 100, type: 'ReactElement' }, - { id: '2', width: 200, height: 200, type: 'ReactElement' }, + { id: '1', width: 100, height: 100 }, + { id: '2', width: 200, height: 200 }, ]) ); }); @@ -188,9 +184,7 @@ describe('GraphProvider Controlled Mode', () => { describe('Rapid consecutive updates', () => { it('should handle rapid consecutive state updates correctly', async () => { - const initialElements = createElements([ - { id: '1', width: 100, height: 100, type: 'ReactElement' }, - ]); + const initialElements = createElements([{ id: '1', width: 100, height: 100 }]); let elementCount = 0; @@ -222,23 +216,23 @@ describe('GraphProvider Controlled Mode', () => { act(() => { setElementsExternal?.( createElements([ - { id: '1', width: 100, height: 100, type: 'ReactElement' }, - { id: '2', width: 200, height: 200, type: 'ReactElement' }, + { id: '1', width: 100, height: 100 }, + { id: '2', width: 200, height: 200 }, ]) ); setElementsExternal?.( createElements([ - { id: '1', width: 100, height: 100, type: 'ReactElement' }, - { id: '2', width: 200, height: 200, type: 'ReactElement' }, - { id: '3', width: 300, height: 300, type: 'ReactElement' }, + { id: '1', width: 100, height: 100 }, + { id: '2', width: 200, height: 200 }, + { id: '3', width: 300, height: 300 }, ]) ); setElementsExternal?.( createElements([ - { id: '1', width: 100, height: 100, type: 'ReactElement' }, - { id: '2', width: 200, height: 200, type: 'ReactElement' }, - { id: '3', width: 300, height: 300, type: 'ReactElement' }, - { id: '4', width: 400, height: 400, type: 'ReactElement' }, + { id: '1', width: 100, height: 100 }, + { id: '2', width: 200, height: 200 }, + { id: '3', width: 300, height: 300 }, + { id: '4', width: 400, height: 400 }, ]) ); }); @@ -252,9 +246,7 @@ describe('GraphProvider Controlled Mode', () => { }); it('should handle 10 rapid updates without losing state', async () => { - const initialElements = createElements([ - { id: '1', width: 100, height: 100, type: 'ReactElement' }, - ]); + const initialElements = createElements([{ id: '1', width: 100, height: 100 }]); let elementCount = 0; @@ -290,7 +282,6 @@ describe('GraphProvider Controlled Mode', () => { id: String(elementIndex + 1), width: 100 * (elementIndex + 1), height: 100 * (elementIndex + 1), - type: 'ReactElement' as const, })) ) ); @@ -308,9 +299,7 @@ describe('GraphProvider Controlled Mode', () => { describe('Concurrent updates', () => { it('should handle concurrent element and link updates', async () => { - const initialElements = createElements([ - { id: '1', width: 100, height: 100, type: 'ReactElement' }, - ]); + const initialElements = createElements([{ id: '1', width: 100, height: 100 }]); const initialLink = new dia.Link({ id: 'link1', type: 'standard.Link', @@ -358,8 +347,8 @@ describe('GraphProvider Controlled Mode', () => { act(() => { setElementsExternal?.( createElements([ - { id: '1', width: 100, height: 100, type: 'ReactElement' }, - { id: '2', width: 200, height: 200, type: 'ReactElement' }, + { id: '1', width: 100, height: 100 }, + { id: '2', width: 200, height: 200 }, ]) ); setLinksExternal?.([ @@ -392,9 +381,7 @@ describe('GraphProvider Controlled Mode', () => { }); it('should handle multiple rapid updates with callbacks', async () => { - const initialElements = createElements([ - { id: '1', width: 100, height: 100, type: 'ReactElement' }, - ]); + const initialElements = createElements([{ id: '1', width: 100, height: 100 }]); let elementCount = 0; @@ -414,7 +401,6 @@ describe('GraphProvider Controlled Mode', () => { id: String(previous.length + 1), width: 100 * (previous.length + 1), height: 100 * (previous.length + 1), - type: 'ReactElement' as const, }, ]); }, []); @@ -454,9 +440,7 @@ describe('GraphProvider Controlled Mode', () => { describe('User interaction sync back to React state', () => { it('should sync graph changes back to React state in controlled mode', async () => { - const initialElements = createElements([ - { id: '1', width: 100, height: 100, type: 'ReactElement' }, - ]); + const initialElements = createElements([{ id: '1', width: 100, height: 100 }]); let reactStateElements: GraphElement[] = []; let storeElements: GraphElement[] = []; @@ -525,9 +509,7 @@ describe('GraphProvider Controlled Mode', () => { }); it('should handle element position changes from user interaction', async () => { - const initialElements = createElements([ - { id: '1', width: 100, height: 100, x: 0, y: 0, type: 'ReactElement' }, - ]); + const initialElements = createElements([{ id: '1', width: 100, height: 100, x: 0, y: 0 }]); let reactStateElements: GraphElement[] = []; @@ -587,9 +569,7 @@ describe('GraphProvider Controlled Mode', () => { describe('Edge cases', () => { it('should handle empty arrays correctly', async () => { - const initialElements = createElements([ - { id: '1', width: 100, height: 100, type: 'ReactElement' }, - ]); + const initialElements = createElements([{ id: '1', width: 100, height: 100 }]); let elementCount = 0; @@ -629,8 +609,8 @@ describe('GraphProvider Controlled Mode', () => { act(() => { setElementsExternal?.( createElements([ - { id: '1', width: 100, height: 100, type: 'ReactElement' }, - { id: '2', width: 200, height: 200, type: 'ReactElement' }, + { id: '1', width: 100, height: 100 }, + { id: '2', width: 200, height: 200 }, ]) ); }); diff --git a/packages/joint-react/src/components/paper/__tests__/graph-provider.test.tsx b/packages/joint-react/src/components/paper/__tests__/graph-provider.test.tsx index 1af64589d1..21909b3190 100644 --- a/packages/joint-react/src/components/paper/__tests__/graph-provider.test.tsx +++ b/packages/joint-react/src/components/paper/__tests__/graph-provider.test.tsx @@ -1,14 +1,16 @@ -import React, { createRef, useState } from 'react'; +import React, { createRef, useState, useCallback } from 'react'; import { act, render, waitFor } from '@testing-library/react'; import { GraphStoreContext } from '../../../context'; import { GraphStore } from '../../../store'; import { dia, shapes } from '@joint/core'; import { useElements, useLinks } from '../../../hooks'; -import { createElements } from '../../../utils/create'; +import { createElements, createLinks } from '../../../utils/create'; import type { GraphElement } from '../../../types/element-types'; import type { GraphLink } from '../../../types/link-types'; import { mapLinkFromGraph } from '../../../utils/cell/cell-utilities'; import { GraphProvider } from '../../graph/graph-provider'; +import { Paper } from '../../paper/paper'; +import type { RenderLink } from '../../paper/paper.types'; describe('graph', () => { it('should render children and match snapshot', () => { @@ -373,4 +375,181 @@ describe('graph', () => { expect(graphRef.current).not.toBeNull(); expect(graphRef.current?.destroy).toBeDefined(); }); + + it('should pass correct link data to renderLink function', async () => { + const elements = createElements([ + { + id: 'element-1', + x: 0, + y: 0, + width: 100, + height: 100, + }, + { + id: 'element-2', + x: 200, + y: 200, + width: 100, + height: 100, + }, + ]); + + const links = createLinks([ + { + id: 'link-1', + source: 'element-1', + target: 'element-2', + type: 'standard.Link', + z: 1, + }, + { + id: 'link-2', + source: 'element-2', + target: 'element-1', + type: 'standard.Link', + z: 2, + customProperty: 'custom-value', + }, + ]); + + const receivedLinks: GraphLink[] = []; + + function TestComponent() { + const renderLink: RenderLink = useCallback((link) => { + receivedLinks.push(link); + return ; + }, []); + + return ( + } + /> + ); + } + + render( + + + + ); + + await waitFor(() => { + expect(receivedLinks.length).toBe(2); + }); + + // Verify first link data + const link1 = receivedLinks.find((link) => link.id === 'link-1'); + expect(link1).toBeDefined(); + expect(link1?.id).toBe('link-1'); + expect(link1?.source).toBe('element-1'); + expect(link1?.target).toBe('element-2'); + expect(link1?.type).toBe('standard.Link'); + expect(link1?.z).toBe(1); + + // Verify second link data + const link2 = receivedLinks.find((link) => link.id === 'link-2'); + expect(link2).toBeDefined(); + expect(link2?.id).toBe('link-2'); + expect(link2?.source).toBe('element-2'); + expect(link2?.target).toBe('element-1'); + expect(link2?.type).toBe('standard.Link'); + expect(link2?.z).toBe(2); + expect(link2?.customProperty).toBe('custom-value'); + }); + + it('should pass updated link data to renderLink when links change', async () => { + const elements = createElements([ + { + id: 'element-1', + x: 0, + y: 0, + width: 100, + height: 100, + }, + { + id: 'element-2', + x: 200, + y: 200, + width: 100, + height: 100, + }, + ]); + + const initialLinks = createLinks([ + { + id: 'link-1', + source: 'element-1', + target: 'element-2', + }, + ]); + + const receivedLinks: GraphLink[] = []; + + let setLinksExternal: ((links: GraphLink[]) => void) | null = null; + + function ControlledGraph() { + const [links, setLinks] = useState(() => initialLinks); + setLinksExternal = setLinks as unknown as (links: GraphLink[]) => void; + + const renderLink: RenderLink = useCallback((link) => { + receivedLinks.push(link); + return ; + }, []); + + return ( + + } + /> + + ); + } + + render(); + + await waitFor(() => { + expect(receivedLinks.length).toBeGreaterThanOrEqual(1); + }); + + const initialLink = receivedLinks.find((link) => link.id === 'link-1'); + expect(initialLink).toBeDefined(); + expect(initialLink?.source).toBe('element-1'); + expect(initialLink?.target).toBe('element-2'); + + // Clear received links to track new ones + receivedLinks.length = 0; + + // Update links + act(() => { + setLinksExternal?.( + createLinks([ + { + id: 'link-2', + source: 'element-2', + target: 'element-1', + customProperty: 'updated-value', + }, + ]) + ); + }); + + await waitFor(() => { + expect(receivedLinks.length).toBeGreaterThanOrEqual(1); + }); + + const updatedLink = receivedLinks.find((link) => link.id === 'link-2'); + expect(updatedLink).toBeDefined(); + expect(updatedLink?.id).toBe('link-2'); + expect(updatedLink?.source).toBe('element-2'); + expect(updatedLink?.target).toBe('element-1'); + expect(updatedLink?.customProperty).toBe('updated-value'); + }); }); diff --git a/packages/joint-react/src/components/paper/__tests__/paper.test.tsx b/packages/joint-react/src/components/paper/__tests__/paper.test.tsx index 3ecc1cc845..6183e2fd42 100644 --- a/packages/joint-react/src/components/paper/__tests__/paper.test.tsx +++ b/packages/joint-react/src/components/paper/__tests__/paper.test.tsx @@ -144,7 +144,10 @@ describe('Paper Component', () => { const customEvents = { MyCustomEventOnClick: handleCustomEvent }; render( - customEvents={customEvents}> + + customEvents={customEvents} + renderElement={({ label }) =>
{label}
} + >
@@ -158,7 +161,7 @@ describe('Paper Component', () => { it('applies default clickThreshold and custom clickThreshold', () => { render( - /> + renderElement={() =>
Test
} />
); const PaperElement = document.querySelector('.joint-paper'); @@ -166,7 +169,7 @@ describe('Paper Component', () => { render( - clickThreshold={20} /> + clickThreshold={20} renderElement={() =>
Test
} />
); // Ensure no errors occur when custom clickThreshold is applied @@ -176,7 +179,7 @@ describe('Paper Component', () => { it('applies scale to the Paper', async () => { render( - scale={2} /> + scale={2} renderElement={() =>
Test
} />
); @@ -190,7 +193,7 @@ describe('Paper Component', () => { const onElementsSizeReadyMock = jest.fn(); render( - onElementsSizeReady={onElementsSizeReadyMock} /> + onElementsSizeReady={onElementsSizeReadyMock} renderElement={() =>
Test
} />
); await waitFor(() => { @@ -338,7 +341,7 @@ describe('Paper Component', () => { currentOutsideElements = currentElements as Element[]; return ( - /> + renderElement={() =>
Test
} />
); diff --git a/packages/joint-react/src/components/paper/paper.stories.tsx b/packages/joint-react/src/components/paper/paper.stories.tsx index a198d058bd..216fff92d5 100644 --- a/packages/joint-react/src/components/paper/paper.stories.tsx +++ b/packages/joint-react/src/components/paper/paper.stories.tsx @@ -452,6 +452,7 @@ export const WithDataWithoutWidthAndHeightAndXAndY: Story = { id: '1', label: 'Element 1', hoverColor: 'red', + somethingMine: true, } as GraphElement & { label: string; hoverColor: string }, { id: '2', label: 'Element 1', hoverColor: 'red' } as GraphElement & { label: string; diff --git a/packages/joint-react/src/components/paper/paper.tsx b/packages/joint-react/src/components/paper/paper.tsx index b7d8a4fd54..f076b03e86 100644 --- a/packages/joint-react/src/components/paper/paper.tsx +++ b/packages/joint-react/src/components/paper/paper.tsx @@ -21,15 +21,18 @@ import { useMemo, useRef, useState, + useDeferredValue, type CSSProperties, } from 'react'; -import { useElements } from '../../hooks'; +import { useElements, useLinks } from '../../hooks'; import type { GraphElement } from '../../types/element-types'; -import type { PaperProps, RenderElement } from './paper.types'; +import type { GraphLink } from '../../types/link-types'; +import type { PaperProps, RenderElement, RenderLink } from './paper.types'; import { assignOptions, dependencyExtract } from '../../utils/object-utilities'; import { PaperHTMLContainer } from './render-element/paper-html-container'; import { CellIdContext, PaperConfigContext, PaperStoreContext } from '../../context'; import { HTMLElementItem, SVGElementItem } from './render-element/paper-element-item'; +import { createPortal } from 'react-dom'; import { handlePaperEvents, PAPER_EVENT_KEYS } from '../../utils/handle-paper-events'; import type { PaperStore } from '../../store'; import { @@ -39,6 +42,24 @@ import { const EMPTY_OBJECT = {} as Record; +// eslint-disable-next-line jsdoc/require-jsdoc +function LinkItem({ + link, + portalElement, + renderLink, +}: { + link: GraphLink; + portalElement: SVGAElement; + renderLink: RenderLink; +}) { + if (!portalElement) { + return null; + } + + const linkContent = renderLink(link); + return createPortal(linkContent, portalElement); +} + /** * Paper component renders the visual representation of the graph using JointJS Paper. * This component is responsible for managing the rendering of elements and links, handling events, and providing customization options for the graph view. @@ -67,6 +88,7 @@ function PaperBase( ) { const { renderElement, + renderLink, defaultLink, style, className, @@ -82,7 +104,17 @@ function PaperBase( const areElementsMeasured = useAreElementsMeasured(); const elementsState = useElements(); + const linksState = useLinks(); useDebugValue(elementsState); + + // Defer rendering for large graphs to improve initial render performance + // Threshold: only defer if we have more than 100 elements or links + // Always call useDeferredValue (hooks rules), but only use deferred value when threshold is met + const deferredElementsStateRaw = useDeferredValue(elementsState); + const deferredLinksStateRaw = useDeferredValue(linksState); + const shouldDefer = elementsState.length > 100 || linksState.length > 100; + const deferredElementsState = shouldDefer ? deferredElementsStateRaw : elementsState; + const deferredLinksState = shouldDefer ? deferredLinksStateRaw : linksState; const reactId = useId(); const id = props.id ?? `paper-${reactId}`; const { overWrite } = useContext(PaperConfigContext) ?? {}; @@ -91,10 +123,32 @@ function PaperBase( (snapshot) => snapshot.papers[id]?.paperElementViews ?? EMPTY_OBJECT ); + const paperLinkViews = useGraphInternalStoreSelector( + (snapshot) => snapshot.papers[id]?.linkViews ?? EMPTY_OBJECT + ); + + // Check if all links have views (or if there are no links) + // This prevents the blink where elements appear before links + const areLinksReady = useMemo(() => { + if (!renderLink) { + return true; // No custom link rendering, so links are "ready" + } + if (deferredLinksState.length === 0) { + return true; // No links, so links are "ready" + } + // Check if all links have views + for (const link of deferredLinksState) { + if (link.id && !paperLinkViews[link.id]) { + return false; // At least one link doesn't have a view yet + } + } + return true; + }, [renderLink, deferredLinksState, paperLinkViews]); + const { addPaper, graph, getPaperStore } = useGraphStore(); const paperStore = getPaperStore(id) ?? null; - const { paper, ReactElementView } = paperStore ?? {}; + const { paper, ReactElementView, ReactLinkView } = paperStore ?? {}; const paperHTMLElement = useRef(null); const measured = useRef(false); const previousSizesRef = useRef([]); @@ -102,6 +156,7 @@ function PaperBase( const [HTMLRendererContainer, setHTMLRendererContainer] = useState(null); const hasRenderElement = !!renderElement; + const hasRenderLink = !!renderLink; useImperativeHandle(forwardedRef, () => paperStore as PaperStore, [paperStore]); @@ -133,6 +188,7 @@ function PaperBase( }, overWrite, renderElement: renderElement as RenderElement, + renderLink: renderLink as RenderLink | undefined, scale, }); return () => { @@ -210,6 +266,7 @@ function PaperBase( if (!paper) return; // Build current list of [currWidth, currHeight] to avoid shadowing outer scope variables + // Use elementsState (not deferred) for accurate size tracking const currentSizes = elementsState.map( ({ width: elementWidth = 0, height: elementHeight = 0 }) => [elementWidth, elementHeight] ); @@ -273,7 +330,7 @@ function PaperBase( if (!hasRenderElement) { return null; } - return elementsState.map((elementState) => { + return deferredElementsState.map((elementState) => { if (!elementState.id) { return null; } @@ -312,7 +369,7 @@ function PaperBase( }); }, [ hasRenderElement, - elementsState, + deferredElementsState, paperElementViews, ReactElementView, useHTMLOverlay, @@ -320,11 +377,53 @@ function PaperBase( renderElement, ]); + const renderedLinks = useMemo(() => { + if (!ReactLinkView) { + return null; + } + + if (!hasRenderLink) { + return null; + } + return deferredLinksState.map((linkState) => { + if (!linkState.id) { + return null; + } + + const linkView = paperLinkViews[linkState.id]; + if (!linkView) { + return null; + } + + const SVG = linkView.el; + if (!SVG) { + return null; + } + + const isReactLink = linkView instanceof ReactLinkView; + + if (!isReactLink) { + return null; + } + + if (!renderLink) { + return null; + } + + return ( + + + + ); + }); + }, [hasRenderLink, deferredLinksState, paperLinkViews, ReactLinkView, renderLink]); + const content = ( <> {hasRenderElement && useHTMLOverlay && ( )} + {renderedLinks} {renderedElements} ); @@ -339,13 +438,17 @@ function PaperBase( }; }, [height, width, style]); + // Only show paper when both elements are measured AND links are ready + // This prevents the blink where elements appear before links + const isContentReady = areElementsMeasured && areLinksReady; + const paperContainerStyle = useMemo( (): CSSProperties => ({ - opacity: areElementsMeasured ? 1 : 0, + opacity: isContentReady ? 1 : 0, position: 'relative', ...defaultStyle, }), - [areElementsMeasured, defaultStyle] + [isContentReady, defaultStyle] ); return ( diff --git a/packages/joint-react/src/components/paper/paper.types.ts b/packages/joint-react/src/components/paper/paper.types.ts index f669191920..71d7254df7 100644 --- a/packages/joint-react/src/components/paper/paper.types.ts +++ b/packages/joint-react/src/components/paper/paper.types.ts @@ -26,6 +26,10 @@ export type RenderElement = ( element: ElementItem ) => ReactNode; +export type RenderLink = ( + link: LinkItem +) => ReactNode; + /** * The props for the Paper component. Extend the `dia.Paper.Options` interface. * For more information, see the JointJS documentation. @@ -63,6 +67,44 @@ export interface PaperProps */ readonly renderElement?: RenderElement; + /** + * A function that renders the link. + * + * Note: JointJS works with SVG by default, so `renderLink` content is appended inside an SVG node. + * To render HTML elements, use an SVG `foreignObject`. + * + * This is called when the link data changes. + * @example + * Example with `global component`: + * ```tsx + * function RenderLink({ id, ...data }) { + * return ( + * <> + * + * + * Label + * + * + * ); + * } + * ``` + * @example + * Example with `local component`: + * ```tsx + * const renderLink: RenderLink = useCallback( + * (link) => ( + * <> + * + * + * {link.label} + * + * + * ), + * [] + * ) + * ``` + */ + readonly renderLink?: RenderLink; /** * Event called when all elements are properly measured (has all elements width and height greater than 1 - default). * In react, we cannot detect jointjs paper render:done event properly, so we use this special event to check if all elements are measured. diff --git a/packages/joint-react/src/components/port/port-group.tsx b/packages/joint-react/src/components/port/port-group.tsx index fb4bbbbdff..9e790a3518 100644 --- a/packages/joint-react/src/components/port/port-group.tsx +++ b/packages/joint-react/src/components/port/port-group.tsx @@ -3,8 +3,9 @@ /* eslint-disable @typescript-eslint/no-shadow */ /* eslint-disable no-shadow */ import type { dia } from '@joint/core'; -import { memo, useEffect } from 'react'; -import { useCellId, useGraph } from '../../hooks'; +import { memo, useLayoutEffect } from 'react'; +import { useCellId } from '../../hooks'; +import { useGraphStore } from '../../hooks/use-graph-store'; import { PortGroupContext } from '../../context/port-group-context'; import type { PortLayout, Position } from './port.types'; @@ -96,14 +97,13 @@ function Component(props: PortGroupProps) { } } const cellId = useCellId(); - const graph = useGraph(); + const graphStore = useGraphStore(); + const { graph } = graphStore; - useEffect(() => { + useLayoutEffect(() => { const cell = graph.getCell(cellId); if (!cell?.isElement()) return; - const ports = cell.get('ports') || {}; - const groups = ports.groups || {}; const newGroup = getGroupBody({ position, width, @@ -120,23 +120,21 @@ function Component(props: PortGroupProps) { step, compensateRotation, }); - cell.set('ports', { - ...ports, - groups: { - ...groups, - [id]: { - ...newGroup, - size: { height, width }, - }, + + // Set port group via graphStore for batching + const groupData: dia.Element.PortGroup = { + ...newGroup, + size: { + height: typeof height === 'number' ? height : Number(height) || 0, + width: typeof width === 'number' ? width : Number(width) || 0, }, - }); + }; + graphStore.setPortGroup(cellId, id, groupData); + graphStore.flushPendingUpdates(); return () => { - const ports = cell.get('ports') || {}; - const groups = { ...ports.groups }; - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete groups[id]; - cell.set('ports', { ...ports, groups }); + // Remove port group via graphStore for batching + graphStore.removePortGroup(cellId, id); }; }, [ angle, @@ -147,6 +145,7 @@ function Component(props: PortGroupProps) { dy, end, graph, + graphStore, height, id, position, diff --git a/packages/joint-react/src/components/port/port-item.tsx b/packages/joint-react/src/components/port/port-item.tsx index e15fdc16a3..66c1462d83 100644 --- a/packages/joint-react/src/components/port/port-item.tsx +++ b/packages/joint-react/src/components/port/port-item.tsx @@ -1,5 +1,5 @@ import type { dia } from '@joint/core'; -import { memo, useContext, useEffect } from 'react'; +import { memo, useContext, useLayoutEffect } from 'react'; import { createPortal } from 'react-dom'; import { useCellId } from '../../hooks'; import { PortGroupContext } from '../../context/port-group-context'; @@ -66,11 +66,12 @@ function Component(props: PortItemProps) { throw new Error('PortItem must be used within a Paper context'); } const { paper, paperId } = paperStore; - const { graph } = useGraphStore(); + const graphStore = useGraphStore(); + const { graph } = graphStore; const contextGroupId = useContext(PortGroupContext); - useEffect(() => { + useLayoutEffect(() => { const cell = graph.getCell(cellId); if (!cell) { throw new Error(`Cell with id ${cellId} not found`); @@ -105,19 +106,22 @@ function Component(props: PortItemProps) { markup: elementMarkup, }; - cell.addPort(port); + // Add port via graphStore for batching + graphStore.setPort(cellId, id, port); + graphStore.flushPendingUpdates(); return () => { - cell.removePort(id); + // Remove port via graphStore for batching + graphStore.removePort(cellId, id); }; - }, [cellId, contextGroupId, graph, groupId, id, x, y, z, magnet, dx, dy]); + }, [cellId, contextGroupId, graph, graphStore, groupId, id, x, y, z, magnet, dx, dy]); const portalNode = useGraphInternalStoreSelector((state) => { const portId = paperStore.getPortId(cellId, id); return state.papers[paperId]?.portsData?.[portId]; }); - useEffect(() => { + useLayoutEffect(() => { if (!portalNode) { return; } diff --git a/packages/joint-react/src/hooks/use-node-size.tsx b/packages/joint-react/src/hooks/use-node-size.tsx index 829e9585cf..dcb321929d 100644 --- a/packages/joint-react/src/hooks/use-node-size.tsx +++ b/packages/joint-react/src/hooks/use-node-size.tsx @@ -35,7 +35,7 @@ export interface MeasureNodeOptions { } const EMPTY_OBJECT: MeasureNodeOptions = {}; -const EMPTY_NODE_LAYOUT: NodeLayout = { x: 0, y: 0, width: 0, height: 0 }; +const EMPTY_NODE_LAYOUT: NodeLayout = { x: 0, y: 0, width: 0, height: 0, angle: 0 }; /** * Custom hook to automatically measure the size of a DOM element and synchronize it with the graph element's size. diff --git a/packages/joint-react/src/hooks/use-state-to-external-store.ts b/packages/joint-react/src/hooks/use-state-to-external-store.ts index 5e7e356b06..051913846f 100644 --- a/packages/joint-react/src/hooks/use-state-to-external-store.ts +++ b/packages/joint-react/src/hooks/use-state-to-external-store.ts @@ -12,7 +12,6 @@ import type { ExternalStoreLike } from '../utils/create-state'; import type { GraphStoreSnapshot } from '../store'; import { isUpdater } from '../utils/is'; import { util } from '@joint/core'; -import { sendToDevTool } from '../utils/dev-tools'; import type { dia } from '@joint/core'; @@ -98,11 +97,6 @@ export function useStateToExternalStore extends dia.Link< + dia.Link.Attributes & Attributes +> { + /** + * Sets the default attributes for the ReactLink. + * @returns The default attributes. + */ + defaults() { + return { + ...super.defaults, + type: REACT_LINK_TYPE, + } as unknown as dia.Link.Attributes & Attributes; + } + markup: string | dia.MarkupJSON = [ + { + tagName: 'path', + selector: 'wrapper', + }, + { + tagName: 'path', + selector: 'line', + }, + ]; +} diff --git a/packages/joint-react/src/state/__tests__/graph-state-selectors.test.ts b/packages/joint-react/src/state/__tests__/graph-state-selectors.test.ts index 6e971c2449..37d20c91a5 100644 --- a/packages/joint-react/src/state/__tests__/graph-state-selectors.test.ts +++ b/packages/joint-react/src/state/__tests__/graph-state-selectors.test.ts @@ -5,13 +5,13 @@ import { ReactElement } from '../../models/react-element'; import type { GraphElement } from '../../types/element-types'; import type { GraphLink } from '../../types/link-types'; import { - defaultElementFromGraphSelector, + defaultGraphToElementSelector, defaultElementToGraphSelector, - defaultLinkFromGraphSelector, + defaultGraphToLinkSelector, defaultLinkToGraphSelector, - type ElementFromGraphOptions, + type GraphToElementOptions, type ElementToGraphOptions, - type LinkFromGraphOptions, + type GraphToLinkOptions, type LinkToGraphOptions, } from '../graph-state-selectors'; @@ -60,7 +60,7 @@ describe('graph-state-selectors', () => { // Round-trip: element → graph → element graph.syncCells([elementAsGraphJson], { remove: true }); - const elementFromGraph = defaultElementFromGraphSelector({ + const elementFromGraph = defaultGraphToElementSelector({ cell: graph.getCell('element-1') as dia.Element, graph, }); @@ -95,7 +95,7 @@ describe('graph-state-selectors', () => { // Round-trip: element → graph → element graph.syncCells([elementAsGraphJson], { remove: true }); - const elementFromGraph = defaultElementFromGraphSelector({ + const elementFromGraph = defaultGraphToElementSelector({ cell: graph.getCell('element-1') as dia.Element, graph, }); @@ -137,7 +137,7 @@ describe('graph-state-selectors', () => { // Round-trip: element → graph → element graph.syncCells([elementAsGraphJson], { remove: true }); - const elementFromGraph = defaultElementFromGraphSelector({ + const elementFromGraph = defaultGraphToElementSelector({ cell: graph.getCell('element-1') as dia.Element, graph, }); @@ -154,7 +154,7 @@ describe('graph-state-selectors', () => { }); }); - describe('defaultElementFromGraphSelector', () => { + describe('defaultGraphToElementSelector', () => { it('should map graph cell to element without previous state', () => { const elementAsGraphJson = { type: 'ReactElement', @@ -165,12 +165,12 @@ describe('graph-state-selectors', () => { graph.syncCells([elementAsGraphJson], { remove: true }); const cell = graph.getCell('element-1') as dia.Element; - const options: ElementFromGraphOptions = { + const options: GraphToElementOptions = { cell, graph, }; - const elementFromGraph = defaultElementFromGraphSelector(options); + const elementFromGraph = defaultGraphToElementSelector(options); expect(elementFromGraph).toMatchObject({ id: 'element-1', @@ -188,7 +188,7 @@ describe('graph-state-selectors', () => { graph.clear(); graph.syncCells([recreatedElementAsGraphJson], { remove: true }); - const elementFromRoundTrip = defaultElementFromGraphSelector({ + const elementFromRoundTrip = defaultGraphToElementSelector({ cell: graph.getCell('element-1') as dia.Element, graph, }); @@ -229,13 +229,13 @@ describe('graph-state-selectors', () => { // extraProp is not in previous, so it should be filtered out }; - const options: ElementFromGraphOptions = { + const options: GraphToElementOptions = { cell, graph, previous, }; - const result = defaultElementFromGraphSelector(options); + const result = defaultGraphToElementSelector(options); // Should only include properties that exist in previous state expect(result).toMatchObject({ @@ -274,13 +274,13 @@ describe('graph-state-selectors', () => { customProp: undefined, // Explicitly undefined in previous }; - const options: ElementFromGraphOptions = { + const options: GraphToElementOptions = { cell, graph, previous, }; - const result = defaultElementFromGraphSelector(options); + const result = defaultGraphToElementSelector(options); // Should include customProp even though it's undefined in previous expect(result).toHaveProperty('customProp'); @@ -297,12 +297,12 @@ describe('graph-state-selectors', () => { graph.syncCells([elementAsGraphJson], { remove: true }); const cell = graph.getCell('element-1') as dia.Element; - const options: ElementFromGraphOptions = { + const options: GraphToElementOptions = { cell, graph, }; - const elementFromGraph = defaultElementFromGraphSelector(options); + const elementFromGraph = defaultGraphToElementSelector(options); expect(elementFromGraph.type).toBe('standard.Rectangle'); }); @@ -326,12 +326,12 @@ describe('graph-state-selectors', () => { graph.syncCells([elementAsGraphJson], { remove: true }); const cell = graph.getCell('element-1') as dia.Element; - const options: ElementFromGraphOptions = { + const options: GraphToElementOptions = { cell, graph, }; - const result = defaultElementFromGraphSelector(options); + const result = defaultGraphToElementSelector(options); expect(result.ports).toEqual(ports); }); @@ -363,7 +363,7 @@ describe('graph-state-selectors', () => { // Round-trip: link → graph → link graph.syncCells([linkAsGraphJson], { remove: true }); - const linkFromGraph = defaultLinkFromGraphSelector({ + const linkFromGraph = defaultGraphToLinkSelector({ cell: graph.getCell('link-1') as dia.Link, graph, }); @@ -401,7 +401,7 @@ describe('graph-state-selectors', () => { // Round-trip: link → graph → link graph.syncCells([linkAsGraphJson], { remove: true }); - const linkFromGraph = defaultLinkFromGraphSelector({ + const linkFromGraph = defaultGraphToLinkSelector({ cell: graph.getCell('link-1') as dia.Link, graph, }); @@ -465,7 +465,7 @@ describe('graph-state-selectors', () => { }); }); - describe('defaultLinkFromGraphSelector', () => { + describe('defaultGraphToLinkSelector', () => { it('should map graph link to link without previous state', () => { const linkAsGraphJson = { type: 'standard.Link', @@ -477,12 +477,12 @@ describe('graph-state-selectors', () => { graph.syncCells([linkAsGraphJson], { remove: true }); const link = graph.getCell('link-1') as dia.Link; - const options: LinkFromGraphOptions = { + const options: GraphToLinkOptions = { cell: link, graph, }; - const linkFromGraph = defaultLinkFromGraphSelector(options); + const linkFromGraph = defaultGraphToLinkSelector(options); expect(linkFromGraph).toMatchObject({ id: 'link-1', @@ -521,13 +521,13 @@ describe('graph-state-selectors', () => { // extraProp is not in previous, so it should be filtered out }; - const options: LinkFromGraphOptions = { + const options: GraphToLinkOptions = { cell: link, graph, previous, }; - const linkFromGraph = defaultLinkFromGraphSelector(options); + const linkFromGraph = defaultGraphToLinkSelector(options); // Should only include properties that exist in previous state expect(linkFromGraph).toMatchObject({ @@ -565,13 +565,13 @@ describe('graph-state-selectors', () => { customProp: undefined, // Explicitly undefined in previous }; - const options: LinkFromGraphOptions = { + const options: GraphToLinkOptions = { cell: link, graph, previous, }; - const linkFromGraph = defaultLinkFromGraphSelector(options); + const linkFromGraph = defaultGraphToLinkSelector(options); // Should include customProp even though it's undefined in previous expect(linkFromGraph).toHaveProperty('customProp'); @@ -588,12 +588,12 @@ describe('graph-state-selectors', () => { graph.syncCells([linkAsGraphJson], { remove: true }); const link = graph.getCell('link-1') as dia.Link; - const options: LinkFromGraphOptions = { + const options: GraphToLinkOptions = { cell: link, graph, }; - const linkFromGraph = defaultLinkFromGraphSelector(options); + const linkFromGraph = defaultGraphToLinkSelector(options); expect(linkFromGraph.source).toEqual({ id: 'element-1', port: 'port-1' }); expect(linkFromGraph.target).toEqual({ id: 'element-2', port: 'port-2' }); @@ -612,12 +612,12 @@ describe('graph-state-selectors', () => { graph.syncCells([linkAsGraphJson], { remove: true }); const link = graph.getCell('link-1') as dia.Link; - const options: LinkFromGraphOptions = { + const options: GraphToLinkOptions = { cell: link, graph, }; - const linkFromGraph = defaultLinkFromGraphSelector(options); + const linkFromGraph = defaultGraphToLinkSelector(options); expect(linkFromGraph.z).toBe(10); expect(linkFromGraph.markup).toEqual([{ tagName: 'path' }]); @@ -640,12 +640,12 @@ describe('graph-state-selectors', () => { graph.syncCells([linkAsGraphJson], { remove: true }); const link = graph.getCell('link-1') as dia.Link; - const options: LinkFromGraphOptions = { + const options: GraphToLinkOptions = { cell: link, graph, }; - const linkFromGraph = defaultLinkFromGraphSelector(options); + const linkFromGraph = defaultGraphToLinkSelector(options); expect(linkFromGraph.attrs).toBeDefined(); expect(linkFromGraph.attrs).toMatchObject({ @@ -681,13 +681,13 @@ describe('graph-state-selectors', () => { // graphOnlyProp and anotherGraphProp are NOT in previous state }; - const options: ElementFromGraphOptions = { + const options: GraphToElementOptions = { cell, graph, previous, }; - const elementFromGraph = defaultElementFromGraphSelector(options); + const elementFromGraph = defaultGraphToElementSelector(options); // Should only have properties from previous state expect(elementFromGraph).not.toHaveProperty('graphOnlyProp'); @@ -721,13 +721,13 @@ describe('graph-state-selectors', () => { customProp: undefined, // Exists but undefined }; - const options: ElementFromGraphOptions = { + const options: GraphToElementOptions = { cell, graph, previous, }; - const elementFromGraph = defaultElementFromGraphSelector(options); + const elementFromGraph = defaultGraphToElementSelector(options); // Should update customProp from graph expect((elementFromGraph as ExtendedElement).customProp).toBe('updated-value'); @@ -758,7 +758,7 @@ describe('graph-state-selectors', () => { expect(graphLink).toBeDefined(); // Convert back to link - const linkFromGraph = defaultLinkFromGraphSelector({ + const linkFromGraph = defaultGraphToLinkSelector({ cell: graphLink, graph, }); @@ -797,7 +797,7 @@ describe('graph-state-selectors', () => { graph.syncCells([linkAsGraphJson], { remove: true }); const graphLink = graph.getCell('link-1') as dia.Link; - const linkFromGraph = defaultLinkFromGraphSelector({ + const linkFromGraph = defaultGraphToLinkSelector({ cell: graphLink, graph, }); @@ -858,7 +858,7 @@ describe('graph-state-selectors', () => { }; const graphLink = graph.getCell('link-1') as dia.Link; - const linkFromGraph = defaultLinkFromGraphSelector({ + const linkFromGraph = defaultGraphToLinkSelector({ cell: graphLink, graph, previous, @@ -937,7 +937,7 @@ describe('graph-state-selectors', () => { const retrievedLinks = graph.getLinks().map((graphLink) => { const previous = previousLinks.find((l) => l.id === graphLink.id); - return defaultLinkFromGraphSelector({ + return defaultGraphToLinkSelector({ cell: graphLink, graph, previous, @@ -1000,7 +1000,7 @@ describe('graph-state-selectors', () => { }; const graphLink = graph.getCell('link-1') as dia.Link; - const linkFromGraph = defaultLinkFromGraphSelector({ + const linkFromGraph = defaultGraphToLinkSelector({ cell: graphLink, graph, previous, @@ -1065,7 +1065,7 @@ describe('graph-state-selectors', () => { }; const graphLink = graph.getCell('link-1') as dia.Link; - const linkFromGraph = defaultLinkFromGraphSelector({ + const linkFromGraph = defaultGraphToLinkSelector({ cell: graphLink, graph, previous, @@ -1107,7 +1107,7 @@ describe('graph-state-selectors', () => { graph.syncCells([linkAsGraphJson], { remove: true }); const graphLink = graph.getCell('link-1') as dia.Link; - const linkFromGraph = defaultLinkFromGraphSelector({ + const linkFromGraph = defaultGraphToLinkSelector({ cell: graphLink, graph, }); @@ -1159,7 +1159,7 @@ describe('graph-state-selectors', () => { }; const graphElement = graph.getCell('element-1') as dia.Element; - const elementFromGraph = defaultElementFromGraphSelector({ + const elementFromGraph = defaultGraphToElementSelector({ cell: graphElement, graph, previous, @@ -1209,7 +1209,7 @@ describe('graph-state-selectors', () => { }; const graphLink = graph.getCell('link-1') as dia.Link; - const linkFromGraph = defaultLinkFromGraphSelector({ + const linkFromGraph = defaultGraphToLinkSelector({ cell: graphLink, graph, previous, @@ -1262,7 +1262,7 @@ describe('graph-state-selectors', () => { }; const graphElement = graph.getCell('element-1') as dia.Element; - const elementFromGraph = defaultElementFromGraphSelector({ + const elementFromGraph = defaultGraphToElementSelector({ cell: graphElement, graph, previous, @@ -1323,7 +1323,7 @@ describe('graph-state-selectors', () => { }; const graphLink = graph.getCell('link-1') as dia.Link; - const linkFromGraph = defaultLinkFromGraphSelector({ + const linkFromGraph = defaultGraphToLinkSelector({ cell: graphLink, graph, previous, @@ -1382,7 +1382,7 @@ describe('graph-state-selectors', () => { }; const graphElement = graph.getCell('element-1') as dia.Element; - const elementFromGraph = defaultElementFromGraphSelector({ + const elementFromGraph = defaultGraphToElementSelector({ cell: graphElement, graph, previous, @@ -1432,7 +1432,7 @@ describe('graph-state-selectors', () => { }; const graphLink = graph.getCell('link-1') as dia.Link; - const linkFromGraph = defaultLinkFromGraphSelector({ + const linkFromGraph = defaultGraphToLinkSelector({ cell: graphLink, graph, previous, @@ -1496,7 +1496,7 @@ describe('graph-state-selectors', () => { }; const graphElement = graph.getCell('element-1') as dia.Element; - const elementFromGraph = defaultElementFromGraphSelector({ + const elementFromGraph = defaultGraphToElementSelector({ cell: graphElement, graph, previous, diff --git a/packages/joint-react/src/state/graph-state-selectors.ts b/packages/joint-react/src/state/graph-state-selectors.ts index 84c25d1ced..dc99d2e3e3 100644 --- a/packages/joint-react/src/state/graph-state-selectors.ts +++ b/packages/joint-react/src/state/graph-state-selectors.ts @@ -4,13 +4,14 @@ import type { GraphElement } from '../types/element-types'; import type { GraphLink } from '../types/link-types'; import { getTargetOrSource } from '../utils/cell/get-link-targe-and-source-ids'; import { REACT_TYPE } from '../models/react-element'; +import { REACT_LINK_TYPE } from '../models/react-link'; export interface ElementToGraphOptions { readonly element: Element; readonly graph: dia.Graph; } -export interface ElementFromGraphOptions { +export interface GraphToElementOptions { readonly cell: dia.Element; readonly previous?: Element; readonly graph: dia.Graph; @@ -21,21 +22,19 @@ export interface LinkToGraphOptions { readonly graph: dia.Graph; } -export interface LinkFromGraphOptions { +export interface GraphToLinkOptions { readonly cell: dia.Link; readonly previous?: Link; readonly graph: dia.Graph; } export type LinkFromGraphSelector = ( - options: LinkFromGraphOptions + options: GraphToLinkOptions ) => Link; export interface GraphStateSelectors { readonly elementToGraphSelector?: (options: ElementToGraphOptions) => dia.Cell.JSON; - readonly elementFromGraphSelector?: (options: ElementFromGraphOptions) => Element; readonly linkToGraphSelector?: (options: LinkToGraphOptions) => dia.Cell.JSON; - readonly linkFromGraphSelector?: (options: LinkFromGraphOptions) => Link; } /** @@ -64,15 +63,29 @@ export function defaultLinkToGraphSelector( {} ); + // Check if link already exists in graph to preserve its current attributes + // This is important for attributes set by React components like Link.Base + const existingCell = graph.getCell(link.id); + const existingAttributes = existingCell?.isLink() ? existingCell.attr() : {}; + + // Merge attributes: state attrs (highest priority) -> existing graph attrs (preserve React-set) -> defaults (lowest) + // This preserves attributes set by React components (existingAttributes) unless explicitly overridden by state (attrs) + const mergedAttributes = util.defaultsDeep( + {}, + attrs as never, + existingAttributes, + defaults.attrs + ); + const mergedLink = { ...rest, type, - attrs: util.defaultsDeep({}, attrs as never, defaults.attrs), + attrs: mergedAttributes, }; return { ...mergedLink, - type: link.type ?? 'standard.Link', + type: link.type ?? REACT_LINK_TYPE, source, target, } as unknown as dia.Cell.JSON; @@ -120,8 +133,8 @@ export function defaultElementToGraphSelector( * it filters the result to only include properties that existed in the previous state, ensuring * the state shape remains the source of truth. */ -export function defaultLinkFromGraphSelector( - options: LinkFromGraphOptions +export function defaultGraphToLinkSelector( + options: GraphToLinkOptions ): Link { const { cell, previous } = options; @@ -168,8 +181,8 @@ export function defaultLinkFromGraphSelector( * If a previous state is provided, it filters the result to only include properties that existed in the * previous state, ensuring the state shape remains the source of truth. */ -export function defaultElementFromGraphSelector( - options: ElementFromGraphOptions +export function defaultGraphToElementSelector( + options: GraphToElementOptions ): GraphElement { const { cell, previous } = options; @@ -188,7 +201,6 @@ export function defaultElementFromGraphSelector( // If previous state exists, filter to only include properties that exist in previous state // This ensures state shape is the source of truth - // Properties (including x, y, width, height) are only included if they were defined in previous state if (previous !== undefined) { const filtered: Record = {}; const previousRecord = previous as Record; @@ -199,9 +211,6 @@ export function defaultElementFromGraphSelector( filtered[key] = key in cellData ? cellData[key] : previousRecord[key]; } } - - // Always include id as it's the identifier - if ('id' in cellData) filtered.id = cellData.id; return filtered as Element; } diff --git a/packages/joint-react/src/state/state-sync.ts b/packages/joint-react/src/state/state-sync.ts index ee5c4616d1..d1ae54e293 100644 --- a/packages/joint-react/src/state/state-sync.ts +++ b/packages/joint-react/src/state/state-sync.ts @@ -1,3 +1,4 @@ +/* eslint-disable sonarjs/no-duplicated-branches */ /* eslint-disable sonarjs/cognitive-complexity */ import type { GraphStoreDerivedSnapshot, GraphStoreSnapshot } from '../store/graph-store'; import { listenToCellChange, type OnChangeOptions } from '../utils/cell/listen-to-cell-change'; @@ -5,12 +6,18 @@ import { removeDeepReadOnly, type ExternalStoreLike } from '../utils/create-stat import { util, type dia } from '@joint/core'; import type { GraphElement } from '../types/element-types'; import type { GraphLink } from '../types/link-types'; -import type { GraphStateSelectors } from './graph-state-selectors'; +import type { + ElementToGraphOptions, + GraphToElementOptions, + GraphToLinkOptions, + GraphStateSelectors, + LinkToGraphOptions, +} from './graph-state-selectors'; import { defaultElementToGraphSelector, - defaultElementFromGraphSelector, + defaultGraphToElementSelector, defaultLinkToGraphSelector, - defaultLinkFromGraphSelector, + defaultGraphToLinkSelector, } from './graph-state-selectors'; import { fastElementArrayEqual, isPositionOnlyUpdate } from '../utils/fast-equality'; @@ -83,25 +90,23 @@ export interface UpdateGraphOptions< /** The links to sync to the graph */ readonly links: readonly Link[]; /** Selector to convert graph elements to Element format for comparison */ - readonly elementFromGraphSelector: (options: { - readonly cell: dia.Element; - readonly graph: Graph; - }) => Element; + readonly graphToElementSelector: ( + options: GraphToElementOptions & { readonly graph: Graph } + ) => Element; /** Selector to convert graph links to Link format for comparison */ - readonly linkFromGraphSelector: (options: { - readonly cell: dia.Link; - readonly graph: Graph; - }) => Link; + readonly graphToLinkSelector: ( + options: GraphToLinkOptions & { readonly graph: Graph } + ) => Link; /** Selector to convert Element to JointJS Cell JSON format */ - readonly elementToGraphSelector: (options: { - readonly element: Element; - readonly graph: Graph; - }) => dia.Cell.JSON; + readonly elementToGraphSelector: ( + options: ElementToGraphOptions & { readonly graph: Graph } + ) => dia.Cell.JSON; /** Selector to convert Link to JointJS Cell JSON format */ - readonly linkToGraphSelector: (options: { - readonly link: Link; - readonly graph: Graph; - }) => dia.Cell.JSON; + readonly linkToGraphSelector: ( + options: LinkToGraphOptions & { readonly graph: Graph } + ) => dia.Cell.JSON; + + readonly getIdsSnapshot: () => GraphStoreDerivedSnapshot; } /** @@ -122,30 +127,43 @@ export function updateGraph< graph, elements, links, - elementFromGraphSelector, - linkFromGraphSelector, + graphToElementSelector, + graphToLinkSelector, elementToGraphSelector, linkToGraphSelector, + getIdsSnapshot, } = options; if (graph.hasActiveBatch()) { return false; } - - // Compare current graph state with provided state to avoid unnecessary syncs + const { elementIds, linkIds } = getIdsSnapshot(); + // Compare current graph state with provided state to avoid unnecessary s yncs // This prevents syncing when graph and provided state are already in sync - const graphElements = graph.getElements().map((element) => - elementFromGraphSelector({ + const graphElements = graph.getElements().map((element) => { + const previousIndex = elementIds[element.id]; + const previous = + previousIndex != null && previousIndex >= 0 && previousIndex < elements.length + ? elements[previousIndex] + : undefined; + return graphToElementSelector({ cell: element, graph, - }) - ); - const graphLinks = graph.getLinks().map((link) => - linkFromGraphSelector({ + previous, + }); + }); + const graphLinks = graph.getLinks().map((link) => { + const previousIndex = linkIds[link.id]; + const previous = + previousIndex != null && previousIndex >= 0 && previousIndex < links.length + ? links[previousIndex] + : undefined; + return graphToLinkSelector({ cell: link, graph, - }) - ); + previous, + }); + }); // Fast path: Check if arrays have same length first if (elements.length !== graphElements.length || links.length !== graphLinks.length) { @@ -216,11 +234,12 @@ export function stateSync< store, areBatchUpdatesDisabled = false, getIdsSnapshot, - elementFromGraphSelector = defaultElementFromGraphSelector, - linkFromGraphSelector = defaultLinkFromGraphSelector, + elementToGraphSelector = defaultElementToGraphSelector, linkToGraphSelector = defaultLinkToGraphSelector, } = options; + const graphToElementSelector = defaultGraphToElementSelector; + const graphToLinkSelector = defaultGraphToLinkSelector; // We need to ensure several things: // 1. Graph can update itself, via onCellChange or via onBatchStop - this change is internal and must update the external store - but only if the external store do not trigger the same change. @@ -281,13 +300,13 @@ export function stateSync< if (isReset) { // unfortunately this will create always new object references, so we need to compare them with more deeply const graphElements = graph.getElements().map((element) => - elementFromGraphSelector({ + graphToElementSelector({ cell: element, graph, }) ); const graphLinks = graph.getLinks().map((link) => - linkFromGraphSelector({ + graphToLinkSelector({ cell: link, graph, }) @@ -329,7 +348,7 @@ export function stateSync< ? previous.links[linkIndex] : undefined; - const updatedLink = linkFromGraphSelector({ + const updatedLink = graphToLinkSelector({ cell: cell as dia.Link, graph, previous: previousLink, @@ -343,7 +362,7 @@ export function stateSync< ? previous.elements[elementIndex] : undefined; - const updatedElement = elementFromGraphSelector({ + const updatedElement = graphToElementSelector({ cell: cell as dia.Element, graph, previous: previousElement, @@ -466,6 +485,12 @@ export function stateSync< }; } + /** + * Subscribes to cell change events in the graph. + * The callback receives change information and should return a cleanup function. + * @param callback - Function that receives change options and returns a cleanup function + * @returns Unsubscribe function to remove the listener + */ function subscribeToCellChange(callback: (change: OnChangeOptions) => () => void) { cellChangeListeners.add(callback); return () => { @@ -573,13 +598,13 @@ export function stateSync< // Only sync if store is empty and graph has cells if (storeElements.length === 0 && storeLinks.length === 0) { const existingElements = graph.getElements().map((element) => - elementFromGraphSelector({ + graphToElementSelector({ cell: element, graph, }) ) as Element[]; const existingLinks = graph.getLinks().map((link) => - linkFromGraphSelector({ + graphToLinkSelector({ cell: link, graph, }) @@ -624,22 +649,19 @@ export function stateSync< graph, elements, links, - elementFromGraphSelector: elementFromGraphSelector as (options: { - readonly cell: dia.Element; - readonly graph: Graph; - }) => Element, - linkFromGraphSelector: linkFromGraphSelector as (options: { - readonly cell: dia.Link; - readonly graph: Graph; - }) => Link, - elementToGraphSelector: elementToGraphSelector as (options: { - readonly element: Element; - readonly graph: Graph; - }) => dia.Cell.JSON, - linkToGraphSelector: linkToGraphSelector as (options: { - readonly link: Link; - readonly graph: Graph; - }) => dia.Cell.JSON, + getIdsSnapshot, + graphToElementSelector: graphToElementSelector as unknown as ( + options: GraphToElementOptions & { readonly graph: Graph } + ) => Element, + graphToLinkSelector: graphToLinkSelector as ( + options: GraphToLinkOptions & { readonly graph: Graph } + ) => Link, + elementToGraphSelector: elementToGraphSelector as ( + options: ElementToGraphOptions & { readonly graph: Graph } + ) => dia.Cell.JSON, + linkToGraphSelector: linkToGraphSelector as ( + options: LinkToGraphOptions & { readonly graph: Graph } + ) => dia.Cell.JSON, }); // Only reset the flag if there's no batch (events were processed synchronously) diff --git a/packages/joint-react/src/store/__tests__/graph-store.test.ts b/packages/joint-react/src/store/__tests__/graph-store.test.ts index defe2d73bb..1f1ab6f66b 100644 --- a/packages/joint-react/src/store/__tests__/graph-store.test.ts +++ b/packages/joint-react/src/store/__tests__/graph-store.test.ts @@ -7,13 +7,9 @@ import type { GraphElement } from '../../types/element-types'; import type { GraphLink } from '../../types/link-types'; import { defaultElementToGraphSelector, - defaultElementFromGraphSelector, defaultLinkToGraphSelector, - defaultLinkFromGraphSelector, type ElementToGraphOptions, - type ElementFromGraphOptions, type LinkToGraphOptions, - type LinkFromGraphOptions, } from '../../state/graph-state-selectors'; const DEFAULT_TEST_NAMESPACE = { ...shapes, ReactElement }; @@ -101,21 +97,13 @@ describe('GraphStore', () => { const customElementToGraph = jest.fn((options: ElementToGraphOptions) => { return defaultElementToGraphSelector(options); }); - const customElementFromGraph = jest.fn((options: ElementFromGraphOptions) => { - return defaultElementFromGraphSelector(options); - }); const customLinkToGraph = jest.fn((options: LinkToGraphOptions) => { return defaultLinkToGraphSelector(options); }); - const customLinkFromGraph = jest.fn((options: LinkFromGraphOptions) => { - return defaultLinkFromGraphSelector(options); - }); const store = new GraphStore({ elementToGraphSelector: customElementToGraph, - elementFromGraphSelector: customElementFromGraph, linkToGraphSelector: customLinkToGraph, - linkFromGraphSelector: customLinkFromGraph, }); // Add an element to trigger the selector @@ -521,7 +509,7 @@ describe('GraphStore', () => { expect(derived.linkIds['link-2']).toBe(1); }); - it('should track areElementsMeasured correctly', () => { + it('should track areElementsMeasured correctly', (done) => { const store = new GraphStore({}); // Initially, no elements, so should be false @@ -535,19 +523,27 @@ describe('GraphStore', () => { }); store.graph.addCell(measuredElement); - // After adding measured element to graph, should be true - expect(store.areElementsMeasuredState.getSnapshot()).toBe(true); - - // Once measured, it stays true even if we add unmeasured elements - const unmeasuredElement = new ReactElement({ - id: 'element-2', - position: { x: 30, y: 40 }, - size: { width: 0, height: 0 }, - }); - store.graph.addCell(unmeasuredElement); - - // Should remain true because wasElementsMeasuredBefore is true - expect(store.areElementsMeasuredState.getSnapshot()).toBe(true); + // Wait for layout state update (uses startTransition which defers updates) + setTimeout(() => { + // After adding measured element to graph, should be true + expect(store.areElementsMeasuredState.getSnapshot()).toBe(true); + + // Once measured, it stays true even if we add unmeasured elements + const unmeasuredElement = new ReactElement({ + id: 'element-2', + position: { x: 30, y: 40 }, + size: { width: 0, height: 0 }, + }); + store.graph.addCell(unmeasuredElement); + + // Wait for next update and check final state + // eslint-disable-next-line sonarjs/no-nested-functions + setTimeout(() => { + // Should remain true because wasElementsMeasuredBefore is true + expect(store.areElementsMeasuredState.getSnapshot()).toBe(true); + done(); + }, 50); + }, 50); }); }); diff --git a/packages/joint-react/src/store/create-elements-size-observer.ts b/packages/joint-react/src/store/create-elements-size-observer.ts index 1c2170615d..46427a75a4 100644 --- a/packages/joint-react/src/store/create-elements-size-observer.ts +++ b/packages/joint-react/src/store/create-elements-size-observer.ts @@ -69,7 +69,7 @@ interface Options { /** Options to pass to the ResizeObserver constructor */ readonly resizeObserverOptions?: ResizeObserverOptions; /** Function to get the current size of a cell from the graph */ - readonly getCellTransform: (id: dia.Cell.ID) => NodeLayoutOptionalXY & { element: dia.Element }; + readonly getCellTransform: (id: dia.Cell.ID) => NodeLayoutOptionalXY & { element: dia.Element; angle: number }; /** Function to get the current IDs snapshot for efficient lookups */ readonly getIdsSnapshot: () => MarkDeepReadOnly; /** Function to get the current public snapshot containing all elements */ @@ -208,12 +208,13 @@ export function createElementsSizeObserver(options: Options): GraphStoreObserver throw new Error(`Element with id ${cellId} not found in graph data ref`); } - const { x, y, element: cell } = currentCellTransform; + const { x, y, angle, element: cell } = currentCellTransform; updatedElements[elementArrayIndex] = { ...graphElement, ...sizeTransformFunction({ x: x ?? 0, y: y ?? 0, + angle: angle ?? 0, element: cell, width: measuredWidth, height: measuredHeight, diff --git a/packages/joint-react/src/store/graph-store.ts b/packages/joint-react/src/store/graph-store.ts index ff74d606d0..6fe6513402 100644 --- a/packages/joint-react/src/store/graph-store.ts +++ b/packages/joint-react/src/store/graph-store.ts @@ -10,20 +10,25 @@ import { type SetMeasuredNodeOptions, } from './create-elements-size-observer'; import { ReactElement } from '../models/react-element'; +import { ReactLink } from '../models/react-link'; import type { ExternalStoreLike, State } from '../utils/create-state'; import { createState, derivedState, getValue } from '../utils/create-state'; import { stateSync, type StateSync, updateGraph } from '../state/state-sync'; import type { GraphStateSelectors } from '../state/graph-state-selectors'; import { - defaultElementFromGraphSelector, + defaultGraphToElementSelector, defaultElementToGraphSelector, - defaultLinkFromGraphSelector, + defaultGraphToLinkSelector, defaultLinkToGraphSelector, } from '../state/graph-state-selectors'; import type { OnChangeOptions } from '../utils/cell/listen-to-cell-change'; import { createScheduler } from '../utils/scheduler'; -export const DEFAULT_CELL_NAMESPACE: Record = { ...shapes, ReactElement }; +export const DEFAULT_CELL_NAMESPACE: Record = { + ...shapes, + ReactElement, + ReactLink, +}; /** * External store interface compatible with GraphStore. @@ -76,6 +81,8 @@ export interface NodeLayout { readonly width: number; /** Height of the node */ readonly height: number; + /** Rotation angle of the node in degrees */ + readonly angle: number; } /** @@ -98,6 +105,44 @@ export interface GraphStoreInternalSnapshot { readonly papers: Record; } +/** + * Cache entry for batched link updates. + * @internal + */ +interface LinkUpdateCacheEntry { + /** Line attributes from BaseLink */ + attrs?: Record; + /** Labels by labelId from LinkLabel */ + labels?: Map; + /** Label IDs to remove */ + labelsToRemove?: Set; + /** Mark entire link for removal */ + shouldRemove?: boolean; +} + +/** + * Link label with labelId property for identification. + * @internal + */ +interface LinkLabelWithId extends dia.Link.Label { + readonly labelId: string; +} + +/** + * Cache entry for batched port updates. + * @internal + */ +interface PortUpdateCacheEntry { + /** Ports to add or update by port ID */ + ports?: Map; + /** Port IDs to remove */ + portsToRemove?: Set; + /** Port groups to add or update by group ID */ + groups?: Map; + /** Port group IDs to remove */ + groupsToRemove?: Set; +} + /** * Configuration options for creating a GraphStore instance. * @template Element - The type of elements in the graph @@ -201,12 +246,41 @@ export class GraphStore { private papers = new Map(); private observer: GraphStoreObserver; private stateSync: StateSync; - private readonly elementFromGraphSelector: ( + + /** + * Cache for batched link updates (attrs and labels). + * Updates are collected here and flushed together via syncCells. + * @internal + */ + private linkUpdateCache = new Map(); + + /** + * Cache for batched port updates (items and groups). + * Updates are collected here and flushed together via syncCells. + * @internal + */ + private portUpdateCache = new Map(); + + /** + * Unified scheduler for batching link, port, and paper snapshot updates. + * Triggers flushGraphUpdates when scheduled, which processes all batched updates. + * @internal + */ + private graphUpdateScheduler: ReturnType; + + /** + * Registered callbacks for paper snapshot updates. + * Each PaperStore registers its update callback here to be batched together. + * @internal + */ + private paperUpdateCallbacks = new Set<() => void>(); + + private readonly graphToElementSelector: ( options: { readonly cell: dia.Element; readonly graph: dia.Graph } & { readonly previous?: GraphElement; } ) => GraphElement; - private readonly linkFromGraphSelector: ( + private readonly graphToLinkSelector: ( options: { readonly cell: dia.Link; readonly graph: dia.Graph } & { readonly previous?: GraphLink; } @@ -228,19 +302,18 @@ export class GraphStore { cellNamespace = DEFAULT_CELL_NAMESPACE, graph, externalStore: externalState, - elementFromGraphSelector = defaultElementFromGraphSelector, elementToGraphSelector = defaultElementToGraphSelector, - linkFromGraphSelector = defaultLinkFromGraphSelector, linkToGraphSelector = defaultLinkToGraphSelector, } = config; // Store selectors as instance variables for use in onBatchUpdate - this.elementFromGraphSelector = elementFromGraphSelector as ( + // Always use default implementations for graph-to-state selectors (not configurable) + this.graphToElementSelector = defaultGraphToElementSelector as ( options: { readonly cell: dia.Element; readonly graph: dia.Graph } & { readonly previous?: GraphElement; } ) => GraphElement; - this.linkFromGraphSelector = linkFromGraphSelector as ( + this.graphToLinkSelector = defaultGraphToLinkSelector as ( options: { readonly cell: dia.Link; readonly graph: dia.Graph } & { readonly previous?: GraphLink; } @@ -311,11 +384,9 @@ export class GraphStore { graph: this.graph, getIdsSnapshot: () => this.derivedStore.getSnapshot(), elementToGraphSelector, - elementFromGraphSelector, linkToGraphSelector, - linkFromGraphSelector, store: { - getSnapshot: this.publicState.getSnapshot, + getSnapshot: () => this.publicState.getSnapshot(), subscribe: this.publicState.subscribe, setState: (updater) => { this.publicState.setState((previous) => ({ @@ -371,6 +442,7 @@ export class GraphStore { for (const element of elements) { const size = element.get('size'); const position = element.get('position') ?? { x: 0, y: 0 }; + const angle = element.get('angle') ?? 0; // Only track elements that have size (position defaults to 0,0 if not set) if (size) { const newLayout: NodeLayout = { @@ -378,6 +450,7 @@ export class GraphStore { y: position.y ?? 0, width: size.width ?? 0, height: size.height ?? 0, + angle, }; // Only update if layout actually changed (optimization) @@ -387,7 +460,8 @@ export class GraphStore { previousLayout.x !== newLayout.x || previousLayout.y !== newLayout.y || previousLayout.width !== newLayout.width || - previousLayout.height !== newLayout.height + previousLayout.height !== newLayout.height || + previousLayout.angle !== newLayout.angle ) { layouts[element.id] = newLayout; } else { @@ -434,6 +508,17 @@ export class GraphStore { // Initial layout state computation updateLayoutState(); + // Initialize unified scheduler for all graph-related updates + // Batches link/port updates (syncCells) and paper snapshot updates together + this.graphUpdateScheduler = createScheduler(() => { + // Execute all registered paper update callbacks + for (const callback of this.paperUpdateCallbacks) { + callback(); + } + // Then flush graph updates (links and ports) + this.flushGraphUpdates(); + }); + // Observer for element sizes (uses state.getSnapshot) this.observer = createElementsSizeObserver({ @@ -446,11 +531,12 @@ export class GraphStore { // Update graph directly - this will trigger automatic state sync updateGraph({ + getIdsSnapshot: this.derivedStore.getSnapshot, graph: this.graph, elements: newElements, links: currentLinks, - elementFromGraphSelector: this.elementFromGraphSelector, - linkFromGraphSelector: this.linkFromGraphSelector, + graphToElementSelector: this.graphToElementSelector, + graphToLinkSelector: this.graphToLinkSelector, elementToGraphSelector: this.elementToGraphSelector, linkToGraphSelector: this.linkToGraphSelector, }); @@ -460,11 +546,13 @@ export class GraphStore { if (!cell?.isElement()) throw new Error('Cell not valid'); const size = cell.get('size'); const position = cell.get('position'); + const angle = cell.get('angle') ?? 0; if (!size) throw new Error('Size not found'); return { width: size.width, height: size.height, element: cell, + angle, ...position, }; }, @@ -483,6 +571,101 @@ export class GraphStore { } } + /** + * Merges label updates into the current labels array. + * @param currentLabels - Current labels from the link + * @param entry - Cache entry containing label updates + * @returns Merged labels array + * @internal + */ + private mergeLinkLabels( + currentLabels: dia.Link.Label[], + entry: LinkUpdateCacheEntry + ): dia.Link.Label[] { + let mergedLabels = [...currentLabels]; + + if (entry.labelsToRemove) { + mergedLabels = mergedLabels.filter((l) => { + const labelWithId = l as LinkLabelWithId; + return !labelWithId.labelId || !entry.labelsToRemove!.has(labelWithId.labelId); + }); + } + + if (entry.labels) { + for (const [labelId, labelData] of entry.labels) { + const existingIndex = mergedLabels.findIndex( + (l) => (l as LinkLabelWithId).labelId === labelId + ); + if (existingIndex === -1) { + mergedLabels.push(labelData); + continue; + } + mergedLabels[existingIndex] = labelData; + } + } + + return mergedLabels; + } + + /** + * Flushes all cached link updates and returns cells to sync. + * Called automatically by the scheduler when updates are batched. + * @returns Array of cell JSON objects to sync + * @internal + */ + private flushLinkUpdates = (): dia.Cell.JSON[] => { + if (this.linkUpdateCache.size === 0) { + return []; + } + + // Build cells array for syncCells + const cellsToSync: dia.Cell.JSON[] = []; + + for (const [linkId, entry] of this.linkUpdateCache) { + const link = this.graph.getCell(linkId); + if (!link?.isLink()) { + continue; + } + + // Convert link from graph to GraphLink format (preserves source/target correctly) + const graphLink = this.graphToLinkSelector({ + cell: link, + graph: this.graph, + }); + + // Merge cached attrs into the link + const currentAttributes = graphLink.attrs ?? {}; + const updatedLink = entry.attrs + ? { + ...graphLink, + attrs: { + ...currentAttributes, + ...entry.attrs, + } as typeof graphLink.attrs, + } + : graphLink; + + // Convert back to Cell.JSON using linkToGraphSelector (reuses proper source/target handling) + const cellJson = this.linkToGraphSelector({ + link: updatedLink as GraphLink, + graph: this.graph, + }); + + // Handle labels separately (labels are on the cell, not in GraphLink) + if (entry.labels || entry.labelsToRemove) { + const currentLabels = link.labels(); + cellJson.labels = this.mergeLinkLabels(currentLabels, entry); + } + + cellsToSync.push(cellJson); + } + + // Clear cache BEFORE sync to avoid re-triggering + this.linkUpdateCache.clear(); + + return cellsToSync; + }; + /** * Cleans up all resources and subscriptions. * Should be called when the GraphStore is no longer needed. @@ -556,7 +739,38 @@ export class GraphStore { }); } + /** + * Updates the link view reference for a specific link in a paper. + * Used internally to track link views for rendering and interaction. + * @param paperId - The unique identifier of the paper + * @param linkId - The ID of the link whose view is being updated + * @param view - The JointJS link view instance + */ + public updatePaperLinkView(paperId: string, linkId: dia.Cell.ID, view: dia.LinkView) { + // silent update of the data. + this.updatePaperSnapshot(paperId, (current) => { + const base = current ?? { linkViews: {}, linksData: {} }; + + const existingView = base.linkViews?.[linkId]; + if (existingView === view) return base; + + return { + linkViews: { + ...base.linkViews, + [linkId]: view, + }, + }; + }); + } + private removePaper = (id: string) => { + const paperStore = this.papers.get(id); + // Cleanup paper update callback if it exists + if (paperStore && 'unregisterPaperUpdate' in paperStore) { + const unregister = (paperStore as unknown as { unregisterPaperUpdate?: () => void }) + .unregisterPaperUpdate; + unregister?.(); + } this.papers.delete(id); this.internalState.setState((previous) => { const newPapers: Record = {}; @@ -635,4 +849,327 @@ export class GraphStore { public updateExternalStore = (newStore: ExternalStoreLike) => { this.publicState = newStore; }; + + /** + * Sets link attributes (e.g., line styling from BaseLink). + * Updates are batched and applied via syncCells for performance. + * @param linkId - The ID of the link to update + * @param attributes - Attributes to set on the link + */ + public setLink = (linkId: dia.Cell.ID, attributes: Record) => { + const entry = this.linkUpdateCache.get(linkId) ?? {}; + entry.attrs = { ...entry.attrs, ...attributes }; + this.linkUpdateCache.set(linkId, entry); + this.graphUpdateScheduler(); + }; + + /** + * Marks a link for removal. + * Updates are batched and applied via syncCells for performance. + * @param linkId - The ID of the link to remove + */ + public removeLink = (linkId: dia.Cell.ID) => { + const entry = this.linkUpdateCache.get(linkId) ?? {}; + entry.shouldRemove = true; + this.linkUpdateCache.set(linkId, entry); + this.graphUpdateScheduler(); + }; + + /** + * Sets or updates a link label (from LinkLabel component). + * Updates are batched and applied via syncCells for performance. + * @param linkId - The ID of the link + * @param labelId - Unique identifier for the label + * @param labelData - Label data to set + */ + public setLinkLabel = (linkId: dia.Cell.ID, labelId: string, labelData: dia.Link.Label) => { + const entry = this.linkUpdateCache.get(linkId) ?? {}; + entry.labels = entry.labels ?? new Map(); + entry.labels.set(labelId, labelData); + this.linkUpdateCache.set(linkId, entry); + this.graphUpdateScheduler(); + }; + + /** + * Removes a link label (from LinkLabel component unmount). + * Updates are batched and applied via syncCells for performance. + * @param linkId - The ID of the link + * @param labelId - Unique identifier for the label to remove + */ + public removeLinkLabel = (linkId: dia.Cell.ID, labelId: string) => { + const entry = this.linkUpdateCache.get(linkId) ?? {}; + entry.labelsToRemove = entry.labelsToRemove ?? new Set(); + entry.labelsToRemove.add(labelId); + this.linkUpdateCache.set(linkId, entry); + this.graphUpdateScheduler(); + }; + + /** + * Merges port item updates into the current ports array. + * @param currentPorts - Current port items array + * @param entry - Cache entry containing port updates + * @returns Merged port items array + * @internal + */ + private mergePortItems( + currentPorts: dia.Element.Port[], + entry: PortUpdateCacheEntry + ): dia.Element.Port[] { + // Start with current ports, filter out removed ones + const filteredPorts = entry.portsToRemove + ? currentPorts.filter((p) => { + const portId = p.id; + if (!portId) { + return true; + } + return !entry.portsToRemove!.has(portId); + }) + : [...currentPorts]; + + // Add/update ports + if (!entry.ports) { + return filteredPorts; + } + + const mergedPorts = [...filteredPorts]; + for (const [portId, portData] of entry.ports) { + const existingIndex = mergedPorts.findIndex((p) => p.id === portId); + if (existingIndex === -1) { + mergedPorts.push(portData); + continue; + } + mergedPorts[existingIndex] = portData; + } + + return mergedPorts; + } + + /** + * Merges port group updates into the current groups object. + * @param currentGroups - Current port groups object + * @param entry - Cache entry containing port updates + * @returns Merged port groups object + * @internal + */ + private mergePortGroups( + currentGroups: Record | undefined, + entry: PortUpdateCacheEntry + ): Record { + const mergedGroups = { ...currentGroups }; + + // Remove groups + if (entry.groupsToRemove) { + for (const groupId of entry.groupsToRemove) { + if (groupId) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete mergedGroups[groupId]; + } + } + } + + // Add/update groups + if (entry.groups) { + for (const [groupId, groupData] of entry.groups) { + mergedGroups[groupId] = groupData; + } + } + + return mergedGroups; + } + + /** + * Merges port updates into the current ports structure. + * @param currentPorts - Current port items array + * @param currentGroups - Current port groups object + * @param entry - Cache entry containing port updates + * @returns Merged ports and groups + * @internal + */ + private mergePortUpdates( + currentPorts: dia.Element.Port[], + currentGroups: Record | undefined, + entry: PortUpdateCacheEntry + ): { + ports: dia.Element.Port[]; + groups: Record; + } { + return { + ports: this.mergePortItems(currentPorts, entry), + groups: this.mergePortGroups(currentGroups, entry), + }; + } + + /** + * Flushes all cached port updates and returns cells to sync. + * Called automatically by the scheduler when updates are batched. + * @returns Array of cell JSON objects to sync + * @internal + */ + private flushPortUpdates = (): dia.Cell.JSON[] => { + if (this.portUpdateCache.size === 0) { + return []; + } + + // Build cells array for syncCells + const cellsToSync: dia.Cell.JSON[] = []; + + for (const [elementId, entry] of this.portUpdateCache) { + const element = this.graph.getCell(elementId); + if (!element?.isElement()) { + continue; + } + + // Get current ports structure from element + const currentPorts = element.get('ports') || {}; + const currentPortItems = currentPorts.items || []; + const currentGroups = currentPorts.groups || {}; + + // Merge port updates + const { ports, groups } = this.mergePortUpdates(currentPortItems, currentGroups, entry); + + // Build cell JSON with merged ports + // Use toJSON to get current state, then update ports + const cellJson = element.toJSON(); + + // Update ports in cellJson + cellJson.ports = { + items: ports, + groups, + }; + + cellsToSync.push(cellJson); + } + + // Clear cache BEFORE sync to avoid re-triggering + this.portUpdateCache.clear(); + + return cellsToSync; + }; + + /** + * Flushes all cached graph updates (links and ports) in a single syncCells call. + * Called automatically by the unified scheduler when updates are batched. + * @internal + */ + private flushGraphUpdates = () => { + const linkCells = this.flushLinkUpdates(); + const portCells = this.flushPortUpdates(); + + // Combine all updates into single syncCells call for maximum efficiency + const allCells = [...linkCells, ...portCells]; + + if (allCells.length > 0) { + this.graph.syncCells(allCells, { remove: false }); + } + }; + + /** + * Flushes graph updates immediately (synchronously). + * Used during initial render to ensure labels appear immediately with elements/links. + * @internal + */ + private flushGraphUpdatesImmediate = () => { + // Execute all registered paper update callbacks + for (const callback of this.paperUpdateCallbacks) { + callback(); + } + // Then flush graph updates (links and ports) + this.flushGraphUpdates(); + }; + + /** + * Flushes all pending graph updates synchronously. + * Call this from useLayoutEffect to ensure updates are applied before paint. + */ + public flushPendingUpdates = () => { + if (this.linkUpdateCache.size === 0 && this.portUpdateCache.size === 0) { + return; + } + this.flushGraphUpdatesImmediate(); + }; + + /** + * Sets or updates a port (from Port.Item component). + * Updates are batched and applied via syncCells for performance. + * @param elementId - The ID of the element containing the port + * @param portId - Unique identifier for the port + * @param portData - Port data to set + */ + public setPort = (elementId: dia.Cell.ID, portId: string, portData: dia.Element.Port) => { + const entry = this.portUpdateCache.get(elementId) ?? {}; + entry.ports = entry.ports ?? new Map(); + entry.ports.set(portId, portData); + this.portUpdateCache.set(elementId, entry); + this.graphUpdateScheduler(); + }; + + /** + * Removes a port (from Port.Item component unmount). + * Updates are batched and applied via syncCells for performance. + * @param elementId - The ID of the element containing the port + * @param portId - Unique identifier for the port to remove + */ + public removePort = (elementId: dia.Cell.ID, portId: string) => { + const entry = this.portUpdateCache.get(elementId) ?? {}; + entry.portsToRemove = entry.portsToRemove ?? new Set(); + entry.portsToRemove.add(portId); + this.portUpdateCache.set(elementId, entry); + this.graphUpdateScheduler(); + }; + + /** + * Sets or updates a port group (from Port.Group component). + * Updates are batched and applied via syncCells for performance. + * @param elementId - The ID of the element containing the port group + * @param groupId - Unique identifier for the port group + * @param groupData - Port group data to set + */ + public setPortGroup = ( + elementId: dia.Cell.ID, + groupId: string, + groupData: dia.Element.PortGroup + ) => { + const entry = this.portUpdateCache.get(elementId) ?? {}; + entry.groups = entry.groups ?? new Map(); + entry.groups.set(groupId, groupData); + this.portUpdateCache.set(elementId, entry); + this.graphUpdateScheduler(); + }; + + /** + * Removes a port group (from Port.Group component unmount). + * Updates are batched and applied via syncCells for performance. + * @param elementId - The ID of the element containing the port group + * @param groupId - Unique identifier for the port group to remove + */ + public removePortGroup = (elementId: dia.Cell.ID, groupId: string) => { + const entry = this.portUpdateCache.get(elementId) ?? {}; + entry.groupsToRemove = entry.groupsToRemove ?? new Set(); + entry.groupsToRemove.add(groupId); + this.portUpdateCache.set(elementId, entry); + this.graphUpdateScheduler(); + }; + + /** + * Registers a paper update callback to be executed during batch flush. + * Used by PaperStore to batch paper snapshot updates together with graph updates. + * @param callback - Callback function to execute during batch flush + * @returns Cleanup function to unregister the callback + * @internal + */ + public registerPaperUpdate = (callback: () => void): (() => void) => { + this.paperUpdateCallbacks.add(callback); + return () => { + this.paperUpdateCallbacks.delete(callback); + }; + }; + + /** + * Schedules a paper update to be batched with other updates. + * Triggers the unified scheduler which will execute all registered callbacks. + * @internal + */ + public schedulePaperUpdate = () => { + this.graphUpdateScheduler(); + }; } diff --git a/packages/joint-react/src/store/index.ts b/packages/joint-react/src/store/index.ts index 8f6c12a969..8e10a0eea4 100644 --- a/packages/joint-react/src/store/index.ts +++ b/packages/joint-react/src/store/index.ts @@ -1,3 +1,3 @@ export * from './graph-store'; export * from './paper-store'; -export * from './create-elements-size-observer'; \ No newline at end of file +export * from './create-elements-size-observer'; diff --git a/packages/joint-react/src/store/paper-store.ts b/packages/joint-react/src/store/paper-store.ts index ac4c2760fc..6238d4502b 100644 --- a/packages/joint-react/src/store/paper-store.ts +++ b/packages/joint-react/src/store/paper-store.ts @@ -1,14 +1,15 @@ +/* eslint-disable sonarjs/cognitive-complexity */ import { dia, util, type Vectorizer } from '@joint/core'; import type { OverWriteResult } from '../context'; -import type { RenderElement } from '../components'; +import type { RenderElement, RenderLink } from '../components'; import type { GraphElement } from '../types/element-types'; +import type { GraphLink } from '../types/link-types'; import type { GraphState, GraphStore } from './graph-store'; -import { createScheduler } from '../utils/scheduler'; import { REACT_TYPE } from '../models/react-element'; const DEFAULT_CLICK_THRESHOLD = 10; export const PORTAL_SELECTOR = 'react-port-portal'; - +export const LINK_LABEL_PORTAL_SELECTOR = 'react-link-label-portal'; /** * Cache entry for port-related DOM elements and selectors. * Used internally to track port rendering state. @@ -42,6 +43,8 @@ export interface AddPaperOptions { readonly scale?: number; /** Optional custom renderer for elements */ readonly renderElement?: RenderElement; + /** Optional custom renderer for links */ + readonly renderLink?: RenderLink; } /** @@ -64,6 +67,10 @@ export interface PaperStoreSnapshot { paperElementViews?: Record; /** Map of port IDs to their SVG elements */ portsData?: Record; + /** Map of link IDs to their link views in this paper */ + linkViews?: Record; + /** Map of link label IDs to their SVG elements */ + linksData?: Record; } /** @@ -84,9 +91,18 @@ export class PaperStore { public overWriteResultRef?: OverWriteResult; /** Optional custom element renderer */ private renderElement?: RenderElement; + /** Optional custom link renderer */ + public renderLink?: RenderLink; public ReactElementView: typeof dia.ElementView; + /** + * Cleanup function to unregister paper update callback from GraphStore. + * @internal + */ + private unregisterPaperUpdate?: () => void; + public ReactLinkView: typeof dia.LinkView; + constructor(options: PaperStoreOptions) { const { graphStore, @@ -95,31 +111,43 @@ export class PaperStore { paperElement, scale, renderElement, + renderLink, id, } = options; const { width, height } = paperOptions; const { graph } = graphStore; this.paperId = id; this.renderElement = renderElement; + this.renderLink = renderLink; const cache: { portsData: Record; elementViews: Record; + linkViews: Record; + linksData: Record; } = { portsData: {}, elementViews: {}, + linkViews: {}, + linksData: {}, }; - const scheduler = createScheduler(() => { + // Register paper update callback with GraphStore's unified scheduler + // This ensures paper updates are batched together with link/port updates + const paperUpdateCallback = () => { graphStore.updatePaperSnapshot(options.id, (current) => { return { ...current, portsData: cache.portsData, paperElementViews: cache.elementViews, + linkViews: cache.linkViews, + linksData: cache.linksData, }; }); - }); + }; + this.unregisterPaperUpdate = graphStore.registerPaperUpdate(paperUpdateCallback); // eslint-disable-next-line unicorn/no-this-assignment, @typescript-eslint/no-this-alias const store = this; + const hasRenderElement = renderElement !== undefined; this.ReactElementView = dia.ElementView.extend({ renderMarkup() { const ele: HTMLElement = this.vel; @@ -134,7 +162,7 @@ export class PaperStore { ...cache.elementViews, [cellId]: view, }; - scheduler(); + graphStore.schedulePaperUpdate(); }, _renderPorts() { // @ts-expect-error we use private jointjs api @@ -150,12 +178,114 @@ export class PaperStore { }); cache.portsData = newPorts ?? {}; - scheduler(); + graphStore.schedulePaperUpdate(); + }, + }); + + const hasRenderLink = renderLink !== undefined; + this.ReactLinkView = dia.LinkView.extend({ + // renderMarkup() {}, + onRender() { + // eslint-disable-next-line unicorn/no-this-assignment, @typescript-eslint/no-this-alias + const view: dia.LinkView = this; + const linkId = view.model.id as dia.Cell.ID; + + cache.linkViews = { + ...cache.linkViews, + [linkId]: view, + }; + // Flush pending updates to ensure labels are synced before rendering + graphStore.flushPendingUpdates(); + graphStore.schedulePaperUpdate(); + }, + renderLabels() { + // Call parent implementation to render labels + // @ts-expect-error renderLabels exists on LinkView but not in types + dia.LinkView.prototype.renderLabels.call(this); + + // eslint-disable-next-line unicorn/no-this-assignment, @typescript-eslint/no-this-alias + const view: dia.LinkView = this; + const linkId = view.model.id as dia.Cell.ID; + const link = view.model; + // @ts-expect-error we use private jointjs api + const labelCache: Record = view._labelCache; + // @ts-expect-error we use private jointjs api + const labelSelectors: Record> = view._labelSelectors; + + if (!labelCache || !labelSelectors) { + return this; + } + + const newLinksData = { ...cache.linksData }; + let isChanged = false; + + // Get all existing label IDs for this link + const existingLabelIds = new Set(); + for (const labelId in cache.linksData) { + if (labelId.startsWith(`${linkId}-label-`)) { + existingLabelIds.add(labelId); + } + } + + // Update or add entries for current labels + // Get labels from the link model to check for React labels (those with labelId) + const linkLabels = link.isLink() ? link.labels() : []; + for (const labelIndex in labelCache) { + const index = Number.parseInt(labelIndex, 10); + const label = linkLabels[index]; + + // Only process React labels (those with labelId property) + if (!label || !('labelId' in label)) { + continue; + } + + // Use the label container element directly from labelCache + const portalElement = labelCache[index]; + if (!portalElement) { + continue; + } + + const linkLabelId = store.getLinkLabelId(linkId, index); + existingLabelIds.delete(linkLabelId); + + if (util.isEqual(newLinksData[linkLabelId], portalElement)) { + continue; + } + if (!portalElement.isConnected) { + continue; + } + isChanged = true; + // Use the label container element directly as the portal target + newLinksData[linkLabelId] = portalElement; + } + + // Remove entries for labels that no longer exist + // Clean up orphaned entries that are not in the current labelCache + // Only skip cleanup if link has labels but cache is empty (labels are still rendering) + if (existingLabelIds.size > 0) { + const hasRenderedLabels = Object.keys(labelCache).length > 0; + const linkHasLabels = link.isLink() && link.labels().length > 0; + // Clean up if labels are rendered OR link has no labels (safe to clean up) + if (hasRenderedLabels || !linkHasLabels) { + for (const removedLabelId of existingLabelIds) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete newLinksData[removedLabelId]; + } + isChanged = true; + } + } + + if (isChanged && !util.isEqual(cache.linksData, newLinksData)) { + cache.linksData = newLinksData; + graphStore.schedulePaperUpdate(); + } + + return this; }, }); // Create a new JointJS Paper with the provided options - const { ReactElementView } = this; + const { ReactElementView, ReactLinkView } = this; this.paper = new dia.Paper({ async: true, sorting: dia.Paper.sorting.APPROX, @@ -163,11 +293,17 @@ export class PaperStore { frozen: true, model: graph, elementView: (element) => { - if (element.get('type') === REACT_TYPE) { + if (hasRenderElement && element.get('type') === REACT_TYPE) { return ReactElementView; } return undefined as unknown as typeof dia.ElementView; }, + linkView: () => { + if (hasRenderLink) { + return ReactLinkView; + } + return undefined as unknown as typeof dia.LinkView; + }, // 👇 override to always allow connection validateConnection: () => true, // 👇 also, allow links to start or end on empty space @@ -261,4 +397,14 @@ export class PaperStore { public getPortId(cellId: dia.Cell.ID, portId: string) { return `${cellId}-${portId}`; } + + /** + * Generates a unique link label ID by combining link ID and label index. + * @param linkId - The ID of the link containing the label + * @param labelIndex - The index of the label in the labels array + * @returns A unique identifier for the link label + */ + public getLinkLabelId(linkId: dia.Cell.ID, labelIndex: number) { + return `${linkId}-label-${labelIndex}`; + } } diff --git a/packages/joint-react/src/stories/demos/flowchart/code.tsx b/packages/joint-react/src/stories/demos/flowchart/code.tsx index 06f96f22f6..a42fc7a216 100644 --- a/packages/joint-react/src/stories/demos/flowchart/code.tsx +++ b/packages/joint-react/src/stories/demos/flowchart/code.tsx @@ -375,30 +375,30 @@ function Main() { className={PAPER_CLASSNAME} renderElement={RenderFlowchartNode} interactive={{ linkMove: false }} - // defaultConnectionPoint={{ - // name: 'anchor', - // args: { - // offset: unit * 2, - // extrapolate: true, - // useModelGeometry: true, - // }, - // }} - // defaultAnchor={{ - // name: 'midSide', - // args: { - // useModelGeometry: true, - // }, - // }} - // defaultRouter={{ - // name: 'rightAngle', - // args: { - // margin: unit * 7, - // }, - // }} - // defaultConnector={{ - // name: 'straight', - // args: { cornerType: 'line', cornerPreserveAspectRatio: true }, - // }} + defaultConnectionPoint={{ + name: 'anchor', + args: { + offset: unit * 2, + extrapolate: true, + useModelGeometry: true, + }, + }} + defaultAnchor={{ + name: 'midSide', + args: { + useModelGeometry: true, + }, + }} + defaultRouter={{ + name: 'rightAngle', + args: { + margin: unit * 7, + }, + }} + defaultConnector={{ + name: 'straight', + args: { cornerType: 'line', cornerPreserveAspectRatio: true }, + }} /> ); } diff --git a/packages/joint-react/src/stories/examples/stress/code.tsx b/packages/joint-react/src/stories/examples/stress/code.tsx index 00f1f40d07..6de5e841c1 100644 --- a/packages/joint-react/src/stories/examples/stress/code.tsx +++ b/packages/joint-react/src/stories/examples/stress/code.tsx @@ -5,14 +5,17 @@ import { createElements, createLinks, GraphProvider, + Link, Paper, type InferElement, type GraphElement, type GraphLink, + type RenderLink, } from '@joint/react'; import '../index.css'; import React, { useCallback, useRef, useState, startTransition, memo } from 'react'; -import { PAPER_CLASSNAME, PRIMARY, LIGHT } from 'storybook-config/theme'; +import { PAPER_CLASSNAME, PRIMARY, LIGHT, SECONDARY } from 'storybook-config/theme'; +import { REACT_LINK_TYPE } from '../../../models/react-link'; function initialElements(xNodes = 15, yNodes = 30) { const nodes = []; @@ -38,15 +41,10 @@ function initialElements(xNodes = 15, yNodes = 30) { if (recentNodeId !== null && nodeId <= xNodes * yNodes) { edges.push({ id: `edge-${edgeId.toString()}`, + type: REACT_LINK_TYPE, source: `stress-${recentNodeId.toString()}`, target: `stress-${nodeId.toString()}`, z: -1, - attrs: { - line: { - stroke: LIGHT, - strokeWidth: 0.5, - }, - }, }); edgeId++; } @@ -92,6 +90,32 @@ function Main({ (element: BaseElementWithData) => , [] ); + + const renderLink: RenderLink = useCallback( + (link) => ( + <> + + + +
+ {link.id.toString().split('-')[1]} +
+
+
+ + ), + [] + ); + const updatePos = useCallback(() => { // Use startTransition to mark this as a non-urgent update // This allows React to keep the UI responsive during the update @@ -116,6 +140,7 @@ function Main({ className={PAPER_CLASSNAME} height={600} renderElement={renderElement} + renderLink={renderLink} />
); @@ -487,7 +486,7 @@ function Main() { defaultLink={() => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { id, ...rest } = links[0]; - return new shapes.standard.Link(rest); + return new shapes.standard.Link(rest as shapes.standard.LinkAttributes); }} width="100%" renderElement={renderElement} diff --git a/packages/joint-react/src/stories/demos/pulsing-port/code.tsx b/packages/joint-react/src/stories/demos/pulsing-port/code.tsx index 86ff35c6bb..1868bfd3bc 100644 --- a/packages/joint-react/src/stories/demos/pulsing-port/code.tsx +++ b/packages/joint-react/src/stories/demos/pulsing-port/code.tsx @@ -3,7 +3,7 @@ import { useRef } from 'react'; import { dia, highlighters, linkTools, V } from '@joint/core'; import { shapes } from '@joint/core'; -import { createElements, type InferElement } from '../../../utils/create'; +import type { GraphElement } from '@joint/react'; import { PAPER_CLASSNAME, PRIMARY, LIGHT, BG } from 'storybook-config/theme'; import { getCellId, @@ -60,7 +60,15 @@ const Pulse = dia.HighlighterView.extend({ this.el.setAttribute('transform', `translate(${position.x}, ${position.y})`); }, }); -const elements = createElements([ +type Element = GraphElement & { + attrs?: { + root?: { + magnet?: boolean; + }; + }; +}; + +const elements: Element[] = [ { id: '1', x: 50, @@ -91,9 +99,7 @@ const elements = createElements([ }, }, }, -]); - -type Element = InferElement; +]; function NodeElement({ id }: Element) { const rectRef = useRef(null); diff --git a/packages/joint-react/src/stories/demos/user-flow/code.tsx b/packages/joint-react/src/stories/demos/user-flow/code.tsx index 9afe7e7d7e..72fb489574 100644 --- a/packages/joint-react/src/stories/demos/user-flow/code.tsx +++ b/packages/joint-react/src/stories/demos/user-flow/code.tsx @@ -1,36 +1,32 @@ /* eslint-disable react-perf/jsx-no-new-function-as-prop */ /* eslint-disable react-perf/jsx-no-new-object-as-prop */ // We have pre-loaded tailwind css -import { - createElements, - createLinks, - GraphProvider, - Paper, - Port, - type InferElement, -} from '@joint/react'; +import { GraphProvider, Paper, Port, type GraphLink } from '@joint/react'; import type { dia } from '@joint/core'; import { util } from '@joint/core'; import { useCallback, useState } from 'react'; import { HTMLNode } from 'storybook-config/decorators/with-simple-data'; -type Data = { +type NodeType = { id: string; title: string; description: string; nodeType: 'user-action' | 'entity' | 'confirm' | 'message'; x: number; y: number; + attrs?: { + root?: { + magnet?: boolean; + }; + }; }; -const nodes = createElements([ +const nodes: NodeType[] = [ { id: '1', - title: 'User Action', description: 'Transfer funds', nodeType: 'user-action', - x: 50, y: 50, attrs: { @@ -41,11 +37,9 @@ const nodes = createElements([ }, { id: '2', - title: 'Entity', description: 'Transfer funds', nodeType: 'entity', - x: 120, y: 200, attrs: { @@ -56,11 +50,9 @@ const nodes = createElements([ }, { id: '3', - title: 'User Action', description: 'Get account balance', nodeType: 'user-action', - attrs: { root: { magnet: false, @@ -69,8 +61,9 @@ const nodes = createElements([ x: 190, y: 350, }, -]); -const links = createLinks([ +]; + +const links: GraphLink[] = [ { id: 'link1', source: { id: '1', port: '1' }, @@ -86,9 +79,7 @@ const links = createLinks([ source: { id: '3', port: '2' }, target: { id: '1', port: 'in' }, }, -]); - -type NodeType = InferElement; +]; interface PortProps { id: string; @@ -122,7 +113,7 @@ function PortItem({ id, label, onRemove, x }: Readonly) { ); } -function RenderElement({ title, description, nodeType }: NodeType) { +function RenderElement({ title, description, nodeType }: Readonly) { let icon: string; switch (nodeType) { case 'user-action': { diff --git a/packages/joint-react/src/stories/examples/stress/code.tsx b/packages/joint-react/src/stories/examples/stress/code.tsx index 6de5e841c1..dbdd76dad8 100644 --- a/packages/joint-react/src/stories/examples/stress/code.tsx +++ b/packages/joint-react/src/stories/examples/stress/code.tsx @@ -2,12 +2,9 @@ /* eslint-disable sonarjs/pseudo-random */ /* eslint-disable react-perf/jsx-no-new-object-as-prop */ import { - createElements, - createLinks, GraphProvider, Link, Paper, - type InferElement, type GraphElement, type GraphLink, type RenderLink, @@ -54,12 +51,12 @@ function initialElements(xNodes = 15, yNodes = 30) { } } - return { nodes: createElements(nodes), edges: createLinks(edges) }; + return { nodes, edges }; } const { nodes: initialNodes, edges: initialEdges } = initialElements(15, 30); -type BaseElementWithData = InferElement; +type BaseElementWithData = (typeof initialNodes)[number]; const RenderElement = memo(function RenderElement({ width, diff --git a/packages/joint-react/src/stories/examples/with-auto-layout/code.tsx b/packages/joint-react/src/stories/examples/with-auto-layout/code.tsx index a14d2514dc..7926ed0f22 100644 --- a/packages/joint-react/src/stories/examples/with-auto-layout/code.tsx +++ b/packages/joint-react/src/stories/examples/with-auto-layout/code.tsx @@ -4,13 +4,11 @@ /* eslint-disable react-perf/jsx-no-new-function-as-prop */ import '../index.css'; import { - createElements, GraphProvider, Paper, useElements, useGraph, useNodeSize, - type InferElement, type OnLoadOptions, type RenderElement, } from '@joint/react'; @@ -19,7 +17,7 @@ import type { dia } from '@joint/core'; import { PAPER_CLASSNAME } from 'storybook-config/theme'; import { useCellActions } from '../../../hooks/use-cell-actions'; -const initialElements = createElements([ +const initialElements = [ { id: '1', label: 'Node 1', width: 100, height: 50 }, { id: '2', label: 'Node 2', width: 100, height: 50 }, { id: '3', label: 'Node 3', width: 100, height: 50 }, @@ -29,13 +27,13 @@ const initialElements = createElements([ { id: '7', label: 'Node 7', width: 100, height: 50 }, { id: '8', label: 'Node 8', width: 100, height: 50 }, { id: '9', label: 'Node 9', width: 100, height: 50 }, -]); +]; -type BaseElementWithData = InferElement; +type BaseElementWithData = (typeof initialElements)[number]; const INPUT_CLASSNAME = 'block w-15 mr-2 p-2 text-gray-900 border border-gray-300 rounded-lg bg-gray-50 text-xs focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500'; -function RenderedRect({ width, height, label }: BaseElementWithData) { +function RenderedRect({ width, height, label }: Readonly) { const elementRef = useRef(null); useNodeSize(elementRef); return ( diff --git a/packages/joint-react/src/stories/examples/with-build-in-shapes/code-bordered-image.tsx b/packages/joint-react/src/stories/examples/with-build-in-shapes/code-bordered-image.tsx deleted file mode 100644 index f22ce48fc1..0000000000 --- a/packages/joint-react/src/stories/examples/with-build-in-shapes/code-bordered-image.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { PAPER_CLASSNAME } from 'storybook-config/theme'; -import '../index.css'; -import { createElements, GraphProvider, Paper } from '@joint/react'; - -const initialElements = createElements([ - { - id: '1', - x: 20, - y: 25, - width: 100, - height: 50, - type: 'standard.BorderedImage', - attrs: { - label: { - text: 'Bordered Image', - }, - border: { - rx: 5, - }, - image: { - xlinkHref: 'https://picsum.photos/100/50', - }, - }, - }, -]); - -function Main() { - return ( - // eslint-disable-next-line react-perf/jsx-no-new-object-as-prop -
- -
- ); -} - -export default function App() { - return ( - -
- - ); -} diff --git a/packages/joint-react/src/stories/examples/with-build-in-shapes/code-circle.tsx b/packages/joint-react/src/stories/examples/with-build-in-shapes/code-circle.tsx deleted file mode 100644 index 458780d3ed..0000000000 --- a/packages/joint-react/src/stories/examples/with-build-in-shapes/code-circle.tsx +++ /dev/null @@ -1,39 +0,0 @@ -/* eslint-disable react-perf/jsx-no-new-object-as-prop */ -import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; -import '../index.css'; -import { createElements, GraphProvider, Paper } from '@joint/react'; - -const initialElements = createElements([ - { - id: '1', - x: 20, - y: 25, - width: 50, - height: 50, - type: 'standard.Circle', - attrs: { - label: { - text: 'Circle', - }, - body: { - fill: PRIMARY, - }, - }, - }, -]); - -function Main() { - return ( -
- -
- ); -} - -export default function App() { - return ( - -
- - ); -} diff --git a/packages/joint-react/src/stories/examples/with-build-in-shapes/code-cylinder.tsx b/packages/joint-react/src/stories/examples/with-build-in-shapes/code-cylinder.tsx deleted file mode 100644 index 0bd9d82ade..0000000000 --- a/packages/joint-react/src/stories/examples/with-build-in-shapes/code-cylinder.tsx +++ /dev/null @@ -1,36 +0,0 @@ -/* eslint-disable react-perf/jsx-no-new-object-as-prop */ -import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; -import '../index.css'; -import { createElements, GraphProvider, Paper } from '@joint/react'; - -const initialElements = createElements([ - { - id: '1', - x: 20, - y: 25, - width: 50, - height: 50, - type: 'standard.Cylinder', - attrs: { - body: { - fill: PRIMARY, - }, - }, - }, -]); - -function Main() { - return ( -
- -
- ); -} - -export default function App() { - return ( - -
- - ); -} diff --git a/packages/joint-react/src/stories/examples/with-build-in-shapes/code-double-link.tsx b/packages/joint-react/src/stories/examples/with-build-in-shapes/code-double-link.tsx deleted file mode 100644 index 5063b4dc8e..0000000000 --- a/packages/joint-react/src/stories/examples/with-build-in-shapes/code-double-link.tsx +++ /dev/null @@ -1,70 +0,0 @@ -/* eslint-disable react-perf/jsx-no-new-object-as-prop */ -import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; -import '../index.css'; -import { createElements, createLinks, GraphProvider, Paper } from '@joint/react'; - -const initialElements = createElements([ - { - id: '1', - x: 20, - y: 25, - width: 100, - height: 50, - type: 'standard.Rectangle', - attrs: { - body: { - fill: PRIMARY, - }, - label: { - text: 'Rectangle1', - fill: 'white', - }, - }, - }, - { - id: '2', - x: 20, - y: 120, - width: 100, - height: 50, - type: 'standard.Rectangle', - attrs: { - body: { - fill: PRIMARY, - }, - label: { - text: 'Rectangle2', - fill: 'white', - }, - }, - }, -]); -const initialEdges = createLinks([ - { - id: 'e1-2', - source: '1', - target: '2', - type: 'standard.DoubleLink', - attrs: { - line: { - stroke: PRIMARY, - }, - }, - }, -]); - -function Main() { - return ( -
- -
- ); -} - -export default function App() { - return ( - -
- - ); -} diff --git a/packages/joint-react/src/stories/examples/with-build-in-shapes/code-ellipse.tsx b/packages/joint-react/src/stories/examples/with-build-in-shapes/code-ellipse.tsx deleted file mode 100644 index 95a87fe09f..0000000000 --- a/packages/joint-react/src/stories/examples/with-build-in-shapes/code-ellipse.tsx +++ /dev/null @@ -1,36 +0,0 @@ -/* eslint-disable react-perf/jsx-no-new-object-as-prop */ -import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; -import '../index.css'; -import { createElements, GraphProvider, Paper } from '@joint/react'; - -const initialElements = createElements([ - { - id: '1', - x: 20, - y: 25, - width: 100, - height: 50, - type: 'standard.Ellipse', - attrs: { - body: { - fill: PRIMARY, - }, - }, - }, -]); - -function Main() { - return ( -
- -
- ); -} - -export default function App() { - return ( - -
- - ); -} diff --git a/packages/joint-react/src/stories/examples/with-build-in-shapes/code-embedded-image.tsx b/packages/joint-react/src/stories/examples/with-build-in-shapes/code-embedded-image.tsx deleted file mode 100644 index 770c242dd4..0000000000 --- a/packages/joint-react/src/stories/examples/with-build-in-shapes/code-embedded-image.tsx +++ /dev/null @@ -1,39 +0,0 @@ -/* eslint-disable react-perf/jsx-no-new-object-as-prop */ -import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; -import '../index.css'; -import { createElements, GraphProvider, Paper } from '@joint/react'; - -const initialElements = createElements([ - { - id: '1', - x: 20, - y: 25, - width: 100, - height: 100, - type: 'standard.EmbeddedImage', - attrs: { - body: { - fill: PRIMARY, - }, - image: { - xlinkHref: 'https://picsum.photos/100/100', - }, - }, - }, -]); - -function Main() { - return ( -
- -
- ); -} - -export default function App() { - return ( - -
- - ); -} diff --git a/packages/joint-react/src/stories/examples/with-build-in-shapes/code-headered-rectangle.tsx b/packages/joint-react/src/stories/examples/with-build-in-shapes/code-headered-rectangle.tsx deleted file mode 100644 index 7e7fd0ade1..0000000000 --- a/packages/joint-react/src/stories/examples/with-build-in-shapes/code-headered-rectangle.tsx +++ /dev/null @@ -1,45 +0,0 @@ -/* eslint-disable react-perf/jsx-no-new-object-as-prop */ -import { PAPER_CLASSNAME, PRIMARY, SECONDARY } from 'storybook-config/theme'; -import '../index.css'; -import { createElements, GraphProvider, Paper } from '@joint/react'; - -const initialElements = createElements([ - { - id: '1', - x: 20, - y: 25, - width: 180, - height: 75, - type: 'standard.HeaderedRectangle', - attrs: { - body: { - fill: PRIMARY, - }, - bodyText: { - text: 'Headered Rectangle', - }, - header: { - fill: SECONDARY, - }, - headerText: { - text: 'Header text', - }, - }, - }, -]); - -function Main() { - return ( -
- -
- ); -} - -export default function App() { - return ( - -
- - ); -} diff --git a/packages/joint-react/src/stories/examples/with-build-in-shapes/code-image.tsx b/packages/joint-react/src/stories/examples/with-build-in-shapes/code-image.tsx deleted file mode 100644 index 457a37f781..0000000000 --- a/packages/joint-react/src/stories/examples/with-build-in-shapes/code-image.tsx +++ /dev/null @@ -1,36 +0,0 @@ -/* eslint-disable react-perf/jsx-no-new-object-as-prop */ -import { PAPER_CLASSNAME } from 'storybook-config/theme'; -import '../index.css'; -import { createElements, GraphProvider, Paper } from '@joint/react'; - -const initialElements = createElements([ - { - id: '1', - x: 20, - y: 25, - width: 100, - height: 100, - type: 'standard.Image', - attrs: { - image: { - xlinkHref: 'https://picsum.photos/100/100', - }, - }, - }, -]); - -function Main() { - return ( -
- -
- ); -} - -export default function App() { - return ( - -
- - ); -} diff --git a/packages/joint-react/src/stories/examples/with-build-in-shapes/code-inscribed-image.tsx b/packages/joint-react/src/stories/examples/with-build-in-shapes/code-inscribed-image.tsx deleted file mode 100644 index db4aa8a7e7..0000000000 --- a/packages/joint-react/src/stories/examples/with-build-in-shapes/code-inscribed-image.tsx +++ /dev/null @@ -1,36 +0,0 @@ -/* eslint-disable react-perf/jsx-no-new-object-as-prop */ -import { PAPER_CLASSNAME } from 'storybook-config/theme'; -import '../index.css'; -import { createElements, GraphProvider, Paper } from '@joint/react'; - -const initialElements = createElements([ - { - id: '1', - x: 20, - y: 25, - width: 100, - height: 100, - type: 'standard.InscribedImage', - attrs: { - image: { - xlinkHref: 'https://picsum.photos/100/100', - }, - }, - }, -]); - -function Main() { - return ( -
- -
- ); -} - -export default function App() { - return ( - -
- - ); -} diff --git a/packages/joint-react/src/stories/examples/with-build-in-shapes/code-link.tsx b/packages/joint-react/src/stories/examples/with-build-in-shapes/code-link.tsx deleted file mode 100644 index ef5b321cd0..0000000000 --- a/packages/joint-react/src/stories/examples/with-build-in-shapes/code-link.tsx +++ /dev/null @@ -1,70 +0,0 @@ -/* eslint-disable react-perf/jsx-no-new-object-as-prop */ -import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; -import '../index.css'; -import { createElements, createLinks, GraphProvider, Paper } from '@joint/react'; - -const initialElements = createElements([ - { - id: '1', - x: 20, - y: 25, - width: 100, - height: 50, - type: 'standard.Rectangle', - attrs: { - body: { - fill: PRIMARY, - }, - label: { - text: 'Rectangle1', - fill: 'white', - }, - }, - }, - { - id: '2', - x: 20, - y: 120, - width: 100, - height: 50, - type: 'standard.Rectangle', - attrs: { - body: { - fill: PRIMARY, - }, - label: { - text: 'Rectangle2', - fill: 'white', - }, - }, - }, -]); -const initialEdges = createLinks([ - { - id: 'e1-2', - source: '1', - target: '2', - type: 'standard.Link', - attrs: { - line: { - stroke: PRIMARY, - }, - }, - }, -]); - -function Main() { - return ( -
- -
- ); -} - -export default function App() { - return ( - -
- - ); -} diff --git a/packages/joint-react/src/stories/examples/with-build-in-shapes/code-path.tsx b/packages/joint-react/src/stories/examples/with-build-in-shapes/code-path.tsx deleted file mode 100644 index eaaef03461..0000000000 --- a/packages/joint-react/src/stories/examples/with-build-in-shapes/code-path.tsx +++ /dev/null @@ -1,40 +0,0 @@ -/* eslint-disable react-perf/jsx-no-new-object-as-prop */ -import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; -import '../index.css'; -import { createElements, GraphProvider, Paper } from '@joint/react'; - -const initialElements = createElements([ - { - id: '1', - x: 20, - y: 25, - width: 100, - height: 100, - type: 'standard.Path', - attrs: { - body: { - d: 'M 0 0 L 100 0 L 100 100 L 0 100 Z', - fill: PRIMARY, - }, - label: { - text: 'Path', - }, - }, - }, -]); - -function Main() { - return ( -
- -
- ); -} - -export default function App() { - return ( - -
- - ); -} diff --git a/packages/joint-react/src/stories/examples/with-build-in-shapes/code-polygon.tsx b/packages/joint-react/src/stories/examples/with-build-in-shapes/code-polygon.tsx deleted file mode 100644 index f5a241693c..0000000000 --- a/packages/joint-react/src/stories/examples/with-build-in-shapes/code-polygon.tsx +++ /dev/null @@ -1,40 +0,0 @@ -/* eslint-disable react-perf/jsx-no-new-object-as-prop */ -import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; -import '../index.css'; -import { createElements, GraphProvider, Paper } from '@joint/react'; - -const initialElements = createElements([ - { - id: '1', - x: 20, - y: 25, - width: 100, - height: 100, - type: 'standard.Polygon', - attrs: { - body: { - points: '0,0 100,0 100,100 0,100', - fill: PRIMARY, - }, - label: { - text: 'Polygon', - }, - }, - }, -]); - -function Main() { - return ( -
- -
- ); -} - -export default function App() { - return ( - -
- - ); -} diff --git a/packages/joint-react/src/stories/examples/with-build-in-shapes/code-polyline.tsx b/packages/joint-react/src/stories/examples/with-build-in-shapes/code-polyline.tsx deleted file mode 100644 index cef07455a6..0000000000 --- a/packages/joint-react/src/stories/examples/with-build-in-shapes/code-polyline.tsx +++ /dev/null @@ -1,40 +0,0 @@ -/* eslint-disable react-perf/jsx-no-new-object-as-prop */ -import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; -import '../index.css'; -import { createElements, GraphProvider, Paper } from '@joint/react'; - -const initialElements = createElements([ - { - id: '1', - x: 20, - y: 25, - width: 100, - height: 100, - type: 'standard.Polyline', - attrs: { - body: { - fill: PRIMARY, - refPoints: '0,0 100,0 100,100 0,100', - }, - label: { - text: 'Polyline', - }, - }, - }, -]); - -function Main() { - return ( -
- -
- ); -} - -export default function App() { - return ( - -
- - ); -} diff --git a/packages/joint-react/src/stories/examples/with-build-in-shapes/code-rectangle.tsx b/packages/joint-react/src/stories/examples/with-build-in-shapes/code-rectangle.tsx deleted file mode 100644 index 8bb4f2707b..0000000000 --- a/packages/joint-react/src/stories/examples/with-build-in-shapes/code-rectangle.tsx +++ /dev/null @@ -1,41 +0,0 @@ -/* eslint-disable react-perf/jsx-no-new-object-as-prop */ -import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; -import '../index.css'; - -import { createElements, GraphProvider, Paper } from '@joint/react'; - -const initialElements = createElements([ - { - id: '1', - x: 20, - y: 25, - width: 100, - height: 50, - type: 'standard.Rectangle', - attrs: { - body: { - fill: PRIMARY, - }, - label: { - text: 'Rectangle1', - fill: 'white', - }, - }, - }, -]); - -function Main() { - return ( -
- -
- ); -} - -export default function App() { - return ( - -
- - ); -} diff --git a/packages/joint-react/src/stories/examples/with-build-in-shapes/code-shadow-link.tsx b/packages/joint-react/src/stories/examples/with-build-in-shapes/code-shadow-link.tsx deleted file mode 100644 index 44fad1a781..0000000000 --- a/packages/joint-react/src/stories/examples/with-build-in-shapes/code-shadow-link.tsx +++ /dev/null @@ -1,70 +0,0 @@ -/* eslint-disable react-perf/jsx-no-new-object-as-prop */ -import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; -import '../index.css'; -import { createElements, createLinks, GraphProvider, Paper } from '@joint/react'; - -const initialElements = createElements([ - { - id: '1', - x: 20, - y: 25, - width: 100, - height: 50, - type: 'standard.Rectangle', - attrs: { - body: { - fill: PRIMARY, - }, - label: { - text: 'Rectangle1', - fill: 'white', - }, - }, - }, - { - id: '2', - x: 20, - y: 120, - width: 100, - height: 50, - type: 'standard.Rectangle', - attrs: { - body: { - fill: PRIMARY, - }, - label: { - text: 'Rectangle2', - fill: 'white', - }, - }, - }, -]); -const initialEdges = createLinks([ - { - id: 'e1-2', - source: '1', - target: '2', - type: 'standard.ShadowLink', - attrs: { - line: { - stroke: PRIMARY, - }, - }, - }, -]); - -function Main() { - return ( -
- -
- ); -} - -export default function App() { - return ( - -
- - ); -} diff --git a/packages/joint-react/src/stories/examples/with-build-in-shapes/code-text-block.tsx b/packages/joint-react/src/stories/examples/with-build-in-shapes/code-text-block.tsx deleted file mode 100644 index 2f5f115ebf..0000000000 --- a/packages/joint-react/src/stories/examples/with-build-in-shapes/code-text-block.tsx +++ /dev/null @@ -1,39 +0,0 @@ -/* eslint-disable react-perf/jsx-no-new-object-as-prop */ -import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; -import '../index.css'; -import { createElements, GraphProvider, Paper } from '@joint/react'; - -const initialElements = createElements([ - { - id: '1', - x: 20, - y: 25, - width: 100, - height: 100, - type: 'standard.TextBlock', - attrs: { - body: { - fill: PRIMARY, - }, - label: { - text: 'TextBlock', - }, - }, - }, -]); - -function Main() { - return ( -
- -
- ); -} - -export default function App() { - return ( - -
- - ); -} diff --git a/packages/joint-react/src/stories/examples/with-build-in-shapes/code.tsx b/packages/joint-react/src/stories/examples/with-build-in-shapes/code.tsx new file mode 100644 index 0000000000..90db8505bc --- /dev/null +++ b/packages/joint-react/src/stories/examples/with-build-in-shapes/code.tsx @@ -0,0 +1,334 @@ +/* eslint-disable react-perf/jsx-no-new-object-as-prop */ +import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; +import type { dia } from '@joint/core'; +import '../index.css'; +import { + GraphProvider, + Paper, + type GraphElement, + type GraphLink, + type ElementToGraphOptions, + type LinkToGraphOptions, +} from '@joint/react'; + +interface NativeElement extends GraphElement { + readonly type: string; +} + +interface NativeLink extends GraphLink { + readonly type: string; +} + +const elementToGraphSelector = ({ + element, + defaultMapper, +}: ElementToGraphOptions): dia.Cell.JSON => { + const result = defaultMapper(); + const nativeElement = element as NativeElement; + return { ...result, type: nativeElement.type }; +}; + +const linkToGraphSelector = ({ + link, + defaultMapper, +}: LinkToGraphOptions): dia.Cell.JSON => { + const result = defaultMapper(); + if (link.type) { + return { ...result, type: link.type }; + } + return result; +}; + +const SECONDARY = '#6366f1'; + +const initialElements: NativeElement[] = [ + // Row 1: Basic shapes + { + id: 'rectangle', + x: 20, + y: 20, + width: 100, + height: 50, + type: 'standard.Rectangle', + attrs: { + body: { fill: PRIMARY }, + label: { text: 'Rectangle', fill: 'white' }, + }, + }, + { + id: 'circle', + x: 150, + y: 20, + width: 60, + height: 60, + type: 'standard.Circle', + attrs: { + body: { fill: SECONDARY }, + label: { text: 'Circle', fill: 'white' }, + }, + }, + { + id: 'ellipse', + x: 240, + y: 20, + width: 100, + height: 50, + type: 'standard.Ellipse', + attrs: { + body: { fill: PRIMARY }, + label: { text: 'Ellipse', fill: 'white' }, + }, + }, + { + id: 'cylinder', + x: 370, + y: 10, + width: 60, + height: 70, + type: 'standard.Cylinder', + attrs: { + body: { fill: SECONDARY }, + top: { fill: '#4f46e5' }, + }, + }, + // Row 2: Path shapes + { + id: 'path', + x: 20, + y: 110, + width: 80, + height: 80, + type: 'standard.Path', + attrs: { + body: { + d: 'M 0 20 L 40 0 L 80 20 L 80 60 L 40 80 L 0 60 Z', + fill: PRIMARY, + }, + label: { text: 'Path', fill: 'white' }, + }, + }, + { + id: 'polygon', + x: 130, + y: 110, + width: 80, + height: 80, + type: 'standard.Polygon', + attrs: { + body: { + points: '40,0 80,30 65,80 15,80 0,30', + fill: SECONDARY, + }, + label: { text: 'Polygon', fill: 'white' }, + }, + }, + { + id: 'polyline', + x: 240, + y: 110, + width: 100, + height: 80, + type: 'standard.Polyline', + attrs: { + body: { + points: '0,40 25,0 50,40 75,0 100,40', + fill: 'none', + stroke: PRIMARY, + strokeWidth: 3, + }, + label: { text: 'Polyline', y: 70, fill: 'white' }, + }, + }, + { + id: 'textblock', + x: 370, + y: 110, + width: 100, + height: 60, + type: 'standard.TextBlock', + attrs: { + body: { fill: '#f3f4f6', stroke: PRIMARY }, + label: { text: 'TextBlock\nwith wrap', style: { color: PRIMARY } }, + }, + }, + // Row 3: Headered and Image shapes + { + id: 'headered', + x: 20, + y: 220, + width: 120, + height: 80, + type: 'standard.HeaderedRectangle', + attrs: { + header: { fill: PRIMARY }, + headerText: { text: 'Header', fill: 'white' }, + body: { fill: '#e5e7eb' }, + bodyText: { text: 'Body', fill: '#374151' }, + }, + }, + { + id: 'image', + x: 170, + y: 220, + width: 60, + height: 60, + type: 'standard.Image', + attrs: { + image: { + xlinkHref: 'https://picsum.photos/60/60?random=1', + }, + label: { text: 'Image', fill: 'white' }, + }, + }, + { + id: 'bordered-image', + x: 260, + y: 220, + width: 70, + height: 70, + type: 'standard.BorderedImage', + attrs: { + border: { stroke: PRIMARY, strokeWidth: 3 }, + image: { + xlinkHref: 'https://picsum.photos/70/70?random=2', + }, + label: { text: 'Bordered', fill: 'white' }, + }, + }, + { + id: 'embedded-image', + x: 360, + y: 220, + width: 100, + height: 70, + type: 'standard.EmbeddedImage', + attrs: { + body: { fill: '#f3f4f6', stroke: SECONDARY }, + image: { + xlinkHref: 'https://picsum.photos/30/30?random=3', + }, + label: { text: 'Embedded', fill: '#374151' }, + }, + }, + // Row 4: More shapes and link targets + { + id: 'inscribed-image', + x: 20, + y: 330, + width: 70, + height: 70, + type: 'standard.InscribedImage', + attrs: { + border: { stroke: PRIMARY, strokeWidth: 2 }, + background: { fill: '#e5e7eb' }, + image: { + xlinkHref: 'https://picsum.photos/50/50?random=4', + }, + label: { text: 'Inscribed', fill: 'white' }, + }, + }, + { + id: 'link-source', + x: 150, + y: 350, + width: 80, + height: 40, + type: 'standard.Rectangle', + attrs: { + body: { fill: PRIMARY }, + label: { text: 'Source', fill: 'white' }, + }, + }, + { + id: 'link-target-1', + x: 350, + y: 320, + width: 80, + height: 40, + type: 'standard.Rectangle', + attrs: { + body: { fill: SECONDARY }, + label: { text: 'Target 1', fill: 'white' }, + }, + }, + { + id: 'link-target-2', + x: 350, + y: 420, + width: 80, + height: 40, + type: 'standard.Rectangle', + attrs: { + body: { fill: PRIMARY }, + label: { text: 'Target 2', fill: 'white' }, + }, + }, + { + id: 'link-target-3', + x: 520, + y: 320, + width: 80, + height: 40, + type: 'standard.Rectangle', + attrs: { + body: { fill: SECONDARY }, + label: { text: 'Target 3', fill: 'white' }, + }, + }, +]; + +const initialLinks: NativeLink[] = [ + { + id: 'link-standard', + source: 'link-source', + target: 'link-target-1', + type: 'standard.Link', + attrs: { + line: { stroke: PRIMARY, strokeWidth: 2 }, + }, + labels: [{ attrs: { text: { text: 'Link' } } }], + }, + { + id: 'link-double', + source: 'link-source', + target: 'link-target-2', + type: 'standard.DoubleLink', + attrs: { + line: { stroke: SECONDARY }, + outline: { stroke: '#c7d2fe' }, + }, + labels: [{ attrs: { text: { text: 'DoubleLink' } } }], + }, + { + id: 'link-shadow', + source: 'link-target-1', + target: 'link-target-3', + type: 'standard.ShadowLink', + attrs: { + line: { stroke: PRIMARY }, + shadow: { stroke: '#9ca3af' }, + }, + labels: [{ attrs: { text: { text: 'ShadowLink' } } }], + }, +]; + +function Main() { + return ( +
+ +
+ ); +} + +export default function App() { + return ( + +
+ + ); +} diff --git a/packages/joint-react/src/stories/examples/with-build-in-shapes/story.tsx b/packages/joint-react/src/stories/examples/with-build-in-shapes/story.tsx index 4155412fb3..d215b857c1 100644 --- a/packages/joint-react/src/stories/examples/with-build-in-shapes/story.tsx +++ b/packages/joint-react/src/stories/examples/with-build-in-shapes/story.tsx @@ -1,201 +1,62 @@ import type { Meta, StoryObj } from '@storybook/react'; import '../index.css'; -import Rectangle from './code-rectangle'; -import BorderedImage from './code-bordered-image'; -import Circle from './code-circle'; -import Cylinder from './code-cylinder'; -import DoubleLink from './code-double-link'; -import Ellipse from './code-ellipse'; -import EmbeddedImage from './code-embedded-image'; -import HeaderedRectangle from './code-headered-rectangle'; -import Image from './code-image'; -import InscribedImage from './code-inscribed-image'; -import Link from './code-link'; -import Path from './code-path'; -import Polygon from './code-polygon'; -import PolyLine from './code-polyline'; -import ShadowLink from './code-shadow-link'; -import TextBlock from './code-text-block'; - -import RectangleRawCode from './code-rectangle?raw'; - -import BorderedImageRawCode from './code-bordered-image?raw'; - -import CircleRawCode from './code-circle?raw'; - -import CylinderRawCode from './code-cylinder?raw'; - -import DoubleLinkRawCode from './code-double-link?raw'; - -import EllipseRawCode from './code-ellipse?raw'; - -import EmbeddedImageRawCode from './code-embedded-image?raw'; - -import HeaderedRectangleRawCode from './code-headered-rectangle?raw'; - -import ImageRawCode from './code-image?raw'; - -import InscribedImageRawCode from './code-inscribed-image?raw'; - -import LinkRawCode from './code-link?raw'; - -import PathRawCode from './code-path?raw'; - -import PolygonRawCode from './code-polygon?raw'; - -import PolyLineRawCode from './code-polyline?raw'; - -import ShadowLinkRawCode from './code-shadow-link?raw'; - -import TextBlockRawCode from './code-text-block?raw'; +import NativeShapes from './code'; +import NativeShapesRawCode from './code?raw'; import { makeStory } from '../../utils/make-story'; -export type Story = StoryObj; +export type Story = StoryObj; export default { title: 'Examples/Built-in shapes', - component: Rectangle, + component: NativeShapes, tags: ['example'], parameters: { docs: { description: { component: ` -Render built-in [standard JointJS shapes](https://docs.jointjs.com/learn/features/shapes/built-in-shapes/standard). +Demonstrates how to use native [JointJS standard shapes](https://docs.jointjs.com/learn/features/shapes/built-in-shapes/standard) with @joint/react using custom selectors. + +By default, @joint/react renders elements using a custom \`ReactElement\` type. To use native JointJS shapes (like \`standard.Rectangle\`, \`standard.Circle\`, etc.), you need to provide custom selectors that preserve the \`type\` property. + +## Key Concept: Custom Selectors + +\`\`\`tsx +// Define element type with native shape type +interface NativeElement extends GraphElement { + type: string; // 'standard.Rectangle', 'standard.Circle', etc. +} + +// Custom selector that preserves the 'type' property +const elementToGraphSelector = ({ + element, + defaultMapper, +}: ElementToGraphOptions): dia.Cell.JSON => { + const result = defaultMapper(); + return { ...result, type: element.type }; +}; + +// Pass to GraphProvider + + + +\`\`\` -Each example below demonstrates a built-in shape with a rendered demo and source code. Refer to the [API reference](https://docs.jointjs.com/api/shapes/standard) for full configuration options. `, }, }, }, -} satisfies Meta; - -// Export stories with descriptions -export const WithRectangle = makeStory({ - component: Rectangle, - code: RectangleRawCode, - name: 'Rectangle', - apiURL: 'https://docs.jointjs.com/api/shapes/standard/Rectangle', - description: 'A rectangle with a label.', -}); - -export const WithBorderedImage = makeStory({ - component: BorderedImage, - code: BorderedImageRawCode, - name: 'BorderedImage', - apiURL: 'https://docs.jointjs.com/api/shapes/standard/BorderedImage', - description: 'An image with a border.', -}); - -export const WithCircle = makeStory({ - component: Circle, - code: CircleRawCode, - name: 'Circle', - apiURL: 'https://docs.jointjs.com/api/shapes/standard/Circle', - description: 'A circle with a label.', -}); - -export const WithCylinder = makeStory({ - component: Cylinder, - code: CylinderRawCode, - name: 'Cylinder', - apiURL: 'https://docs.jointjs.com/api/shapes/standard/Cylinder', - description: 'A cylinder shape.', -}); - -export const WithDoubleLink = makeStory({ - component: DoubleLink, - code: DoubleLinkRawCode, - name: 'DoubleLink', - apiURL: 'https://docs.jointjs.com/api/shapes/standard/DoubleLink', - description: 'A link with two connectors.', -}); - -export const WithEllipse = makeStory({ - component: Ellipse, - code: EllipseRawCode, - name: 'Ellipse', - apiURL: 'https://docs.jointjs.com/api/shapes/standard/Ellipse', - description: 'An ellipse with a label.', -}); - -export const WithEmbeddedImage = makeStory({ - component: EmbeddedImage, - code: EmbeddedImageRawCode, - name: 'EmbeddedImage', - apiURL: 'https://docs.jointjs.com/api/shapes/standard/EmbeddedImage', - description: 'An image embedded into a rectangle with a label.', -}); - -export const WithHeaderedRectangle = makeStory({ - component: HeaderedRectangle, - code: HeaderedRectangleRawCode, - name: 'HeaderedRectangle', - apiURL: 'https://docs.jointjs.com/api/shapes/standard/HeaderedRectangle', - description: 'A rectangle with a header.', -}); - -export const WithImage = makeStory({ - component: Image, - code: ImageRawCode, - name: 'Image', - apiURL: 'https://docs.jointjs.com/api/shapes/standard/Image', - description: 'An image with a label.', -}); - -export const WithInscribedImage = makeStory({ - component: InscribedImage, - code: InscribedImageRawCode, - name: 'InscribedImage', - apiURL: 'https://docs.jointjs.com/api/shapes/standard/InscribedImage', - description: 'An image inscribed in an ellipse with a label.', -}); - -export const WithLink = makeStory({ - component: Link, - code: LinkRawCode, - name: 'Link', - apiURL: 'https://docs.jointjs.com/api/shapes/standard/Link', - description: 'A single line link.', -}); - -export const WithPath = makeStory({ - component: Path, - code: PathRawCode, - name: 'Path', - apiURL: 'https://docs.jointjs.com/api/shapes/standard/Path', - description: 'A path with a label.', -}); - -export const WithPolygon = makeStory({ - component: Polygon, - code: PolygonRawCode, - name: 'Polygon', - apiURL: 'https://docs.jointjs.com/api/shapes/standard/Polygon', - description: 'A polygon with a label.', -}); - -export const WithPolyLine = makeStory({ - component: PolyLine, - code: PolyLineRawCode, - name: 'PolyLine', - apiURL: 'https://docs.jointjs.com/api/shapes/standard/PolyLine', - description: 'A polyline with a label.', -}); - -export const WithShadowLink = makeStory({ - component: ShadowLink, - code: ShadowLinkRawCode, - name: 'ShadowLink', - apiURL: 'https://docs.jointjs.com/api/shapes/standard/ShadowLink', - description: 'A link with a shadow.', -}); - -export const WithTextBlock = makeStory({ - component: TextBlock, - code: TextBlockRawCode, - name: 'TextBlock', - apiURL: 'https://docs.jointjs.com/api/shapes/standard/TextBlock', - description: 'A text block with a label.', +} satisfies Meta; + +export const Default = makeStory({ + component: NativeShapes, + code: NativeShapesRawCode, + name: 'Native Shapes Showcase', + apiURL: 'https://docs.jointjs.com/api/shapes/standard', + description: + 'All native JointJS standard shapes in a single view: Rectangle, Circle, Ellipse, Cylinder, Path, Polygon, Polyline, TextBlock, HeaderedRectangle, Image variants, and native link types (Link, DoubleLink, ShadowLink).', }); diff --git a/packages/joint-react/src/stories/examples/with-card/code.tsx b/packages/joint-react/src/stories/examples/with-card/code.tsx index ffe81c3a2b..5ccdb4c0a0 100644 --- a/packages/joint-react/src/stories/examples/with-card/code.tsx +++ b/packages/joint-react/src/stories/examples/with-card/code.tsx @@ -3,21 +3,20 @@ import '../index.css'; import { useCallback, useRef } from 'react'; import type { OnTransformElement } from '@joint/react'; import { - createElements, - createLinks, GraphProvider, Paper, useNodeSize, - type InferElement, + type GraphLink, type RenderElement, } from '@joint/react'; import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; -const initialElements = createElements([ +const initialElements = [ { id: '1', label: 'Node 1', x: 100, y: 0 }, { id: '2', label: 'Node 2 with longer text', x: 250, y: 150 }, -]); -const initialEdges = createLinks([ +]; + +const initialEdges: GraphLink[] = [ { id: 'e1-2', source: '1', @@ -28,9 +27,9 @@ const initialEdges = createLinks([ }, }, }, -]); +]; -type BaseElementWithData = InferElement; +type BaseElementWithData = (typeof initialElements)[number]; function Card({ label }: Readonly>) { const contentRef = useRef(null); diff --git a/packages/joint-react/src/stories/examples/with-custom-link/code-with-create-links-classname.tsx b/packages/joint-react/src/stories/examples/with-custom-link/code-with-create-links-classname.tsx index a4e1eb61f4..b169d79bce 100644 --- a/packages/joint-react/src/stories/examples/with-custom-link/code-with-create-links-classname.tsx +++ b/packages/joint-react/src/stories/examples/with-custom-link/code-with-create-links-classname.tsx @@ -1,12 +1,10 @@ /* eslint-disable react-perf/jsx-no-new-object-as-prop */ import { - createElements, - createLinks, GraphProvider, Paper, + type GraphLink, type GraphProps, - type InferElement, type RenderElement, } from '@joint/react'; import { useCallback } from 'react'; @@ -14,11 +12,12 @@ import { HTMLNode } from 'storybook-config/decorators/with-simple-data'; import './code-with-create-links-classname.css'; import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; -const initialElements = createElements([ +const initialElements = [ { id: '1', label: 'Node 1', x: 100, y: 0 }, { id: '2', label: 'Node 2', x: 100, y: 200 }, -]); -const initialEdges = createLinks([ +]; + +const initialEdges: GraphLink[] = [ { id: 'e1-2', source: '1', @@ -30,9 +29,9 @@ const initialEdges = createLinks([ }, }, }, -]); +]; -type BaseElementWithData = InferElement; +type BaseElementWithData = (typeof initialElements)[number]; function Main() { const renderElement: RenderElement = useCallback( diff --git a/packages/joint-react/src/stories/examples/with-custom-link/code-with-create-links.tsx b/packages/joint-react/src/stories/examples/with-custom-link/code-with-create-links.tsx index 0a4623f816..3950c8eecc 100644 --- a/packages/joint-react/src/stories/examples/with-custom-link/code-with-create-links.tsx +++ b/packages/joint-react/src/stories/examples/with-custom-link/code-with-create-links.tsx @@ -1,22 +1,21 @@ /* eslint-disable react-perf/jsx-no-new-object-as-prop */ import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; import { - createElements, - createLinks, GraphProvider, Paper, + type GraphLink, type GraphProps, - type InferElement, type RenderElement, } from '@joint/react'; import { useCallback } from 'react'; import { HTMLNode } from 'storybook-config/decorators/with-simple-data'; -const initialElements = createElements([ +const initialElements = [ { id: '1', label: 'Node 1', x: 100, y: 0 }, { id: '2', label: 'Node 2', x: 100, y: 200 }, -]); -const initialEdges = createLinks([ +]; + +const initialEdges: GraphLink[] = [ { id: 'e1-2', source: '1', @@ -29,9 +28,9 @@ const initialEdges = createLinks([ }, }, }, -]); +]; -type BaseElementWithData = InferElement; +type BaseElementWithData = (typeof initialElements)[number]; function Main() { const renderElement: RenderElement = useCallback( diff --git a/packages/joint-react/src/stories/examples/with-custom-link/code-with-dia-links.tsx b/packages/joint-react/src/stories/examples/with-custom-link/code-with-dia-links.tsx index 2163e88cc1..748a35df4e 100644 --- a/packages/joint-react/src/stories/examples/with-custom-link/code-with-dia-links.tsx +++ b/packages/joint-react/src/stories/examples/with-custom-link/code-with-dia-links.tsx @@ -2,21 +2,15 @@ /* eslint-disable react-perf/jsx-no-new-function-as-prop */ import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; import { shapes, util } from '@joint/core'; -import { - createElements, - GraphProvider, - type GraphProps, - type InferElement, - type RenderElement, -} from '@joint/react'; +import { GraphProvider, type GraphProps, type RenderElement } from '@joint/react'; import { useCallback } from 'react'; import { HTMLNode } from 'storybook-config/decorators/with-simple-data'; import { Paper } from '../../../components/paper/paper'; -const initialElements = createElements([ +const initialElements = [ { id: '1', label: 'Node 1', x: 100, y: 0 }, { id: '2', label: 'Node 2', x: 100, y: 200 }, -]); +]; class LinkModel extends shapes.standard.Link { defaults() { @@ -33,7 +27,7 @@ class LinkModel extends shapes.standard.Link { } } -type BaseElementWithData = InferElement; +type BaseElementWithData = (typeof initialElements)[number]; function Main() { const renderElement: RenderElement = useCallback( diff --git a/packages/joint-react/src/stories/examples/with-highlighter/code.tsx b/packages/joint-react/src/stories/examples/with-highlighter/code.tsx index 406520980b..2452634ac9 100644 --- a/packages/joint-react/src/stories/examples/with-highlighter/code.tsx +++ b/packages/joint-react/src/stories/examples/with-highlighter/code.tsx @@ -1,18 +1,11 @@ /* eslint-disable react-perf/jsx-no-new-object-as-prop */ /* eslint-disable react-perf/jsx-no-new-function-as-prop */ -import { - createElements, - createLinks, - GraphProvider, - Highlighter, - Paper, - type InferElement, -} from '@joint/react'; +import { GraphProvider, Highlighter, Paper, type GraphElement, type GraphLink } from '@joint/react'; import '../index.css'; import { useState } from 'react'; import { PAPER_CLASSNAME, PRIMARY, SECONDARY } from 'storybook-config/theme'; -const initialElements = createElements([ +const initialElements = [ { id: '1', label: 'Node 1', @@ -29,9 +22,9 @@ const initialElements = createElements([ width: 120, height: 25, }, -]); +] satisfies GraphElement[]; -const initialEdges = createLinks([ +const initialEdges = [ { id: 'e1-2', source: '1', @@ -42,11 +35,11 @@ const initialEdges = createLinks([ }, }, }, -]); +] satisfies GraphLink[]; -type BaseElementWithData = InferElement; +type BaseElementWithData = (typeof initialElements)[number]; -function RenderItemWithChildren({ height, width, label }: BaseElementWithData) { +function RenderItemWithChildren({ height, width, label }: Readonly) { const [isHighlighted, setIsHighlighted] = useState(false); return ( ; +type BaseElementWithData = (typeof initialElements)[number]; function ResizableNode({ id, label }: Readonly) { const nodeRef = useRef(null); diff --git a/packages/joint-react/src/stories/examples/with-link-tools/code.tsx b/packages/joint-react/src/stories/examples/with-link-tools/code.tsx index bc77d94d0b..899cb4ad43 100644 --- a/packages/joint-react/src/stories/examples/with-link-tools/code.tsx +++ b/packages/joint-react/src/stories/examples/with-link-tools/code.tsx @@ -2,19 +2,11 @@ /* eslint-disable react-perf/jsx-no-new-object-as-prop */ import { dia, linkTools } from '@joint/core'; import '../index.css'; -import { - createElements, - createLinks, - GraphProvider, - jsx, - Paper, - type InferElement, - type RenderElement, -} from '@joint/react'; +import { GraphProvider, jsx, Paper, type RenderElement } from '@joint/react'; import { useCallback } from 'react'; import { PRIMARY, BG, SECONDARY, PAPER_CLASSNAME } from 'storybook-config/theme'; -const initialEdges = createLinks([ +const initialEdges = [ { id: 'e1-2', source: '1', @@ -26,12 +18,12 @@ const initialEdges = createLinks([ }, }, }, -]); +]; -const initialElements = createElements([ +const initialElements = [ { id: '1', label: 'Node 1', x: 100, y: 10, width: 120, height: 30 }, { id: '2', label: 'Node 2', x: 100, y: 200, width: 120, height: 30 }, -]); +]; // 1) creating link tools const verticesTool = new linkTools.Vertices({ @@ -74,9 +66,9 @@ const toolsView = new dia.ToolsView({ tools: [boundaryTool, verticesTool, infoButton], }); -type BaseElementWithData = InferElement; +type BaseElementWithData = (typeof initialElements)[number]; -function RectElement({ width, height }: BaseElementWithData) { +function RectElement({ width, height }: Readonly) { return ( ([ - { id: '1', label: 'Node 1', inputs: [], x: 100, y: 0 }, - { id: '2', label: 'Node 2', inputs: [], x: 500, y: 200 }, -]); -const initialEdges = createLinks([ +const initialElements = [ + { id: '1', label: 'Node 1', inputs: [] as string[], x: 100, y: 0 }, + { id: '2', label: 'Node 2', inputs: [] as string[], x: 500, y: 200 }, +]; +const initialEdges = [ { id: 'e1-2', source: '1', @@ -37,9 +22,9 @@ const initialEdges = createLinks([ }, }, }, -]); +]; -type BaseElementWithData = InferElement; +type BaseElementWithData = (typeof initialElements)[number]; function ListElement({ id, children, inputs }: PropsWithChildren) { const padding = 10; diff --git a/packages/joint-react/src/stories/examples/with-minimap/code.tsx b/packages/joint-react/src/stories/examples/with-minimap/code.tsx index 52e614c5da..4606569166 100644 --- a/packages/joint-react/src/stories/examples/with-minimap/code.tsx +++ b/packages/joint-react/src/stories/examples/with-minimap/code.tsx @@ -1,22 +1,14 @@ /* eslint-disable react-perf/jsx-no-new-object-as-prop */ import '../index.css'; import { useCallback, useRef } from 'react'; -import { - createElements, - createLinks, - GraphProvider, - Paper, - useNodeSize, - type InferElement, - type RenderElement, -} from '@joint/react'; +import { GraphProvider, Paper, useNodeSize, type RenderElement } from '@joint/react'; import { PRIMARY, SECONDARY, LIGHT, PAPER_CLASSNAME } from 'storybook-config/theme'; -const initialElements = createElements([ +const initialElements = [ { id: '1', label: 'Node 1', color: PRIMARY, x: 100, y: 10, width: 100, height: 50 }, { id: '2', label: 'Node 2', color: SECONDARY, x: 100, y: 200, width: 100, height: 50 }, -]); -const initialEdges = createLinks([ +]; +const initialEdges = [ { id: 'e1-2', source: '1', @@ -27,9 +19,9 @@ const initialEdges = createLinks([ }, }, }, -]); +]; -type BaseElementWithData = InferElement; +type BaseElementWithData = (typeof initialElements)[number]; function MiniMap() { const renderElement: RenderElement = useCallback( diff --git a/packages/joint-react/src/stories/examples/with-node-update/code-add-remove-node.tsx b/packages/joint-react/src/stories/examples/with-node-update/code-add-remove-node.tsx index 7cbf09479b..38bb0c9f13 100644 --- a/packages/joint-react/src/stories/examples/with-node-update/code-add-remove-node.tsx +++ b/packages/joint-react/src/stories/examples/with-node-update/code-add-remove-node.tsx @@ -1,27 +1,26 @@ /* eslint-disable react-perf/jsx-no-new-object-as-prop */ /* eslint-disable react-perf/jsx-no-new-function-as-prop */ import { - createElements, - createLinks, GraphProvider, Paper, useCellId, useElements, useGraph, useNodeSize, - type InferElement, + type GraphLink, } from '@joint/react'; import '../index.css'; import { useRef } from 'react'; import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; import { useCellActions } from '../../../hooks/use-cell-actions'; -const initialElements = createElements([ +const initialElements = [ { id: '1', label: 'Node 1', color: '#ffffff', x: 40, y: 70 }, { id: '2', label: 'Node 2', color: '#ffffff', x: 170, y: 120 }, { id: '3', label: 'Node 2', color: '#ffffff', x: 30, y: 180 }, -]); -const initialEdges = createLinks([ +]; + +const initialEdges: GraphLink[] = [ { id: 'e1-1', source: '1', @@ -32,11 +31,11 @@ const initialEdges = createLinks([ }, }, }, -]); +]; -type BaseElementWithData = InferElement; +type BaseElementWithData = (typeof initialElements)[number]; -function ElementInput({ id, label }: BaseElementWithData) { +function ElementInput({ id, label }: Readonly) { const { set } = useCellActions(); return ( ) { const graph = useGraph(); const id = useCellId(); const elementRef = useRef(null); diff --git a/packages/joint-react/src/stories/examples/with-node-update/code-with-color.tsx b/packages/joint-react/src/stories/examples/with-node-update/code-with-color.tsx index 4caa1bc183..ee0fb6e998 100644 --- a/packages/joint-react/src/stories/examples/with-node-update/code-with-color.tsx +++ b/packages/joint-react/src/stories/examples/with-node-update/code-with-color.tsx @@ -1,16 +1,17 @@ /* eslint-disable react-perf/jsx-no-new-object-as-prop */ /* eslint-disable react-perf/jsx-no-new-function-as-prop */ -import { createElements, createLinks, GraphProvider, Paper, type InferElement } from '@joint/react'; +import { GraphProvider, Paper, type GraphLink } from '@joint/react'; import '../index.css'; import { PRIMARY, LIGHT, PAPER_CLASSNAME } from 'storybook-config/theme'; import { HTMLNode } from 'storybook-config/decorators/with-simple-data'; import { useCellActions } from '../../../hooks/use-cell-actions'; -const initialElements = createElements([ +const initialElements = [ { id: '1', label: 'Node 1', color: PRIMARY, x: 100, y: 0 }, { id: '2', label: 'Node 2', color: PRIMARY, x: 100, y: 200 }, -]); -const initialEdges = createLinks([ +]; + +const initialEdges: GraphLink[] = [ { id: 'e1-2', source: '1', @@ -21,11 +22,11 @@ const initialEdges = createLinks([ }, }, }, -]); +]; -type BaseElementWithData = InferElement; +type BaseElementWithData = (typeof initialElements)[number]; -function RenderElement({ color, id }: BaseElementWithData) { +function RenderElement({ color, id }: Readonly) { const { set } = useCellActions(); return ( ; +type BaseElementWithData = (typeof initialElements)[number]; -function ElementInput({ id, color }: BaseElementWithData) { +function ElementInput({ id, color }: Readonly) { const { set } = useCellActions(); return ( ) { return ; } diff --git a/packages/joint-react/src/stories/examples/with-node-update/code.tsx b/packages/joint-react/src/stories/examples/with-node-update/code.tsx index dd984e571a..6ef26c58db 100644 --- a/packages/joint-react/src/stories/examples/with-node-update/code.tsx +++ b/packages/joint-react/src/stories/examples/with-node-update/code.tsx @@ -1,24 +1,17 @@ /* eslint-disable react-perf/jsx-no-new-object-as-prop */ /* eslint-disable react-perf/jsx-no-new-function-as-prop */ -import { - createElements, - createLinks, - GraphProvider, - Paper, - useElements, - useNodeSize, - type InferElement, -} from '@joint/react'; +import { GraphProvider, Paper, useElements, useNodeSize, type GraphLink } from '@joint/react'; import '../index.css'; import { useRef } from 'react'; import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; import { useCellActions } from '../../../hooks/use-cell-actions'; -const initialElements = createElements([ +const initialElements = [ { id: '1', label: 'Node 1', color: '#ffffff', x: 100, y: 0 }, { id: '2', label: 'Node 2', color: '#ffffff', x: 100, y: 200 }, -]); -const initialEdges = createLinks([ +]; + +const initialEdges: GraphLink[] = [ { id: 'e1-2', source: '1', @@ -29,11 +22,11 @@ const initialEdges = createLinks([ }, }, }, -]); +]; -type BaseElementWithData = InferElement; +type BaseElementWithData = (typeof initialElements)[number]; -function ElementInput({ id, label }: BaseElementWithData) { +function ElementInput({ id, label }: Readonly) { const { set } = useCellActions(); return ( ) { const elementRef = useRef(null); const { width, height } = useNodeSize(elementRef); return ( diff --git a/packages/joint-react/src/stories/examples/with-proximity-link/code.tsx b/packages/joint-react/src/stories/examples/with-proximity-link/code.tsx index 24c96b935e..dce5457d7a 100644 --- a/packages/joint-react/src/stories/examples/with-proximity-link/code.tsx +++ b/packages/joint-react/src/stories/examples/with-proximity-link/code.tsx @@ -1,5 +1,5 @@ /* eslint-disable react-perf/jsx-no-new-object-as-prop */ -import { createElements, GraphProvider, Paper, type InferElement, useNodeSize } from '@joint/react'; +import { GraphProvider, Paper, useNodeSize } from '@joint/react'; import '../index.css'; import { useRef } from 'react'; import { shapes, util } from '@joint/core'; @@ -7,14 +7,14 @@ import { PAPER_CLASSNAME, SECONDARY } from 'storybook-config/theme'; import type { dia } from '../../../../../joint-core/types'; import { useCellChangeEffect } from '../../../hooks/use-cell-change-effect'; -const initialElements = createElements([ +const initialElements = [ { id: '1', label: 'Node 1', x: 100, y: 0 }, { id: '2', label: 'Node 2', x: 100, y: 200 }, { id: '3', label: 'Node 3', x: 280, y: 100 }, { id: '4', label: 'Node 4', x: 0, y: 100 }, -]); +]; -type BaseElementWithData = InferElement; +type BaseElementWithData = (typeof initialElements)[number]; class DashedLink extends shapes.standard.Link { defaults() { diff --git a/packages/joint-react/src/stories/examples/with-render-link/code.tsx b/packages/joint-react/src/stories/examples/with-render-link/code.tsx index 5b359d42d2..776bba12a1 100644 --- a/packages/joint-react/src/stories/examples/with-render-link/code.tsx +++ b/packages/joint-react/src/stories/examples/with-render-link/code.tsx @@ -3,18 +3,18 @@ import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; import '../index.css'; -import { createElements, createLinks, GraphProvider, Link, Paper, type RenderLink } from '@joint/react'; +import { GraphProvider, Link, Paper, type RenderLink } from '@joint/react'; import { useCallback } from 'react'; import { HTMLNode } from 'storybook-config/decorators/with-simple-data'; import { REACT_LINK_TYPE } from '../../../models/react-link'; -const initialElements = createElements([ +const initialElements = [ { id: '1', label: 'Node 1', x: 100, y: 0 }, { id: '2', label: 'Node 2', x: 100, y: 200 }, { id: '3', label: 'Node 3', x: 300, y: 100 }, -]); +]; -const initialLinks = createLinks([ +const initialLinks = [ { id: 'link-1', type: REACT_LINK_TYPE, @@ -27,7 +27,7 @@ const initialLinks = createLinks([ source: '2', target: '3', }, -]); +]; function Main() { const renderElement = useCallback( diff --git a/packages/joint-react/src/stories/examples/with-resizable-node/code.tsx b/packages/joint-react/src/stories/examples/with-resizable-node/code.tsx index b0c193e858..eedf3ef94b 100644 --- a/packages/joint-react/src/stories/examples/with-resizable-node/code.tsx +++ b/packages/joint-react/src/stories/examples/with-resizable-node/code.tsx @@ -1,23 +1,15 @@ /* eslint-disable react-perf/jsx-no-new-object-as-prop */ -import { - createElements, - createLinks, - GraphProvider, - Paper, - useElements, - useNodeSize, - type InferElement, -} from '@joint/react'; +import { GraphProvider, Paper, useElements, useNodeSize } from '@joint/react'; import '../index.css'; import { useCallback, useRef } from 'react'; import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; -const initialElements = createElements([ +const initialElements = [ { id: '1', label: 'Node 1', x: 100, y: 0 }, { id: '2', label: 'Node 2', x: 100, y: 200 }, -]); +]; -const initialEdges = createLinks([ +const initialEdges = [ { id: 'e1-2', source: '1', @@ -28,9 +20,9 @@ const initialEdges = createLinks([ }, }, }, -]); +]; -type BaseElementWithData = InferElement; +type BaseElementWithData = (typeof initialElements)[number]; function ResizableNode({ label }: Readonly) { const nodeRef = useRef(null); diff --git a/packages/joint-react/src/stories/examples/with-rotable-node/code.tsx b/packages/joint-react/src/stories/examples/with-rotable-node/code.tsx index 00b1c6e3be..04e92ad7ef 100644 --- a/packages/joint-react/src/stories/examples/with-rotable-node/code.tsx +++ b/packages/joint-react/src/stories/examples/with-rotable-node/code.tsx @@ -1,25 +1,16 @@ /* eslint-disable react-perf/jsx-no-new-object-as-prop */ -import { - createElements, - createLinks, - GraphProvider, - Paper, - useElements, - usePaper, - useNodeSize, - type InferElement, -} from '@joint/react'; +import { GraphProvider, Paper, useElements, usePaper, useNodeSize } from '@joint/react'; import '../index.css'; import { useCallback, useRef } from 'react'; import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; import { useCellActions } from '../../../hooks/use-cell-actions'; -const initialElements = createElements([ +const initialElements = [ { id: '1', label: 'Node 1', x: 20, y: 100 }, { id: '2', label: 'Node 2', x: 200, y: 100 }, -]); +]; -const initialEdges = createLinks([ +const initialEdges = [ { id: 'e1-2', source: '1', @@ -30,9 +21,9 @@ const initialEdges = createLinks([ }, }, }, -]); +]; -type BaseElementWithData = InferElement; +type BaseElementWithData = (typeof initialElements)[number]; function RotatableNode({ label, id }: Readonly) { const paper = usePaper(); diff --git a/packages/joint-react/src/stories/examples/with-svg-node/code.tsx b/packages/joint-react/src/stories/examples/with-svg-node/code.tsx index 8f0baf57dd..ff640af491 100644 --- a/packages/joint-react/src/stories/examples/with-svg-node/code.tsx +++ b/packages/joint-react/src/stories/examples/with-svg-node/code.tsx @@ -2,18 +2,15 @@ import { LIGHT, PAPER_CLASSNAME, PRIMARY, TEXT } from 'storybook-config/theme'; import '../index.css'; import { - createElements, - createLinks, GraphProvider, Paper, useNodeSize, - type InferElement, type OnTransformElement, type RenderElement, } from '@joint/react'; import { useCallback, useRef } from 'react'; -const initialEdges = createLinks([ +const initialEdges = [ { id: 'e1-2', source: '1', @@ -24,16 +21,16 @@ const initialEdges = createLinks([ }, }, }, -]); +]; -const initialElements = createElements([ +const initialElements = [ { id: '1', label: 'Node 1', x: 100, y: 0 }, { id: '2', label: 'Node 2', x: 100, y: 200 }, -]); +]; -type BaseElementWithData = InferElement; +type BaseElementWithData = (typeof initialElements)[number]; -function RenderedRect({ label }: BaseElementWithData) { +function RenderedRect({ label }: Readonly) { const textMargin = 20; const cornerRadius = 5; const textRef = useRef(null); diff --git a/packages/joint-react/src/stories/introduction.mdx b/packages/joint-react/src/stories/introduction.mdx index 353de49463..9c3d4d3146 100644 --- a/packages/joint-react/src/stories/introduction.mdx +++ b/packages/joint-react/src/stories/introduction.mdx @@ -69,24 +69,16 @@ Use the graph APIs to update state. Hooks provide convenient access: - {getAPIDocumentationLink('usePaper')}: Access the JointJS [Paper](https://docs.jointjs.com/learn/quickstart/paper/) ### 🔹 Creating Nodes and Links -- {getAPIDocumentationLink('createElements')}: Utility for creating nodes. +Elements and links are plain objects with at least an `id` field and geometry properties. ```ts -import { createElements } from '@joint/react'; - -const initialElements = createElements([ +const initialElements = [ { id: '1', type: 'rect', x: 10, y: 10, width: 100, height: 100 }, -]); -``` - -- {getAPIDocumentationLink('createLinks')}: Utility for creating links between nodes. - -```ts -import { createLinks } from '@joint/react'; +]; -const initialLinks = createLinks([ - { source: '1', target: '2', id: '1-2' }, -]); +const initialLinks = [ + { id: '1-2', source: '1', target: '2' }, +]; ``` --- diff --git a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-jotai.tsx b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-jotai.tsx index 29db8ea1a5..4239a4d456 100644 --- a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-jotai.tsx +++ b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-jotai.tsx @@ -33,13 +33,10 @@ */ import { - createElements, - createLinks, GraphProvider, type GraphProps, type GraphElement, type GraphLink, - type InferElement, Paper, type ExternalGraphStore, } from '@joint/react'; @@ -54,12 +51,17 @@ import type { Update } from '../../../utils/create-state'; // STEP 1: Define Initial Graph Data // ============================================================================ -const defaultElements = createElements([ +/** + * Custom element type with a label property. + */ +type CustomElement = GraphElement & { label: string }; + +const defaultElements: CustomElement[] = [ { id: '1', label: 'Hello', x: 100, y: 0, width: 100, height: 50 }, { id: '2', label: 'World', x: 100, y: 200, width: 100, height: 50 }, -]); +]; -const defaultLinks = createLinks([ +const defaultLinks: GraphLink[] = [ { id: 'e1-2', source: '1', @@ -70,9 +72,7 @@ const defaultLinks = createLinks([ }, }, }, -]); - -type CustomElement = InferElement; +]; // ============================================================================ // STEP 2: Custom Element Renderer diff --git a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-peerjs.tsx b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-peerjs.tsx index 8a988779b6..e7cc8ea9a0 100644 --- a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-peerjs.tsx +++ b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-peerjs.tsx @@ -38,13 +38,10 @@ */ import { - createElements, - createLinks, GraphProvider, type GraphProps, type GraphElement, type GraphLink, - type InferElement, Paper, type ExternalGraphStore, } from '@joint/react'; @@ -59,12 +56,17 @@ import type { Update } from '../../../utils/create-state'; // STEP 1: Define Initial Graph Data // ============================================================================ -const defaultElements = createElements([ +/** + * Custom element type with a label property. + */ +type CustomElement = GraphElement & { label: string }; + +const defaultElements: CustomElement[] = [ { id: '1', label: 'Hello', x: 100, y: 0, width: 100, height: 50 }, { id: '2', label: 'World', x: 100, y: 200, width: 100, height: 50 }, -]); +]; -const defaultLinks = createLinks([ +const defaultLinks: GraphLink[] = [ { id: 'e1-2', source: '1', @@ -75,9 +77,7 @@ const defaultLinks = createLinks([ }, }, }, -]); - -type CustomElement = InferElement; +]; // ============================================================================ // STEP 2: Custom Element Renderer diff --git a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-redux.tsx b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-redux.tsx index 0edf365f09..018c490f17 100644 --- a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-redux.tsx +++ b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-redux.tsx @@ -3,13 +3,10 @@ /* eslint-disable react-perf/jsx-no-new-function-as-prop */ import { - createElements, - createLinks, GraphProvider, type GraphProps, type GraphElement, type GraphLink, - type InferElement, Paper, type ExternalGraphStore, } from '@joint/react'; @@ -84,20 +81,23 @@ interface GraphState { // STEP 2: Create Redux Slice with Actions // ============================================================================ +/** + * Custom element type with a label property. + */ +type CustomElement = GraphElement & { label: string }; + /** * Initial elements for the graph. - * Defined separately to enable type inference for CustomElement. */ -const defaultElements = createElements([ +const defaultElements: CustomElement[] = [ { id: '1', label: 'Hello', x: 100, y: 0, width: 100, height: 50 }, { id: '2', label: 'World', x: 100, y: 200, width: 100, height: 50 }, -]); +]; /** * Initial links for the graph. - * Defined separately to enable type inference for CustomLink. */ -const defaultLinks = createLinks([ +const defaultLinks: GraphLink[] = [ { id: 'e1-2', source: '1', @@ -108,7 +108,7 @@ const defaultLinks = createLinks([ }, }, }, -]); +]; /** * Redux slice for managing graph state. @@ -307,12 +307,6 @@ function useReduxAdapter(): ExternalGraphStore { // STEP 5: Component Implementation // ============================================================================ -/** - * Type inference for custom elements. - * Uses InferElement to extract the element type from the initial elements array. - */ -type CustomElement = InferElement; - /** * Custom render function for graph elements. * This defines how each element is rendered in the SVG. diff --git a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-zustand.tsx b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-zustand.tsx index e28bef8213..2d54f4b10b 100644 --- a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-zustand.tsx +++ b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-zustand.tsx @@ -33,13 +33,10 @@ */ import { - createElements, - createLinks, GraphProvider, type GraphProps, type GraphElement, type GraphLink, - type InferElement, Paper, type ExternalGraphStore, } from '@joint/react'; @@ -54,12 +51,17 @@ import type { Update } from '../../../utils/create-state'; // STEP 1: Define Initial Graph Data // ============================================================================ -const defaultElements = createElements([ +/** + * Custom element type with a label property. + */ +type CustomElement = GraphElement & { label: string }; + +const defaultElements: CustomElement[] = [ { id: '1', label: 'Hello', x: 100, y: 0, width: 100, height: 50 }, { id: '2', label: 'World', x: 100, y: 200, width: 100, height: 50 }, -]); +]; -const defaultLinks = createLinks([ +const defaultLinks: GraphLink[] = [ { id: 'e1-2', source: '1', @@ -70,9 +72,7 @@ const defaultLinks = createLinks([ }, }, }, -]); - -type CustomElement = InferElement; +]; // ============================================================================ // STEP 2: Custom Element Renderer diff --git a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode.tsx b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode.tsx index e3f2001a1b..7a5c92d21c 100644 --- a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode.tsx +++ b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode.tsx @@ -37,13 +37,10 @@ */ import { - createElements, - createLinks, GraphProvider, type GraphProps, type GraphElement, type GraphLink, - type InferElement, Paper, } from '@joint/react'; import '../../examples/index.css'; @@ -54,30 +51,40 @@ import { useState, type Dispatch, type SetStateAction } from 'react'; // STEP 1: Define Initial Graph Data // ============================================================================ +/** + * Custom element type with a label property. + * Extends GraphElement with our custom 'label' property. + */ +type CustomElement = GraphElement & { label: string }; + +/** + * Custom link type. + * Uses GraphLink as the base type for our links. + */ +type CustomLink = GraphLink; + /** * Initial elements (nodes) for the graph. - * createElements is a helper function that creates properly formatted element - * objects compatible with JointJS. Each element needs: + * Each element needs: * - id: unique identifier * - label: text to display (custom property) * - x, y: position on the canvas * - width, height: dimensions */ -const defaultElements = createElements([ +const defaultElements: CustomElement[] = [ { id: '1', label: 'Hello', x: 100, y: 0, width: 100, height: 50 }, { id: '2', label: 'World', x: 100, y: 200, width: 100, height: 50 }, -]); +]; /** * Initial links (edges) for the graph. - * createLinks is a helper function that creates properly formatted link objects. * Each link needs: * - id: unique identifier * - source: id of the source element * - target: id of the target element * - attrs: visual attributes (colors, stroke width, etc.) */ -const defaultLinks = createLinks([ +const defaultLinks: CustomLink[] = [ { id: 'e1-2', source: '1', @@ -88,26 +95,7 @@ const defaultLinks = createLinks([ }, }, }, -]); - -// ============================================================================ -// STEP 2: Type Inference for Custom Elements and Links -// ============================================================================ - -/** - * InferElement extracts the TypeScript type from the elements array. - * This gives us a CustomElement type that includes all properties from - * the initial elements, including our custom 'label' property. - * - * Example: CustomElement = { id: string, label: string, x: number, ... } - */ -type CustomElement = InferElement; - -/** - * Extract the link type from the links array. - * This gives us proper TypeScript typing for our links. - */ -type CustomLink = (typeof defaultLinks)[number]; +]; // ============================================================================ // STEP 3: Custom Element Renderer diff --git a/packages/joint-react/src/stories/tutorials/step-by-step/code-html-renderer.tsx b/packages/joint-react/src/stories/tutorials/step-by-step/code-html-renderer.tsx index e4a585cfc0..e00cc65a3a 100644 --- a/packages/joint-react/src/stories/tutorials/step-by-step/code-html-renderer.tsx +++ b/packages/joint-react/src/stories/tutorials/step-by-step/code-html-renderer.tsx @@ -1,25 +1,27 @@ import { useCallback, useRef, useState } from 'react'; import { - createElements, - createLinks, GraphProvider, Paper, usePaper, useNodeSize, type GraphProps, - type InferElement, + type GraphElement, + type GraphLink, } from '@joint/react'; import '../../examples/index.css'; import { BUTTON_CLASSNAME } from 'storybook-config/theme'; +// Define element type with custom properties +type CustomElement = GraphElement & { data: { label: string } }; + // Define initial elements -const initialElements = createElements([ +const initialElements: CustomElement[] = [ { id: '1', data: { label: 'Hello' }, x: 100, y: 0, width: 100, height: 25 }, { id: '2', data: { label: 'World' }, x: 100, y: 200, width: 100, height: 25 }, -]); +]; // Define initial edges -const initialEdges = createLinks([ +const initialEdges: GraphLink[] = [ { id: 'e1-2', source: '1', @@ -32,9 +34,7 @@ const initialEdges = createLinks([ }, }, }, -]); - -type CustomElement = InferElement; +]; let zoomLevel = 1; @@ -113,7 +113,12 @@ function Main() { export default function App(props: Readonly) { return ( - +
); diff --git a/packages/joint-react/src/stories/tutorials/step-by-step/code-html.tsx b/packages/joint-react/src/stories/tutorials/step-by-step/code-html.tsx index fb3954f740..ee143f8e4e 100644 --- a/packages/joint-react/src/stories/tutorials/step-by-step/code-html.tsx +++ b/packages/joint-react/src/stories/tutorials/step-by-step/code-html.tsx @@ -1,24 +1,27 @@ /* eslint-disable react-perf/jsx-no-new-object-as-prop */ import React from 'react'; import { - createElements, - createLinks, GraphProvider, Paper, type GraphProps, - type InferElement, + type GraphElement, + type GraphLink, useNodeSize, } from '@joint/react'; import '../../examples/index.css'; import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; + +// define element type with custom properties +type CustomElement = GraphElement & { label: string }; + // define initial elements -const initialElements = createElements([ +const initialElements: CustomElement[] = [ { id: '1', label: 'Hello', x: 100, y: 0, width: 100, height: 50 }, { id: '2', label: 'World', x: 100, y: 200, width: 100, height: 50 }, -]); +]; // define initial edges -const initialEdges = createLinks([ +const initialEdges: GraphLink[] = [ { id: 'e1-2', source: '1', @@ -31,10 +34,7 @@ const initialEdges = createLinks([ }, }, }, -]); - -// infer element type from the initial elements (this type can be used for later usage like RenderItem props) -type CustomElement = InferElement; +]; function RenderItem(props: CustomElement) { const { label, width, height } = props; const elementRef = React.useRef(null); diff --git a/packages/joint-react/src/stories/tutorials/step-by-step/code-svg.tsx b/packages/joint-react/src/stories/tutorials/step-by-step/code-svg.tsx index f9d3adedf7..69ccfc3e62 100644 --- a/packages/joint-react/src/stories/tutorials/step-by-step/code-svg.tsx +++ b/packages/joint-react/src/stories/tutorials/step-by-step/code-svg.tsx @@ -2,22 +2,24 @@ import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; import { - createElements, - createLinks, GraphProvider, Paper, type GraphProps, - type InferElement, + type GraphElement, + type GraphLink, } from '@joint/react'; +// define element type with custom properties +type CustomElement = GraphElement & { color: string }; + // define initial elements -const initialElements = createElements([ +const initialElements: CustomElement[] = [ { id: '1', color: PRIMARY, x: 100, y: 0, width: 100, height: 25 }, { id: '2', color: PRIMARY, x: 100, y: 200, width: 100, height: 25 }, -]); +]; // define initial edges -const initialEdges = createLinks([ +const initialEdges: GraphLink[] = [ { id: 'e1-2', source: '1', @@ -30,10 +32,7 @@ const initialEdges = createLinks([ }, }, }, -]); - -// infer element type from the initial elements (this type can be used for later usage like RenderItem props) -type CustomElement = InferElement; +]; function RenderItem({ width, height, color }: CustomElement) { return ; diff --git a/packages/joint-react/src/stories/tutorials/step-by-step/docs.mdx b/packages/joint-react/src/stories/tutorials/step-by-step/docs.mdx index 10ce041c57..48f6f81829 100644 --- a/packages/joint-react/src/stories/tutorials/step-by-step/docs.mdx +++ b/packages/joint-react/src/stories/tutorials/step-by-step/docs.mdx @@ -42,39 +42,23 @@ Welcome! This guide will help you get started with the new `@joint/react` librar ### a. Elements (Nodes) -`createElements` is a convenience helper. You can also pass plain objects with at least an `id` and geometry (`x`, `y`, `width`, `height`). +Elements are plain objects with at least an `id` and geometry properties (`x`, `y`, `width`, `height`). ```tsx -// Using createElements (sugar code) -import { createElements } from '@joint/react'; - -const elements = createElements([ +const elements = [ { id: '1', label: 'Node 1', x: 100, y: 0, width: 100, height: 25 }, { id: '2', label: 'Node 2', x: 100, y: 200, width: 100, height: 25 }, -]); - -// Without createElements -const elements = [ - { id: 'sta', label: 'Custom Node', x: 50, y: 50, width: 80, height: 40 }, -] as const; +]; ``` ### b. Links (Edges) -Similarly, `createLinks` is a helper for defining links. Plain objects with `id`, `source`, and `target` also work. +Links are plain objects with `id`, `source`, and `target` properties. ```tsx -// Using createLinks (sugar code) -import { createLinks } from '@joint/react'; - -const links = createLinks([ - { id: 'l1', source: '1', target: '2' }, -]); - -// Without createLinks const links = [ - { id: 'l2', source: 'sta', target: '2' }, -] as const; + { id: 'l1', source: '1', target: '2' }, +]; ``` --- @@ -505,10 +489,10 @@ You can render multiple `Paper` instances inside one `GraphProvider` to create f ```tsx import { GraphProvider } from '@joint/react'; -const elements = createElements([ +const elements = [ { id: '1', label: 'A', x: 50, y: 50, width: 80, height: 40 }, { id: '2', label: 'B', x: 250, y: 180, width: 80, height: 40 }, -]); +]; export function MultiViews() { return ( diff --git a/packages/joint-react/src/types/element-types.ts b/packages/joint-react/src/types/element-types.ts index fdf5458f3b..8dcfe8d50f 100644 --- a/packages/joint-react/src/types/element-types.ts +++ b/packages/joint-react/src/types/element-types.ts @@ -24,16 +24,11 @@ export interface StandardShapesTypeMapper { export type StandardShapesType = keyof StandardShapesTypeMapper; -export interface GraphElement { +export interface GraphElement extends Record { /** * Unique identifier of the element. */ id: dia.Cell.ID; - /** - * Optional element type. - * @default `REACT_TYPE` - */ - type?: string | keyof StandardShapesTypeMapper; /** * Ports of the element. */ @@ -62,4 +57,16 @@ export interface GraphElement { * Optional angle of the element. */ angle?: number; + /** + * Z-index of the element. + */ + z?: number; + /** + * Parent element id. + */ + parent?: string; + /** + * Attributes of the element. + */ + attrs?: dia.Cell.Selectors; } diff --git a/packages/joint-react/src/types/link-types.ts b/packages/joint-react/src/types/link-types.ts index 5de7548108..41dc9dab26 100644 --- a/packages/joint-react/src/types/link-types.ts +++ b/packages/joint-react/src/types/link-types.ts @@ -13,25 +13,24 @@ export type StandardLinkShapesType = keyof StandardLinkShapesTypeMapper; * @group Graph * @see @see https://docs.jointjs.com/learn/features/shapes/links/#dialink */ -export interface GraphLink - extends dia.Link.EndJSON, - Record { +export interface GraphLink extends Record { /** * Unique identifier of the link. */ readonly id: dia.Cell.ID; /** - * Source element id. + * Source element id or endpoint definition. */ readonly source: dia.Cell.ID | dia.Link.EndJSON; /** - * Target element id. + * Target element id or endpoint definition. */ readonly target: dia.Cell.ID | dia.Link.EndJSON; /** * Optional link type. + * @default 'standard.Link' */ - readonly type?: Type; + readonly type?: string; /** * Z index of the link. */ @@ -41,14 +40,27 @@ export interface GraphLink; + portsData: Record; +} + +/** + * GraphStore reference interface for element view. + */ +export interface ReactElementViewGraphStoreRef { + schedulePaperUpdate: () => void; + readonly internalState: GraphState; +} + +/** + * PaperStore reference interface for element view. + */ +export interface ReactElementViewPaperStoreRef { + getNewPorts: (options: { + state: GraphState; + cellId: dia.Cell.ID; + portElementsCache: Record; + portsData: Record; + }) => Record | undefined; +} + +/** + * Cache interface for link view state. + */ +export interface ReactLinkViewCache { + linkViews: Record; + linksData: Record; +} + +/** + * GraphStore reference interface for link view. + */ +export interface ReactLinkViewGraphStoreRef { + schedulePaperUpdate: () => void; + flushPendingUpdates: () => void; +} + +/** + * PaperStore reference interface for link view. + */ +export interface ReactLinkViewPaperStoreRef { + getLinkLabelId: (linkId: dia.Cell.ID, labelIndex: number) => string; +} + +/** + * Extended Paper interface with React-specific properties for both element and link views. + */ +export interface ReactPaper extends dia.Paper { + reactElementCache: ReactElementViewCache; + reactElementGraphStore: ReactElementViewGraphStoreRef; + reactElementPaperStore: ReactElementViewPaperStoreRef; + reactLinkCache: ReactLinkViewCache; + reactLinkGraphStore: ReactLinkViewGraphStoreRef; + reactLinkPaperStore: ReactLinkViewPaperStoreRef; +} diff --git a/packages/joint-react/src/utils/__tests__/create.test.ts b/packages/joint-react/src/utils/__tests__/create.test.ts deleted file mode 100644 index 0d74c6ae1a..0000000000 --- a/packages/joint-react/src/utils/__tests__/create.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { createElements, type InferElement } from '../create'; - -describe('create', () => { - it('should create element with ts support for react element', () => { - const elements = createElements([ - { - id: '1', - x: 10, - y: 10, - width: 100, - height: 100, - somethingElse: 'test', - attrs: { - rect: { 'alignment-baseline': 'middle' }, - }, - }, - ]); - expect(elements).toHaveLength(1); - expect(elements[0]).toHaveProperty('id', '1'); - expect(elements[0]).toHaveProperty('x', 10); - expect(elements[0]).toHaveProperty('y', 10); - expect(elements[0]).toHaveProperty('width', 100); - expect(elements[0]).toHaveProperty('height', 100); - expect(elements[0]).toHaveProperty('somethingElse', 'test'); - expect(elements[0]).toHaveProperty('attrs.rect', { 'alignment-baseline': 'middle' }); - }); - it('should create element with ts support for react element defined in type', () => { - const elements = createElements([ - { - id: '1', - x: 10, - y: 10, - width: 100, - height: 100, - somethingElse: 'test', - type: 'ReactElement', - attrs: { - rect: { 'alignment-baseline': 'middle' }, - }, - }, - ]); - expect(elements).toHaveLength(1); - expect(elements[0]).toHaveProperty('id', '1'); - expect(elements[0]).toHaveProperty('x', 10); - expect(elements[0]).toHaveProperty('y', 10); - expect(elements[0]).toHaveProperty('width', 100); - expect(elements[0]).toHaveProperty('height', 100); - expect(elements[0]).toHaveProperty('somethingElse', 'test'); - expect(elements[0]).toHaveProperty('attrs.rect', { 'alignment-baseline': 'middle' }); - }); - it('should create element with ts support for build-in element defined in type', () => { - const elements = createElements([ - { - id: '1', - x: 10, - y: 10, - width: 100, - height: 100, - type: 'standard.Rectangle', - attrs: { - body: { fill: 'red' }, - }, - }, - ]); - expect(elements).toHaveLength(1); - expect(elements[0]).toHaveProperty('id', '1'); - expect(elements[0]).toHaveProperty('x', 10); - expect(elements[0]).toHaveProperty('y', 10); - expect(elements[0]).toHaveProperty('width', 100); - expect(elements[0]).toHaveProperty('height', 100); - expect(elements[0]).toHaveProperty('type', 'standard.Rectangle'); - expect(elements[0]).toHaveProperty('attrs.body', { fill: 'red' }); - }); - it('should create element with custom element', () => { - type MyCustomElement = { - id: string; - somethingNice: string; - }; - const elements = createElements([ - { - somethingNice: 'test', - id: '1', - }, - ]); - expect(elements).toHaveLength(1); - expect(elements[0]).toHaveProperty('id', '1'); - expect(elements[0]).toHaveProperty('somethingNice', 'test'); - - type NodeData = { - id: string; - label: string; - nodeType: 'start' | 'step' | 'decision'; - cx: number; - cy: number; - }; - const flowchartNodes = createElements([ - { id: 'start', label: 'Start', nodeType: 'start', cx: 50, cy: 40 }, - { - id: 'addToCart', - label: 'Add to Cart', - nodeType: 'step', - cx: 200, - cy: 40, - }, - ]); - type FlowchartNode = InferElement; - expect(flowchartNodes).toHaveLength(2); - expect(flowchartNodes[0]).toHaveProperty('id', 'start'); - expect(flowchartNodes[0]).toHaveProperty('label', 'Start'); - expect(flowchartNodes[0]).toHaveProperty('nodeType', 'start'); - const test: FlowchartNode = { - id: 'start', - label: 'Start', - nodeType: 'start', - cx: 50, - cy: 40, - height: 100, - width: 100, - }; - expect(test).toHaveProperty('id', 'start'); - }); - it('should return an empty array when called with an empty array', () => { - const elements = createElements([]); - expect(Array.isArray(elements)).toBe(true); - expect(elements).toHaveLength(0); - }); -}); diff --git a/packages/joint-react/src/utils/cell/cell-utilities.ts b/packages/joint-react/src/utils/cell/cell-utilities.ts index 45f1c567b0..697f43c46c 100644 --- a/packages/joint-react/src/utils/cell/cell-utilities.ts +++ b/packages/joint-react/src/utils/cell/cell-utilities.ts @@ -17,7 +17,7 @@ export function mapLinkToGraph(link: GraphLink, graph: dia.Graph): CellOrJsonCel const target = getTargetOrSource(link.target); const { attrs, type = 'standard.Link', ...rest } = link; - // TODO: this is not optimal solution + // Note: Accessing prototype defaults directly. Consider caching defaults for performance. const defaults = util.result( util.getByPath(graph.layerCollection.cellNamespace, type, '.').prototype, 'defaults', diff --git a/packages/joint-react/src/utils/clear-view.ts b/packages/joint-react/src/utils/clear-view.ts index ad87161360..32825043af 100644 --- a/packages/joint-react/src/utils/clear-view.ts +++ b/packages/joint-react/src/utils/clear-view.ts @@ -12,9 +12,12 @@ const DEFAULT_ON_VALIDATE_LINK = () => true; * Clear the view of the cell and the links connected to it. * @internal * @group Utils - * @description - * This function is used to clear the view of the cell and the links connected to it. + * @description This function is used to clear the view of the cell and the links connected to it. * It is used to ensure that the view is recalculated and the links are updated. + * + * NOTE: For internal React components, prefer using graphStore.scheduleClearView() + * which batches multiple calls for the same cell, improving performance when + * multiple ports exist on a single node. * @param options - The options for the clear view. */ export function clearView(options: Options) { diff --git a/packages/joint-react/src/utils/create-state.ts b/packages/joint-react/src/utils/create-state.ts index 0bc29d009e..6f3944e75f 100644 --- a/packages/joint-react/src/utils/create-state.ts +++ b/packages/joint-react/src/utils/create-state.ts @@ -111,6 +111,8 @@ interface Options { readonly isEqual?: (a: T, b: T) => boolean; /** Name for the state (used for debugging and dev tools) */ readonly name: string; + /** Whether to enable dev tools integration for this state. Defaults to false. */ + readonly isDevToolEnabled?: boolean; } /** diff --git a/packages/joint-react/src/utils/create.ts b/packages/joint-react/src/utils/create.ts deleted file mode 100644 index 03a37ab1ae..0000000000 --- a/packages/joint-react/src/utils/create.ts +++ /dev/null @@ -1,132 +0,0 @@ -import type { GraphElement, StandardShapesTypeMapper } from '../types/element-types'; -import type { GraphLink, StandardLinkShapesType } from '../types/link-types'; - -type RequiredElementProps = { - width?: number; - height?: number; -}; - -type ElementWithAttributes = - T extends keyof StandardShapesTypeMapper - ? { type?: T; attrs?: StandardShapesTypeMapper[T] } - : // eslint-disable-next-line sonarjs/no-redundant-optional - { type?: undefined; attrs?: StandardShapesTypeMapper['ReactElement'] }; - -/** - * Create a single element helper function. - * @group Utils - * @param item - Element to create. - * @returns The created element. (Node) - * @example - * without custom data - * ```ts - * const element = createElementItem({ - * id: '1', - * type: 'rect', - * x: 10, - * y: 10, - * width: 100, - * height: 100, - * }); - * ``` - * @example - * with custom data - * ```ts - * const element = createElementItem({ - * id: '1', - * type: 'rect', - * x: 10, - * y: 10, - * data: { label: 'Node 1' }, - * width: 100, - * height: 100, - * }); - * ``` - */ -export function createElementItem< - Element extends GraphElement, - Type extends string | undefined = 'ReactElement', ->(item: Element & ElementWithAttributes): Element & RequiredElementProps { - return { ...item } as Element & RequiredElementProps; -} - -/** - * Create elements helper function. - * @group Utils - * @param items - Array of elements to create. - * @returns Array of elements. (Nodes) - * @example - * without custom data - * ```ts - * const elements = createElements([ - * { id: '1', type: 'rect', x: 10, y: 10, width: 100, height: 100 }, - * { id: '2', type: 'circle', x: 200, y: 200, width: 100, height: 100 }, - * ]); - * ``` - * @example - * with custom data - * ```ts - * const elements = createElements([ - * { id: '1', type: 'rect', x: 10, y: 10 ,data : { label: 'Node 1' }, width: 100, height: 100 }, - * { id: '2', type: 'circle', x: 200, y: 200, data : { label: 'Node 2' }, width: 100, height: 100 }, - * ]); - * ``` - */ -export function createElements< - Element extends GraphElement, - Type extends string | undefined = 'ReactElement', ->(items: Array>): Array { - return items.map((item) => ({ ...item })) as Array; -} - -/** - * Infer element based on typeof createElements - * @group Utils - * @example - * ```ts - * const elements = createElements([ - * { id: '1', type: 'rect', x: 10, y: 10 ,data : { label: 'Node 1' }, width: 100, height: 100 }, - * { id: '2', type: 'circle', x: 200, y: 200, data : { label: 'Node 2' }, width: 100, height: 100 }, - * ]); - * - * type BaseElementWithData = InferElement; - * ``` - */ -export type InferElement>> = T[number]; - -/** - * Create links helper function. - * @group Utils - * @param data - Array of links to create. - * @returns Array of links. (Edges) - * @example - * ```ts - * const links = createLinks([ - * { id: '1', source: '1', target: '2' }, - * { id: '2', source: '2', target: '3' }, - * ]); - * ``` - */ -export function createLinks< - Link extends GraphLink, - Type extends StandardLinkShapesType | string = 'standard.Link', ->(data: Array>): Array { - return data.map((link) => link); -} - -/** - * Create a single link helper function. - * @group Utils - * @param link - Link to create. - * @returns The created link. (Edge) - * @example - * ```ts - * const link = createLinkItem({ id: '1', source: '1', target: '2' }); - * ``` - */ -export function createLinkItem< - Link extends GraphLink, - Type extends StandardLinkShapesType | string = 'standard.Link', ->(link: Link & GraphLink): Link & GraphLink { - return link; -} diff --git a/packages/joint-react/src/utils/test-wrappers.tsx b/packages/joint-react/src/utils/test-wrappers.tsx index 4a375e88e3..1070407c29 100644 --- a/packages/joint-react/src/utils/test-wrappers.tsx +++ b/packages/joint-react/src/utils/test-wrappers.tsx @@ -3,6 +3,12 @@ import { GraphProvider, Paper, type GraphProps, type PaperProps } from '../compo import { dia } from '@joint/core'; import { DEFAULT_CELL_NAMESPACE } from '../store/graph-store'; +/** + * Testing helper to create a new JointJS graph instance. + * @returns A new JointJS graph. + * @internal + * @group utils + */ export function getTestGraph() { return new dia.Graph({}, { cellNamespace: DEFAULT_CELL_NAMESPACE }); } From 25bf1ceb13bc61fd290b64946bf03a1be62bd99c Mon Sep 17 00:00:00 2001 From: samuelgja Date: Fri, 23 Jan 2026 22:11:28 +0700 Subject: [PATCH 24/24] chore(joint-react): remove TypeScript error suppression for Vite plugins --- .../src/hooks/use-cell-actions.stories.tsx | 2 +- .../joint-react/src/hooks/use-element.stories.tsx | 2 +- packages/joint-react/src/hooks/use-node-size.tsx | 14 +++++++------- .../state/__tests__/graph-state-selectors.test.ts | 4 ++-- .../src/state/__tests__/state-sync.test.ts | 5 ++--- packages/joint-react/src/state/state-sync.ts | 4 ++-- .../src/store/create-elements-size-observer.ts | 2 +- packages/joint-react/src/store/graph-store.ts | 4 ++-- packages/joint-react/vite.config.ts | 2 +- 9 files changed, 19 insertions(+), 20 deletions(-) diff --git a/packages/joint-react/src/hooks/use-cell-actions.stories.tsx b/packages/joint-react/src/hooks/use-cell-actions.stories.tsx index 35f55449dc..cd583f85f6 100644 --- a/packages/joint-react/src/hooks/use-cell-actions.stories.tsx +++ b/packages/joint-react/src/hooks/use-cell-actions.stories.tsx @@ -1,4 +1,4 @@ -/* eslint-disable @eslint-react/dom/no-missing-button-type */ + /* eslint-disable react-perf/jsx-no-new-function-as-prop */ import type { Meta, StoryObj } from '@storybook/react'; import type { SimpleElement } from '../../.storybook/decorators/with-simple-data'; diff --git a/packages/joint-react/src/hooks/use-element.stories.tsx b/packages/joint-react/src/hooks/use-element.stories.tsx index 5504400112..108c3269b1 100644 --- a/packages/joint-react/src/hooks/use-element.stories.tsx +++ b/packages/joint-react/src/hooks/use-element.stories.tsx @@ -101,5 +101,5 @@ function Component() { description: 'Extracts element coordinates (x, y) using a selector function. The component re-renders only when the position changes, not when other properties like size or color change.', details: - "**Use Case:** Perfect for displaying position information or creating position-dependent UI elements that don't need to update when other element properties change.", + '**Use Case:** Perfect for displaying position information or creating position-dependent UI elements that don\'t need to update when other element properties change.', }); diff --git a/packages/joint-react/src/hooks/use-node-size.tsx b/packages/joint-react/src/hooks/use-node-size.tsx index dcb321929d..1f92ab890e 100644 --- a/packages/joint-react/src/hooks/use-node-size.tsx +++ b/packages/joint-react/src/hooks/use-node-size.tsx @@ -154,12 +154,12 @@ export function useNodeSize( process.env.NODE_ENV === 'production' ? `Multiple useNodeSize hooks detected for element "${id}". Only one useNodeSize hook can be used per element.` : `Multiple useNodeSize hooks detected for element with id "${id}".\n\n` + - `Only one useNodeSize hook can be used per element. Multiple useNodeSize hooks ` + - `trying to set the size for the same element will cause conflicts and unexpected behavior.\n\n` + - `Solution:\n` + - `- Use only one useNodeSize hook per element\n` + - `- If you need multiple measurements, use a single useNodeSize hook with a custom \`transform\` handler\n` + - `- Check your renderElement function to ensure you're not using multiple useNodeSize hooks for the same element`; + 'Only one useNodeSize hook can be used per element. Multiple useNodeSize hooks ' + + 'trying to set the size for the same element will cause conflicts and unexpected behavior.\n\n' + + 'Solution:\n' + + '- Use only one useNodeSize hook per element\n' + + '- If you need multiple measurements, use a single useNodeSize hook with a custom `transform` handler\n' + + '- Check your renderElement function to ensure you\'re not using multiple useNodeSize hooks for the same element'; throw new Error(errorMessage); } @@ -175,5 +175,5 @@ export function useNodeSize( }, [elementRef, graph, hasMeasuredNode, id, setMeasuredNode]); // This hook itself does not return anything. - return layout + return layout; } diff --git a/packages/joint-react/src/state/__tests__/graph-state-selectors.test.ts b/packages/joint-react/src/state/__tests__/graph-state-selectors.test.ts index 7248ed5259..07c90fb291 100644 --- a/packages/joint-react/src/state/__tests__/graph-state-selectors.test.ts +++ b/packages/joint-react/src/state/__tests__/graph-state-selectors.test.ts @@ -687,8 +687,8 @@ describe('graph-state-selectors', () => { // Should only have properties from previous state expect(elementFromGraph).not.toHaveProperty('graphOnlyProp'); expect(elementFromGraph).not.toHaveProperty('anotherGraphProp'); - expect(Object.keys(elementFromGraph).sort()).toEqual( - ['id', 'x', 'y', 'width', 'height'].sort() + expect(Object.keys(elementFromGraph).toSorted()).toEqual( + ['id', 'x', 'y', 'width', 'height'].toSorted() ); }); diff --git a/packages/joint-react/src/state/__tests__/state-sync.test.ts b/packages/joint-react/src/state/__tests__/state-sync.test.ts index 6248878759..3bc025786e 100644 --- a/packages/joint-react/src/state/__tests__/state-sync.test.ts +++ b/packages/joint-react/src/state/__tests__/state-sync.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable unicorn/consistent-function-scoping */ /* eslint-disable sonarjs/no-element-overwrite */ /* eslint-disable sonarjs/no-nested-functions */ import { dia } from '@joint/core'; @@ -350,9 +351,7 @@ describe('stateSync', () => { defaultElementToGraphSelector(createElementToGraphOptions(element, graph)) ); const link = { id: 'link1', source: '1', target: '2' }; - const linkItems = [ - defaultLinkToGraphSelector(createLinkToGraphOptions(link, graph)), - ]; + const linkItems = [defaultLinkToGraphSelector(createLinkToGraphOptions(link, graph))]; graph.syncCells([...elementItems, ...linkItems], { remove: true }); // Create empty store diff --git a/packages/joint-react/src/state/state-sync.ts b/packages/joint-react/src/state/state-sync.ts index ea1e791c20..e05cb83ffd 100644 --- a/packages/joint-react/src/state/state-sync.ts +++ b/packages/joint-react/src/state/state-sync.ts @@ -436,7 +436,7 @@ export function stateSync< } if (updates.has(id)) { const update = updates.get(id); - if (update && update.type === 'element') { + if (update?.type === 'element') { if (util.isEqual(cellElement, update.data)) { nextElements.push(cellElement); } else { @@ -473,7 +473,7 @@ export function stateSync< } if (updates.has(id)) { const update = updates.get(id); - if (update && update.type === 'link') { + if (update?.type === 'link') { if (util.isEqual(link, update.data)) { nextLinks.push(link); } else { diff --git a/packages/joint-react/src/store/create-elements-size-observer.ts b/packages/joint-react/src/store/create-elements-size-observer.ts index 46427a75a4..4e86fbb6eb 100644 --- a/packages/joint-react/src/store/create-elements-size-observer.ts +++ b/packages/joint-react/src/store/create-elements-size-observer.ts @@ -149,7 +149,7 @@ export function createElementsSizeObserver(options: Options): GraphStoreObserver const cellId = cellIdByDomElement.get(target as HTMLElement | SVGElement); if (!cellId) { - throw new Error(`DOM element not found in resize observer`); + throw new Error('DOM element not found in resize observer'); } // If borderBoxSize is not available or empty, continue to the next entry. diff --git a/packages/joint-react/src/store/graph-store.ts b/packages/joint-react/src/store/graph-store.ts index 70a222c194..acf05fa62f 100644 --- a/packages/joint-react/src/store/graph-store.ts +++ b/packages/joint-react/src/store/graph-store.ts @@ -484,8 +484,7 @@ export class GraphStore { // Only update if layout actually changed (optimization) const previousLayout = previousLayouts[element.id]; if ( - !previousLayout || - previousLayout.x !== newLayout.x || + previousLayout?.x !== newLayout.x || previousLayout.y !== newLayout.y || previousLayout.width !== newLayout.width || previousLayout.height !== newLayout.height || @@ -528,6 +527,7 @@ export class GraphStore { scheduleLayoutUpdate(); // Return cleanup function (no-op since we don't need per-cell cleanup) + // eslint-disable-next-line unicorn/consistent-function-scoping return () => { // No cleanup needed }; diff --git a/packages/joint-react/vite.config.ts b/packages/joint-react/vite.config.ts index c1b11273ae..fac0909030 100644 --- a/packages/joint-react/vite.config.ts +++ b/packages/joint-react/vite.config.ts @@ -5,11 +5,11 @@ import path from 'node:path'; import tsconfigPaths from 'vite-tsconfig-paths'; export default defineConfig({ - // @ts-expect-error - vite defaults plugins: [react(), mdPlugin(), tsconfigPaths()], assetsInclude: ['**/*.md'], build: { lib: { + // eslint-disable-next-line unicorn/prefer-module entry: path.resolve(__dirname, 'src/index.ts'), name: 'JointReact', fileName: (format) => `joint-react.${format}.js`,