├── .github ├── FUNDING.yml └── workflows │ ├── build-push.yml │ ├── ci.yml │ ├── codeql.yml │ ├── commit-linter.yml │ ├── release.yml │ ├── stale.yml │ └── update-docs.yml ├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── eslint.config.mjs ├── package-lock.json ├── package.json ├── rete.config.ts ├── src ├── flow │ ├── base.ts │ ├── builtin │ │ ├── bidirect.ts │ │ └── classic │ │ │ ├── index.ts │ │ │ └── sync-connections.ts │ ├── index.ts │ └── utils.ts ├── index.ts ├── presets │ ├── classic.ts │ └── index.ts ├── pseudoconnection.ts ├── types.ts └── utils.ts └── tsconfig.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | patreon: ni55an 2 | open_collective: rete 3 | -------------------------------------------------------------------------------- /.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/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/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 | -------------------------------------------------------------------------------- /.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/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 * * 2' 7 | 8 | jobs: 9 | stale: 10 | uses: retejs/.github/.github/workflows/stale.yml@main 11 | secrets: inherit 12 | -------------------------------------------------------------------------------- /.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: '3.rete-connection-plugin' 14 | package: rete-connection-plugin 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .idea/ 3 | .vscode/ 4 | npm-debug.log 5 | dist 6 | docs 7 | /coverage 8 | .rete-cli 9 | .sonar 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [2.0.5](https://github.com/retejs/connection-plugin/compare/v2.0.4...v2.0.5) (2024-08-30) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * tsconfig ([fad39dc](https://github.com/retejs/connection-plugin/commit/fad39dc03095f23fa5dfc9c4f4dddca2f7349da0)) 7 | * update cli and fix linting errors ([c3854ad](https://github.com/retejs/connection-plugin/commit/c3854ad549249ae42fa4946087d114714c4864a2)) 8 | 9 | ## [2.0.4](https://github.com/retejs/connection-plugin/compare/v2.0.3...v2.0.4) (2024-08-13) 10 | 11 | 12 | ### Bug Fixes 13 | 14 | * infinite recursion when shadow root is empty ([142cc5a](https://github.com/retejs/connection-plugin/commit/142cc5aedf43300dcb78bb625c0272db4975fd6f)) 15 | 16 | ## [2.0.3](https://github.com/retejs/connection-plugin/compare/v2.0.2...v2.0.3) (2024-08-03) 17 | 18 | 19 | ### Bug Fixes 20 | 21 | * shadow dom support ([d858479](https://github.com/retejs/connection-plugin/commit/d85847964fa2610584b2fbada44859e45e78eb5f)) 22 | 23 | ## [2.0.2](https://github.com/retejs/connection-plugin/compare/v2.0.1...v2.0.2) (2024-05-06) 24 | 25 | 26 | ### Bug Fixes 27 | 28 | * do not prevent pointer event if socket isn't picked ([b6ec088](https://github.com/retejs/connection-plugin/commit/b6ec088dd31e0d81d0cb9d3c807d60a222cc4be5)) 29 | 30 | ## [2.0.1](https://github.com/retejs/connection-plugin/compare/v2.0.0...v2.0.1) (2024-01-27) 31 | 32 | 33 | ### Bug Fixes 34 | 35 | * **build:** source maps ([950265c](https://github.com/retejs/connection-plugin/commit/950265c28a2e0b39d7197f219be46f59db7e587d)) 36 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | Check out the [Code of Conduct](https://retejs.org/docs/code-of-conduct) 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Check out the [Contribution guide](https://retejs.org/docs/contribution) 2 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Rete.js Connection 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 | - **Interactive connection**: adding or removing connections with the cursor 11 | - **Swappable flows**: you can choose different ways to interact 12 | 13 | ## Getting Started 14 | 15 | Please refer to the [guide](https://retejs.org/docs/guides/basic#interactive-connections) and [example](https://retejs.org/examples) using this plugin 16 | 17 | ## Contribution 18 | 19 | Please refer to the [Contribution](https://retejs.org/docs/contribution) guide 20 | 21 | ## License 22 | 23 | [MIT](https://github.com/retejs/connection-plugin/blob/main/LICENSE) 24 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import tseslint from 'typescript-eslint'; 2 | import configs from 'rete-cli/eslint.config.mjs'; 3 | import gloals from 'globals' 4 | 5 | export default tseslint.config( 6 | ...configs, 7 | { 8 | languageOptions: { 9 | globals: { 10 | ...gloals.browser 11 | } 12 | } 13 | } 14 | ) -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rete-connection-plugin", 3 | "version": "2.0.5", 4 | "description": "", 5 | "scripts": { 6 | "build": "rete build -c rete.config.ts", 7 | "lint": "rete lint", 8 | "doc": "rete doc" 9 | }, 10 | "author": "Vitaliy Stoliarov", 11 | "license": "MIT", 12 | "keywords": [ 13 | "plugin", 14 | "rete", 15 | "Rete.js" 16 | ], 17 | "homepage": "https://retejs.org", 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/retejs/connection-plugin.git" 21 | }, 22 | "bugs": { 23 | "url": "https://github.com/retejs/connection-plugin/issues" 24 | }, 25 | "peerDependencies": { 26 | "rete": "^2.0.1", 27 | "rete-area-plugin": "^2.0.0" 28 | }, 29 | "devDependencies": { 30 | "globals": "^15.9.0", 31 | "rete": "^2.0.1", 32 | "rete-area-plugin": "^2.0.0", 33 | "rete-cli": "~2.0.1", 34 | "rollup-plugin-sass": "^0.6.1", 35 | "ts-node": "^8.0.2", 36 | "typescript": "4.8.4" 37 | }, 38 | "dependencies": { 39 | "@babel/runtime": "^7.21.0" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /rete.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/naming-convention */ 2 | import { ReteOptions } from 'rete-cli' 3 | import sass from 'rollup-plugin-sass' 4 | 5 | export default { 6 | input: 'src/index.ts', 7 | name: 'ReteConnectionPlugin', 8 | plugins: [ 9 | sass({ 10 | insert: true 11 | }) 12 | ], 13 | globals: { 14 | 'rete': 'Rete', 15 | 'rete-area-plugin': 'ReteAreaPlugin' 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/flow/base.ts: -------------------------------------------------------------------------------- 1 | import { BaseSchemes, NodeEditor, Scope } from 'rete' 2 | 3 | import { Connection, SocketData } from '../types' 4 | 5 | export type Context = { 6 | editor: NodeEditor 7 | scope: Scope 8 | socketsCache: Map 9 | } 10 | export type EventType = 'up' | 'down' 11 | export type PickParams = { socket: SocketData, event: EventType } 12 | 13 | export abstract class Flow { 14 | public abstract pick(params: PickParams, context: Context): Promise 15 | public abstract getPickedSocket(): SocketData | undefined 16 | public abstract drop(context: Context): void 17 | } 18 | -------------------------------------------------------------------------------- /src/flow/builtin/bidirect.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/require-await */ 2 | import { ClassicScheme, SocketData } from '../../types' 3 | import { Context, Flow, PickParams } from '../base' 4 | import { makeConnection as defaultMakeConnection, State, StateContext } from '../utils' 5 | 6 | /** 7 | * Bidirect flow params 8 | */ 9 | export type BidirectParams = { 10 | /** If true, user can pick a pseudo-connection by clicking on socket, not only by pointerdown */ 11 | pickByClick: boolean 12 | /** Custom function to make connection */ 13 | makeConnection: (from: SocketData, to: SocketData, context: Context) => boolean | undefined 14 | } 15 | 16 | class Picked extends State { 17 | constructor(public initial: SocketData, private params: BidirectParams) { 18 | super() 19 | } 20 | 21 | async pick({ socket }: PickParams, context: Context): Promise { 22 | if (this.params.makeConnection(this.initial, socket, context)) { 23 | this.drop(context, socket, true) 24 | } else if (!this.params.pickByClick) { 25 | this.drop(context, socket) 26 | } 27 | } 28 | 29 | drop(context: Context, socket: SocketData | null = null, created = false): void { 30 | if (this.initial) { 31 | void context.scope.emit({ type: 'connectiondrop', data: { initial: this.initial, socket, created } }) 32 | } 33 | this.context.switchTo(new Idle(this.params)) 34 | } 35 | } 36 | 37 | class Idle extends State { 38 | constructor(private params: BidirectParams) { 39 | super() 40 | } 41 | 42 | async pick({ socket, event }: PickParams, context: Context): Promise { 43 | if (event === 'down') { 44 | if (await context.scope.emit({ type: 'connectionpick', data: { socket } })) { 45 | this.context.switchTo(new Picked(socket, this.params)) 46 | } else { 47 | this.drop(context) 48 | } 49 | } 50 | } 51 | 52 | drop(context: Context, socket: SocketData | null = null, created = false): void { 53 | if (this.initial) { 54 | void context.scope.emit({ type: 'connectiondrop', data: { initial: this.initial, socket, created } }) 55 | } 56 | delete this.initial 57 | } 58 | } 59 | 60 | /** 61 | * Bidirect flow. User can pick a socket and connect it by releasing mouse button. 62 | * More simple than classic flow, but less functional (can't remove connection by clicking on input socket). 63 | */ 64 | export class BidirectFlow implements StateContext, Flow { 65 | currentState!: State 66 | 67 | constructor(params?: Partial>) { 68 | const pickByClick = Boolean(params?.pickByClick) 69 | const makeConnection = params?.makeConnection || defaultMakeConnection 70 | 71 | this.switchTo(new Idle({ pickByClick, makeConnection })) 72 | } 73 | 74 | public async pick(params: PickParams, context: Context) { 75 | await this.currentState.pick(params, context) 76 | } 77 | 78 | public getPickedSocket() { 79 | return this.currentState.initial 80 | } 81 | 82 | public drop(context: Context) { 83 | this.currentState.drop(context) 84 | } 85 | 86 | public switchTo(state: State): void { 87 | state.setContext(this) 88 | this.currentState = state 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/flow/builtin/classic/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/require-await */ 2 | 3 | import { ClassicScheme, SocketData } from '../../../types' 4 | import { Context, Flow, PickParams } from '../../base' 5 | import { 6 | canMakeConnection as defaultCanMakeConnection, makeConnection as defaultMakeConnection, State, StateContext 7 | } from '../../utils' 8 | import { syncConnections } from './sync-connections' 9 | 10 | /** 11 | * Classic flow params 12 | */ 13 | export type ClassicParams = { 14 | /** Custom function to check if connection can be made */ 15 | canMakeConnection: (from: SocketData, to: SocketData) => boolean | undefined 16 | /** Custom function to make connection */ 17 | makeConnection: (from: SocketData, to: SocketData, context: Context) => boolean | undefined 18 | } 19 | 20 | class Picked extends State { 21 | constructor(public initial: SocketData, private params: ClassicParams) { 22 | super() 23 | } 24 | 25 | async pick({ socket }: PickParams, context: Context): Promise { 26 | if (this.params.canMakeConnection(this.initial, socket)) { 27 | syncConnections([this.initial, socket], context.editor).commit() 28 | const created = this.params.makeConnection(this.initial, socket, context) 29 | 30 | this.drop(context, created 31 | ? socket 32 | : null, created) 33 | } 34 | } 35 | 36 | drop(context: Context, socket: SocketData | null = null, created = false): void { 37 | if (this.initial) { 38 | void context.scope.emit({ type: 'connectiondrop', data: { initial: this.initial, socket, created } }) 39 | } 40 | this.context.switchTo(new Idle(this.params)) 41 | } 42 | } 43 | 44 | class PickedExisting extends State { 45 | initial!: SocketData 46 | outputSocket: SocketData 47 | 48 | constructor(public connection: Schemes['Connection'], private params: ClassicParams, context: Context) { 49 | super() 50 | const outputSocket = Array.from(context.socketsCache.values()).find(data => { 51 | return data.nodeId === this.connection.source 52 | && data.side === 'output' 53 | && data.key === this.connection.sourceOutput 54 | }) 55 | 56 | if (!outputSocket) throw new Error('cannot find output socket') 57 | 58 | this.outputSocket = outputSocket 59 | } 60 | 61 | async init(context: Context) { 62 | void context.scope.emit({ type: 'connectionpick', data: { socket: this.outputSocket } }).then(response => { 63 | if (response) { 64 | void context.editor.removeConnection(this.connection.id) 65 | this.initial = this.outputSocket 66 | } else { 67 | this.drop(context) 68 | } 69 | }) 70 | } 71 | 72 | async pick({ socket, event }: PickParams, context: Context): Promise { 73 | if (this.initial && !(socket.side === 'input' && this.connection.target === socket.nodeId && this.connection.targetInput === socket.key)) { 74 | if (this.params.canMakeConnection(this.initial, socket)) { 75 | syncConnections([this.initial, socket], context.editor).commit() 76 | const created = this.params.makeConnection(this.initial, socket, context) 77 | const droppedSocket = created 78 | ? socket 79 | : null 80 | 81 | this.drop(context, droppedSocket, created) 82 | } 83 | } else if (event === 'down') { 84 | if (this.initial) { 85 | syncConnections([this.initial, socket], context.editor).commit() 86 | const created = this.params.makeConnection(this.initial, socket, context) 87 | const droppedSocket = created 88 | ? null 89 | : socket 90 | 91 | this.drop(context, droppedSocket, created) 92 | } 93 | } 94 | } 95 | 96 | drop(context: Context, socket: SocketData | null = null, created = false): void { 97 | if (this.initial) { 98 | void context.scope.emit({ type: 'connectiondrop', data: { initial: this.initial, socket, created } }) 99 | } 100 | this.context.switchTo(new Idle(this.params)) 101 | } 102 | } 103 | 104 | class Idle extends State { 105 | constructor(private params: ClassicParams) { 106 | super() 107 | } 108 | 109 | async pick({ socket, event }: PickParams, context: Context): Promise { 110 | if (event !== 'down') return 111 | if (socket.side === 'input') { 112 | const connection = context 113 | .editor.getConnections() 114 | .find(item => item.target === socket.nodeId && item.targetInput === socket.key) 115 | 116 | if (connection) { 117 | const state = new PickedExisting(connection, this.params, context) 118 | 119 | await state.init(context) 120 | this.context.switchTo(state) 121 | return 122 | } 123 | } 124 | 125 | if (await context.scope.emit({ type: 'connectionpick', data: { socket } })) { 126 | this.context.switchTo(new Picked(socket, this.params)) 127 | } else { 128 | this.drop(context) 129 | } 130 | } 131 | 132 | drop(context: Context, socket: SocketData | null = null, created = false): void { 133 | if (this.initial) { 134 | void context.scope.emit({ type: 'connectiondrop', data: { initial: this.initial, socket, created } }) 135 | } 136 | delete this.initial 137 | } 138 | } 139 | 140 | /** 141 | * Classic flow. User can pick/click a socket and connect it by releasing/clicking on another socket. 142 | * If connection already exists and user clicks on input socket, connection will be removed. 143 | */ 144 | export class ClassicFlow implements StateContext, Flow { 145 | currentState!: State 146 | 147 | constructor(params?: Partial>) { 148 | const canMakeConnection = params?.canMakeConnection || defaultCanMakeConnection 149 | const makeConnection = params?.makeConnection || defaultMakeConnection 150 | 151 | this.switchTo(new Idle({ canMakeConnection, makeConnection })) 152 | } 153 | 154 | public async pick(params: PickParams, context: Context) { 155 | await this.currentState.pick(params, context) 156 | } 157 | 158 | public getPickedSocket() { 159 | return this.currentState.initial 160 | } 161 | 162 | public switchTo(state: State): void { 163 | state.setContext(this) 164 | this.currentState = state 165 | } 166 | 167 | public drop(context: Context) { 168 | this.currentState.drop(context) 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/flow/builtin/classic/sync-connections.ts: -------------------------------------------------------------------------------- 1 | import { NodeEditor } from 'rete' 2 | 3 | import { ClassicScheme, SocketData } from '../../../types' 4 | 5 | function findPort(socket: SocketData, editor: NodeEditor) { 6 | const node = editor.getNode(socket.nodeId) 7 | 8 | if (!node) throw new Error('cannot find node') 9 | 10 | const list = socket.side === 'input' 11 | ? node.inputs 12 | : node.outputs 13 | 14 | return list[socket.key] 15 | } 16 | function findConnections(socket: SocketData, editor: NodeEditor) { 17 | const { nodeId, side, key } = socket 18 | 19 | return editor.getConnections().filter(connection => { 20 | if (side === 'input') { 21 | return connection.target === nodeId && connection.targetInput === key 22 | } 23 | if (side === 'output') { 24 | return connection.source === nodeId && connection.sourceOutput === key 25 | } 26 | }) 27 | } 28 | 29 | /** 30 | * Remove existing connections if Port doesnt allow multiple connections 31 | */ 32 | export function syncConnections(sockets: SocketData[], editor: NodeEditor) { 33 | const connections: Schemes['Connection'][] = sockets.map(socket => { 34 | const port = findPort(socket, editor) 35 | const multiple = port?.multipleConnections 36 | 37 | if (multiple) return [] 38 | 39 | return findConnections(socket, editor) 40 | }).flat() 41 | 42 | return { 43 | commit() { 44 | const uniqueIds = Array.from(new Set(connections.map(({ id }) => id))) 45 | 46 | uniqueIds.forEach(id => void editor.removeConnection(id)) 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/flow/index.ts: -------------------------------------------------------------------------------- 1 | export * from './base' 2 | export type { BidirectParams } from './builtin/bidirect' 3 | export { BidirectFlow } from './builtin/bidirect' 4 | export type { ClassicParams } from './builtin/classic' 5 | export { ClassicFlow } from './builtin/classic' 6 | export type { State, StateContext } from './utils' 7 | export { getSourceTarget } from './utils' 8 | export * from './utils' 9 | -------------------------------------------------------------------------------- /src/flow/utils.ts: -------------------------------------------------------------------------------- 1 | import { getUID } from 'rete' 2 | 3 | import { ClassicScheme, SocketData } from '../types' 4 | import { Context, PickParams } from './base' 5 | 6 | export interface StateContext { 7 | currentState: State 8 | switchTo(state: State): void 9 | } 10 | 11 | export abstract class State { 12 | context!: StateContext 13 | initial: SocketData | undefined 14 | 15 | setContext(context: StateContext) { 16 | this.context = context 17 | } 18 | 19 | abstract pick(params: PickParams, context: Context): Promise 20 | abstract drop(context: Context): void 21 | } 22 | 23 | export function getSourceTarget(initial: SocketData, socket: SocketData) { 24 | const forward = initial.side === 'output' && socket.side === 'input' 25 | const backward = initial.side === 'input' && socket.side === 'output' 26 | const [source, target] = forward 27 | ? [initial, socket] 28 | : backward 29 | ? [socket, initial] 30 | : [] 31 | 32 | if (source && target) return [source, target] 33 | } 34 | 35 | export function canMakeConnection(initial: SocketData, socket: SocketData) { 36 | return Boolean(getSourceTarget(initial, socket)) 37 | } 38 | 39 | export function makeConnection(initial: SocketData, socket: SocketData, context: Context) { 40 | const [source, target] = getSourceTarget(initial, socket) || [null, null] 41 | 42 | if (source && target) { 43 | void context.editor.addConnection({ 44 | id: getUID(), 45 | source: source.nodeId, 46 | sourceOutput: source.key, 47 | target: target.nodeId, 48 | targetInput: target.key 49 | }) 50 | return true 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { NodeEditor, Scope } from 'rete' 2 | import { BaseArea, BaseAreaPlugin, RenderSignal } from 'rete-area-plugin' 3 | 4 | import { Flow } from './flow' 5 | import { EventType } from './flow/base' 6 | import { createPseudoconnection } from './pseudoconnection' 7 | import { ClassicScheme, Connection, Position, Preset, Side, SocketData } from './types' 8 | import { elementsFromPoint, findSocket } from './utils' 9 | 10 | export * from './flow' 11 | export * as Presets from './presets' 12 | export { createPseudoconnection } from './pseudoconnection' 13 | export type { Connection, ConnectionExtra, Preset, Side, SocketData } from './types' 14 | 15 | type Requires = 16 | | { type: 'pointermove', data: { position: Position, event: PointerEvent } } 17 | | { type: 'pointerup', data: { position: Position, event: PointerEvent } } 18 | | RenderSignal<'socket', { 19 | nodeId: string 20 | side: Side 21 | key: string 22 | }> 23 | | { type: 'unmount', data: { element: HTMLElement } } 24 | 25 | /** 26 | * Connection plugin. Responsible for user interaction with connections (creation, deletion) 27 | * @priority 9 28 | * @emits connectionpick 29 | * @emits connectiondrop 30 | * @listens pointermove 31 | * @listens pointerup 32 | * @listens render 33 | * @listens unmount 34 | */ 35 | export class ConnectionPlugin extends Scope { 36 | presets: Preset[] = [] 37 | private areaPlugin!: BaseAreaPlugin> 38 | private editor!: NodeEditor 39 | private currentFlow: Flow | null = null 40 | private preudoconnection = createPseudoconnection({ isPseudo: true }) 41 | private socketsCache = new Map() 42 | 43 | constructor() { 44 | super('connection') 45 | } 46 | 47 | /** 48 | * Add preset to the plugin 49 | * @param preset Preset to add 50 | */ 51 | public addPreset(preset: Preset) { 52 | this.presets.push(preset) 53 | } 54 | 55 | private findPreset(data: SocketData) { 56 | for (const preset of this.presets) { 57 | const flow = preset(data) 58 | 59 | if (flow) return flow 60 | } 61 | return null 62 | } 63 | 64 | update() { 65 | if (!this.currentFlow) return 66 | const socket = this.currentFlow.getPickedSocket() 67 | 68 | if (socket) { 69 | this.preudoconnection.render(this.areaPlugin, this.areaPlugin.area.pointer, socket) 70 | } 71 | } 72 | 73 | /** 74 | * Drop pseudo-connection if exists 75 | * @emits connectiondrop 76 | */ 77 | drop() { 78 | const flowContext = { editor: this.editor, scope: this, socketsCache: this.socketsCache } 79 | 80 | if (this.currentFlow) { 81 | this.currentFlow.drop(flowContext) 82 | this.preudoconnection.unmount(this.areaPlugin) 83 | this.currentFlow = null 84 | } 85 | } 86 | 87 | // eslint-disable-next-line max-statements 88 | async pick(event: PointerEvent, type: EventType) { 89 | const flowContext = { editor: this.editor, scope: this, socketsCache: this.socketsCache } 90 | const pointedElements = elementsFromPoint(event.clientX, event.clientY) 91 | const pickedSocket = findSocket(this.socketsCache, pointedElements) 92 | 93 | if (pickedSocket) { 94 | event.preventDefault() 95 | event.stopPropagation() 96 | this.currentFlow = this.currentFlow || this.findPreset(pickedSocket) 97 | 98 | if (this.currentFlow) { 99 | await this.currentFlow.pick({ socket: pickedSocket, event: type }, flowContext) 100 | this.preudoconnection.mount(this.areaPlugin) 101 | } 102 | } else if (this.currentFlow) { 103 | this.currentFlow.drop(flowContext) 104 | } 105 | if (this.currentFlow && !this.currentFlow.getPickedSocket()) { 106 | this.preudoconnection.unmount(this.areaPlugin) 107 | this.currentFlow = null 108 | } 109 | this.update() 110 | } 111 | 112 | setParent(scope: Scope): void { 113 | super.setParent(scope) 114 | this.areaPlugin = this.parentScope>>(BaseAreaPlugin) 115 | this.editor = this.areaPlugin.parentScope>(NodeEditor) 116 | 117 | const pointerdownSocket = (e: PointerEvent) => { 118 | void this.pick(e, 'down') 119 | } 120 | 121 | this.addPipe(context => { 122 | if (!context || typeof context !== 'object' || !('type' in context)) return context 123 | 124 | if (context.type === 'pointermove') { 125 | this.update() 126 | } else if (context.type === 'pointerup') { 127 | void this.pick(context.data.event, 'up') 128 | } else if (context.type === 'render') { 129 | if (context.data.type === 'socket') { 130 | const { element } = context.data 131 | 132 | element.addEventListener('pointerdown', pointerdownSocket) 133 | this.socketsCache.set(element, context.data) 134 | } 135 | } else if (context.type === 'unmount') { 136 | const { element } = context.data 137 | 138 | element.removeEventListener('pointerdown', pointerdownSocket) 139 | this.socketsCache.delete(element) 140 | } 141 | return context 142 | }) 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/presets/classic.ts: -------------------------------------------------------------------------------- 1 | import { ClassicFlow } from '../flow' 2 | 3 | /** 4 | * Classic preset. Uses `ClassicFlow` for managing connections by user 5 | */ 6 | export function setup() { 7 | return () => new ClassicFlow() 8 | } 9 | -------------------------------------------------------------------------------- /src/presets/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Built-in presets 3 | * @module 4 | */ 5 | export * as classic from './classic' 6 | -------------------------------------------------------------------------------- /src/pseudoconnection.ts: -------------------------------------------------------------------------------- 1 | import { getUID } from 'rete' 2 | import { BaseArea, BaseAreaPlugin } from 'rete-area-plugin' 3 | 4 | import { ClassicScheme, Position, SocketData } from './types' 5 | 6 | /** 7 | * Create pseudoconnection. Used to trigger rendering of connection that is being created by user. 8 | * Has additional `isPseudo` property in payload. 9 | * @param extra Extra payload to add to connection 10 | */ 11 | export function createPseudoconnection(extra?: Partial) { 12 | let element: HTMLElement | null = null 13 | let id: string | null = null 14 | 15 | function unmount(areaPlugin: BaseAreaPlugin | K>) { 16 | if (id) { 17 | areaPlugin.removeConnectionView(id) 18 | } 19 | element = null 20 | id = null 21 | } 22 | function mount(areaPlugin: BaseAreaPlugin | K>) { 23 | unmount(areaPlugin) 24 | id = `pseudo_${getUID()}` 25 | } 26 | 27 | return { 28 | isMounted() { 29 | return Boolean(id) 30 | }, 31 | mount, 32 | render(areaPlugin: BaseAreaPlugin | K>, { x, y }: Position, data: SocketData) { 33 | const isOutput = data.side === 'output' 34 | const pointer = { 35 | x: x + (isOutput 36 | ? -3 37 | : 3), 38 | y 39 | } // fix hover of underlying elements 40 | 41 | if (!id) throw new Error('pseudo connection id wasn\'t generated') 42 | 43 | const payload = isOutput 44 | ? { 45 | id, 46 | source: data.nodeId, 47 | sourceOutput: data.key, 48 | target: '', 49 | targetInput: '', 50 | ...extra ?? {} 51 | } 52 | : { 53 | id, 54 | target: data.nodeId, 55 | targetInput: data.key, 56 | source: '', 57 | sourceOutput: '', 58 | ...extra ?? {} 59 | } 60 | 61 | if (!element) { 62 | const view = areaPlugin.addConnectionView(payload) 63 | 64 | element = view.element 65 | } 66 | 67 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 68 | if (!element) return 69 | 70 | void areaPlugin.emit({ 71 | type: 'render', 72 | data: { 73 | element, 74 | type: 'connection', 75 | payload, 76 | ...isOutput 77 | ? { end: pointer } 78 | : { start: pointer } 79 | } 80 | }) 81 | }, 82 | unmount 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { ClassicPreset as Classic, GetSchemes } from 'rete' 2 | 3 | import { Flow } from './flow' 4 | 5 | export type Position = { x: number, y: number } 6 | export type Side = 'input' | 'output' 7 | export type SocketData = { 8 | element: HTMLElement 9 | type: 'socket' 10 | nodeId: string 11 | side: Side 12 | key: string 13 | // wrongField: true 14 | } 15 | 16 | export type ConnectionExtra = { 17 | isPseudo?: boolean 18 | } 19 | 20 | export type ClassicScheme = GetSchemes< 21 | Classic.Node, 22 | Classic.Connection & ConnectionExtra 23 | > 24 | 25 | /** 26 | * Signal types produced by ConnectionPlugin instance 27 | * @priority 10 28 | */ 29 | export type Connection = 30 | | { type: 'connectionpick', data: { socket: SocketData } } 31 | | { type: 'connectiondrop', data: { initial: SocketData, socket: SocketData | null, created: boolean } } 32 | 33 | export type Preset = (data: SocketData) => Flow | undefined 34 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { SocketData } from './types' 2 | /** 3 | * @param elements list of Element returned by document.elementsFromPoint 4 | */ 5 | export function findSocket(socketsCache: WeakMap, elements: Element[]) { 6 | for (const element of elements) { 7 | const found = socketsCache.get(element) 8 | 9 | if (found) { 10 | return found 11 | } 12 | } 13 | } 14 | 15 | /** 16 | * Alternative to document.elementsFromPoint that traverses shadow roots 17 | * @param x x coordinate 18 | * @param y y coordinate 19 | * @param root root element to search in 20 | */ 21 | export function elementsFromPoint(x: number, y: number, root: ShadowRoot | Document = document) { 22 | const elements = root.elementsFromPoint(x, y) 23 | const shadowRoot = elements[0]?.shadowRoot 24 | 25 | if (shadowRoot && shadowRoot !== root) { 26 | elements.unshift(...elementsFromPoint(x, y, shadowRoot)) 27 | } 28 | 29 | return elements 30 | } 31 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "rete-cli/configs/tsconfig.json", 3 | "include": ["src"] 4 | } 5 | --------------------------------------------------------------------------------