├── doc
├── static
│ └── .nojekyll
├── img
│ └── canvas-editor.png
├── babel.config.js
├── postcss.config.js
├── sidebars.js
├── tsconfig.json
├── .gitignore
├── src
│ ├── pages
│ │ └── index.tsx
│ ├── css
│ │ └── custom.css
│ └── components
│ │ ├── singleNodePlayground.tsx
│ │ └── node.tsx
├── README.md
├── package.json
├── docs
│ ├── quickstart.mdx
│ └── introduction.mdx
└── docusaurus.config.js
├── .env
├── .eslintrc.json
├── src
├── react-app-env.d.ts
├── index.ts
├── node_editor
│ ├── utils
│ │ ├── index.ts
│ │ └── drag.ts
│ ├── node
│ │ ├── index.ts
│ │ ├── footer.tsx
│ │ ├── header.tsx
│ │ ├── pin.tsx
│ │ ├── node.tsx
│ │ └── connector.tsx
│ ├── index.tsx
│ ├── connector_content
│ │ ├── error.tsx
│ │ ├── common.ts
│ │ ├── default.tsx
│ │ ├── button.tsx
│ │ ├── check_box.tsx
│ │ ├── index.tsx
│ │ ├── string.tsx
│ │ ├── number.tsx
│ │ ├── range.tsx
│ │ ├── select.tsx
│ │ └── text_area.tsx
│ ├── theme
│ │ ├── index.ts
│ │ ├── theme.ts
│ │ └── dark.ts
│ ├── background.tsx
│ ├── link_canvas.tsx
│ ├── link
│ │ └── link_bezier.tsx
│ ├── model.ts
│ ├── use_node_editor.ts
│ ├── node_canvas.tsx
│ ├── pan_zoom.tsx
│ └── node_editor.tsx
├── contextual_menu
│ ├── common.ts
│ ├── index.ts
│ ├── selection_management_contextual_menu.tsx
│ ├── menu_item_list.tsx
│ ├── basic_contextual_menu.tsx
│ └── add_node_contextual_menu.tsx
└── index.css
├── postcss.config.cjs
├── .prettierrc.json
├── tsconfig.node.json
├── .gitignore
├── tailwind.config.cjs
├── tsconfig.json
├── vite.config.ts
├── LICENSE
├── package.json
└── README.md
/doc/static/.nojekyll:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.env:
--------------------------------------------------------------------------------
1 | SKIP_PREFLIGHT_CHECK=true
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "react-app"
3 | }
4 |
--------------------------------------------------------------------------------
/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./node_editor";
2 | export * from "./contextual_menu";
3 |
--------------------------------------------------------------------------------
/src/node_editor/utils/index.ts:
--------------------------------------------------------------------------------
1 | import { useDrag } from "./drag";
2 |
3 | export { useDrag };
4 |
--------------------------------------------------------------------------------
/doc/img/canvas-editor.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mathieuguyot/oura-node-editor/HEAD/doc/img/canvas-editor.png
--------------------------------------------------------------------------------
/doc/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: [require.resolve('@docusaurus/core/lib/babel/preset')],
3 | };
4 |
--------------------------------------------------------------------------------
/src/node_editor/node/index.ts:
--------------------------------------------------------------------------------
1 | import { Node, NodeProps } from "./node";
2 |
3 | export { Node };
4 | export type { NodeProps };
5 |
--------------------------------------------------------------------------------
/postcss.config.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {}
5 | }
6 | };
7 |
--------------------------------------------------------------------------------
/doc/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {
4 | content: ["./src/**/*.{html,js}"]
5 | },
6 | autoprefixer: {}
7 | }
8 | };
9 |
--------------------------------------------------------------------------------
/src/contextual_menu/common.ts:
--------------------------------------------------------------------------------
1 | export type MenuItemProps = {
2 | name: string;
3 | category?: string;
4 | onClick?: () => void;
5 | onMouseEnter?: () => void;
6 | onMouseLeave?: () => void;
7 | };
8 |
--------------------------------------------------------------------------------
/doc/sidebars.js:
--------------------------------------------------------------------------------
1 | /** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */
2 | const sidebars = {
3 | tutorialSidebar: [{ type: "autogenerated", dirName: "." }]
4 | };
5 |
6 | module.exports = sidebars;
7 |
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 100,
3 | "tabWidth": 4,
4 | "useTabs": false,
5 | "semi": true,
6 | "singleQuote": false,
7 | "trailingComma": "none",
8 | "bracketSpacing": true
9 | }
10 |
--------------------------------------------------------------------------------
/src/node_editor/index.tsx:
--------------------------------------------------------------------------------
1 | export { default as NodeEditor } from "./node_editor";
2 | export * from "./model";
3 | export * from "./node";
4 | export * from "./theme";
5 | export * from "./connector_content";
6 | export * from "./use_node_editor";
7 |
--------------------------------------------------------------------------------
/doc/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | // This file is not used in compilation. It is here just for a nice editor experience.
3 | "extends": "@tsconfig/docusaurus/tsconfig.json",
4 | "compilerOptions": {
5 | "jsx": "react",
6 | "baseUrl": "."
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "module": "ESNext",
5 | "moduleResolution": "Node",
6 | "allowSyntheticDefaultImports": true
7 | },
8 | "include": ["vite.config.ts"]
9 | }
10 |
--------------------------------------------------------------------------------
/doc/.gitignore:
--------------------------------------------------------------------------------
1 | # Dependencies
2 | /node_modules
3 |
4 | # Production
5 | /build
6 |
7 | # Generated files
8 | .docusaurus
9 | .cache-loader
10 |
11 | # Misc
12 | .DS_Store
13 | .env.local
14 | .env.development.local
15 | .env.test.local
16 | .env.production.local
17 |
18 | npm-debug.log*
19 | yarn-debug.log*
20 | yarn-error.log*
21 |
--------------------------------------------------------------------------------
/src/node_editor/connector_content/error.tsx:
--------------------------------------------------------------------------------
1 | export type ErrorConnectorContentProps = {
2 | message: string;
3 | };
4 |
5 | const ErrorConnectorComponent = (props: ErrorConnectorContentProps): JSX.Element => {
6 | const { message } = props;
7 | return
{`error: ${message}`}
;
8 | };
9 |
10 | export default ErrorConnectorComponent;
11 |
--------------------------------------------------------------------------------
/src/node_editor/connector_content/common.ts:
--------------------------------------------------------------------------------
1 | import { ConnectorModel, NodeModel } from "../model";
2 |
3 | export type ConnectorContentProps = {
4 | nodeId: string;
5 | cId: string;
6 | node: NodeModel;
7 | connector: ConnectorModel;
8 |
9 | getZoom: () => number;
10 | onConnectorUpdate: (nodeId: string, cId: string, connector: ConnectorModel) => void;
11 | };
12 |
--------------------------------------------------------------------------------
/doc/src/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import Layout from "@theme/Layout";
3 | import IntroNodeEditor from "../components/node";
4 |
5 | export default function Home(): JSX.Element {
6 | return (
7 |
8 |
9 |
10 | );
11 | }
12 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # dependencies
2 | /node_modules
3 | /.pnp
4 | .pnp.js
5 |
6 | # testing
7 | /coverage
8 |
9 | # production
10 | /build
11 | /lib
12 |
13 | # misc
14 | .DS_Store
15 | .env.local
16 | .env.development.local
17 | .env.test.local
18 | .env.production.local
19 |
20 | npm-debug.log*
21 | yarn-debug.log*
22 | yarn-error.log*
23 | yarn.lock
24 | package-lock.json
25 |
26 | cypress/videos/
27 | cypress/screenshots/
28 | .nyc_output/
29 | dist
--------------------------------------------------------------------------------
/tailwind.config.cjs:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: ["./src/**/*.{js,ts,jsx,tsx}"],
4 | theme: {
5 | extend: {}
6 | },
7 | prefix: "one-",
8 | plugins: [require("daisyui")],
9 | corePlugins: {
10 | preflight: false
11 | },
12 | daisyui: {
13 | base: false,
14 | themes: [
15 | "light",
16 | "dark",
17 | "nord",
18 | ]
19 | }
20 | };
21 |
--------------------------------------------------------------------------------
/src/node_editor/theme/index.ts:
--------------------------------------------------------------------------------
1 | import { createContext } from "react";
2 | import CSS from "csstype";
3 |
4 | import { PanZoomModel } from "../model";
5 | import darkTheme from "./dark";
6 | import { Theme } from "./theme";
7 |
8 | type ThemeContextType = {
9 | theme: Theme;
10 | buildBackgroundStyle?: (panZoomInfo: PanZoomModel) => CSS.Properties;
11 | };
12 |
13 | const ThemeContext = createContext(darkTheme);
14 |
15 | export type { ThemeContextType, Theme };
16 | export { ThemeContext, darkTheme };
17 |
--------------------------------------------------------------------------------
/src/node_editor/connector_content/default.tsx:
--------------------------------------------------------------------------------
1 | import { ConnectorContentProps } from "./common";
2 | import { PinLayout } from "../model";
3 |
4 | export default function DefaultConnectorContent(props: ConnectorContentProps) {
5 | return (
6 |
12 | {props.connector.name}
13 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/src/node_editor/node/footer.tsx:
--------------------------------------------------------------------------------
1 | import React, { useContext } from "react";
2 | import { NodeModel } from "../model";
3 | import { ThemeContext } from "../theme";
4 |
5 | export type FooterProps = {
6 | node: NodeModel;
7 | };
8 |
9 | export default function Footer({ node }: FooterProps): JSX.Element {
10 | const { theme } = useContext(ThemeContext);
11 |
12 | return (
13 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/src/contextual_menu/index.ts:
--------------------------------------------------------------------------------
1 | import { BasicContextualMenu, BasicContextualMenuProps } from "./basic_contextual_menu";
2 | import { AddNodeContextualMenu, AddNodeContextualMenuProps } from "./add_node_contextual_menu";
3 | import {
4 | SelectionManagementContextualMenu,
5 | SelectionManagementContextualMenuProps
6 | } from "./selection_management_contextual_menu";
7 |
8 | export { BasicContextualMenu, AddNodeContextualMenu, SelectionManagementContextualMenu };
9 | export type {
10 | BasicContextualMenuProps,
11 | AddNodeContextualMenuProps,
12 | SelectionManagementContextualMenuProps
13 | };
14 |
--------------------------------------------------------------------------------
/src/node_editor/theme/theme.ts:
--------------------------------------------------------------------------------
1 | import CSS from "csstype";
2 |
3 | export type NodeTheme = {
4 | selected?: CSS.Properties;
5 | unselected?: CSS.Properties;
6 | header?: CSS.Properties;
7 | body?: CSS.Properties;
8 | footer?: CSS.Properties;
9 | };
10 |
11 | export type LinkTheme = {
12 | selected?: CSS.Properties;
13 | unselected?: CSS.Properties;
14 | };
15 |
16 | export type Theme = {
17 | node?: NodeTheme & {
18 | basePin?: CSS.Properties;
19 | customPins?: { [contentType: string]: CSS.Properties };
20 | };
21 | link?: LinkTheme;
22 | connectors?: { [cId: string]: CSS.Properties };
23 | };
24 |
--------------------------------------------------------------------------------
/src/node_editor/node/header.tsx:
--------------------------------------------------------------------------------
1 | import React, { useContext } from "react";
2 | import { NodeModel } from "..";
3 | import { ThemeContext } from "../theme";
4 |
5 | export type HeaderProps = {
6 | node: NodeModel;
7 | };
8 |
9 | export default function Header({ node }: HeaderProps): JSX.Element {
10 | const { theme } = useContext(ThemeContext);
11 |
12 | return (
13 |
17 | {node.name}
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "useDefineForClassFields": true,
5 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
6 | "allowJs": false,
7 | "skipLibCheck": true,
8 | "esModuleInterop": false,
9 | "allowSyntheticDefaultImports": true,
10 | "strict": true,
11 | "forceConsistentCasingInFileNames": true,
12 | "module": "ESNext",
13 | "moduleResolution": "Node",
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "noEmit": true,
17 | "jsx": "react-jsx"
18 | },
19 | "include": ["src"],
20 | "references": [
21 | {
22 | "path": "./tsconfig.node.json"
23 | }
24 | ]
25 | }
26 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 | import react from "@vitejs/plugin-react";
3 | import dts from "vite-plugin-dts";
4 | import eslint from "vite-plugin-eslint";
5 | import cssInjectedByJsPlugin from "vite-plugin-css-injected-by-js";
6 |
7 | // https://vitejs.dev/config/
8 | export default defineConfig({
9 | build: {
10 | lib: {
11 | entry: "src/index.ts",
12 | name: "oura-node-editor",
13 | fileName: "oura-node-editor"
14 | },
15 | rollupOptions: {
16 | external: ["react", "react-dom"],
17 | output: {
18 | globals: {
19 | react: "React",
20 | "react-dom": "ReactDOM"
21 | }
22 | }
23 | }
24 | },
25 | plugins: [react(), dts(), eslint(), cssInjectedByJsPlugin()]
26 | });
27 |
--------------------------------------------------------------------------------
/src/node_editor/background.tsx:
--------------------------------------------------------------------------------
1 | import React, { useContext } from "react";
2 | import CSS from "csstype";
3 |
4 | import { PanZoomModel } from "./model";
5 | import { ThemeContext } from "./theme";
6 |
7 | export interface BackGroundProps {
8 | panZoomInfo: PanZoomModel;
9 | }
10 |
11 | export default function BackGround(props: React.PropsWithChildren): JSX.Element {
12 | const { buildBackgroundStyle } = useContext(ThemeContext);
13 | const { panZoomInfo, children } = props;
14 |
15 | let style: CSS.Properties = {
16 | position: "relative",
17 | top: "0",
18 | left: "0",
19 | width: "100%",
20 | height: "100%",
21 | overflow: "hidden"
22 | };
23 |
24 | if (buildBackgroundStyle) {
25 | style = { ...style, ...buildBackgroundStyle(panZoomInfo) };
26 | }
27 |
28 | return {children}
;
29 | }
30 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | .noselect {
6 | -webkit-touch-callout: none; /* iOS Safari */
7 | -webkit-user-select: none; /* Safari */
8 | -khtml-user-select: none; /* Konqueror HTML */
9 | -moz-user-select: none; /* Old versions of Firefox */
10 | -ms-user-select: none; /* Internet Explorer/Edge */
11 | user-select: none; /* Non-prefixed version, currently
12 | supported by Chrome, Edge, Opera and Firefox */
13 | }
14 |
15 | .one-preflight button,
16 | input,
17 | optgroup,
18 | select,
19 | textarea {
20 | font-family: inherit;
21 | font-feature-settings: inherit;
22 | font-variation-settings: inherit;
23 | font-size: 100%;
24 | font-weight: inherit;
25 | line-height: inherit;
26 | color: inherit;
27 | margin: 0;
28 | padding: 0;
29 | border: 1px solid oklch(var(--p));
30 | }
31 |
--------------------------------------------------------------------------------
/src/node_editor/connector_content/button.tsx:
--------------------------------------------------------------------------------
1 | import ErrorConnectorContent from "./error";
2 | import { ConnectorContentProps } from "./common";
3 |
4 | const ButtonConnectorContent = ({ connector, node }: ConnectorContentProps): JSX.Element => {
5 | if (!("label" in connector.data) || !("onClick" in connector.data)) {
6 | const message =
7 | "'button' connector types must provide a string field named 'label' and a callback 'onClick'";
8 | return ;
9 | }
10 |
11 | return (
12 |
19 | );
20 | };
21 |
22 | export default ButtonConnectorContent;
23 |
--------------------------------------------------------------------------------
/doc/README.md:
--------------------------------------------------------------------------------
1 | # Website
2 |
3 | This website is built using [Docusaurus 2](https://docusaurus.io/), a modern static website generator.
4 |
5 | ### Installation
6 |
7 | ```
8 | $ yarn
9 | ```
10 |
11 | ### Local Development
12 |
13 | ```
14 | $ yarn start
15 | ```
16 |
17 | This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server.
18 |
19 | ### Build
20 |
21 | ```
22 | $ yarn build
23 | ```
24 |
25 | This command generates static content into the `build` directory and can be served using any static contents hosting service.
26 |
27 | ### Deployment
28 |
29 | Using SSH:
30 |
31 | ```
32 | $ USE_SSH=true yarn deploy
33 | ```
34 |
35 | Not using SSH:
36 |
37 | ```
38 | $ GIT_USER= yarn deploy
39 | ```
40 |
41 | If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch.
42 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Mathieu GUYOT
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/doc/src/css/custom.css:
--------------------------------------------------------------------------------
1 | /**
2 | * Any CSS included here will be global. The classic template
3 | * bundles Infima by default. Infima is a CSS framework designed to
4 | * work well for content-centric websites.
5 | */
6 |
7 | /* You can override the default Infima variables here. */
8 | :root {
9 | --ifm-color-primary: #2e8555;
10 | --ifm-color-primary-dark: #29784c;
11 | --ifm-color-primary-darker: #277148;
12 | --ifm-color-primary-darkest: #205d3b;
13 | --ifm-color-primary-light: #33925d;
14 | --ifm-color-primary-lighter: #359962;
15 | --ifm-color-primary-lightest: #3cad6e;
16 | --ifm-code-font-size: 95%;
17 | --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1);
18 | }
19 |
20 | /* For readability concerns, you should choose a lighter palette in dark mode. */
21 | [data-theme='dark'] {
22 | --ifm-color-primary: #25c2a0;
23 | --ifm-color-primary-dark: #21af90;
24 | --ifm-color-primary-darker: #1fa588;
25 | --ifm-color-primary-darkest: #1a8870;
26 | --ifm-color-primary-light: #29d5b0;
27 | --ifm-color-primary-lighter: #32d8b4;
28 | --ifm-color-primary-lightest: #4fddbf;
29 | --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3);
30 | }
31 |
--------------------------------------------------------------------------------
/src/contextual_menu/selection_management_contextual_menu.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | import { MenuItemProps } from "./common";
4 | import { BasicContextualMenu } from "./basic_contextual_menu";
5 |
6 | export type SelectionManagementContextualMenuProps = {
7 | onMouseHover: (isMouseHover: boolean) => void;
8 | onDeleteSelection: () => void;
9 | };
10 |
11 | export const SelectionManagementContextualMenu = (
12 | props: SelectionManagementContextualMenuProps
13 | ): JSX.Element => {
14 | const { onMouseHover, onDeleteSelection } = props;
15 |
16 | const items: { [id: string]: MenuItemProps[] } = {};
17 | items.actions = [
18 | {
19 | name: "Delete selection",
20 | onClick: () => {
21 | onDeleteSelection();
22 | }
23 | }
24 | ];
25 |
26 | const onMouseEnter = React.useCallback(() => {
27 | onMouseHover(true);
28 | }, [onMouseHover]);
29 |
30 | const onMouseLeaves = React.useCallback(() => {
31 | onMouseHover(false);
32 | }, [onMouseHover]);
33 |
34 | return (
35 |
36 |
37 |
38 | );
39 | };
40 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "oura-node-editor",
3 | "version": "0.2.1",
4 | "license": "MIT",
5 | "author": "Mathieu Guyot",
6 | "type": "module",
7 | "files": [
8 | "dist"
9 | ],
10 | "main": "./dist/oura-node-editor.umd.cjs",
11 | "module": "./dist/oura-node-editor.js",
12 | "types": "./dist/index.d.ts",
13 | "scripts": {
14 | "dev": "vite build --watch",
15 | "build": "vite build"
16 | },
17 | "dependencies": {
18 | "@types/lodash": "^4.14.202",
19 | "@types/react": "^18.2.55",
20 | "@types/react-dom": "^18.2.19",
21 | "@vitejs/plugin-react": "^4.2.1",
22 | "autoprefixer": "^10.4.17",
23 | "daisyui": "^4.6.2",
24 | "eslint": "^8.56.0",
25 | "eslint-config-react-app": "^7.0.1",
26 | "immer": "^9.0.21",
27 | "lodash": "^4.17.21",
28 | "postcss": "^8.4.35",
29 | "prettier": "^3.2.5",
30 | "react": "^18.2.0",
31 | "react-dom": "^18.2.0",
32 | "react-zoom-pan-pinch": "^3.4.2",
33 | "tailwindcss": "^3.4.1",
34 | "typescript": "^5.3.3",
35 | "@typescript-eslint/parser": "^6.21.0",
36 | "vite": "^5.0.12",
37 | "vite-plugin-css-injected-by-js": "^3.4.0",
38 | "vite-plugin-dts": "^3.7.2",
39 | "vite-plugin-eslint": "^1.8.1"
40 | },
41 | "publishConfig": {
42 | "registry": "https://registry.npmjs.org/"
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/node_editor/node/pin.tsx:
--------------------------------------------------------------------------------
1 | import React, { useContext } from "react";
2 | import CSS from "csstype";
3 |
4 | import { ThemeContext } from "../theme";
5 |
6 | type PinProps = {
7 | className: string;
8 | contentType: string;
9 | leftPinPosition: number;
10 | pinPxRadius: number;
11 | pinColor?: string;
12 |
13 | onMouseDown: (event: React.MouseEvent) => void;
14 | };
15 |
16 | const Pin = (props: PinProps): JSX.Element => {
17 | const { className, contentType, leftPinPosition, pinPxRadius, pinColor, onMouseDown } = props;
18 | const { theme } = useContext(ThemeContext);
19 |
20 | let customStyle: CSS.Properties | undefined = undefined;
21 | if (theme?.node?.customPins && theme?.node?.customPins[contentType]) {
22 | customStyle = theme?.node?.customPins[contentType];
23 | }
24 |
25 | const style: CSS.Properties = {
26 | ...{
27 | position: "absolute",
28 | width: `${pinPxRadius * 2}px`,
29 | height: `${pinPxRadius * 2}px`,
30 | left: `${leftPinPosition}px`,
31 | top: `calc(50% - ${pinPxRadius}px)`,
32 | border: "1.5px solid oklch(var(--p))",
33 | boxSizing: "border-box"
34 | },
35 | ...theme?.node?.basePin,
36 | ...customStyle
37 | };
38 |
39 | if (pinColor) {
40 | style.backgroundColor = pinColor;
41 | }
42 |
43 | return ;
44 | };
45 |
46 | export default Pin;
47 |
--------------------------------------------------------------------------------
/src/node_editor/connector_content/check_box.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback } from "react";
2 | import produce from "immer";
3 |
4 | import ErrorConnectorContent from "./error";
5 | import { ConnectorContentProps } from "./common";
6 | import { ConnectorModel } from "../model";
7 |
8 | export default function CheckBox(props: ConnectorContentProps) {
9 | const onChange = useCallback(() => {
10 | const newConnector = produce(props.connector, (draft: ConnectorModel) => {
11 | draft.data.value = !draft.data.value;
12 | });
13 | props.onConnectorUpdate(props.nodeId, props.cId, newConnector);
14 | }, [props]);
15 |
16 | if (!("value" in props.connector.data)) {
17 | const message = "'check_box' connector types must provide a bool field named 'value'";
18 | return ;
19 | }
20 |
21 | return (
22 |
26 |
34 |
35 | {props.connector.name}
36 |
37 |
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/doc/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "oura-node-editor",
3 | "version": "0.0.0",
4 | "private": true,
5 | "scripts": {
6 | "docusaurus": "docusaurus",
7 | "start": "docusaurus start",
8 | "build": "docusaurus build",
9 | "swizzle": "docusaurus swizzle",
10 | "deploy": "docusaurus deploy",
11 | "clear": "docusaurus clear",
12 | "serve": "docusaurus serve",
13 | "write-translations": "docusaurus write-translations",
14 | "write-heading-ids": "docusaurus write-heading-ids",
15 | "typecheck": "tsc"
16 | },
17 | "dependencies": {
18 | "@docusaurus/core": "^3.4.0",
19 | "@docusaurus/preset-classic": "^3.4.0",
20 | "@mdx-js/react": "^3.0.1",
21 | "@monaco-editor/react": "^4.6.0",
22 | "clsx": "^2.1.1",
23 | "link": "^2.1.1",
24 | "oura-node-editor": "file:..",
25 | "prism-react-renderer": "^2.3.1",
26 | "react": "file:../node_modules/react",
27 | "react-dom": "file:../node_modules/react-dom"
28 | },
29 | "devDependencies": {
30 | "@docusaurus/module-type-aliases": "^3.1.1",
31 | "@docusaurus/tsconfig": "^3.1.1",
32 | "typescript": "^5.3.3"
33 | },
34 | "browserslist": {
35 | "production": [
36 | ">0.5%",
37 | "not dead",
38 | "not op_mini all"
39 | ],
40 | "development": [
41 | "last 1 chrome version",
42 | "last 1 firefox version",
43 | "last 1 safari version"
44 | ]
45 | },
46 | "engines": {
47 | "node": ">=18.0"
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/src/contextual_menu/menu_item_list.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { MenuItemProps } from "./common";
3 |
4 | const MenuItem = (props: MenuItemProps): JSX.Element => {
5 | const { name, onClick, onMouseEnter, onMouseLeave } = props;
6 | return (
7 |
8 | {name}
9 |
10 | );
11 | };
12 |
13 | type MenuItemListProps = {
14 | items: { [id: string]: MenuItemProps[] };
15 | };
16 |
17 | const MenuItemList = (props: MenuItemListProps): JSX.Element => {
18 | const { items } = props;
19 | return (
20 |
43 | );
44 | };
45 |
46 | export default MenuItemList;
47 |
--------------------------------------------------------------------------------
/src/node_editor/connector_content/index.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/jsx-props-no-spreading */
2 | import React from "react";
3 |
4 | import { ConnectorContentProps } from "./common";
5 | import DefaultConnectorContent from "./default";
6 | import CheckBox from "./check_box";
7 | import ErrorConnectorComponent from "./error";
8 | import NumberConnectorContent from "./number";
9 | import SelectConnectorContent from "./select";
10 | import ButtonConnectorContent from "./button";
11 | import TextAreaConnectorContent from "./text_area";
12 | import StringConnectorContent from "./string";
13 | import RangeConnectorContent from "./range";
14 |
15 | export type { ConnectorContentProps };
16 | export { ErrorConnectorComponent };
17 |
18 | export function createConnectorComponent(props: ConnectorContentProps): JSX.Element {
19 | const { connector } = props;
20 | if (connector.contentType === "string") {
21 | return ;
22 | }
23 | if (connector.contentType === "text_area") {
24 | return ;
25 | }
26 | if (connector.contentType === "number") {
27 | return ;
28 | }
29 | if (connector.contentType === "check_box") {
30 | return ;
31 | }
32 | if (connector.contentType === "select") {
33 | return ;
34 | }
35 | if (connector.contentType === "button") {
36 | return ;
37 | }
38 | if (connector.contentType === "range") {
39 | return ;
40 | }
41 | // Defaut return connector name
42 | return ;
43 | }
44 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # oura-node-editor
2 |
3 | [](https://www.npmjs.com/package/oura-node-editor)
4 |
5 | A react component library that lets you create node based editors ! inspired by blender node editor.
6 |
7 | Try me: [oura-canvas-creator](https://mathieuguyot.github.io/oura-canvas-creator/) !
8 |
9 | [Documentation](https://mathieuguyot.github.io/oura-node-editor/docs/introduction)
10 |
11 | Warning: Project in an experimental state, API may change a lot and npm repository is not up to date (so pull the lib and link it to your project if you wish test it!)
12 |
13 | 
14 | Example project that use the lib:
15 |
16 | ## current features:
17 |
18 | - Nodes can be created easelly. A node consists of a name, x/y position, a width, a selected state and 0 one or many connectors
19 | - A connector is a composed of two elements: pin layout (no-pins, left, rigth, left and right) and connector-content
20 | - Library provides generic connector-content: none, string, number, button, select, check-box
21 | - Nodes can be linked between each others using links
22 | - User can create they own connector-content (like the canvas in the oura-canvas-creator example project)
23 | - Node can be moved and their width can be resized
24 | - The working sheet where nodes are displayed can be zoomed in and out and dragged
25 | - One or many nodes and links can be selected
26 | - It is possible to extend the existing theme or create your own
27 | - Node rendering is virtualized (eg. node are not rendered if not displayed on screen), which improves general performances
28 |
29 | ## Install
30 |
31 | `npm install oura-node-editor`
32 |
33 | License MIT © [Mathieu Guyot](https://github.com/mathieuguyot)
34 |
--------------------------------------------------------------------------------
/src/contextual_menu/basic_contextual_menu.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import produce from "immer";
3 |
4 | import { MenuItemProps } from "./common";
5 | import MenuItemList from "./menu_item_list";
6 |
7 | export type BasicContextualMenuProps = {
8 | menuTitle: string;
9 | items: { [id: string]: MenuItemProps[] };
10 | };
11 |
12 | export const BasicContextualMenu = (props: BasicContextualMenuProps): JSX.Element => {
13 | const { menuTitle, items } = props;
14 |
15 | const [searchText, setSearchText] = React.useState("");
16 |
17 | const onChange = React.useCallback((event: React.FormEvent) => {
18 | setSearchText(event.currentTarget.value);
19 | }, []);
20 |
21 | const filteredItems = produce(items, (draft) => {
22 | Object.keys(items).forEach((id) => {
23 | const newItems = draft[id].filter(
24 | (item) => searchText === "" || item.name.toLowerCase().includes(searchText)
25 | );
26 | if (newItems.length === 0) {
27 | delete draft[id];
28 | } else {
29 | draft[id] = newItems as any;
30 | }
31 | });
32 | });
33 |
34 | return (
35 |
36 |
{menuTitle}
37 |
44 |
45 |
46 | );
47 | };
48 |
--------------------------------------------------------------------------------
/src/node_editor/connector_content/string.tsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback } from "react";
2 | import produce from "immer";
3 |
4 | import ErrorConnectorContent from "./error";
5 | import { ConnectorContentProps } from "./common";
6 | import { ConnectorModel } from "../model";
7 |
8 | export default function StringConnectorContent({
9 | connector,
10 | nodeId,
11 | cId,
12 | onConnectorUpdate
13 | }: ConnectorContentProps) {
14 | const onChange = useCallback(
15 | (event: React.FormEvent) => {
16 | const newConnector = produce(connector, (draft: ConnectorModel) => {
17 | draft.data.value = event.currentTarget.value;
18 | });
19 | onConnectorUpdate(nodeId, cId, newConnector);
20 | },
21 | [cId, connector, nodeId, onConnectorUpdate]
22 | );
23 |
24 | if (!("value" in connector.data)) {
25 | const message = "'string' connector types must provide a string field named 'value'";
26 | return ;
27 | }
28 | return (
29 |
30 |
33 |
41 |
42 | );
43 | }
44 |
--------------------------------------------------------------------------------
/src/node_editor/connector_content/number.tsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback } from "react";
2 | import produce from "immer";
3 |
4 | import ErrorConnectorContent from "./error";
5 | import { ConnectorContentProps } from "./common";
6 | import { ConnectorModel } from "../model";
7 |
8 | export default function NumberConnectorContent({
9 | connector,
10 | nodeId,
11 | cId,
12 | onConnectorUpdate
13 | }: ConnectorContentProps) {
14 | const onChange = useCallback(
15 | (event: React.FormEvent) => {
16 | if (isNaN(Number(event.currentTarget.value))) {
17 | return;
18 | }
19 | const newConnector = produce(connector, (draft: ConnectorModel) => {
20 | draft.data.value = event.currentTarget.value;
21 | });
22 | onConnectorUpdate(nodeId, cId, newConnector);
23 | },
24 | [cId, connector, nodeId, onConnectorUpdate]
25 | );
26 |
27 | if (!("value" in connector.data)) {
28 | const message = "'number' connector types must provide a string field named 'value'";
29 | return ;
30 | }
31 | return (
32 |
33 |
36 |
43 |
44 | );
45 | }
46 |
--------------------------------------------------------------------------------
/src/node_editor/link_canvas.tsx:
--------------------------------------------------------------------------------
1 | import { CSSProperties } from "react";
2 | import _ from "lodash";
3 |
4 | import { LinkCollection, LinkPositionModel, SelectionItem } from "./model";
5 | import BezierLink from "./link/link_bezier";
6 |
7 | export interface LinkCanvasProps {
8 | links: LinkCollection;
9 | linksPositions: { [linkId: string]: LinkPositionModel };
10 | selectedItems: Array;
11 | draggedLink?: LinkPositionModel;
12 |
13 | onSelectItem: (selection: SelectionItem | null, shiftKey: boolean) => void;
14 | }
15 |
16 | export default function LinkCanvas(props: LinkCanvasProps): JSX.Element {
17 | const { links, linksPositions, selectedItems, draggedLink } = props;
18 | const { onSelectItem } = props;
19 |
20 | const style: CSSProperties = {
21 | position: "absolute",
22 | top: "0",
23 | left: "0",
24 | width: "100%",
25 | height: "100%",
26 | overflow: "visible"
27 | };
28 |
29 | return (
30 |
49 | );
50 | }
51 |
--------------------------------------------------------------------------------
/src/node_editor/connector_content/range.tsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback } from "react";
2 | import produce from "immer";
3 |
4 | import ErrorConnectorContent from "./error";
5 | import { ConnectorContentProps } from "./common";
6 | import { ConnectorModel } from "../model";
7 |
8 | export default function RangeConnectorContent({
9 | connector,
10 | nodeId,
11 | cId,
12 | onConnectorUpdate
13 | }: ConnectorContentProps) {
14 | const onChange = useCallback(
15 | (event: React.FormEvent) => {
16 | if (isNaN(Number(event.currentTarget.value))) {
17 | return;
18 | }
19 | const newConnector = produce(connector, (draft: ConnectorModel) => {
20 | draft.data.value = event.currentTarget.value;
21 | });
22 | onConnectorUpdate(nodeId, cId, newConnector);
23 | },
24 | [cId, connector, nodeId, onConnectorUpdate]
25 | );
26 |
27 | if (!("value" in connector.data) || !("min" in connector.data) || !("max" in connector.data)) {
28 | const message =
29 | "'range' connector types must provide a numbers fields called 'value', 'min' and 'max'";
30 | return ;
31 | }
32 | return (
33 |
34 |
37 |
46 |
47 | );
48 | }
49 |
--------------------------------------------------------------------------------
/src/node_editor/connector_content/select.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback } from "react";
2 | import ErrorConnectorContent from "./error";
3 | import { ConnectorContentProps } from "./common";
4 | import { ConnectorModel } from "..";
5 | import produce from "immer";
6 |
7 | const SelectConnectorContent = ({
8 | connector,
9 | nodeId,
10 | cId,
11 | onConnectorUpdate
12 | }: ConnectorContentProps): JSX.Element => {
13 | const setSelectedValue = useCallback(
14 | (selected_index: number) => {
15 | const newConnector = produce(connector, (draft: ConnectorModel) => {
16 | draft.data.selected_index = selected_index;
17 | });
18 | onConnectorUpdate(nodeId, cId, newConnector);
19 | },
20 | [onConnectorUpdate, nodeId, cId, connector]
21 | );
22 |
23 | if (!("values" in connector.data) || !("selected_index" in connector.data)) {
24 | const message =
25 | "'select' connector types must provide a string array field named 'values' and a number field 'selected_index'";
26 | return ;
27 | }
28 |
29 | return (
30 |
31 |
34 |
45 |
46 | );
47 | };
48 |
49 | export default SelectConnectorContent;
50 |
--------------------------------------------------------------------------------
/src/node_editor/link/link_bezier.tsx:
--------------------------------------------------------------------------------
1 | import { useContext } from "react";
2 |
3 | import { XYPosition, LinkModel, LinkPositionModel } from "../model";
4 | import { ThemeContext } from "../theme";
5 |
6 | export type LinkProps = {
7 | linkId?: string;
8 | linkPosition: LinkPositionModel;
9 | link?: LinkModel;
10 |
11 | isLinkSelected: boolean;
12 | onSelectLink?: (linkId: string, shiftKey: boolean) => void;
13 |
14 | key?: string;
15 | };
16 |
17 | const getCenter = (source: XYPosition, target: XYPosition): XYPosition => {
18 | const offsetX = Math.abs(target.x - source.x) / 2;
19 | const xCenter = target.x < source.x ? target.x + offsetX : target.x - offsetX;
20 | const offsetY = Math.abs(target.y - source.y) / 2;
21 | const yCenter = target.y < source.y ? target.y + offsetY : target.y - offsetY;
22 | return { x: xCenter, y: yCenter };
23 | };
24 |
25 | export default function BezierLink({
26 | isLinkSelected,
27 | linkPosition,
28 | link,
29 | linkId,
30 | onSelectLink
31 | }: LinkProps) {
32 | const { theme } = useContext(ThemeContext);
33 | const sourceX = linkPosition.inputPinPosition.x;
34 | const sourceY = linkPosition.inputPinPosition.y;
35 | const targetX = linkPosition.outputPinPosition.x;
36 | const targetY = linkPosition.outputPinPosition.y;
37 | const center = getCenter(linkPosition.inputPinPosition, linkPosition.outputPinPosition);
38 |
39 | const path = `M${sourceX},${sourceY} C${center.x},${sourceY} ${center.x},${targetY} ${targetX},${targetY}`;
40 | const style = isLinkSelected
41 | ? { ...theme?.link?.selected, ...link?.theme?.selected }
42 | : { ...theme?.link?.unselected, ...link?.theme?.unselected };
43 |
44 | return (
45 | {
51 | if (onSelectLink && linkId) onSelectLink(linkId, e.shiftKey);
52 | }}
53 | />
54 | );
55 | }
56 |
--------------------------------------------------------------------------------
/src/node_editor/model.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-bitwise */
2 |
3 | import { LinkTheme, NodeTheme } from "./theme/theme";
4 |
5 | export interface SelectionItem {
6 | id: string;
7 | type: string;
8 | }
9 |
10 | export interface XYPosition {
11 | x: number;
12 | y: number;
13 | }
14 |
15 | export interface PanZoomModel {
16 | zoom: number;
17 | topLeftCorner: XYPosition;
18 | }
19 |
20 | export function arePositionEquals(rPos: XYPosition, lPos: XYPosition): boolean {
21 | return rPos.x === lPos.x && rPos.y === lPos.y;
22 | }
23 |
24 | export interface LinkModel {
25 | leftNodeId: string;
26 | leftNodeConnectorId: string;
27 | rightNodeId: string;
28 | rightNodeConnectorId: string;
29 | theme?: LinkTheme;
30 | }
31 |
32 | export type LinkCollection = { [id: string]: LinkModel };
33 | export type ConnectorCollection = { [cId: string]: ConnectorModel };
34 |
35 | export interface LinkPositionModel {
36 | linkId: string;
37 | inputPinPosition: XYPosition;
38 | outputPinPosition: XYPosition;
39 | }
40 |
41 | export function generateUuid(): string {
42 | return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
43 | const r = (Math.random() * 16) | 0;
44 | const v = c === "x" ? r : (r & 0x3) | 0x8;
45 | return v.toString(16);
46 | });
47 | }
48 |
49 | export interface NodeModel {
50 | name: string;
51 | position: XYPosition;
52 | width: number;
53 | connectors: ConnectorCollection;
54 | theme?: NodeTheme;
55 | category?: string;
56 | description?: string;
57 | }
58 |
59 | export type NodeCollection = { [id: string]: NodeModel };
60 |
61 | export enum PinLayout {
62 | NO_PINS,
63 | LEFT_PIN,
64 | RIGHT_PIN,
65 | BOTH_PINS
66 | }
67 |
68 | export interface ConnectorModel {
69 | name: string;
70 | pinLayout: PinLayout;
71 | contentType: string;
72 |
73 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
74 | data: any;
75 |
76 | leftPinColor?: string;
77 | rightPinColor?: string;
78 |
79 | isMultiInputAllowed?: boolean;
80 | }
81 |
82 | export type PinPosition = XYPosition | null;
83 | export type NodePinPositions = { [cId: string]: Array };
84 |
--------------------------------------------------------------------------------
/src/node_editor/connector_content/text_area.tsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback, useRef } from "react";
2 | import produce from "immer";
3 |
4 | import ErrorConnectorContent from "./error";
5 | import { ConnectorContentProps } from "./common";
6 | import { ConnectorModel } from "../model";
7 |
8 | export default function TextAreaConnectorContent({
9 | connector,
10 | nodeId,
11 | cId,
12 | onConnectorUpdate
13 | }: ConnectorContentProps) {
14 | const textAreaRef = useRef(null);
15 |
16 | const onChange = useCallback(
17 | (event: React.FormEvent) => {
18 | const newConnector = produce(connector, (draft: ConnectorModel) => {
19 | draft.data.value = event.currentTarget.value;
20 | });
21 | onConnectorUpdate(nodeId, cId, newConnector);
22 | },
23 | [cId, connector, nodeId, onConnectorUpdate]
24 | );
25 |
26 | const onMouseUp = useCallback(() => {
27 | if (textAreaRef.current) {
28 | const height = textAreaRef.current.style.height;
29 | const newConnector = produce(connector, (draft: ConnectorModel) => {
30 | draft.data.height = height;
31 | });
32 | onConnectorUpdate(nodeId, cId, newConnector);
33 | }
34 | }, [cId, connector, nodeId, onConnectorUpdate]);
35 |
36 | if (!("value" in connector.data)) {
37 | const message = "'text_area' connector types must provide a string field named 'value'";
38 | return ;
39 | }
40 | const height = "height" in connector.data ? connector.data.height : 100;
41 | return (
42 |
43 |
46 |
60 |
61 | );
62 | }
63 |
--------------------------------------------------------------------------------
/src/node_editor/use_node_editor.ts:
--------------------------------------------------------------------------------
1 | import produce from "immer";
2 | import { useCallback, useState } from "react";
3 | import {
4 | NodeCollection,
5 | LinkCollection,
6 | SelectionItem,
7 | PanZoomModel,
8 | LinkModel,
9 | generateUuid,
10 | ConnectorModel
11 | } from "./model";
12 |
13 | export function useNodeEditor() {
14 | const [nodes, setNodes] = useState({});
15 | const [links, setLinks] = useState({});
16 | const [selectedItems, setSelectedItems] = useState([]);
17 | const [panZoomInfo, setPanZoomInfo] = useState({
18 | zoom: 1,
19 | topLeftCorner: { x: 0, y: 0 }
20 | });
21 |
22 | const onNodeMove = useCallback((id: string, newX: number, newY: number, newWidth: number) => {
23 | setNodes((nodes) =>
24 | produce(nodes, (draft: NodeCollection) => {
25 | draft[id].position = { x: newX, y: newY };
26 | draft[id].width = newWidth;
27 | })
28 | );
29 | }, []);
30 |
31 | const onCreateLink = useCallback((link: LinkModel) => {
32 | setLinks((links) =>
33 | produce(links, (draft) => {
34 | draft[generateUuid()] = link;
35 | })
36 | );
37 | }, []);
38 |
39 | const onConnectorUpdate = useCallback(
40 | (nodeId: string, cId: string, connector: ConnectorModel) => {
41 | setNodes((nodes) =>
42 | produce(nodes, (draft) => {
43 | draft[nodeId].connectors[cId] = connector;
44 | })
45 | );
46 | },
47 | []
48 | );
49 |
50 | const setSelectedItemsAndMoveSelectedNodeFront = useCallback((selection: SelectionItem[]) => {
51 | if (selection.length === 1 && selection[0].type === "node") {
52 | setNodes((nodes: NodeCollection) => {
53 | const selectedNodeId = selection[0].id;
54 | const newNodes: NodeCollection = {};
55 | Object.keys(nodes).forEach((key) => {
56 | if (key !== selectedNodeId) {
57 | newNodes[key] = nodes[key];
58 | }
59 | });
60 | newNodes[selectedNodeId] = nodes[selectedNodeId];
61 | return newNodes;
62 | });
63 | }
64 | setSelectedItems(selection);
65 | }, []);
66 |
67 | return {
68 | nodes,
69 | links,
70 | panZoomInfo,
71 | selectedItems,
72 | setNodes,
73 | setLinks,
74 | onNodeMove,
75 | onCreateLink,
76 | onConnectorUpdate,
77 | setPanZoomInfo,
78 | setSelectedItems: setSelectedItemsAndMoveSelectedNodeFront
79 | };
80 | }
81 |
--------------------------------------------------------------------------------
/doc/docs/quickstart.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | sidebar_position: 2
3 | ---
4 |
5 | # Quickstart
6 |
7 | ## Install the package
8 |
9 | ```bash
10 | npm install oura-node-editor
11 | ```
12 |
13 | ## Basic component
14 |
15 | ```typescript
16 | import { NodeEditor, PinLayout, useNodeEditor } from "oura-node-editor";
17 | import { useEffect } from "react";
18 |
19 | function BasicNodeEditorDisplayer() {
20 | const {
21 | nodes,
22 | links,
23 | panZoomInfo,
24 | selectedItems,
25 | setNodes,
26 | onNodeMove,
27 | setPanZoomInfo,
28 | setSelectedItems,
29 | onConnectorUpdate,
30 | } = useNodeEditor();
31 |
32 | useEffect(() => {
33 | setNodes({
34 | addition: {
35 | name: "addition",
36 | position: { x: 100, y: 100 },
37 | width: 100,
38 | connectors: {
39 | result: {
40 | name: "result",
41 | pinLayout: PinLayout.RIGHT_PIN,
42 | contentType: "none",
43 | data: {},
44 | },
45 | a: {
46 | name: "a",
47 | pinLayout: PinLayout.NO_PINS,
48 | contentType: "number",
49 | data: { value: 0 },
50 | },
51 | b: {
52 | name: "b",
53 | pinLayout: PinLayout.NO_PINS,
54 | contentType: "number",
55 | data: { value: 10 },
56 | },
57 | },
58 | },
59 | });
60 | }, [setNodes]);
61 |
62 | return (
63 |
73 | );
74 | }
75 | ```
76 |
77 | ## Node editor logic
78 |
79 | As today, oura-node-editor focus on the UI side and is just in charge to display node editors.
80 | The logic of computation / functional behaviour has to be written and can be complex.
81 | Later in the project, this may be implemented and given to the easelly user.
82 | The project [oura-canvas-creator](https://github.com/mathieuguyot/oura-canvas-creator) gives a complex example of a node/edge propagation algorithm (even with reusable node and edges functions blocs). The logic is located in [node.ts file](https://github.com/mathieuguyot/oura-canvas-creator/blob/main/src/nodes/node.ts) and propagateAll or propagateNode functions (Warning, this is experimental and can contains bugs).
83 |
84 | ## Change theme
85 |
86 | oura-node-editor uses [DaisyUI](https://daisyui.com/). To consult themes provided by daisyUI, go [here](https://daisyui.com/docs/themes/).
87 | As stated in documentation, on your index.html file, add data-theme attribute to html tag to set the theme you wish to use.
88 |
89 | ## More complex use cases
90 |
91 | The project [oura-canvas-creator](https://github.com/mathieuguyot/oura-canvas-creator) was created mainly to test all features provided by oura-node-editor.
92 | Do not hesitate to look at how the project is made. In particular, the project allow to user to:
93 | - create and remove nodes
94 | - create edges
95 | - implement connectors to be reused in nodes (canvas, 3d view)
96 | - implement node logic as expained above
--------------------------------------------------------------------------------
/src/node_editor/utils/drag.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useState } from "react";
2 | import { XYPosition } from "../model";
3 |
4 | type MouseMoveCb = (
5 | initialPos: XYPosition,
6 | finalPos: XYPosition,
7 | offSetPos: XYPosition,
8 | targetClassName: string
9 | ) => void;
10 | type MouseUpCb = (initialPos: XYPosition, finalPos: XYPosition, event: MouseEvent) => void;
11 |
12 | /* useDrag custom hook helping to simplify computations of drag motions */
13 | export function useDrag(
14 | getZoom: () => number,
15 | onMouseMoveCb: MouseMoveCb,
16 | onMouseUpCb: MouseUpCb,
17 | switchAxis: boolean = false
18 | ) {
19 | const [initialPos, setInitialPos] = useState({ x: 0, y: 0 });
20 | const [finalPos, setFinalPos] = useState({ x: 0, y: 0 });
21 | const [tmpPos, setTmpPos] = useState({ x: 0, y: 0 });
22 | const [lastZoom, setLastZoom] = useState(0);
23 | const [mouseDown, setMouseDown] = useState(false);
24 |
25 | const onMouseDown = useCallback(
26 | (event: React.MouseEvent, initialPos: XYPosition) => {
27 | setInitialPos(initialPos);
28 | setFinalPos(initialPos);
29 | const zoom = getZoom();
30 | setTmpPos({ x: event.pageX / zoom, y: event.pageY / zoom });
31 | setLastZoom(zoom);
32 | setMouseDown(true);
33 | },
34 | [getZoom]
35 | );
36 |
37 | const onMouseMove = useCallback(
38 | (event: MouseEvent) => {
39 | const zoom = getZoom();
40 | const offsetPos = {
41 | x: event.pageX / lastZoom - tmpPos.x,
42 | y: event.pageY / lastZoom - tmpPos.y
43 | };
44 | const newFinalPos = {
45 | x: finalPos.x + (switchAxis ? offsetPos.y : offsetPos.x),
46 | y: finalPos.y + (switchAxis ? offsetPos.x : offsetPos.y)
47 | };
48 | setFinalPos(newFinalPos);
49 | const newTmpPos = { x: event.pageX / zoom, y: event.pageY / zoom };
50 | setTmpPos(newTmpPos);
51 | const targetClassName = (event.target as Element).className;
52 | onMouseMoveCb({ ...initialPos }, { ...newFinalPos }, offsetPos, targetClassName);
53 | setLastZoom(zoom);
54 | },
55 | [
56 | finalPos.x,
57 | finalPos.y,
58 | getZoom,
59 | initialPos,
60 | lastZoom,
61 | onMouseMoveCb,
62 | switchAxis,
63 | tmpPos.x,
64 | tmpPos.y
65 | ]
66 | );
67 |
68 | const onMouseUp = useCallback(
69 | (event: MouseEvent) => {
70 | setMouseDown(false);
71 | onMouseUpCb({ ...initialPos }, { ...finalPos }, event);
72 | },
73 | [finalPos, initialPos, onMouseUpCb]
74 | );
75 |
76 | useEffect(() => {
77 | if (mouseDown) {
78 | window.addEventListener("mousemove", onMouseMove);
79 | window.addEventListener("mouseup", onMouseUp);
80 | }
81 | return () => {
82 | window.removeEventListener("mousemove", onMouseMove);
83 | window.removeEventListener("mouseup", onMouseUp);
84 | };
85 | }, [mouseDown, onMouseMove, onMouseUp]);
86 |
87 | return { onMouseDown };
88 | }
89 |
--------------------------------------------------------------------------------
/doc/docs/introduction.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | sidebar_position: 1
3 | ---
4 |
5 | import SingleNodePlayground from "@site/src/components/singleNodePlayground";
6 |
7 | # Introduction
8 |
9 | Oura-node-editor is a React component library allowing you the creation of node based editors. Node based editors are used to create and edit [node graph architecture](https://en.wikipedia.org/wiki/Node_graph_architecture).
10 | This architecture relies on atomic functional units called **nodes**. Each node can have a state, inputs and outputs. Those inputs and outputs can be connected together using **links**. By doing so, it is possible to model business logic, 3d materials and shaders, programming logic, among many other things.
11 |
12 | Today, node based editors are used in various software to achieve many tasks:
13 |
14 | - Creating shaders and materials in 3d software
15 | - Creating game logic in some game engines
16 | - Editing and visualizing data and mathematical operations
17 | - Creating a variety of domain specific langages
18 | - Creating cool and strange creative coding stuff!
19 |
20 | ## Another node editor ?
21 |
22 | Many react UI libs allows to charts and somehow node editors.
23 | Oura-node-editor is focus on those objectives:
24 |
25 | - Be easy to setup and use
26 | - Be highly modulable allowing users to create custom node and custom node connectors (more on that later)
27 | - Be highly efficient and allowing to render hundreds of nodes
28 | - Have many themes to be fit every projects
29 |
30 | ## Experimental project
31 |
32 | This project is an experimental one. As a result, many design choices can be updated
33 | The documentation is also far from complete as of today !
34 |
35 | ## Anatomy of a node
36 |
37 | A node in oura-node-editor have several properties:
38 |
39 | ```typescript
40 | export interface NodeModel {
41 | name: string; // A name: "rectangle"
42 | position: XYPosition; // A position: {x: 10, y: 20}
43 | width: number; // A width in pixel: 200
44 | connectors: ConnectorCollection; // Zero, one or many node connectors, see bellow
45 | category?: string; // An optional category ("math", "canvas", "3d", ... nodes)
46 | description?: string; // An optional description: "draw a circle"
47 | }
48 | ```
49 |
50 | Node connector create the body and behaviour of our node, each node connector have those properties:
51 |
52 | ```typescript
53 | export interface ConnectorModel {
54 | name: string; // A name: "width"
55 | /*
56 | A "pin layout" indicates if the node connector have an input, output, both or none
57 | (NO_PINS=0, LEFT_PIN=1, RIGHT_PIN=2, BOTH_PINS=3)
58 | */
59 | pinLayout: PinLayout;
60 | /*
61 | A content type to indicate the nature of the node connector.
62 | Some are already provided by oura-node-editor ("number", "string", "text_area", "select", "check_box", "button", "range").
63 | You can also create yours (canvas, images, 3d views, ...)
64 | */
65 | contentType: string;
66 | /*
67 | Data to store content type (a string, a number, a color, ...) or other pieces of information
68 | */
69 | data: any;
70 |
71 | leftPinColor?: string; //An optional left pin color ("#ff0011")
72 | rightPinColor?: string; //An optional right pin color ("red")
73 | }
74 | ```
75 |
76 | Bellow you have an editable json representing a single node and the result on the node editor. Feel free to try creating your first node and understand the data model!
77 |
78 |
79 |
80 | ## Anatomy of a edge
81 |
82 | An edge objective is to link a left pin of a connector of a node A to a right pin of a connector of node B.
83 |
84 | ```typescript
85 | export interface LinkModel {
86 | leftNodeId: string;
87 | leftNodeConnectorId: string;
88 | rightNodeId: string;
89 | rightNodeConnectorId: string;
90 | }
91 | ```
92 |
--------------------------------------------------------------------------------
/doc/src/components/singleNodePlayground.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useState } from "react";
2 | import { NodeEditor, useNodeEditor } from "oura-node-editor";
3 | import Editor from "@monaco-editor/react";
4 | import { useColorMode } from "@docusaurus/theme-common";
5 |
6 | const defaultNode = {
7 | name: "rectangle",
8 | position: { x: 50, y: 50 },
9 | width: 200,
10 | connectors: {
11 | "0": { name: "draw", pinLayout: 2, contentType: "none", data: {} },
12 | "1": {
13 | name: "x",
14 | pinLayout: 1,
15 | contentType: "number",
16 | data: { value: 20, disabled: true }
17 | },
18 | "2": {
19 | name: "y",
20 | pinLayout: 1,
21 | contentType: "number",
22 | data: { value: 20, disabled: true }
23 | },
24 | "3": {
25 | name: "width",
26 | pinLayout: 1,
27 | contentType: "number",
28 | data: { value: 100, disabled: false }
29 | },
30 | "4": {
31 | name: "height",
32 | pinLayout: 1,
33 | contentType: "number",
34 | data: { value: 100, disabled: false }
35 | },
36 | "5": {
37 | name: "color",
38 | pinLayout: 1,
39 | contentType: "none",
40 | data: { value: "black" },
41 | leftPinColor: "orange"
42 | },
43 | "6": {
44 | name: "type",
45 | pinLayout: 0,
46 | contentType: "select",
47 | data: { values: ["fill", "stroke", "clear"], selected_index: 0 }
48 | },
49 | "7": {
50 | name: "line width",
51 | pinLayout: 1,
52 | contentType: "number",
53 | data: { value: 1, disabled: false }
54 | }
55 | },
56 | category: "canvas"
57 | };
58 |
59 | export default function SingleNodePlayground() {
60 | const { isDarkTheme } = useColorMode();
61 | const { nodes, links, panZoomInfo, setNodes, setLinks, setPanZoomInfo, onConnectorUpdate } =
62 | useNodeEditor();
63 |
64 | useEffect(() => {
65 | setNodes([defaultNode]);
66 | setLinks({});
67 | }, [setLinks, setNodes]);
68 |
69 | const [nodeJson, setNodeJson] = useState(JSON.stringify(defaultNode, null, 4));
70 |
71 | const onGenerate = useCallback(() => {
72 | try {
73 | const node = JSON.parse(nodeJson);
74 | setNodes([node]);
75 | } catch (e) {
76 | console.error(e);
77 | }
78 | }, [nodeJson, setNodes]);
79 |
80 | return (
81 |
87 |
88 | setNodeJson(e)}
94 | />
95 |
96 |
97 |
98 |
99 | {}}
106 | onConnectorUpdate={onConnectorUpdate}
107 | />
108 |
109 |
110 | );
111 | }
112 |
--------------------------------------------------------------------------------
/doc/src/components/node.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from "react";
2 | import { NodeEditor, useNodeEditor } from "oura-node-editor";
3 |
4 | const nodesStub = {
5 | "2528811a-3c8f-425e-939e-16dc4c185b82": {
6 | name: "rectangle",
7 | position: { x: 611.1725757080437, y: 96.64498202026894 },
8 | width: 200,
9 | connectors: {
10 | "0": { name: "draw", pinLayout: 2, contentType: "none", data: {} },
11 | "1": {
12 | name: "x",
13 | pinLayout: 1,
14 | contentType: "number",
15 | data: { value: 20, disabled: true }
16 | },
17 | "2": {
18 | name: "y",
19 | pinLayout: 1,
20 | contentType: "number",
21 | data: { value: 20, disabled: true }
22 | },
23 | "3": {
24 | name: "width",
25 | pinLayout: 1,
26 | contentType: "number",
27 | data: { value: 100, disabled: false }
28 | },
29 | "4": {
30 | name: "height",
31 | pinLayout: 1,
32 | contentType: "number",
33 | data: { value: 100, disabled: false }
34 | },
35 | "5": {
36 | name: "color",
37 | pinLayout: 1,
38 | contentType: "none",
39 | data: { value: "black" },
40 | leftPinColor: "orange"
41 | },
42 | "6": {
43 | name: "type",
44 | pinLayout: 0,
45 | contentType: "select",
46 | data: { values: ["fill", "stroke", "clear"], selected_index: 0 }
47 | },
48 | "7": {
49 | name: "line width",
50 | pinLayout: 1,
51 | contentType: "number",
52 | data: { value: 1, disabled: false }
53 | }
54 | },
55 | category: "canvas"
56 | },
57 | "bbbb9f7e-5769-478a-85a3-1097a675ba17": {
58 | name: "number",
59 | position: { x: 312, y: 182 },
60 | width: 170,
61 | connectors: {
62 | "0": { name: "number", pinLayout: 2, contentType: "number", data: { value: "20" } }
63 | },
64 | category: "math"
65 | }
66 | };
67 |
68 | const linkStub = {
69 | "433c4ede-7181-43fd-9249-000614bcb858": {
70 | leftNodeId: "2528811a-3c8f-425e-939e-16dc4c185b82",
71 | leftNodeConnectorId: "1",
72 | rightNodeId: "bbbb9f7e-5769-478a-85a3-1097a675ba17",
73 | rightNodeConnectorId: "0"
74 | },
75 | "77468d68-7eeb-4bce-ae3e-15585af52a02": {
76 | leftNodeId: "2528811a-3c8f-425e-939e-16dc4c185b82",
77 | leftNodeConnectorId: "2",
78 | rightNodeId: "bbbb9f7e-5769-478a-85a3-1097a675ba17",
79 | rightNodeConnectorId: "0"
80 | }
81 | };
82 |
83 | export default function IntroNodeEditor() {
84 | const {
85 | nodes,
86 | links,
87 | panZoomInfo,
88 | selectedItems,
89 | setNodes,
90 | setLinks,
91 | onNodeMove,
92 | setPanZoomInfo,
93 | setSelectedItems,
94 | onCreateLink,
95 | onConnectorUpdate
96 | } = useNodeEditor();
97 |
98 | useEffect(() => {
99 | setNodes(nodesStub);
100 | setLinks(linkStub);
101 | }, [setLinks, setNodes]);
102 |
103 | return (
104 |
105 |
116 |
117 | );
118 | }
119 |
--------------------------------------------------------------------------------
/doc/docusaurus.config.js:
--------------------------------------------------------------------------------
1 | // @ts-check
2 | // Note: type annotations allow type checking and IDEs autocompletion
3 |
4 | const { themes } = require("prism-react-renderer");
5 | const lightCodeTheme = themes.github;
6 | const darkCodeTheme = themes.dracula;
7 |
8 | /** @type {import('@docusaurus/types').Config} */
9 | const config = {
10 | title: "oura node editor",
11 | favicon: "img/favicon.ico",
12 |
13 | // Set the production url of your site here
14 | url: "https://mathieuguyot.github.io/",
15 | // Set the // pathname under which your site is served
16 | // For GitHub pages deployment, it is often '//'
17 | baseUrl: "oura-node-editor/",
18 |
19 | // GitHub pages deployment config.
20 | // If you aren't using GitHub pages, you don't need these.
21 | organizationName: "mathieuguyot", // Usually your GitHub org/user name.
22 | projectName: "oura-node-editor", // Usually your repo name.
23 |
24 | onBrokenLinks: "throw",
25 | onBrokenMarkdownLinks: "warn",
26 |
27 | // Even if you don't use internalization, you can use this field to set useful
28 | // metadata like html lang. For example, if your site is Chinese, you may want
29 | // to replace "en" with "zh-Hans".
30 | i18n: {
31 | defaultLocale: "en",
32 | locales: ["en"]
33 | },
34 |
35 | presets: [
36 | [
37 | "classic",
38 | /** @type {import('@docusaurus/preset-classic').Options} */
39 | ({
40 | docs: {
41 | sidebarPath: require.resolve("./sidebars.js")
42 | },
43 | theme: {
44 | customCss: require.resolve("./src/css/custom.css")
45 | }
46 | })
47 | ]
48 | ],
49 |
50 | themeConfig:
51 | /** @type {import('@docusaurus/preset-classic').ThemeConfig} */
52 | ({
53 | // Replace with your project's social card
54 | image: "img/docusaurus-social-card.jpg",
55 | navbar: {
56 | title: "Oura node editor",
57 | // logo: {
58 | // alt: "My Site Logo",
59 | // src: "img/logo.svg"
60 | // },
61 | items: [
62 | {
63 | type: "docSidebar",
64 | sidebarId: "tutorialSidebar",
65 | position: "left",
66 | label: "Tutorial"
67 | },
68 | {
69 | to: "https://github.com/mathieuguyot/oura-node-editor",
70 | label: "GitHub",
71 | position: "right"
72 | }
73 | ]
74 | },
75 | footer: {
76 | links: [
77 | {
78 | title: "Docs",
79 | items: [
80 | {
81 | label: "Introduction",
82 | to: "docs/introduction"
83 | }
84 | ]
85 | },
86 | {
87 | title: "More",
88 | items: [
89 | {
90 | label: "Stack Overflow",
91 | to: "https://stackoverflow.com/users/10781901/mathieu-guyot"
92 | },
93 | {
94 | label: "GitHub",
95 | to: "https://github.com/mathieuguyot"
96 | }
97 | ]
98 | }
99 | ],
100 | copyright: `Copyright © ${new Date().getFullYear()} 🇫🇷 Mathieu Guyot - Made by ❤️ with Docusaurus`
101 | },
102 | prism: {
103 | theme: lightCodeTheme,
104 | darkTheme: darkCodeTheme
105 | }
106 | })
107 | };
108 |
109 | module.exports = config;
110 |
--------------------------------------------------------------------------------
/src/contextual_menu/add_node_contextual_menu.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | import { Node, NodeModel, NodeCollection, ConnectorContentProps } from "../node_editor";
4 | import { MenuItemProps } from "./common";
5 | import { BasicContextualMenu } from "./basic_contextual_menu";
6 |
7 | type NodePrevisualizerProps = {
8 | node: NodeModel | null;
9 | createCustomConnectorComponent: (props: ConnectorContentProps) => JSX.Element | null;
10 | };
11 |
12 | const NodePrevisualizer = (props: NodePrevisualizerProps): JSX.Element => {
13 | const { node, createCustomConnectorComponent } = props;
14 | const previewDivRef = React.useRef(null);
15 |
16 | let nodeElem: JSX.Element | null = null;
17 | if (node && previewDivRef && previewDivRef.current) {
18 | const divDim = previewDivRef.current.getBoundingClientRect();
19 | const displayedNode = { ...node };
20 | displayedNode.position = { x: 10, y: 10 };
21 | displayedNode.width = divDim.width - 20;
22 | nodeElem = node ? (
23 | 1}
26 | isNodeSelected
27 | node={displayedNode}
28 | onConnectorUpdate={() => null}
29 | onNodePinPositionsUpdate={() => null}
30 | onNodeHeightUpdate={() => null}
31 | createCustomConnectorComponent={createCustomConnectorComponent}
32 | />
33 | ) : null;
34 | }
35 |
36 | return (
37 |
47 | {nodeElem}
48 |
49 | );
50 | };
51 |
52 | export type AddNodeContextualMenuProps = {
53 | nodesSchema: NodeCollection;
54 | onNodeSelection: (id: string) => void;
55 | onMouseHover: (isMouseHover: boolean) => void;
56 | createCustomConnectorComponent: (props: ConnectorContentProps) => JSX.Element | null;
57 | };
58 |
59 | export const AddNodeContextualMenu = (props: AddNodeContextualMenuProps): JSX.Element => {
60 | const { nodesSchema, onNodeSelection, createCustomConnectorComponent, onMouseHover } = props;
61 |
62 | const [previsualizedNodeId, setPrevisualizedNodeId] = React.useState("");
63 |
64 | const items: { [id: string]: [MenuItemProps] } = {};
65 | Object.keys(nodesSchema).forEach((id) => {
66 | const newItem = {
67 | name: nodesSchema[id].name,
68 | category: nodesSchema[id].category,
69 | onMouseEnter: () => {
70 | setPrevisualizedNodeId(id);
71 | },
72 | onMouseLeave: () => {
73 | setPrevisualizedNodeId("");
74 | },
75 | onClick: () => {
76 | onNodeSelection(id);
77 | }
78 | };
79 | const categoryName = nodesSchema[id].category
80 | ? (nodesSchema[id].category as string)
81 | : "no category";
82 | if (categoryName in items) {
83 | items[categoryName].push(newItem);
84 | } else {
85 | items[categoryName] = [newItem];
86 | }
87 | });
88 |
89 | const onMouseEnter = React.useCallback(() => {
90 | onMouseHover(true);
91 | }, [onMouseHover]);
92 |
93 | const onMouseLeaves = React.useCallback(() => {
94 | onMouseHover(false);
95 | }, [onMouseHover]);
96 |
97 | return (
98 |
99 |
100 |
101 |
102 |
106 |
107 | );
108 | };
109 |
--------------------------------------------------------------------------------
/src/node_editor/node_canvas.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback, useState } from "react";
2 | import _ from "lodash";
3 |
4 | import {
5 | ConnectorModel,
6 | LinkModel,
7 | LinkPositionModel,
8 | NodeCollection,
9 | NodePinPositions,
10 | SelectionItem
11 | } from "./model";
12 | import { Node } from "./node";
13 | import { ConnectorContentProps } from "./connector_content";
14 |
15 | export interface NodeCanvasProps {
16 | nodes: NodeCollection;
17 | selectedItems: Array;
18 |
19 | onSelectItem: (selection: SelectionItem | null, shiftKey: boolean) => void;
20 | onUpdatePreviewLink: (previewLink?: LinkPositionModel) => void;
21 | getZoom: () => number;
22 |
23 | onNodeMove: (id: string, newX: number, newY: number, newWidth: number) => void;
24 | onCreateLink?: (link: LinkModel) => void;
25 | onConnectorUpdate: (nodeId: string, cId: string, connector: ConnectorModel) => void;
26 | onNodePinPositionsUpdate: (nodeId: string, pinPositions: NodePinPositions) => void;
27 | onNodeHeightUpdate: (nodeId: string, height: number) => void;
28 |
29 | createCustomConnectorComponent?(props: ConnectorContentProps): JSX.Element | null;
30 | }
31 |
32 | export default function NodeCanvas({
33 | nodes,
34 | selectedItems,
35 | onSelectItem,
36 | onUpdatePreviewLink,
37 | getZoom,
38 | onNodeMove,
39 | onCreateLink,
40 | onConnectorUpdate,
41 | onNodePinPositionsUpdate,
42 | createCustomConnectorComponent,
43 | onNodeHeightUpdate
44 | }: NodeCanvasProps) {
45 | const [lastSettedSelection, setLastSettedSelection] = useState();
46 |
47 | const onNodeMoveStart = useCallback(
48 | (id: string, shiftKey: boolean) => {
49 | const selection = { id, type: "node" };
50 | let alreadySelected = false;
51 | selectedItems.forEach((item) => {
52 | if (item.id === selection.id && item.type === selection.type) {
53 | alreadySelected = true;
54 | }
55 | });
56 | if (alreadySelected) {
57 | return;
58 | }
59 | let newLastSettedSelection: SelectionItem | undefined = selection;
60 | selectedItems.forEach((item) => {
61 | if (item.id === id && item.type === "node") {
62 | newLastSettedSelection = undefined;
63 | }
64 | });
65 | if (shiftKey && newLastSettedSelection) {
66 | setLastSettedSelection(newLastSettedSelection);
67 | onSelectItem(selection, shiftKey);
68 | }
69 | if (!shiftKey) {
70 | setLastSettedSelection(newLastSettedSelection);
71 | onSelectItem(selection, shiftKey);
72 | }
73 | },
74 | [onSelectItem, selectedItems]
75 | );
76 |
77 | const onNodeMoveInternal = useCallback(
78 | (offsetX: number, offsetY: number, offsetWidth: number) => {
79 | // Move each selected node
80 | selectedItems.forEach((item) => {
81 | if (item.type === "node") {
82 | const newX = nodes[item.id].position.x + offsetX;
83 | const newY = nodes[item.id].position.y + offsetY;
84 | const newWidth = nodes[item.id].width + offsetWidth;
85 | onNodeMove(item.id, newX, newY, newWidth > 100 ? newWidth : 100);
86 | }
87 | });
88 | },
89 | [nodes, onNodeMove, selectedItems]
90 | );
91 |
92 | const onNodeMoveEnd = useCallback(
93 | (id: string, wasNodeMoved: boolean, shiftKey: boolean) => {
94 | const selection = { id, type: "node" };
95 | if (!wasNodeMoved && !shiftKey) {
96 | onSelectItem(selection, shiftKey);
97 | } else if (!wasNodeMoved && shiftKey && !_.isEqual(selection, lastSettedSelection)) {
98 | onSelectItem(selection, shiftKey);
99 | }
100 | setLastSettedSelection(undefined);
101 | },
102 | [lastSettedSelection, onSelectItem]
103 | );
104 |
105 | return (
106 | <>
107 | {Object.keys(nodes).map((key) => (
108 |
124 | ))}
125 | >
126 | );
127 | }
128 |
--------------------------------------------------------------------------------
/src/node_editor/theme/dark.ts:
--------------------------------------------------------------------------------
1 | import CSS from "csstype";
2 |
3 | import { PanZoomModel } from "../model";
4 | import { Theme } from "./theme";
5 |
6 | const radius = 5;
7 |
8 | const darkTheme: Theme = {
9 | node: {
10 | selected: {
11 | boxShadow: "0px 0px 0px 2px oklch(var(--pf))",
12 | borderRadius: `${radius}px ${radius}px ${radius}px ${radius}px`
13 | },
14 | unselected: {},
15 | header: {
16 | borderRadius: `${radius}px ${radius}px 0px 0px`,
17 | height: "25px",
18 | cursor: "grab",
19 | overflow: "hidden",
20 | paddingLeft: "10px"
21 | },
22 | body: {
23 | cursor: "grab"
24 | },
25 | footer: {
26 | borderRadius: `0px 0px ${radius}px ${radius}px`,
27 | height: "10px",
28 | cursor: "ew-resize"
29 | },
30 | basePin: {
31 | borderRadius: "5px 5px 5px 5px",
32 | cursor: "grab",
33 | backgroundColor: "red"
34 | },
35 | customPins: {}
36 | },
37 | link: {
38 | selected: {
39 | //stroke: "white",
40 | strokeWidth: "3px",
41 | fill: "none"
42 | },
43 | unselected: {
44 | //stroke: "rgba(170, 170, 170, 0.75)",
45 | strokeWidth: "3px",
46 | fill: "none"
47 | }
48 | },
49 | connectors: {
50 | leftText: {
51 | width: "40%",
52 | textOverflow: "ellipsis",
53 | overflow: "hidden"
54 | },
55 | string: {
56 | width: "100%",
57 | backgroundColor: "#585858",
58 | color: "white",
59 | border: 0,
60 | outline: "none"
61 | },
62 | number: {
63 | width: "60%",
64 | boxSizing: "border-box",
65 | backgroundColor: "#585858",
66 | color: "white",
67 | border: 0,
68 | outline: "none",
69 | textAlign: "right",
70 | borderRadius: "8px",
71 | paddingRight: "5px"
72 | },
73 | select: {
74 | width: "60%",
75 | backgroundColor: "#585858",
76 | boxSizing: "border-box",
77 | color: "white",
78 | border: 0,
79 | outline: "none",
80 | textAlign: "right",
81 | borderRadius: "8px",
82 | paddingRight: "5px"
83 | },
84 | button: {
85 | width: "100%",
86 | backgroundColor: "#585858",
87 | color: "white",
88 | border: 0,
89 | outline: "none"
90 | }
91 | }
92 | };
93 |
94 | function darkThemeBuildBackgroundStyle(panZoomInfo: PanZoomModel): CSS.Properties {
95 | const grid = String(50 * panZoomInfo.zoom);
96 | const size = 1 * panZoomInfo.zoom;
97 | return {
98 | backgroundColor: "oklch(var(--b1))",
99 | backgroundSize: `${grid}px ${grid}px`,
100 | backgroundPosition: `${panZoomInfo.topLeftCorner.x}px ${panZoomInfo.topLeftCorner.y}px`,
101 | //backgroundPosition: `${panZoomInfo.topLeftCorner.x}px ${panZoomInfo.topLeftCorner.y}px`,
102 | //backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='${grid}' height='${grid}' viewBox='0 0 100 100'%3E%3Cg fill-rule='evenodd'%3E%3Cg fill='black' fill-opacity='0.4'%3E%3Cpath opacity='.5' d='M96 95h4v1h-4v4h-1v-4h-9v4h-1v-4h-9v4h-1v-4h-9v4h-1v-4h-9v4h-1v-4h-9v4h-1v-4h-9v4h-1v-4h-9v4h-1v-4h-9v4h-1v-4H0v-1h15v-9H0v-1h15v-9H0v-1h15v-9H0v-1h15v-9H0v-1h15v-9H0v-1h15v-9H0v-1h15v-9H0v-1h15v-9H0v-1h15V0h1v15h9V0h1v15h9V0h1v15h9V0h1v15h9V0h1v15h9V0h1v15h9V0h1v15h9V0h1v15h9V0h1v15h4v1h-4v9h4v1h-4v9h4v1h-4v9h4v1h-4v9h4v1h-4v9h4v1h-4v9h4v1h-4v9h4v1h-4v9zm-1 0v-9h-9v9h9zm-10 0v-9h-9v9h9zm-10 0v-9h-9v9h9zm-10 0v-9h-9v9h9zm-10 0v-9h-9v9h9zm-10 0v-9h-9v9h9zm-10 0v-9h-9v9h9zm-10 0v-9h-9v9h9zm-9-10h9v-9h-9v9zm10 0h9v-9h-9v9zm10 0h9v-9h-9v9zm10 0h9v-9h-9v9zm10 0h9v-9h-9v9zm10 0h9v-9h-9v9zm10 0h9v-9h-9v9zm10 0h9v-9h-9v9zm9-10v-9h-9v9h9zm-10 0v-9h-9v9h9zm-10 0v-9h-9v9h9zm-10 0v-9h-9v9h9zm-10 0v-9h-9v9h9zm-10 0v-9h-9v9h9zm-10 0v-9h-9v9h9zm-10 0v-9h-9v9h9zm-9-10h9v-9h-9v9zm10 0h9v-9h-9v9zm10 0h9v-9h-9v9zm10 0h9v-9h-9v9zm10 0h9v-9h-9v9zm10 0h9v-9h-9v9zm10 0h9v-9h-9v9zm10 0h9v-9h-9v9zm9-10v-9h-9v9h9zm-10 0v-9h-9v9h9zm-10 0v-9h-9v9h9zm-10 0v-9h-9v9h9zm-10 0v-9h-9v9h9zm-10 0v-9h-9v9h9zm-10 0v-9h-9v9h9zm-10 0v-9h-9v9h9zm-9-10h9v-9h-9v9zm10 0h9v-9h-9v9zm10 0h9v-9h-9v9zm10 0h9v-9h-9v9zm10 0h9v-9h-9v9zm10 0h9v-9h-9v9zm10 0h9v-9h-9v9zm10 0h9v-9h-9v9zm9-10v-9h-9v9h9zm-10 0v-9h-9v9h9zm-10 0v-9h-9v9h9zm-10 0v-9h-9v9h9zm-10 0v-9h-9v9h9zm-10 0v-9h-9v9h9zm-10 0v-9h-9v9h9zm-10 0v-9h-9v9h9zm-9-10h9v-9h-9v9zm10 0h9v-9h-9v9zm10 0h9v-9h-9v9zm10 0h9v-9h-9v9zm10 0h9v-9h-9v9zm10 0h9v-9h-9v9zm10 0h9v-9h-9v9zm10 0h9v-9h-9v9z'/%3E%3Cpath d='M6 5V0H5v5H0v1h5v94h1V6h94V5H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`
103 | //backgroundImage: `radial-gradient(#474bff ${size}px, transparent ${size}px)`
104 | backgroundImage: `linear-gradient(oklch(var(--b3)) ${size}px, transparent ${size}px), linear-gradient(to right, oklch(var(--b3)) ${size}px, transparent ${size}px)`
105 | };
106 | }
107 |
108 | const exp = {
109 | theme: darkTheme,
110 | buildBackgroundStyle: darkThemeBuildBackgroundStyle
111 | };
112 |
113 | export default exp;
114 |
--------------------------------------------------------------------------------
/src/node_editor/node/node.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/jsx-no-bind */
2 | import React, { useCallback, useContext, useEffect, useRef, useState } from "react";
3 | import CSS from "csstype";
4 |
5 | import {
6 | LinkModel,
7 | NodeModel,
8 | XYPosition,
9 | arePositionEquals,
10 | ConnectorModel,
11 | NodePinPositions,
12 | PinPosition,
13 | LinkPositionModel
14 | } from "../model";
15 | import Connector from "./connector";
16 | import Header from "./header";
17 | import Footer from "./footer";
18 | import { ThemeContext } from "../theme";
19 | import { ConnectorContentProps } from "../connector_content";
20 | import { PinLayout } from "..";
21 | import produce from "immer";
22 | import { useDrag } from "../utils/drag";
23 |
24 | export type NodeProps = {
25 | nodeId: string;
26 | node: NodeModel;
27 | isNodeSelected: boolean;
28 |
29 | getZoom: () => number;
30 |
31 | onNodePinPositionsUpdate: (nodeId: string, pinPositions: NodePinPositions) => void;
32 |
33 | onNodeMoveStart?: (id: string, shiftKey: boolean) => void;
34 | onNodeMove?: (offsetX: number, offsetY: number, offsetWidth: number) => void;
35 | onNodeMoveEnd?: (ids: string, wasNodeMoved: boolean, shiftKey: boolean) => void;
36 |
37 | onConnectorUpdate: (nodeId: string, cId: string, connector: ConnectorModel) => void;
38 | onCreateLink?: (link: LinkModel) => void;
39 | onUpdatePreviewLink?: (previewLink?: LinkPositionModel) => void;
40 | onNodeHeightUpdate: (nodeId: string, height: number) => void;
41 |
42 | createCustomConnectorComponent?(props: ConnectorContentProps): JSX.Element | null;
43 | };
44 |
45 | export function Node(props: NodeProps) {
46 | const nodeDivRef = useRef(null);
47 | const {
48 | nodeId,
49 | node,
50 | isNodeSelected,
51 | getZoom,
52 | onNodePinPositionsUpdate,
53 | onNodeMoveStart,
54 | onNodeMove,
55 | onNodeMoveEnd,
56 | onConnectorUpdate,
57 | onCreateLink,
58 | onUpdatePreviewLink,
59 | createCustomConnectorComponent,
60 | onNodeHeightUpdate
61 | } = props;
62 | const [pinPositions, setPinPositions] = useState({});
63 | const onPinPositionUpdate = useCallback(
64 | (cId: string, leftPinPos: PinPosition, rightPinPos: PinPosition) => {
65 | setPinPositions((pinPos) =>
66 | produce(pinPos, (draft) => {
67 | draft[cId] = [leftPinPos, rightPinPos];
68 | })
69 | );
70 | },
71 | []
72 | );
73 |
74 | useEffect(() => {
75 | const connectableConnectorsLength = Object.keys(node.connectors).filter((name) => {
76 | return node.connectors[name].pinLayout !== PinLayout.NO_PINS;
77 | }).length;
78 | if (
79 | Object.keys(pinPositions).length === connectableConnectorsLength &&
80 | nodeDivRef &&
81 | nodeDivRef.current
82 | ) {
83 | const height = nodeDivRef.current.getBoundingClientRect().height;
84 | onNodePinPositionsUpdate(nodeId, pinPositions);
85 | onNodeHeightUpdate(nodeId, height);
86 | }
87 | }, [node.connectors, nodeId, onNodeHeightUpdate, onNodePinPositionsUpdate, pinPositions]);
88 |
89 | const [className, setClassName] = useState("");
90 | const onMouseMoveCb = useCallback(
91 | (_initialPos: XYPosition, _finalPos: XYPosition, offsetPos: XYPosition) => {
92 | if (onNodeMove && className.includes("node-footer")) {
93 | onNodeMove(0, 0, offsetPos.x);
94 | } else if (onNodeMove) {
95 | onNodeMove(offsetPos.x, offsetPos.y, 0);
96 | }
97 | },
98 | [className, onNodeMove]
99 | );
100 |
101 | const onMouseUpCb = useCallback(
102 | (iPos: XYPosition, fPos: XYPosition, mouseUpEv: MouseEvent) => {
103 | if (onNodeMoveEnd) {
104 | onNodeMoveEnd(nodeId, !arePositionEquals(iPos, fPos), mouseUpEv.shiftKey);
105 | }
106 | },
107 | [nodeId, onNodeMoveEnd]
108 | );
109 |
110 | const { onMouseDown } = useDrag(getZoom, onMouseMoveCb, onMouseUpCb);
111 |
112 | const localOnMouseDown = useCallback(
113 | (event: React.MouseEvent) => {
114 | // Left mouse button only trigger node move
115 | if (event.button !== 0) {
116 | return;
117 | }
118 | if (!onNodeMoveStart || !onNodeMove || !onNodeMoveEnd) {
119 | return;
120 | }
121 |
122 | // Check if its the node that is targeted to be moved or something else
123 | // eg. node can be moved only if target element has node-background in is class name
124 | let stopEvent = true;
125 | if (event.target) {
126 | const { className } = event.target as Element;
127 | if (typeof className === "string" && className.includes("node-background")) {
128 | setClassName(className);
129 | stopEvent = false;
130 | }
131 | }
132 | if (stopEvent) {
133 | return;
134 | }
135 | const zoom = getZoom();
136 | const initialPos = { x: event.pageX / zoom, y: event.pageY / zoom };
137 | onNodeMoveStart(nodeId, event.shiftKey);
138 | onMouseDown(event, initialPos);
139 | },
140 | [getZoom, nodeId, onMouseDown, onNodeMove, onNodeMoveEnd, onNodeMoveStart]
141 | );
142 |
143 | const { theme } = useContext(ThemeContext);
144 | const nodeCoreSelectionStyle = isNodeSelected
145 | ? { ...theme?.node?.selected, ...node?.theme?.selected }
146 | : { ...theme?.node?.unselected, ...node?.theme?.unselected };
147 |
148 | const style: CSS.Properties = {
149 | position: "absolute",
150 | width: `${node.width}px`,
151 | top: `${node.position.y}px`,
152 | left: `${node.position.x}px`
153 | };
154 |
155 | return (
156 |
163 |
164 | {/* Node body (list of connectors) */}
165 |
166 | {Object.keys(node.connectors).map((key) => (
167 |
180 | ))}
181 |
182 |
183 |
184 | );
185 | }
186 |
--------------------------------------------------------------------------------
/src/node_editor/node/connector.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useCallback, useEffect, useRef } from "react";
2 | import _ from "lodash";
3 |
4 | import {
5 | ConnectorModel,
6 | PinLayout,
7 | XYPosition,
8 | NodeModel,
9 | LinkModel,
10 | PinPosition,
11 | LinkPositionModel
12 | } from "../model";
13 | import { createConnectorComponent, ConnectorContentProps } from "../connector_content";
14 | import Pin from "./pin";
15 | import { useDrag } from "../utils/drag";
16 |
17 | type ConnectorProps = {
18 | nodeId: string;
19 | cId: string;
20 | node: NodeModel;
21 | connector: ConnectorModel;
22 |
23 | getZoom: () => number;
24 | onPinPositionUpdate: (cId: string, leftPinPos: PinPosition, rightPinPos: PinPosition) => void;
25 | onConnectorUpdate: (nodeId: string, cId: string, connector: ConnectorModel) => void;
26 | onCreateLink?: (link: LinkModel) => void;
27 | onUpdatePreviewLink?: (previewLink?: LinkPositionModel) => void;
28 | createCustomConnectorComponent?(props: ConnectorContentProps): JSX.Element | null;
29 | };
30 |
31 | const PIN_RADIUS_PX = 6;
32 |
33 | export default function Connector(props: ConnectorProps) {
34 | const {
35 | nodeId,
36 | cId,
37 | node,
38 | connector,
39 | getZoom,
40 | onPinPositionUpdate,
41 | onCreateLink,
42 | onUpdatePreviewLink,
43 | createCustomConnectorComponent
44 | } = props;
45 |
46 | const [leftPinPos, setLeftPinPos] = useState(null);
47 | const [rightPinPos, setRightPinPos] = useState(null);
48 | const connectorRef = useRef(null);
49 |
50 | const getConnectorPinPosition = useCallback(
51 | (isLeftPin: boolean) => {
52 | if (
53 | !connectorRef ||
54 | !connectorRef.current ||
55 | connector.pinLayout === PinLayout.NO_PINS ||
56 | (isLeftPin && connector.pinLayout === PinLayout.RIGHT_PIN) ||
57 | (!isLeftPin && connector.pinLayout === PinLayout.LEFT_PIN)
58 | ) {
59 | return null;
60 | }
61 | return {
62 | x: !isLeftPin ? node.width + node.position.x : node.position.x,
63 | y:
64 | node.position.y +
65 | connectorRef.current.offsetTop +
66 | connectorRef.current.offsetHeight / 2
67 | };
68 | },
69 | [connector.pinLayout, node.position.x, node.position.y, node.width]
70 | );
71 |
72 | useEffect(() => {
73 | const newLeftPinPos = getConnectorPinPosition(true);
74 | const newRightPinPos = getConnectorPinPosition(false);
75 | if (!_.isEqual(leftPinPos, newLeftPinPos) || !_.isEqual(rightPinPos, newRightPinPos)) {
76 | setLeftPinPos(newLeftPinPos);
77 | setRightPinPos(newRightPinPos);
78 | onPinPositionUpdate(cId, newLeftPinPos, newRightPinPos);
79 | }
80 | }, [cId, getConnectorPinPosition, leftPinPos, onPinPositionUpdate, rightPinPos]);
81 |
82 | // Connector content component creation follows two steps
83 | let connectorContent: JSX.Element | null = null;
84 | // 1. First, try to get custom provided connector content component
85 | if (createCustomConnectorComponent) {
86 | connectorContent = createCustomConnectorComponent(props);
87 | }
88 | // 2. If no specific component is provided by the customer, use lib ones
89 | if (!connectorContent) {
90 | connectorContent = createConnectorComponent(props);
91 | }
92 |
93 | const onMouseMoveCb = useCallback(
94 | (initialPos: XYPosition, finalPos: XYPosition) => {
95 | if (onUpdatePreviewLink)
96 | onUpdatePreviewLink({
97 | linkId: "preview",
98 | outputPinPosition: finalPos,
99 | inputPinPosition: initialPos
100 | });
101 | },
102 | [onUpdatePreviewLink]
103 | );
104 |
105 | const [isDragFromLeftPin, setIsDragFromLeftPin] = useState(false);
106 | const onMouseUpCb = useCallback(
107 | (_iPos: XYPosition, _fPos: XYPosition, mouseUpEvent: MouseEvent) => {
108 | if (!onCreateLink || !onUpdatePreviewLink) {
109 | return;
110 | }
111 | const connectorRegex = /node-(.+)-connector-(.+)-(left|right)/;
112 | let tag: RegExpMatchArray | null = null;
113 | if (mouseUpEvent.target) {
114 | const { className } = mouseUpEvent.target as Element;
115 | if (typeof className === "string") {
116 | tag = (mouseUpEvent.target as Element).className.match(connectorRegex);
117 | }
118 | }
119 | if (
120 | (isDragFromLeftPin && tag && tag[3] === "left") ||
121 | (!isDragFromLeftPin && tag && tag[3] === "right")
122 | ) {
123 | console.log("ici");
124 | } else if (tag !== null && tag[3] === "left") {
125 | onCreateLink({
126 | leftNodeId: tag[1],
127 | leftNodeConnectorId: tag[2],
128 | rightNodeId: nodeId,
129 | rightNodeConnectorId: cId
130 | });
131 | } else if (tag !== null) {
132 | onCreateLink({
133 | leftNodeId: nodeId,
134 | leftNodeConnectorId: cId,
135 | rightNodeId: tag[1],
136 | rightNodeConnectorId: tag[2]
137 | });
138 | }
139 | onUpdatePreviewLink(undefined);
140 | },
141 | [cId, nodeId, isDragFromLeftPin, onCreateLink, onUpdatePreviewLink]
142 | );
143 |
144 | const { onMouseDown } = useDrag(getZoom, onMouseMoveCb, onMouseUpCb);
145 |
146 | return (
147 |
154 | {[PinLayout.LEFT_PIN, PinLayout.BOTH_PINS].includes(connector.pinLayout) && (
155 |
{
161 | setIsDragFromLeftPin(true);
162 | const pinPos = getConnectorPinPosition(true);
163 | if (pinPos) onMouseDown(e, pinPos);
164 | }}
165 | pinColor={connector.leftPinColor}
166 | />
167 | )}
168 |
169 | {[PinLayout.RIGHT_PIN, PinLayout.BOTH_PINS].includes(connector.pinLayout) && (
170 | {
176 | setIsDragFromLeftPin(false);
177 | const pinPos = getConnectorPinPosition(false);
178 | if (pinPos) onMouseDown(e, pinPos);
179 | }}
180 | pinColor={connector.rightPinColor}
181 | />
182 | )}
183 |
184 |
192 | {connectorContent}
193 |
194 |
195 | );
196 | }
197 |
--------------------------------------------------------------------------------
/src/node_editor/pan_zoom.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-explicit-any */
2 | /* eslint-disable @typescript-eslint/explicit-module-boundary-types */
3 | import * as React from "react";
4 | import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch";
5 |
6 | import { PanZoomModel, SelectionItem, XYPosition } from "./model";
7 | import { useCallback, useState } from "react";
8 | import { useDrag } from "./utils/drag";
9 |
10 | export interface PanZoomInputProps {
11 | panZoomInfo: PanZoomModel;
12 | onPanZoomInfo: (panZoomInfo: PanZoomModel) => void;
13 | onSelectItem: (selection: SelectionItem | null, shiftKey: boolean) => void;
14 | onRectSelection: (
15 | topLeft: XYPosition,
16 | width: number,
17 | height: number,
18 | shiftKey: boolean
19 | ) => void;
20 | children?: React.ReactNode;
21 | }
22 |
23 | // Can't use state for this are callbacks in TransformWrapper aren't updated :|
24 | // Not that important since two node editor won't likelly be moved at the same time :)
25 | let isMouseDownOnLinkCanvas: boolean = false;
26 | let leftMouseButton = false;
27 | let isMouseDownOnPanCanvas: boolean = false;
28 | let panStartPosition: XYPosition | null = null;
29 | let rectActivated = false;
30 | let shiftKey = false;
31 |
32 | type SelectionRectProps = {
33 | topX: number;
34 | topY: number;
35 | width: number;
36 | height: number;
37 | };
38 |
39 | function SelectionRect({ topX, topY, width, height }: SelectionRectProps) {
40 | return (
41 |
52 | );
53 | }
54 |
55 | export default function PanZoom({
56 | panZoomInfo,
57 | onPanZoomInfo,
58 | onSelectItem,
59 | children,
60 | onRectSelection
61 | }: PanZoomInputProps) {
62 | const divRef = React.useRef(null);
63 | const [panDisabled, setPanDisabled] = useState(false);
64 |
65 | const [selectionRect, setSelectionRect] = useState(null);
66 |
67 | const onMouseMoveCb = useCallback(
68 | (initialPos: XYPosition, finalPos: XYPosition) => {
69 | if (rectActivated) {
70 | const rectPosStart = {
71 | x: initialPos.x - panZoomInfo.topLeftCorner.y / panZoomInfo.zoom,
72 | y: initialPos.y - panZoomInfo.topLeftCorner.x / panZoomInfo.zoom
73 | };
74 |
75 | const rectPosEnd = {
76 | x: finalPos.x - panZoomInfo.topLeftCorner.y / panZoomInfo.zoom,
77 | y: finalPos.y - panZoomInfo.topLeftCorner.x / panZoomInfo.zoom
78 | };
79 |
80 | setSelectionRect({
81 | topX: Math.min(rectPosStart.x, rectPosEnd.x),
82 | topY: Math.min(rectPosStart.y, rectPosEnd.y),
83 | width: Math.abs(rectPosEnd.y - rectPosStart.y),
84 | height: Math.abs(rectPosEnd.x - rectPosStart.x)
85 | });
86 | }
87 | },
88 | [panZoomInfo]
89 | );
90 |
91 | const onMouseUpCb = useCallback(
92 | (_iPos: XYPosition, _fPos: XYPosition, mouseUpEvent: MouseEvent) => {},
93 | []
94 | );
95 | const drag = useDrag(
96 | () => {
97 | return panZoomInfo.zoom;
98 | },
99 | onMouseMoveCb,
100 | onMouseUpCb,
101 | true
102 | );
103 |
104 | const onMouseDown = useCallback(
105 | (e: React.MouseEvent) => {
106 | if (!divRef || !divRef.current) {
107 | return;
108 | }
109 | // Register shift key status
110 | shiftKey = e.shiftKey;
111 | leftMouseButton = e.button === 0;
112 | const target = e.target as HTMLTextAreaElement;
113 | // Check if mouse button down was on link_canvas
114 | isMouseDownOnLinkCanvas = false;
115 | if (typeof target.id === "string") {
116 | isMouseDownOnLinkCanvas = target.id === "link_canvas";
117 | }
118 | // Check if mouse button down was on react-zoom-pan-pinch canvas
119 | isMouseDownOnPanCanvas = false;
120 | if (typeof target.className === "string") {
121 | isMouseDownOnPanCanvas = target.className.includes("react-transform-wrapper");
122 | }
123 |
124 | // Disable pad interactions if mouse ckick is not left was not on link or react-zoom-pan-pinch canvas
125 | setPanDisabled(
126 | e.altKey || e.button !== 0 || (!isMouseDownOnLinkCanvas && !isMouseDownOnPanCanvas)
127 | );
128 | if (e.altKey === true) {
129 | rectActivated = true;
130 | const element = divRef.current.getBoundingClientRect();
131 | drag.onMouseDown(e, {
132 | y: (e.clientX - element.left) / panZoomInfo.zoom,
133 | x: (e.clientY - element.top) / panZoomInfo.zoom
134 | });
135 | }
136 | },
137 | [drag, panZoomInfo.zoom]
138 | );
139 |
140 | const onMouseUp = useCallback(
141 | (e: React.MouseEvent) => {
142 | shiftKey = e.shiftKey;
143 | if (rectActivated && selectionRect) {
144 | onRectSelection(
145 | { x: selectionRect.topX, y: selectionRect.topY },
146 | selectionRect.width,
147 | selectionRect.height,
148 | shiftKey
149 | );
150 | }
151 | setSelectionRect(null);
152 | setPanDisabled(false);
153 | },
154 | [selectionRect, onRectSelection]
155 | );
156 |
157 | const onZoomChange = useCallback(
158 | (ref: any) => {
159 | onPanZoomInfo({
160 | zoom: ref.state.scale,
161 | topLeftCorner: { x: ref.state.positionX, y: ref.state.positionY }
162 | });
163 | },
164 | [onPanZoomInfo]
165 | );
166 |
167 | const onPanning = useCallback(
168 | (ref: any) => {
169 | onPanZoomInfo({
170 | zoom: ref.state.scale,
171 | topLeftCorner: { x: ref.state.positionX, y: ref.state.positionY }
172 | });
173 | },
174 | [onPanZoomInfo]
175 | );
176 |
177 | const onPanningStart = useCallback((ref: any) => {
178 | panStartPosition = { x: ref.state.positionX, y: ref.state.positionY };
179 | }, []);
180 |
181 | const onPanningStop = useCallback(
182 | (ref: any) => {
183 | const panEndPosition = { x: ref.state.positionX, y: ref.state.positionY };
184 | if (
185 | leftMouseButton &&
186 | !rectActivated &&
187 | (isMouseDownOnLinkCanvas || isMouseDownOnPanCanvas) &&
188 | panStartPosition &&
189 | panStartPosition.x === panEndPosition.x &&
190 | panStartPosition.y === panEndPosition.y
191 | ) {
192 | onSelectItem(null, shiftKey);
193 | }
194 | rectActivated = false;
195 | panStartPosition = null;
196 | },
197 | [onSelectItem]
198 | );
199 |
200 | return (
201 | {
211 | shiftKey = e.shiftKey;
212 | }}
213 | onMouseUp={onMouseUp}
214 | >
215 |
234 |
235 |
236 | {children}
237 | {selectionRect && }
238 |
239 |
240 |
241 |
242 | );
243 | }
244 |
--------------------------------------------------------------------------------
/src/node_editor/node_editor.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useState } from "react";
2 | import { produce, enableMapSet, setAutoFreeze } from "immer";
3 | import _ from "lodash";
4 |
5 | import {
6 | LinkModel,
7 | LinkPositionModel,
8 | arePositionEquals,
9 | PanZoomModel,
10 | ConnectorModel,
11 | NodeCollection,
12 | LinkCollection,
13 | SelectionItem,
14 | NodePinPositions,
15 | XYPosition
16 | } from "./model";
17 | import PanZoom from "./pan_zoom";
18 | import BackGround from "./background";
19 | import LinkCanvas from "./link_canvas";
20 | import NodeCanvas from "./node_canvas";
21 | import { ThemeContext, darkTheme, ThemeContextType } from "./theme";
22 | import { ConnectorContentProps } from "./connector_content/common";
23 | import "../index.css";
24 |
25 | enableMapSet();
26 | setAutoFreeze(false);
27 |
28 | type NodeEditorProps = {
29 | nodes: NodeCollection;
30 | links: LinkCollection;
31 | panZoomInfo: PanZoomModel;
32 | selectedItems: Array;
33 | theme?: ThemeContextType;
34 |
35 | onNodeMove?(id: string, newX: number, newY: number, newWidth: number): void;
36 | onCreateLink?(link: LinkModel): void;
37 | onConnectorUpdate?: (nodeId: string, cId: string, connector: ConnectorModel) => void;
38 |
39 | onPanZoomInfo: (panZoomInfo: PanZoomModel) => void;
40 | onSelectedItems: (selection: Array) => void;
41 |
42 | createCustomConnectorComponent?(props: ConnectorContentProps): JSX.Element | null;
43 | };
44 |
45 | function NodeEditor(props: NodeEditorProps) {
46 | const {
47 | nodes,
48 | links,
49 | panZoomInfo,
50 | selectedItems,
51 | theme,
52 | onNodeMove,
53 | onCreateLink,
54 | onConnectorUpdate,
55 | onPanZoomInfo,
56 | onSelectedItems,
57 | createCustomConnectorComponent
58 | } = props;
59 | const [linksPositions, setLinksPositions] = useState<{ [linkId: string]: LinkPositionModel }>(
60 | {}
61 | );
62 | const [nodesPinPositions, setNodesPinPositions] = useState<{
63 | [nodeId: string]: NodePinPositions;
64 | }>({});
65 | const [nodesHeights, setNodesHeights] = useState<{
66 | [nodeId: string]: number;
67 | }>({});
68 | const [draggedLink, setDraggedLink] = useState();
69 |
70 | useEffect(() => {
71 | let redrawPinPosition = false;
72 | const newLinksPositions = produce(linksPositions, (draft) => {
73 | // Create or update position of all links positions that need so
74 | Object.keys(links).forEach((key) => {
75 | const link = links[key];
76 | if (
77 | link.leftNodeId in nodesPinPositions === false ||
78 | link.rightNodeId in nodesPinPositions === false
79 | ) {
80 | return;
81 | }
82 | const iNPins = nodesPinPositions[link.leftNodeId][link.leftNodeConnectorId];
83 | const oNPins = nodesPinPositions[link.rightNodeId][link.rightNodeConnectorId];
84 | if (iNPins && oNPins) {
85 | const inputPinPosition = iNPins[0];
86 | const outputPinPosition = oNPins[1];
87 | if (inputPinPosition && outputPinPosition) {
88 | if (
89 | !(key in draft) ||
90 | !arePositionEquals(draft[key].inputPinPosition, inputPinPosition) ||
91 | !arePositionEquals(draft[key].outputPinPosition, outputPinPosition)
92 | ) {
93 | draft[key] = { linkId: key, inputPinPosition, outputPinPosition };
94 | redrawPinPosition = true;
95 | }
96 | }
97 | }
98 | });
99 | // Remove link positions that belongs to a deleted links
100 | Object.keys(linksPositions).forEach((key) => {
101 | if (!(key in links)) {
102 | delete draft[key];
103 | redrawPinPosition = true;
104 | }
105 | });
106 | });
107 | if (redrawPinPosition) {
108 | setLinksPositions(newLinksPositions);
109 | }
110 | }, [linksPositions, nodesPinPositions, links, nodes]);
111 |
112 | const onSelectItem = useCallback(
113 | (selection: SelectionItem | null, shiftKey: boolean) => {
114 | if (!selection && !shiftKey) {
115 | onSelectedItems([]);
116 | } else if (selection && shiftKey && _.some(selectedItems, selection)) {
117 | const newSelection = [...selectedItems];
118 | let indexToDelete = -1;
119 | selectedItems.forEach((item, index) => {
120 | if (item.id === selection.id && item.type === selection.type) {
121 | indexToDelete = index;
122 | }
123 | });
124 | if (indexToDelete !== -1) {
125 | newSelection.splice(indexToDelete, 1);
126 | }
127 | onSelectedItems(newSelection);
128 | } else if (selection && !_.some(selectedItems, selection)) {
129 | let newSelection = [...selectedItems];
130 | if (!shiftKey) {
131 | newSelection = [];
132 | }
133 | newSelection.push(selection);
134 | onSelectedItems(newSelection);
135 | }
136 | if (!shiftKey && selection) {
137 | onSelectedItems([selection]);
138 | }
139 | },
140 | [onSelectedItems, selectedItems]
141 | );
142 |
143 | const onNodePinPositionsUpdate = useCallback(
144 | (nodeId: string, pinPositions: NodePinPositions) => {
145 | setNodesPinPositions((nodesPinPos) => {
146 | return produce(nodesPinPos, (draft) => {
147 | draft[nodeId] = pinPositions;
148 | });
149 | });
150 | },
151 | []
152 | );
153 |
154 | const onNodeHeightUpdate = useCallback((nodeId: string, height: number) => {
155 | setNodesHeights((nodesHeights) => {
156 | return produce(nodesHeights, (draft) => {
157 | draft[nodeId] = height;
158 | });
159 | });
160 | }, []);
161 |
162 | const onRectSelection = useCallback(
163 | (topLeft: XYPosition, width: number, height: number, shiftKey: boolean) => {
164 | const selection: Array = shiftKey ? [...selectedItems] : [];
165 | Object.keys(nodes).forEach((key) => {
166 | const node = nodes[key];
167 | if (
168 | !selection.map((s) => s.id).includes(key) &&
169 | topLeft.y < node.position.x + node.width &&
170 | topLeft.y + width > node.position.x &&
171 | topLeft.x < node.position.y + nodesHeights[key] &&
172 | height + topLeft.x > node.position.y
173 | ) {
174 | selection.push({ id: key, type: "node" });
175 | }
176 | });
177 | onSelectedItems(selection);
178 | },
179 | [nodes, nodesHeights, selectedItems, onSelectedItems]
180 | );
181 |
182 | const localOnCreateLink = useCallback(
183 | (link: LinkModel) => {
184 | if (onCreateLink && link.leftNodeId !== link.rightNodeId) {
185 | onCreateLink(link);
186 | }
187 | },
188 | [onCreateLink]
189 | );
190 |
191 | return (
192 |
193 |
194 |
195 |
201 |
208 | {
211 | return panZoomInfo.zoom;
212 | }}
213 | onNodeMove={onNodeMove ? onNodeMove : () => {}}
214 | onCreateLink={localOnCreateLink}
215 | onUpdatePreviewLink={(p) => {
216 | setDraggedLink(p);
217 | }}
218 | onConnectorUpdate={onConnectorUpdate ? onConnectorUpdate : () => {}}
219 | onNodePinPositionsUpdate={onNodePinPositionsUpdate}
220 | selectedItems={selectedItems}
221 | onSelectItem={onSelectItem}
222 | createCustomConnectorComponent={createCustomConnectorComponent}
223 | onNodeHeightUpdate={onNodeHeightUpdate}
224 | />
225 |
226 |
227 |
228 |
229 | );
230 | }
231 |
232 | export default NodeEditor;
233 |
--------------------------------------------------------------------------------