├── 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 |
      24 | {Object.keys(items).map((categoryName) => { 25 | return ( 26 |
      27 |
    • 28 | {categoryName} 29 |
    • 30 | {items[categoryName].map((item) => ( 31 | 38 | ))} 39 |
      40 | ); 41 | })} 42 |
    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 | [![NPM](https://img.shields.io/npm/v/oura-node-editor.svg)](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 | ![canvas editor](doc/img/canvas-editor.png) 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 | 31 | {/* Render all links */} 32 | {Object.keys(links) 33 | .filter((key) => key in linksPositions) 34 | .map((key) => ( 35 | 42 | onSelectItem({ id, type: "link" }, shiftKey) 43 | } 44 | /> 45 | ))} 46 | {/* Render draggedLink if set */} 47 | {draggedLink && } 48 | 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 |