├── src ├── shared │ ├── index.ts │ ├── utils.ts │ └── drag.tsx ├── presets │ ├── minimap │ │ ├── utils.ts │ │ ├── types.ts │ │ ├── components │ │ │ ├── MiniNode.tsx │ │ │ ├── MiniViewport.tsx │ │ │ └── Minimap.tsx │ │ └── index.tsx │ ├── classic │ │ ├── vars.ts │ │ ├── utility-types.ts │ │ ├── components │ │ │ ├── refs │ │ │ │ ├── RefControl.tsx │ │ │ │ └── RefSocket.tsx │ │ │ ├── Connection.tsx │ │ │ ├── Socket.tsx │ │ │ ├── Control.tsx │ │ │ ├── ConnectionWrapper.tsx │ │ │ └── Node.tsx │ │ ├── types.ts │ │ └── index.tsx │ ├── context-menu │ │ ├── vars.ts │ │ ├── hooks.ts │ │ ├── types.ts │ │ ├── styles.ts │ │ ├── components │ │ │ ├── Search.tsx │ │ │ ├── Menu.tsx │ │ │ └── Item.tsx │ │ └── index.tsx │ ├── index.ts │ ├── types.ts │ └── reroute-pins │ │ ├── types.ts │ │ ├── Pin.tsx │ │ └── index.tsx ├── globals.d.ts ├── types.ts ├── ref-component.tsx ├── utils.ts ├── renderer.ts └── index.tsx ├── .github ├── FUNDING.yml └── workflows │ ├── commit-linter.yml │ ├── ci.yml │ ├── build-push.yml │ ├── release.yml │ ├── stale.yml │ ├── codeql.yml │ └── update-docs.yml ├── CONTRIBUTING.md ├── .gitignore ├── CODE_OF_CONDUCT.md ├── tsconfig.json ├── rete.config.ts ├── LICENSE ├── eslint.config.mjs ├── README.md ├── package.json └── CHANGELOG.md /src/shared/index.ts: -------------------------------------------------------------------------------- 1 | export * as Drag from './drag' 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | patreon: ni55an 2 | open_collective: rete 3 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Check out the [Contribution guide](https://retejs.org/docs/contribution) 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | docs 4 | .vscode 5 | /coverage 6 | .rete-cli 7 | .sonar 8 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | Check out the [Code of Conduct](https://retejs.org/docs/code-of-conduct) 2 | -------------------------------------------------------------------------------- /src/presets/minimap/utils.ts: -------------------------------------------------------------------------------- 1 | export function px(value: number) { 2 | return `${value}px` 3 | } 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "rete-cli/configs/tsconfig.json", 3 | "compilerOptions": { 4 | "jsx": "react" 5 | }, 6 | "include": ["src"] 7 | } 8 | -------------------------------------------------------------------------------- /.github/workflows/commit-linter.yml: -------------------------------------------------------------------------------- 1 | name: Commit linter 2 | 3 | on: 4 | pull_request: 5 | branches: [ "main", "beta" ] 6 | 7 | jobs: 8 | lint: 9 | uses: retejs/.github/.github/workflows/commit-linter.yml@main 10 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | branches: [ "main", "beta" ] 7 | 8 | jobs: 9 | ci: 10 | uses: retejs/.github/.github/workflows/ci.yml@main 11 | secrets: inherit 12 | -------------------------------------------------------------------------------- /.github/workflows/build-push.yml: -------------------------------------------------------------------------------- 1 | name: Build and Push 2 | run-name: Build and Push to dist/${{ github.ref_name }} 3 | 4 | on: 5 | workflow_dispatch: 6 | 7 | jobs: 8 | push: 9 | uses: retejs/.github/.github/workflows/build-push.yml@main 10 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: [ "main", "beta" ] 7 | 8 | jobs: 9 | release: 10 | uses: retejs/.github/.github/workflows/release.yml@main 11 | secrets: inherit 12 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: Close stale issues and PRs 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: '30 1 * * 5' 7 | 8 | jobs: 9 | stale: 10 | uses: retejs/.github/.github/workflows/stale.yml@main 11 | secrets: inherit 12 | -------------------------------------------------------------------------------- /src/presets/classic/vars.ts: -------------------------------------------------------------------------------- 1 | export const $nodecolor = 'rgba(110,136,255,0.8)' 2 | export const $nodecolorselected = '#ffd92c' 3 | export const $socketsize = 24 4 | export const $socketmargin = 6 5 | export const $socketcolor = '#96b38a' 6 | export const $nodewidth = 180 7 | -------------------------------------------------------------------------------- /src/globals.d.ts: -------------------------------------------------------------------------------- 1 | import type { JSX as Jsx } from 'react/jsx-runtime' 2 | 3 | declare global { 4 | namespace JSX { 5 | type ElementClass = Jsx.ElementClass 6 | type Element = Jsx.Element 7 | type IntrinsicElements = Jsx.IntrinsicElements 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: CodeQL 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: [ "main", "beta" ] 7 | pull_request: 8 | branches: [ "main", "beta" ] 9 | 10 | jobs: 11 | codeql: 12 | uses: retejs/.github/.github/workflows/codeql.yml@main 13 | -------------------------------------------------------------------------------- /src/presets/context-menu/vars.ts: -------------------------------------------------------------------------------- 1 | 2 | export const $contextColor = 'rgba(110,136,255,0.8)' 3 | export const $contextColorLight = 'rgba(130, 153, 255, 0.8)' 4 | export const $contextColorDark = 'rgba(69, 103, 255, 0.8)' 5 | export const $contextMenuRound = '5px' 6 | export const $width = 120 7 | -------------------------------------------------------------------------------- /src/presets/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Built-in presets, responsible for rendering different parts of the editor. 3 | * @module 4 | */ 5 | export * as classic from './classic' 6 | export * as contextMenu from './context-menu' 7 | export * as minimap from './minimap' 8 | export * as reroute from './reroute-pins' 9 | -------------------------------------------------------------------------------- /.github/workflows/update-docs.yml: -------------------------------------------------------------------------------- 1 | name: Update docs 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: [ "main" ] 7 | 8 | jobs: 9 | pull: 10 | uses: retejs/.github/.github/workflows/update-docs.yml@main 11 | secrets: inherit 12 | with: 13 | filename: '7.rete-react-plugin' 14 | package: rete-react-plugin 15 | -------------------------------------------------------------------------------- /src/presets/types.ts: -------------------------------------------------------------------------------- 1 | import { ReactElement } from 'react' 2 | import { BaseSchemes } from 'rete' 3 | 4 | import { ReactPlugin } from '..' 5 | 6 | export type RenderPreset = { 7 | attach?: (plugin: ReactPlugin) => void 8 | render: (context: Extract, plugin: ReactPlugin) => ReactElement | null | undefined 9 | } 10 | -------------------------------------------------------------------------------- /src/presets/reroute-pins/types.ts: -------------------------------------------------------------------------------- 1 | import { ConnectionId } from 'rete' 2 | 3 | import { Position, RenderSignal } from '../../types' 4 | 5 | export type Pin = { 6 | id: string 7 | position: Position 8 | selected?: boolean 9 | } 10 | export type PinData = { 11 | id: ConnectionId 12 | pins: Pin[] 13 | } 14 | 15 | export type PinsRender = 16 | | RenderSignal<'reroute-pins', { data: PinData }> 17 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | 2 | export type Position = { x: number, y: number } 3 | 4 | export type ExtraRender = { type: 'render', data: any } | { type: 'rendered', data: any } | { type: 'unmount', data: any } 5 | 6 | export type RenderSignal = 7 | | { 8 | type: 'render' 9 | data: { element: HTMLElement, filled?: boolean, type: Type } & Data 10 | } 11 | | { 12 | type: 'rendered' 13 | data: { element: HTMLElement, type: Type } & Data 14 | } 15 | -------------------------------------------------------------------------------- /src/presets/minimap/types.ts: -------------------------------------------------------------------------------- 1 | import { RenderSignal } from '../../types' 2 | 3 | export type Rect = { 4 | width: number 5 | height: number 6 | left: number 7 | top: number 8 | } 9 | export type Transform = { 10 | x: number 11 | y: number 12 | k: number 13 | } 14 | export type Translate = (dx: number, dy: number) => void 15 | 16 | export type MinimapRender = 17 | | RenderSignal<'minimap', { 18 | ratio: number 19 | nodes: Rect[] 20 | viewport: Rect 21 | start(): Transform 22 | translate: Translate 23 | point(x: number, y: number): void 24 | }> 25 | -------------------------------------------------------------------------------- /src/presets/context-menu/hooks.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react' 2 | 3 | export function useDebounce(cb: () => void, timeout: number): [null | (() => void), () => void] { 4 | const ref = useRef>(undefined) 5 | 6 | function cancel() { 7 | if (ref.current) { 8 | clearTimeout(ref.current) 9 | } 10 | } 11 | const func = () => { 12 | cancel() 13 | 14 | ref.current = setTimeout(() => { 15 | cb() 16 | }, timeout) 17 | } 18 | 19 | useEffect(() => cancel, []) 20 | 21 | return [ 22 | func, 23 | cancel 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /src/presets/minimap/components/MiniNode.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import styled from 'styled-components' 3 | 4 | import { px } from '../utils' 5 | 6 | const Styles = styled.div` 7 | position: absolute; 8 | background: rgba(110, 136, 255, 0.8); 9 | border: 1px solid rgb(192 206 212 / 60%); 10 | ` 11 | 12 | export function MiniNode(props: { left: number, top: number, width: number, height: number }) { 13 | return 19 | } 20 | -------------------------------------------------------------------------------- /src/presets/context-menu/types.ts: -------------------------------------------------------------------------------- 1 | import { RenderSignal } from '../../types' 2 | 3 | export type Item = { 4 | label: string 5 | key: string 6 | handler(): void 7 | subitems?: Item[] 8 | } 9 | 10 | export type ContextMenuRender = 11 | | RenderSignal<'contextmenu', { items: Item[], onHide(): void, searchBar?: boolean }> 12 | 13 | export type ComponentType = React.ComponentType> 14 | 15 | export type Customize = { 16 | main?: () => ComponentType 17 | item?: (item: Item) => ComponentType 18 | search?: () => ComponentType 19 | common?: () => ComponentType 20 | subitems?: (Item: Item) => ComponentType 21 | } 22 | -------------------------------------------------------------------------------- /src/ref-component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | type RefUpdate = (ref: HTMLElement) => void 4 | type BaseProps = { init: RefUpdate, unmount: RefUpdate } & Record 5 | 6 | /** 7 | * Component for rendering various elements embedded in the React.js component tree. 8 | */ 9 | export function RefComponent({ init, unmount, ...props }: Props) { 10 | const ref = React.useRef(null) 11 | 12 | React.useEffect(() => { 13 | const element = ref.current 14 | 15 | return () => { 16 | if (element) unmount(element) 17 | } 18 | }, []) 19 | React.useEffect(() => { 20 | if (ref.current) init(ref.current) 21 | }) 22 | 23 | return 24 | } 25 | -------------------------------------------------------------------------------- /src/presets/context-menu/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | import { $contextColor, $contextColorDark, $contextColorLight, $contextMenuRound } from './vars' 4 | 5 | export const CommonStyle = styled.div` 6 | color: #fff; 7 | padding: 4px; 8 | border-bottom: 1px solid ${$contextColorDark}; 9 | background-color: ${$contextColor}; 10 | cursor: pointer; 11 | width: 100%; 12 | position: relative; 13 | &:first-child { 14 | border-top-left-radius: ${$contextMenuRound}; 15 | border-top-right-radius: ${$contextMenuRound}; 16 | } 17 | &:last-child { 18 | border-bottom-left-radius: ${$contextMenuRound}; 19 | border-bottom-right-radius: ${$contextMenuRound}; 20 | } 21 | &:hover { 22 | background-color: ${$contextColorLight}; 23 | } 24 | ` 25 | -------------------------------------------------------------------------------- /src/presets/classic/utility-types.ts: -------------------------------------------------------------------------------- 1 | type UnionToIntersection = ( 2 | U extends never ? never : (arg: U) => never 3 | ) extends (arg: infer I) => void 4 | ? I 5 | : never 6 | 7 | type StrictExcludeInner = 0 extends ( 8 | U extends T ? [T] extends [U] ? 0 : never : never 9 | ) ? never : T 10 | type StrictExclude = T extends unknown ? StrictExcludeInner : never 11 | 12 | type UnionToTuple = UnionToIntersection< 13 | T extends never ? never : (t: T) => T 14 | > extends (_: never) => infer W 15 | ? [...UnionToTuple>, W] 16 | : [] 17 | 18 | type TupleToObject = { 19 | [K in keyof T]: React.JSXElementConstructor<{ data: T[K] } & Rest> 20 | }[number] 21 | 22 | 23 | export type AcceptComponent = TupleToObject, Rest> 24 | -------------------------------------------------------------------------------- /src/presets/minimap/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { BaseSchemes } from 'rete' 3 | 4 | import { RenderPreset } from '../types' 5 | import { Minimap } from './components/Minimap' 6 | import { MinimapRender } from './types' 7 | 8 | /** 9 | * Preset for rendering minimap. 10 | */ 11 | export function setup(props?: { size?: number }): RenderPreset { 12 | return { 13 | render(context) { 14 | if (context.data.type === 'minimap') { 15 | return 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/presets/classic/components/refs/RefControl.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { ClassicPreset } from 'rete' 3 | 4 | import { RefComponent } from '../../../../ref-component' 5 | import { ClassicScheme, GetControls, ReactArea2D } from '../../types' 6 | 7 | type Props = { 8 | name: string 9 | emit: (props: ReactArea2D) => void 10 | payload: ClassicPreset.Control 11 | } 12 | 13 | export function RefControl({ name, emit, payload, ...props }: Props) { 14 | return { 18 | emit({ type: 'render', 19 | data: { 20 | type: 'control', 21 | element: ref, 22 | payload: payload as GetControls 23 | } }) 24 | }} 25 | unmount={ref => { 26 | emit({ type: 'unmount', data: { element: ref } }) 27 | }} 28 | /> 29 | } 30 | -------------------------------------------------------------------------------- /rete.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/naming-convention */ 2 | import babelEnv from '@babel/preset-env' 3 | import babelReact from '@babel/preset-react' 4 | import babelTS from '@babel/preset-typescript' 5 | import commonjs from '@rollup/plugin-commonjs' 6 | import { ReteOptions } from 'rete-cli' 7 | import replace from 'rollup-plugin-replace' 8 | 9 | export default { 10 | input: 'src/index.tsx', 11 | name: 'ReteReactPlugin', 12 | globals: { 13 | 'rete': 'Rete', 14 | 'rete-area-plugin': 'ReteAreaPlugin', 15 | 'rete-render-utils': 'ReteRenderUtils', 16 | 'react': 'React', 17 | 'react-dom': 'ReactDOM', 18 | 'styled-components': 'styled' 19 | }, 20 | plugins: [ 21 | commonjs(), 22 | replace({ 23 | 'process.env.NODE_ENV': JSON.stringify('development') 24 | }) 25 | ], 26 | babel: { 27 | presets: [ 28 | babelEnv, 29 | babelTS, 30 | babelReact 31 | ] 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/presets/classic/components/Connection.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import styled from 'styled-components' 3 | 4 | import { ClassicScheme } from '../types' 5 | import { useConnection } from './ConnectionWrapper' 6 | 7 | const Svg = styled.svg` 8 | overflow: visible !important; 9 | position: absolute; 10 | pointer-events: none; 11 | width: 9999px; 12 | height: 9999px; 13 | ` 14 | 15 | const Path = styled.path<{ styles?: (props: any) => any }>` 16 | fill: none; 17 | stroke-width: 5px; 18 | stroke: steelblue; 19 | pointer-events: auto; 20 | ${props => props.styles?.(props)} 21 | ` 22 | 23 | export function Connection(props: { data: ClassicScheme['Connection'] & { isLoop?: boolean }, styles?: () => any }) { 24 | const { path } = useConnection() 25 | 26 | if (!path) return null 27 | 28 | return ( 29 | 30 | 33 | 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /src/presets/context-menu/components/Search.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import styled from 'styled-components' 3 | 4 | import { ComponentType } from '../types' 5 | 6 | export const SearchInput = styled.input` 7 | color: white; 8 | padding: 1px 8px; 9 | border: 1px solid white; 10 | border-radius: 10px; 11 | font-size: 16px; 12 | font-family: serif; 13 | width: 100%; 14 | box-sizing: border-box; 15 | background: transparent; 16 | ` 17 | 18 | export function Search(props: { value: string, onChange(value: string): void, component?: ComponentType }) { 19 | const Component = props.component || SearchInput 20 | 21 | return ( 22 | ) => { 25 | props.onChange((e.target as HTMLInputElement).value) 26 | }} 27 | onPointerDown={(e: React.PointerEvent) => { 28 | e.stopPropagation() 29 | }} 30 | data-testid="context-menu-search-input" 31 | /> 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /src/presets/minimap/components/MiniViewport.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import styled from 'styled-components' 3 | 4 | import { useDrag } from '../../../shared/drag' 5 | import { Rect, Transform, Translate } from '../types' 6 | import { px } from '../utils' 7 | 8 | const MiniViewportStyles = styled.div` 9 | position: absolute; 10 | background: rgba(255, 251, 128, 0.32); 11 | border: 1px solid #ffe52b; 12 | ` 13 | 14 | export function MiniViewport(props: Rect & { containerWidth: number, start(): Transform, translate: Translate }) { 15 | const scale = (v: number) => v * props.containerWidth 16 | const invert = (v: number) => v / props.containerWidth 17 | const drag = useDrag((dx, dy) => { 18 | props.translate(invert(-dx), invert(-dy)) 19 | }, e => ({ x: e.pageX, y: e.pageY })) 20 | 21 | return 31 | } 32 | -------------------------------------------------------------------------------- /src/presets/classic/components/refs/RefSocket.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { ClassicPreset, NodeId } from 'rete' 3 | 4 | import { RefComponent } from '../../../../ref-component' 5 | import { ClassicScheme, GetSockets, ReactArea2D, Side } from '../../types' 6 | 7 | type Props = { 8 | name: string 9 | emit: (props: ReactArea2D) => void 10 | side: Side 11 | nodeId: NodeId 12 | socketKey: string 13 | payload: ClassicPreset.Socket 14 | } 15 | 16 | export function RefSocket({ name, emit, nodeId, side, socketKey, payload, ...props }: Props) { 17 | return { 21 | emit({ type: 'render', 22 | data: { 23 | type: 'socket', 24 | side, 25 | key: socketKey, 26 | nodeId, 27 | element: ref, 28 | payload: payload as GetSockets 29 | } }) 30 | }} 31 | unmount={ref => { 32 | emit({ type: 'unmount', data: { element: ref } }) 33 | }} 34 | /> 35 | } 36 | -------------------------------------------------------------------------------- /src/presets/classic/components/Socket.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { ClassicPreset } from 'rete' 3 | import styled from 'styled-components' 4 | 5 | import { $socketcolor, $socketmargin, $socketsize } from '../vars' 6 | 7 | const Styles = styled.div` 8 | display: inline-block; 9 | cursor: pointer; 10 | border: 1px solid white; 11 | border-radius: ${$socketsize / 2.0}px; 12 | width: ${$socketsize}px; 13 | height: ${$socketsize}px; 14 | vertical-align: middle; 15 | background: ${$socketcolor}; 16 | z-index: 2; 17 | box-sizing: border-box; 18 | &:hover { 19 | border-width: 4px; 20 | } 21 | &.multiple { 22 | border-color: yellow; 23 | } 24 | ` 25 | 26 | const Hoverable = styled.div` 27 | border-radius: ${($socketsize + $socketmargin * 2) / 2.0}px; 28 | padding: ${$socketmargin}px; 29 | &:hover ${Styles} { 30 | border-width: 4px; 31 | } 32 | ` 33 | 34 | export function Socket(props: { data: T }) { 35 | return ( 36 | 37 | 38 | 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /src/shared/utils.ts: -------------------------------------------------------------------------------- 1 | export function copyEvent>(e: T) { 2 | const newEvent = new (e.constructor as new(type: string) => T)(e.type) 3 | let current = newEvent 4 | 5 | // eslint-disable-next-line no-cond-assign 6 | while (current = Object.getPrototypeOf(current)) { 7 | const keys = Object.getOwnPropertyNames(current) 8 | 9 | for (const k of keys) { 10 | const item = newEvent[k] 11 | 12 | if (typeof item === 'function') continue 13 | 14 | Object.defineProperty(newEvent, k, { value: e[k] }) 15 | } 16 | } 17 | 18 | return newEvent 19 | } 20 | 21 | const rootPrefix = '__reactContainer$' 22 | 23 | type Keys = `${typeof rootPrefix}${string}` | '_reactRootContainer' 24 | type ReactNode = Partial> & HTMLElement 25 | 26 | export function findReactRoot(element: HTMLElement) { 27 | let current: ReactNode | null = element as ReactNode 28 | 29 | while (current) { 30 | if (current._reactRootContainer || Object.keys(current).some(key => key.startsWith(rootPrefix))) return current 31 | current = current.parentElement as ReactNode 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 "Ni55aN" Vitaliy Stoliarov 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 | -------------------------------------------------------------------------------- /src/presets/context-menu/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { BaseSchemes } from 'rete' 3 | 4 | import { RenderPreset } from '../types' 5 | import { Menu } from './components/Menu' 6 | import { ContextMenuRender, Customize } from './types' 7 | 8 | export { ItemStyle as Item, SubitemStyles as Subitems } from './components/Item' 9 | export { Styles as Menu } from './components/Menu' 10 | export { SearchInput as Search } from './components/Search' 11 | export { CommonStyle as Common } from './styles' 12 | 13 | type Props = { 14 | delay?: number 15 | customize?: Customize 16 | } 17 | 18 | /** 19 | * Preset for rendering context menu. 20 | */ 21 | export function setup(props?: Props): RenderPreset { 22 | const delay = typeof props?.delay === 'undefined' 23 | ? 1000 24 | : props.delay 25 | 26 | return { 27 | render(context) { 28 | if (context.data.type === 'contextmenu') { 29 | return 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/presets/classic/components/Control.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { ClassicPreset } from 'rete' 3 | import styled from 'styled-components' 4 | 5 | import { Drag } from '../../../shared' 6 | 7 | const Input = styled.input<{ styles?: (props: any) => any }>` 8 | width: 100%; 9 | border-radius: 30px; 10 | background-color: white; 11 | padding: 2px 6px; 12 | border: 1px solid #999; 13 | font-size: 110%; 14 | box-sizing: border-box; 15 | ${props => props.styles?.(props)} 16 | ` 17 | 18 | export function Control(props: { data: ClassicPreset.InputControl, styles?: () => any }) { 19 | const [value, setValue] = React.useState(props.data.value) 20 | const ref = React.useRef(null) 21 | 22 | Drag.useNoDrag(ref) 23 | 24 | React.useEffect(() => { 25 | setValue(props.data.value) 26 | }, [props.data.value]) 27 | 28 | return ( 29 | ) => { 35 | const val = (props.data.type === 'number' 36 | ? +e.target.value 37 | : e.target.value) as typeof props.data['value'] 38 | 39 | setValue(val) 40 | props.data.setValue(val) 41 | }} 42 | styles={props.styles} 43 | /> 44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import tseslint from 'typescript-eslint' 2 | import react from 'eslint-plugin-react' 3 | import configs, { extendNamingConventions } from 'rete-cli/configs/eslint.mjs' 4 | import globals from 'globals' 5 | 6 | export default tseslint.config( 7 | ...configs, 8 | { 9 | files: ['**/*.{js,jsx,mjs,cjs,ts,tsx}'], 10 | settings: { 11 | react: { 12 | version: 'detect', 13 | } 14 | }, 15 | plugins: { 16 | react, 17 | }, 18 | }, 19 | react.configs.flat.recommended, 20 | { 21 | languageOptions: { 22 | parserOptions: { 23 | ecmaFeatures: { 24 | jsx: true, 25 | }, 26 | }, 27 | globals: { 28 | ...globals.browser, 29 | ...globals.es2017 30 | } 31 | } 32 | }, 33 | { 34 | rules: { 35 | '@typescript-eslint/naming-convention': [ 36 | 'error', 37 | ...extendNamingConventions( 38 | { 39 | 'selector': 'variable', 40 | 'format': ['camelCase', 'UPPER_CASE', 'PascalCase'], 41 | 'leadingUnderscore': 'allow' 42 | }, 43 | { 44 | 'selector': 'function', 45 | 'format': ['camelCase', 'PascalCase'], 46 | 'leadingUnderscore': 'allow' 47 | } 48 | ) 49 | ], 50 | '@typescript-eslint/no-deprecated': 'warn', 51 | 'react/no-deprecated': 'warn', 52 | '@typescript-eslint/unbound-method': 'off', 53 | 'no-undefined': 'off' 54 | } 55 | } 56 | ) -------------------------------------------------------------------------------- /src/presets/reroute-pins/Pin.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import styled from 'styled-components' 3 | 4 | import { useDrag } from '../../shared/drag' 5 | import { Position } from '../../types' 6 | import { Pin as PinType } from './types' 7 | 8 | const pinSize = 20 9 | 10 | const Styles = styled.div<{ selected?: boolean }>` 11 | width: ${pinSize}px; 12 | height: ${pinSize}px; 13 | box-sizing: border-box; 14 | background: ${props => props.selected 15 | ? '#ffd92c' 16 | : 'steelblue'}; 17 | border: 2px solid white; 18 | border-radius: ${pinSize}px; 19 | ` 20 | 21 | type Props = PinType & { 22 | contextMenu(): void 23 | translate(dx: number, dy: number): void 24 | pointerdown(): void 25 | pointer(): Position 26 | } 27 | 28 | export function Pin(props: Props) { 29 | const drag = useDrag((dx, dy) => { 30 | props.translate(dx, dy) 31 | }, props.pointer) 32 | const { x, y } = props.position 33 | 34 | return ( 35 | { 37 | e.stopPropagation() 38 | e.preventDefault() 39 | drag.start(e) 40 | props.pointerdown() 41 | }} 42 | onContextMenu={(e: React.MouseEvent) => { 43 | e.stopPropagation() 44 | e.preventDefault() 45 | props.contextMenu() 46 | }} 47 | selected={props.selected} 48 | style={{ position: 'absolute', top: `${y - pinSize / 2}px`, left: `${x - pinSize / 2}px` }} 49 | data-testid="pin" 50 | > 51 | 52 | 53 | ) 54 | } 55 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Rete.js React plugin 2 | ==== 3 | [![Made in Ukraine](https://img.shields.io/badge/made_in-ukraine-ffd700.svg?labelColor=0057b7)](https://stand-with-ukraine.pp.ua) 4 | [![Discord](https://img.shields.io/discord/1081223198055604244?color=%237289da&label=Discord)](https://discord.gg/cxSFkPZdsV) 5 | 6 | **Rete.js plugin** 7 | 8 | ## Key features 9 | 10 | - **Render elements**: visualize an elements such as nodes and connections using React.js components 11 | - **Customization**: modify appearance and behavior for a personalized workflow 12 | - **Presets**: predefined React.js components for different types of features 13 | - **[Classic](https://retejs.org/docs/guides/renderers/react#connect-plugin)**: provides a classic visualization of nodes, connections, and controls 14 | - **[Context menu](https://retejs.org/docs/guides/context-menu#render-context-menu)**: provides a classic appearance for `rete-context-menu-plugin` 15 | - **[Minimap](https://retejs.org/docs/guides/minimap#render)**: provides a classic appearance for `rete-minimap-plugin` 16 | - **[Reroute](https://retejs.org/docs/guides/reroute#rendering)**: provides a classic appearance for `rete-connection-reroute-plugin` 17 | 18 | ## Getting Started 19 | 20 | Please refer to the [guide](https://retejs.org/docs/guides/renderers/react) and [example](https://retejs.org/examples/react) using this plugin 21 | 22 | ## Contribution 23 | 24 | Please refer to the [Contribution](https://retejs.org/docs/contribution) guide 25 | 26 | ## License 27 | 28 | [MIT](https://github.com/retejs/react-plugin/blob/main/LICENSE) 29 | -------------------------------------------------------------------------------- /src/presets/reroute-pins/index.tsx: -------------------------------------------------------------------------------- 1 | 2 | import * as React from 'react' 3 | import { BaseSchemes } from 'rete' 4 | import { BaseAreaPlugin } from 'rete-area-plugin' 5 | 6 | import { Position } from '../../types' 7 | import { RenderPreset } from '../types' 8 | import { Pin } from './Pin' 9 | import { PinData, PinsRender } from './types' 10 | 11 | type Props = { 12 | translate?: (id: string, dx: number, dy: number) => void 13 | contextMenu?: (id: string) => void 14 | pointerdown?: (id: string) => void 15 | } 16 | 17 | /** 18 | * Preset for rendering pins. 19 | */ 20 | export function setup(props?: Props): RenderPreset { 21 | function renderPins(data: PinData, pointer: () => Position) { 22 | return <> 23 | {data.pins.map(pin => ( 24 | { 28 | props?.contextMenu?.(pin.id) 29 | }} 30 | translate={(dx, dy) => { 31 | props?.translate?.(pin.id, dx, dy) 32 | }} 33 | pointerdown={() => { 34 | props?.pointerdown?.(pin.id) 35 | }} 36 | pointer={pointer} 37 | /> 38 | ))} 39 | 40 | } 41 | 42 | return { 43 | render(context, plugin) { 44 | const data = context.data 45 | const area = plugin.parentScope>(BaseAreaPlugin) 46 | 47 | if (data.type === 'reroute-pins') { 48 | return renderPins(data.data, () => area.area.pointer) 49 | } 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rete-react-plugin", 3 | "version": "2.1.0", 4 | "description": "", 5 | "scripts": { 6 | "build": "rete build -c rete.config.ts", 7 | "lint": "rete lint", 8 | "doc": "rete doc --entries src/index.tsx" 9 | }, 10 | "author": "Vitaliy Stoliarov", 11 | "license": "MIT", 12 | "keywords": [ 13 | "react", 14 | "plugin", 15 | "rete", 16 | "Rete.js" 17 | ], 18 | "homepage": "https://retejs.org", 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/retejs/react-plugin.git" 22 | }, 23 | "bugs": { 24 | "url": "https://github.com/retejs/react-plugin/issues" 25 | }, 26 | "peerDependencies": { 27 | "react": "^16.8.6 || ^17 || ^18 || ^19 ", 28 | "react-dom": "^16.8.6 || ^17 || ^18 || ^19", 29 | "rete": "^2.0.1", 30 | "rete-area-plugin": "^2.0.0", 31 | "rete-render-utils": "^2.0.0", 32 | "styled-components": "^5.3.6 || ^6" 33 | }, 34 | "devDependencies": { 35 | "@babel/preset-react": "^7.18.6", 36 | "@rollup/plugin-commonjs": "^23.0.2", 37 | "@types/react": "^19.0.0", 38 | "@types/react-dom": "^19.0.0", 39 | "@types/styled-components": "^5.1.34", 40 | "eslint-plugin-react": "^7.35.0", 41 | "globals": "^15.9.0", 42 | "react": "^19.0.0", 43 | "react-dom": "^19.0.0", 44 | "rete": "^2.0.1", 45 | "rete-area-plugin": "^2.0.0", 46 | "rete-cli": "~2.0.1", 47 | "rete-render-utils": "^2.0.0", 48 | "rollup-plugin-replace": "^2.2.0", 49 | "rollup-plugin-sass": "^1.2.2", 50 | "styled-components": "^6.1.19" 51 | }, 52 | "dependencies": { 53 | "@babel/runtime": "^7.21.0", 54 | "usehooks-ts": "^3.1.0" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/presets/classic/types.ts: -------------------------------------------------------------------------------- 1 | import { ClassicPreset as Classic, GetSchemes, NodeId } from 'rete' 2 | 3 | import { Position, RenderSignal } from '../../types' 4 | 5 | type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) 6 | ? I 7 | : never 8 | export type GetControls< 9 | T extends ClassicScheme['Node'], 10 | Intersection = UnionToIntersection 11 | > = Intersection[keyof Intersection] extends Classic.Control ? Intersection[keyof Intersection] : Classic.Control 12 | export type GetSockets< 13 | T extends ClassicScheme['Node'], 14 | Intersection = UnionToIntersection, 15 | Union = Exclude 16 | > = Union extends { socket: any } ? Union['socket'] : Classic.Socket 17 | 18 | export type ClassicScheme = GetSchemes & { isLoop?: boolean }> 19 | 20 | export type Side = 'input' | 'output' 21 | 22 | export type ReactArea2D = 23 | | RenderSignal<'node', { payload: T['Node'] }> 24 | | RenderSignal<'connection', { payload: T['Connection'], start?: Position, end?: Position }> 25 | | RenderSignal<'socket', { 26 | payload: GetSockets 27 | nodeId: NodeId 28 | side: Side 29 | key: string 30 | }> 31 | | RenderSignal<'control', { 32 | payload: GetControls 33 | }> 34 | | { type: 'unmount', data: { element: HTMLElement } } 35 | 36 | export type ExtractPayload = Extract, { type: 'render', data: { type: K } }>['data'] 37 | export type RenderEmit = (props: ReactArea2D) => void 38 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { useEffect, useRef, useState } from 'react' 3 | import { flushSync } from 'react-dom' 4 | 5 | export function Root({ children, rendered }: { children: React.JSX.Element | null, rendered: () => void }) { 6 | useEffect(() => { 7 | rendered() 8 | }) 9 | 10 | return children 11 | } 12 | 13 | export function syncFlush() { 14 | const ready = useRef(false) 15 | 16 | useEffect(() => { 17 | ready.current = true 18 | }, []) 19 | 20 | return { 21 | apply(f: () => void) { 22 | if (ready.current) { 23 | queueMicrotask(() => { 24 | flushSync(f) 25 | }) 26 | } else { 27 | f() 28 | } 29 | } 30 | } 31 | } 32 | 33 | export function useRete(create: (el: HTMLElement) => Promise) { 34 | const [container, setContainer] = useState(null) 35 | const editorRef = useRef(undefined) 36 | const [editor, setEditor] = useState(null) 37 | // compatible RefObject type for React 18 and earlier 38 | const ref = useRef(null) as React.RefObject 39 | 40 | useEffect(() => { 41 | if (container) { 42 | if (editorRef.current) { 43 | editorRef.current.destroy() 44 | container.innerHTML = '' 45 | } 46 | void create(container).then(value => { 47 | editorRef.current = value 48 | setEditor(value) 49 | }) 50 | } 51 | }, [container, create]) 52 | 53 | useEffect(() => { 54 | return () => { 55 | if (editorRef.current) { 56 | editorRef.current.destroy() 57 | } 58 | } 59 | }, []) 60 | useEffect(() => { 61 | if (ref.current) { 62 | setContainer(ref.current) 63 | } 64 | }, [ref.current]) 65 | 66 | return [ref, editor] as const 67 | } 68 | -------------------------------------------------------------------------------- /src/presets/context-menu/components/Menu.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import styled from 'styled-components' 3 | 4 | import { useDebounce } from '../hooks' 5 | import { CommonStyle } from '../styles' 6 | import { Customize, Item } from '../types' 7 | import { $width } from '../vars' 8 | import { ItemElement } from './Item' 9 | import { Search } from './Search' 10 | 11 | export const Styles = styled.div` 12 | padding: 10px; 13 | width: ${$width}px; 14 | margin-top: -20px; 15 | margin-left: -${$width / 2}px; 16 | ` 17 | 18 | type Props = { 19 | items: Item[] 20 | delay: number 21 | searchBar?: boolean 22 | onHide(): void 23 | components?: Customize 24 | } 25 | 26 | export function Menu(props: Props) { 27 | const [hide, cancelHide] = useDebounce(props.onHide, props.delay) 28 | const [filter, setFilter] = React.useState('') 29 | const filterRegexp = new RegExp(filter, 'i') 30 | const filteredList = props.items.filter(item => item.label.match(filterRegexp)) 31 | const Component = props.components?.main?.() || Styles 32 | const Common = props.components?.common?.() || CommonStyle 33 | 34 | return { 36 | cancelHide() 37 | }} 38 | onMouseLeave={() => { 39 | hide?.() 40 | }} 41 | onWheel={(e: React.WheelEvent) => { 42 | e.stopPropagation() 43 | }} 44 | data-testid="context-menu" 45 | > 46 | {props.searchBar && ( 47 | 48 | 49 | 50 | )} 51 | {filteredList.map(item => { 52 | return 59 | {item.label} 60 | 61 | })} 62 | 63 | } 64 | -------------------------------------------------------------------------------- /src/shared/drag.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | import { Position } from '../types' 4 | import { copyEvent, findReactRoot } from './utils' 5 | 6 | type Translate = (dx: number, dy: number) => void 7 | type StartEvent = { pageX: number, pageY: number } 8 | 9 | export function useDrag(translate: Translate, getPointer: (e: StartEvent) => Position) { 10 | return { 11 | start(e: StartEvent) { 12 | let previous = { ...getPointer(e) } 13 | 14 | function move(moveEvent: MouseEvent) { 15 | const current = { ...getPointer(moveEvent) } 16 | const dx = current.x - previous.x 17 | const dy = current.y - previous.y 18 | 19 | previous = current 20 | 21 | translate(dx, dy) 22 | } 23 | function up() { 24 | window.removeEventListener('pointermove', move) 25 | window.removeEventListener('pointerup', up) 26 | window.removeEventListener('pointercancel', up) 27 | } 28 | 29 | window.addEventListener('pointermove', move) 30 | window.addEventListener('pointerup', up) 31 | window.addEventListener('pointercancel', up) 32 | } 33 | } 34 | } 35 | 36 | export function useNoDrag(ref: React.MutableRefObject, disabled?: boolean) { 37 | React.useEffect(() => { 38 | const handleClick = (e: PointerEvent) => { 39 | if (disabled) return 40 | 41 | const root = findReactRoot(e.target as HTMLElement) 42 | const target = React.version.startsWith('16') 43 | ? document 44 | : root 45 | 46 | if (target) { 47 | e.stopPropagation() 48 | target.dispatchEvent(copyEvent(e)) 49 | } 50 | } 51 | const el = ref.current 52 | 53 | el?.addEventListener('pointerdown', handleClick) 54 | 55 | return () => { 56 | el?.removeEventListener('pointerdown', handleClick) 57 | } 58 | }, [ref, disabled]) 59 | } 60 | 61 | export function NoDrag(props: { children: React.ReactNode, disabled?: boolean }) { 62 | const ref = React.useRef(null) 63 | 64 | useNoDrag(ref, props.disabled) 65 | 66 | return {props.children} 67 | } 68 | -------------------------------------------------------------------------------- /src/presets/classic/components/ConnectionWrapper.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { createContext, useContext, useEffect, useState } from 'react' 3 | 4 | import { Position } from '../../../types' 5 | import { syncFlush } from '../../../utils' 6 | 7 | export type ConnectionContextValue = { start: Position | null, end: Position | null, path: null | string } 8 | 9 | export const ConnectionContext = createContext({ 10 | start: null, 11 | end: null, 12 | path: null 13 | }) 14 | 15 | type PositionWatcher = (cb: (value: Position) => void) => (() => void) 16 | 17 | type Props = { 18 | children: React.JSX.Element 19 | start: Position | PositionWatcher 20 | end: Position | PositionWatcher 21 | path(start: Position, end: Position): Promise 22 | } 23 | 24 | export function ConnectionWrapper(props: Props) { 25 | const { children } = props 26 | const [computedStart, setStart] = useState(null) 27 | const [computedEnd, setEnd] = useState(null) 28 | const [path, setPath] = useState(null) 29 | const start = 'x' in props.start 30 | ? props.start 31 | : computedStart 32 | const end = 'x' in props.end 33 | ? props.end 34 | : computedEnd 35 | const flush = syncFlush() 36 | 37 | useEffect(() => { 38 | const unwatch1 = typeof props.start === 'function' && props.start(s => { 39 | flush.apply(() => { 40 | setStart(s) 41 | }) 42 | }) 43 | const unwatch2 = typeof props.end === 'function' && props.end(s => { 44 | flush.apply(() => { 45 | setEnd(s) 46 | }) 47 | }) 48 | 49 | return () => { 50 | if (unwatch1) unwatch1() 51 | if (unwatch2) unwatch2() 52 | } 53 | }, []) 54 | useEffect(() => { 55 | if (start && end) void props.path(start, end).then(p => { 56 | flush.apply(() => { 57 | setPath(p) 58 | }) 59 | }) 60 | }, [start, end]) 61 | 62 | return ( 63 | 64 | {children} 65 | 66 | ) 67 | } 68 | 69 | export function useConnection() { 70 | return useContext(ConnectionContext) 71 | } 72 | -------------------------------------------------------------------------------- /src/presets/context-menu/components/Item.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import styled, { css } from 'styled-components' 3 | 4 | import { useDebounce } from '../hooks' 5 | import { CommonStyle } from '../styles' 6 | import { Customize, Item } from '../types' 7 | import { $width } from '../vars' 8 | 9 | export const ItemStyle = styled(CommonStyle) <{ hasSubitems?: boolean }>` 10 | ${props => props.hasSubitems && css`&:after { 11 | content: '►'; 12 | position: absolute; 13 | opacity: 0.6; 14 | right: 5px; 15 | top: 5px; 16 | }`} 17 | ` 18 | 19 | export const SubitemStyles = styled.div` 20 | position: absolute; 21 | top: 0; 22 | left: 100%; 23 | width: ${$width}px; 24 | ` 25 | 26 | type Props = { 27 | data: Item 28 | delay: number 29 | hide(): void 30 | children: React.ReactNode 31 | components?: Pick 32 | } 33 | 34 | export function ItemElement(props: Props) { 35 | const [visibleSubitems, setVisibleSubitems] = React.useState(false) 36 | const setInvisibile = React.useCallback(() => { 37 | setVisibleSubitems(false) 38 | }, [setVisibleSubitems]) 39 | const [hide, cancelHide] = useDebounce(setInvisibile, props.delay) 40 | const Component = props.components?.item?.(props.data) || ItemStyle 41 | const Subitems = props.components?.subitems?.(props.data) || SubitemStyles 42 | 43 | return { 45 | e.stopPropagation() 46 | props.data.handler() 47 | props.hide() 48 | }} 49 | hasSubitems={Boolean(props.data.subitems)} 50 | onPointerDown={(e: React.PointerEvent) => { 51 | e.stopPropagation() 52 | }} 53 | onPointerOver={() => { 54 | cancelHide() 55 | setVisibleSubitems(true) 56 | }} 57 | onPointerLeave={() => { 58 | if (hide) hide() 59 | }} 60 | data-testid="context-menu-item" 61 | > 62 | {props.children} 63 | {props.data.subitems && visibleSubitems && ( 64 | 65 | {props.data.subitems.map(item => ( 66 | {item.label} 73 | ))} 74 | 75 | )} 76 | 77 | } 78 | -------------------------------------------------------------------------------- /src/presets/minimap/components/Minimap.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { useCallback, useRef } from 'react' 3 | import styled from 'styled-components' 4 | import { useResizeObserver } from 'usehooks-ts' 5 | 6 | import { Rect, Transform, Translate } from '../types' 7 | import { px } from '../utils' 8 | import { MiniNode } from './MiniNode' 9 | import { MiniViewport } from './MiniViewport' 10 | 11 | const Styles = styled.div<{ size: number }>` 12 | position: absolute; 13 | right: 24px; 14 | bottom: 24px; 15 | background: rgba(229, 234, 239, 0.65); 16 | padding: 20px; 17 | overflow: hidden; 18 | border: 1px solid #b1b7ff; 19 | border-radius: 8px; 20 | box-sizing: border-box; 21 | ` 22 | 23 | type Props = { 24 | size: number 25 | ratio: number 26 | nodes: Rect[] 27 | viewport: Rect 28 | start(): Transform 29 | translate: Translate 30 | point(x: number, y: number): void 31 | } 32 | 33 | export function Minimap(props: Props) { 34 | const ref = useRef(null) 35 | const { width = 0 } = useResizeObserver({ 36 | // https://github.com/juliencrn/usehooks-ts/issues/663 37 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 38 | // @ts-expect-error 39 | ref: ref 40 | }) 41 | const containerWidth = ref.current?.clientWidth || width 42 | const scale = useCallback((v: number) => v * containerWidth, [containerWidth]) 43 | 44 | return { 51 | e.stopPropagation() 52 | e.preventDefault() 53 | }} 54 | onDoubleClick={(e: React.MouseEvent) => { 55 | e.stopPropagation() 56 | e.preventDefault() 57 | if (!ref.current) return 58 | const box = ref.current.getBoundingClientRect() 59 | const x = (e.clientX - box.left) / (props.size * props.ratio) 60 | const y = (e.clientY - box.top) / (props.size * props.ratio) 61 | 62 | props.point(x, y) 63 | }} 64 | ref={ref} 65 | data-testid="minimap" 66 | > 67 | {containerWidth 68 | ? props.nodes.map((node, i) => ) 75 | : null} 76 | 82 | 83 | } 84 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # [2.1.0](https://github.com/retejs/react-plugin/compare/v2.0.7...v2.1.0) (2025-08-29) 2 | 3 | 4 | ### Features 5 | 6 | * support react 19 ([494db41](https://github.com/retejs/react-plugin/commit/494db417afbcedd0756338ecd9784110dfec0e95)) 7 | 8 | ## [2.0.7](https://github.com/retejs/react-plugin/compare/v2.0.6...v2.0.7) (2024-08-30) 9 | 10 | 11 | ### Bug Fixes 12 | 13 | * update cli and fix linting errors ([95cc34f](https://github.com/retejs/react-plugin/commit/95cc34fabb91610bfcfb78fd8ba5cc81928dcee8)) 14 | 15 | ## [2.0.6](https://github.com/retejs/react-plugin/compare/v2.0.5...v2.0.6) (2024-08-25) 16 | 17 | 18 | ### Bug Fixes 19 | 20 | * minimap size ([52ed15f](https://github.com/retejs/react-plugin/commit/52ed15f2248c16c0429e30a27be58f6f037eaf91)) 21 | 22 | ## [2.0.5](https://github.com/retejs/react-plugin/compare/v2.0.4...v2.0.5) (2024-01-27) 23 | 24 | 25 | ### Bug Fixes 26 | 27 | * **build:** source maps ([131cbec](https://github.com/retejs/react-plugin/commit/131cbecfc8783f5912cc0b17a79d4c143b83cfc6)) 28 | 29 | ## [2.0.4](https://github.com/retejs/react-plugin/compare/v2.0.3...v2.0.4) (2023-10-24) 30 | 31 | 32 | ### Bug Fixes 33 | 34 | * run flushSync in microtask ([fa2ba02](https://github.com/retejs/react-plugin/commit/fa2ba02255c075e14657f4b9ecc8710cdf2e9c1e)) 35 | 36 | ## [2.0.3](https://github.com/retejs/react-plugin/compare/v2.0.2...v2.0.3) (2023-10-20) 37 | 38 | 39 | ### Bug Fixes 40 | 41 | * no drag propagation ([267cfaa](https://github.com/retejs/react-plugin/commit/267cfaa2c2670b479f5d0a1a25b1c9bf12eff341)) 42 | 43 | ## [2.0.2](https://github.com/retejs/react-plugin/compare/v2.0.1...v2.0.2) (2023-10-05) 44 | 45 | 46 | ### Bug Fixes 47 | 48 | * pointerdown propagation ([14ae6ec](https://github.com/retejs/react-plugin/commit/14ae6ece434e19417773ac3f2a9edc8785426ca2)) 49 | 50 | ## v2.0.0-beta.22 51 | 52 | Fix controls losing focus on update 53 | 54 | ## v2.0.0-beta.20 55 | 56 | Breaking changes: 57 | 58 | ```ts 59 | render.addPreset(Presets.reroute.setup({ 60 | translate(id, dx, dy) { 61 | // const { k } = rea.area.transform 62 | // dividing by k isn't needed 63 | reroutePlugin.translate(id, dx, dy); 64 | } 65 | })) 66 | ``` 67 | 68 | 69 | ## 2.0.0-beta.19 70 | 71 | Breaking changes: `area` property omitted from `Presets.classic.setup({ area })` 72 | 73 | ## 2.0.0-beta.18 74 | 75 | Alias Control from classic preset 76 | Use `useNoDrag` in `InputControl` 77 | 78 | ## 2.0.0-beta.17 79 | 80 | - Exposed `useDrag` 81 | - Added `useNoDrag` and `NoDrag` in `Drag` namespace 82 | 83 | ## v2.0.0-beta.12 84 | 85 | Added `RefControl` and `RefSocket` to classic preset 86 | 87 | ## v2.0.0-beta.10 88 | 89 | Breaking changes: `RefComponent` requires `unmount` property 90 | -------------------------------------------------------------------------------- /src/renderer.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import * as ReactDOM from 'react-dom' 3 | 4 | // React 18+ root type 5 | interface Root { 6 | render(children: React.ReactNode): void 7 | unmount(): void 8 | } 9 | 10 | export type HasLegacyRender = (typeof ReactDOM) extends { render(...args: any[]): any } ? true : false 11 | 12 | export type CreateRoot = (container: Element | DocumentFragment) => Root 13 | 14 | type ReactDOMRenderer = ( 15 | element: React.ReactElement, 16 | container: HTMLElement 17 | ) => React.Component | Element 18 | 19 | export type Renderer = { mount: ReactDOMRenderer, unmount: (container: HTMLElement) => void } 20 | 21 | export function getRenderer(props?: { createRoot?: CreateRoot }): Renderer { 22 | const createRoot = props?.createRoot 23 | const wrappers = new WeakMap() 24 | 25 | function getWrapper(container: HTMLElement) { 26 | const wrapper = wrappers.get(container) 27 | 28 | if (wrapper) return wrapper 29 | 30 | const span = document.createElement('span') 31 | 32 | container.appendChild(span) 33 | wrappers.set(container, span) 34 | return span 35 | } 36 | 37 | function removeWrapper(container: HTMLElement): void { 38 | const wrapper = wrappers.get(container) 39 | 40 | if (wrapper) { 41 | wrapper.remove() 42 | wrappers.delete(container) 43 | } 44 | } 45 | 46 | // React 18+ path with createRoot 47 | if (createRoot) { 48 | const roots = new WeakMap() 49 | 50 | return { 51 | mount: (element: React.ReactElement, container: HTMLElement) => { 52 | const wrapper = getWrapper(container) 53 | 54 | let root = roots.get(wrapper) 55 | 56 | if (!root) { 57 | root = createRoot(wrapper) 58 | roots.set(wrapper, root) 59 | } 60 | 61 | root.render(element) 62 | return wrapper.firstElementChild ?? wrapper 63 | }, 64 | unmount: (container: HTMLElement) => { 65 | const wrapper = getWrapper(container) 66 | const root = roots.get(wrapper) 67 | 68 | if (root) { 69 | root.unmount() 70 | roots.delete(wrapper) 71 | } 72 | removeWrapper(container) 73 | } 74 | } 75 | } 76 | 77 | // React 16-17 legacy path with ReactDOM.render 78 | return { 79 | mount: (element: React.ReactElement, container: HTMLElement) => { 80 | const wrapper = getWrapper(container) 81 | 82 | if ('render' in ReactDOM && typeof ReactDOM.render === 'function') { 83 | const result = ReactDOM.render(element, wrapper) as React.Component | Element 84 | 85 | return result || wrapper 86 | } 87 | 88 | throw new Error('ReactDOM.render is not available') 89 | }, 90 | unmount: (container: HTMLElement) => { 91 | const wrapper = getWrapper(container) 92 | 93 | if ('unmountComponentAtNode' in ReactDOM && typeof ReactDOM.unmountComponentAtNode === 'function') { 94 | ReactDOM.unmountComponentAtNode(wrapper) 95 | } else { 96 | throw new Error('ReactDOM.unmountComponentAtNode is not available') 97 | } 98 | 99 | removeWrapper(container) 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { BaseSchemes, CanAssignSignal, Scope } from 'rete' 3 | 4 | import { RenderPreset } from './presets/types' 5 | import { CreateRoot, getRenderer, HasLegacyRender, Renderer } from './renderer' 6 | import { Position, RenderSignal } from './types' 7 | import { Root } from './utils' 8 | 9 | export * as Presets from './presets' 10 | export type { ClassicScheme, ReactArea2D, RenderEmit } from './presets/classic' 11 | export { RefComponent } from './ref-component' 12 | export * from './shared' 13 | export * from './types' 14 | export { useRete } from './utils' 15 | 16 | /** 17 | * Signals that can be emitted by the plugin 18 | * @priority 9 19 | */ 20 | export type Produces = 21 | | { type: 'connectionpath', data: { payload: Schemes['Connection'], path?: string, points: Position[] } } 22 | 23 | type Requires = 24 | | RenderSignal<'node', { payload: Schemes['Node'] }> 25 | | RenderSignal<'connection', { payload: Schemes['Connection'], start?: Position, end?: Position }> 26 | | { type: 'unmount', data: { element: HTMLElement } } 27 | 28 | /** 29 | * Plugin props 30 | */ 31 | export type Props = HasLegacyRender extends true ? { 32 | /** root factory for React.js 18+ */ 33 | createRoot?: CreateRoot 34 | } : { 35 | createRoot: CreateRoot 36 | } 37 | 38 | /** 39 | * React plugin. Renders nodes, connections and other elements using React. 40 | * @priority 10 41 | * @emits connectionpath 42 | * @listens render 43 | * @listens unmount 44 | */ 45 | export class ReactPlugin> extends Scope, [Requires | T]> { 46 | renderer: Renderer 47 | presets: RenderPreset[] = [] 48 | 49 | constructor(...[props]: HasLegacyRender extends true ? [props?: Props] : [props: Props]) { 50 | super('react-render') 51 | this.renderer = getRenderer({ createRoot: props?.createRoot }) 52 | 53 | this.addPipe(context => { 54 | if (!context || typeof context !== 'object' || !('type' in context)) return context 55 | if (context.type === 'unmount') { 56 | this.unmount(context.data.element) 57 | } else if (context.type === 'render') { 58 | if ('filled' in context.data && context.data.filled) { 59 | return context 60 | } 61 | if (this.mount(context.data.element, context)) { 62 | return { 63 | ...context, 64 | data: { 65 | ...context.data, 66 | filled: true 67 | } 68 | } as typeof context 69 | } 70 | } 71 | 72 | return context 73 | }) 74 | } 75 | 76 | setParent(scope: Scope | T>): void { 77 | super.setParent(scope) 78 | 79 | this.presets.forEach(preset => { 80 | if (preset.attach) preset.attach(this) 81 | }) 82 | } 83 | 84 | private mount(element: HTMLElement, context: Requires) { 85 | const parent = this.parentScope() 86 | 87 | for (const preset of this.presets) { 88 | const result = preset.render(context as any, this) 89 | 90 | if (!result) continue 91 | 92 | const reactElement = ( 93 | void parent.emit({ type: 'rendered', data: context.data } as T)}> 94 | {result} 95 | 96 | ) 97 | 98 | this.renderer.mount(reactElement, element) 99 | return true 100 | } 101 | } 102 | 103 | private unmount(element: HTMLElement) { 104 | this.renderer.unmount(element) 105 | } 106 | 107 | /** 108 | * Adds a preset to the plugin. 109 | * @param preset Preset that can render nodes, connections and other elements. 110 | */ 111 | public addPreset(preset: RenderPreset extends true ? K : 'Cannot apply preset. Provided signals are not compatible'>) { 112 | const local = preset as RenderPreset 113 | 114 | if (local.attach) local.attach(this) 115 | this.presets.push(local) 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/presets/classic/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { ClassicPreset, Scope } from 'rete' 3 | import { 4 | classicConnectionPath, getDOMSocketPosition, 5 | loopConnectionPath, SocketPositionWatcher 6 | } from 'rete-render-utils' 7 | 8 | import { Position } from '../../types' 9 | import { RenderPreset } from '../types' 10 | import { Connection } from './components/Connection' 11 | import { ConnectionWrapper } from './components/ConnectionWrapper' 12 | import { Control } from './components/Control' 13 | import { Node } from './components/Node' 14 | import { Socket } from './components/Socket' 15 | import { 16 | ClassicScheme, ExtractPayload, ReactArea2D, RenderEmit 17 | } from './types' 18 | import { AcceptComponent } from './utility-types' 19 | 20 | export { Connection } from './components/Connection' 21 | export { useConnection } from './components/ConnectionWrapper' 22 | export { Control } from './components/Control' 23 | export { Control as InputControl } from './components/Control' 24 | export { Node, NodeStyles } from './components/Node' 25 | export { RefControl } from './components/refs/RefControl' 26 | export { RefSocket } from './components/refs/RefSocket' 27 | export { Socket } from './components/Socket' 28 | export type { ClassicScheme, ReactArea2D, RenderEmit } from './types' 29 | export * as vars from './vars' 30 | 31 | type CustomizationProps = { 32 | node?: (data: ExtractPayload) => AcceptComponent }> | null 33 | connection?: (data: ExtractPayload) => AcceptComponent | null 34 | socket?: (data: ExtractPayload) => AcceptComponent | null 35 | control?: (data: ExtractPayload) => AcceptComponent | null 36 | } 37 | 38 | type ClassicProps = { 39 | socketPositionWatcher?: SocketPositionWatcher> 40 | customize?: CustomizationProps 41 | } 42 | 43 | /** 44 | * Classic preset for rendering nodes, connections, controls and sockets. 45 | */ 46 | export function setup< 47 | Schemes extends ClassicScheme, K extends ReactArea2D 48 | >(props?: ClassicProps): RenderPreset { 49 | const positionWatcher = typeof props?.socketPositionWatcher === 'undefined' 50 | ? getDOMSocketPosition() 51 | : props.socketPositionWatcher 52 | const { node, connection, socket, control } = props?.customize || {} 53 | 54 | return { 55 | attach(plugin) { 56 | positionWatcher.attach(plugin as unknown as Scope) 57 | }, 58 | // eslint-disable-next-line complexity 59 | render(context, plugin) { 60 | if (context.data.type === 'node') { 61 | const parent = plugin.parentScope() 62 | const Component = (node 63 | ? node(context.data) 64 | : Node) as typeof Node 65 | 66 | return Component 67 | && void parent.emit(data as any)} 70 | /> 71 | } else if (context.data.type === 'connection') { 72 | const Component = (connection 73 | ? connection(context.data) 74 | : Connection) as typeof Connection 75 | const payload = context.data.payload 76 | const { sourceOutput, targetInput, source, target } = payload 77 | 78 | return Component 79 | && positionWatcher.listen(source, 'output', sourceOutput, change))} 81 | end={context.data.end || (change => positionWatcher.listen(target, 'input', targetInput, change))} 82 | path={async (start, end) => { 83 | type FixImplicitAny = typeof plugin.__scope.produces | undefined 84 | const response: FixImplicitAny = await plugin.emit({ 85 | type: 'connectionpath', 86 | data: { 87 | payload, 88 | points: [start, end] 89 | } 90 | }) 91 | 92 | if (!response) return '' 93 | 94 | const { path, points } = response.data 95 | const curvature = 0.3 96 | 97 | if (!path && points.length !== 2) throw new Error('cannot render connection with a custom number of points') 98 | if (!path) return payload.isLoop 99 | ? loopConnectionPath(points as [Position, Position], curvature, 120) 100 | : classicConnectionPath(points as [Position, Position], curvature) 101 | 102 | return path 103 | }} 104 | > 105 | 106 | 107 | } else if (context.data.type === 'socket') { 108 | const Component = (socket 109 | ? socket(context.data) 110 | : Socket) as typeof Socket 111 | 112 | return Component && context.data.payload && 113 | } else if (context.data.type === 'control') { 114 | const Component = control && context.data.payload 115 | ? control(context.data) 116 | : context.data.payload instanceof ClassicPreset.InputControl 117 | ? Control 118 | : null 119 | 120 | return Component && 121 | } 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/presets/classic/components/Node.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import styled, { css } from 'styled-components' 3 | 4 | import { ClassicScheme, RenderEmit } from '../types' 5 | import { $nodecolor, $nodecolorselected, $nodewidth, $socketmargin, $socketsize } from '../vars' 6 | import { RefControl } from './refs/RefControl' 7 | import { RefSocket } from './refs/RefSocket' 8 | 9 | type NodeExtraData = { width?: number, height?: number } 10 | 11 | export const NodeStyles = styled.div any }>` 12 | background: ${$nodecolor}; 13 | border: 2px solid #4e58bf; 14 | border-radius: 10px; 15 | cursor: pointer; 16 | box-sizing: border-box; 17 | width: ${props => Number.isFinite(props.width) 18 | ? `${props.width}px` 19 | : `${$nodewidth}px`}; 20 | height: ${props => Number.isFinite(props.height) 21 | ? `${props.height}px` 22 | : 'auto'}; 23 | padding-bottom: 6px; 24 | position: relative; 25 | user-select: none; 26 | line-height: initial; 27 | font-family: Arial; 28 | 29 | &:hover { 30 | background: lighten(${$nodecolor},4%); 31 | } 32 | ${props => props.selected && css` 33 | background: ${$nodecolorselected}; 34 | border-color: #e3c000; 35 | `} 36 | .title { 37 | color: white; 38 | font-family: sans-serif; 39 | font-size: 18px; 40 | padding: 8px; 41 | } 42 | .output { 43 | text-align: right; 44 | } 45 | .input { 46 | text-align: left; 47 | } 48 | .output-socket { 49 | text-align: right; 50 | margin-right: -${$socketsize / 2 + $socketmargin}px; 51 | display: inline-block; 52 | } 53 | .input-socket { 54 | text-align: left; 55 | margin-left: -${$socketsize / 2 + $socketmargin}px; 56 | display: inline-block; 57 | } 58 | .input-title,.output-title { 59 | vertical-align: middle; 60 | color: white; 61 | display: inline-block; 62 | font-family: sans-serif; 63 | font-size: 14px; 64 | margin: ${$socketmargin}px; 65 | line-height: ${$socketsize}px; 66 | } 67 | .input-control { 68 | z-index: 1; 69 | width: calc(100% - ${$socketsize + 2 * $socketmargin}px); 70 | vertical-align: middle; 71 | display: inline-block; 72 | } 73 | .control { 74 | display: block; 75 | padding: ${$socketmargin}px ${$socketsize / 2 + $socketmargin}px; 76 | } 77 | ${props => props.styles?.(props)} 78 | ` 79 | 80 | function sortByIndex(entries: T) { 81 | entries.sort((a, b) => { 82 | const ai = a[1]?.index || 0 83 | const bi = b[1]?.index || 0 84 | 85 | return ai - bi 86 | }) 87 | } 88 | 89 | type Props = { 90 | data: S['Node'] & NodeExtraData 91 | styles?: () => any 92 | emit: RenderEmit 93 | } 94 | export type NodeComponent = (props: Props) => JSX.Element 95 | 96 | export function Node(props: Props) { 97 | const inputs = Object.entries(props.data.inputs) 98 | const outputs = Object.entries(props.data.outputs) 99 | const controls = Object.entries(props.data.controls) 100 | const selected = props.data.selected || false 101 | const { id, label, width, height } = props.data 102 | 103 | sortByIndex(inputs) 104 | sortByIndex(outputs) 105 | sortByIndex(controls) 106 | 107 | return ( 108 | 115 |
{label}
116 | {/* Outputs */} 117 | {outputs.map(([key, output]) => output &&
118 |
{output.label}
119 | 128 |
)} 129 | {/* Controls */} 130 | {controls.map(([key, control]) => { 131 | return control 132 | ? 139 | : null 140 | })} 141 | {/* Inputs */} 142 | {inputs.map(([key, input]) => input &&
143 | 152 | {input && (!input.control || !input.showControl) 153 | &&
{input.label}
154 | } 155 | {input.control && input.showControl && ( 156 | 163 | ) 164 | } 165 |
)} 166 |
167 | ) 168 | } 169 | --------------------------------------------------------------------------------