├── .gitignore
├── .prettierrc.yaml
├── .stylelintrc
├── README.md
├── custom-elements.md
├── eslint.config.js
├── package.json
├── pnpm-lock.yaml
├── server
├── README.md
├── package.json
├── pnpm-lock.yaml
└── server.ts
├── src
├── document.client.ts
├── document.scss
├── document.tsx
├── features
│ ├── demo-nf-wa.tsx
│ ├── docs.scss
│ ├── side-menu.tsx
│ └── wa-theme.ts
├── lib
│ ├── _typedoc.ts
│ ├── adapters
│ │ └── react.ts
│ ├── background.el.tsx
│ ├── canvas.el.tsx
│ ├── canvas.scss
│ ├── events.ts
│ ├── flow.el.tsx
│ ├── flow.ts
│ ├── handle.el.tsx
│ ├── index.ts
│ ├── links.el.tsx
│ ├── links.scss
│ ├── node.el.tsx
│ ├── node.ts
│ ├── port.el.tsx
│ ├── port.ts
│ ├── themes
│ │ ├── lit-renderer.el.ts
│ │ └── webawesome
│ │ │ ├── demo-nodes
│ │ │ ├── broadcast-channel.el.tsx
│ │ │ ├── canvas-color.el.tsx
│ │ │ ├── canvas-comparer.el.tsx
│ │ │ ├── canvas-filters.el.tsx
│ │ │ ├── canvas-mixer.el.tsx
│ │ │ ├── canvas-text.el.tsx
│ │ │ ├── display-number.el.tsx
│ │ │ ├── index.ts
│ │ │ ├── note.el.tsx
│ │ │ ├── number.el.tsx
│ │ │ ├── operation.el.tsx
│ │ │ ├── presets
│ │ │ │ ├── default.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── kitchen-sink.ts
│ │ │ │ └── menu.ts
│ │ │ ├── schemas.ts
│ │ │ └── text.el.tsx
│ │ │ ├── extra
│ │ │ ├── center.el.tsx
│ │ │ ├── center.scss
│ │ │ ├── inventory.el.tsx
│ │ │ ├── inventory.scss
│ │ │ ├── minimap.el.tsx
│ │ │ ├── minimap.scss
│ │ │ ├── navigation.el.tsx
│ │ │ └── navigation.scss
│ │ │ ├── index.ts
│ │ │ ├── node.el.tsx
│ │ │ ├── node.scss
│ │ │ ├── port.el.tsx
│ │ │ ├── port.scss
│ │ │ └── theme.scss
│ └── types.ts
├── routes
│ ├── (home).client.tsx
│ ├── (home).scss
│ ├── (home).tsx
│ ├── elements.client.tsx
│ ├── elements.scss
│ ├── elements.tsx
│ ├── testbed.client.tsx
│ ├── testbed.scss
│ └── testbed.tsx
├── types
│ ├── ambient.d.ts
│ ├── ambient.react.d.ts
│ ├── ambient.vue.d.ts
│ ├── elements.d.ts
│ ├── jsx-vendor.d.ts
│ └── jsx.d.ts
├── vite-env.d.ts
└── ws-client.ts
├── tsconfig.json
├── typedoc.json
├── types
├── common.d.ts
├── lit.d.ts
├── react.d.ts
└── vue.d.ts
├── vite.common.ts
├── vite.config.lib.ts
└── vite.config.ts
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-site
13 | dist-ssr
14 | *.local
15 |
16 | # Editor directories and files
17 | .vscode/*
18 | !.vscode/extensions.json
19 | .idea
20 | .DS_Store
21 | *.suo
22 | *.ntvs*
23 | *.njsproj
24 | *.sln
25 | *.sw?
26 |
27 | # Local Netlify folder
28 | .netlify
29 |
30 | .dev*
31 | .old*
32 | public/api
33 |
--------------------------------------------------------------------------------
/.prettierrc.yaml:
--------------------------------------------------------------------------------
1 | singleQuote: true
2 |
3 | # # TODO: better custom ordering
4 | # importOrder: ['^lit(.*)$', '^@jsfe/(.*)$', '^@(.*)$', '^[./]']
5 | # importOrderSeparation: true
6 | # importOrderSortSpecifiers: true
7 | # importOrderParserPlugins: ['typescript', 'decorators-legacy']
8 |
--------------------------------------------------------------------------------
/.stylelintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [
3 | "stylelint-config-standard",
4 | "stylelint-config-standard-scss",
5 | "stylelint-config-recess-order"
6 | ],
7 | "plugins": ["stylelint-order"],
8 | "rules": {
9 | "custom-property-pattern": null
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | import globals from 'globals';
2 | import pluginJs from '@eslint/js';
3 | import tseslint from 'typescript-eslint';
4 | // import pluginReact from 'eslint-plugin-react';
5 | import eslintPluginUnicorn from 'eslint-plugin-unicorn';
6 | import jsdoc from 'eslint-plugin-jsdoc';
7 | import importX from 'eslint-plugin-import-x';
8 | // import importX from 'eslint-plugin-import-x';
9 | // import simpleImportSort from 'eslint-plugin-simple-import-sort';
10 | import sortClassMembers from 'eslint-plugin-sort-class-members';
11 |
12 | export default [
13 | { files: ['**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'] },
14 | { languageOptions: { globals: { ...globals.browser, ...globals.node } } },
15 | pluginJs.configs.recommended,
16 | ...tseslint.configs.recommended,
17 | // pluginReact.configs.flat.recommended,
18 |
19 | eslintPluginUnicorn.configs['flat/all'],
20 |
21 | jsdoc.configs['flat/recommended-typescript'],
22 |
23 | // importX.configs['recommended'],
24 | // importX.configs['typescript'],
25 |
26 | importX.flatConfigs.recommended,
27 | importX.flatConfigs.typescript,
28 | // importX.flatConfigs.react,
29 |
30 | // sortClassMembers.configs['flat/recommended'],
31 |
32 | {
33 | rules: {
34 | 'import-x/order': [
35 | 'error',
36 | {
37 | 'newlines-between': 'always',
38 | pathGroups: [
39 | {
40 | pattern: '@app/**',
41 | group: 'external',
42 | position: 'after',
43 | },
44 | ],
45 | distinctGroup: false,
46 | },
47 | ],
48 | },
49 | },
50 |
51 | {
52 | rules: {
53 | 'unicorn/no-null': 'off',
54 | 'unicorn/template-indent': 'off',
55 | 'unicorn/prevent-abbreviations': {
56 | checkFilenames: false,
57 | },
58 |
59 | 'jsdoc/require-jsdoc': [
60 | // 'off',
61 | 'off',
62 | // {
63 | // require: {
64 | // FunctionDeclaration: true,
65 | // MethodDefinition: true,
66 | // ClassDeclaration: true,
67 | // },
68 | // },
69 | ],
70 |
71 | '@typescript-eslint/explicit-function-return-type': 'warn',
72 |
73 | 'unicorn/import-style': [
74 | 'off',
75 | // {
76 | // styles: {
77 | // util: false,
78 | // path: {
79 | // named: true,
80 | // },
81 | // },
82 | // },
83 | ],
84 | },
85 | },
86 | ];
87 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@node-flow-elements/nfe",
3 | "version": "0.0.0",
4 | "type": "module",
5 | "exports": {
6 | ".": "./dist/index.js",
7 | "./*": "./dist/*.js",
8 | "./src/*": "./src/*.js",
9 | "./types/react": "./types/react.d.ts",
10 | "./types/vue": "./types/vue.d.ts"
11 | },
12 | "scripts": {
13 | "build": "vite build",
14 | "build:check": "tsc && vite build",
15 | "build:lib": "vite build --config vite.config.lib.ts",
16 | "doc:api": "typedoc --skipErrorChecking",
17 | "deploy:demo": "netlify deploy -d dist-site",
18 | "dev": "vite",
19 | "preview": "vite preview"
20 | },
21 | "dependencies": {
22 | "@lit-labs/motion": "^1.0.8",
23 | "@lit-labs/signals": "^0.1.2",
24 | "@lit/context": "^1.1.4",
25 | "clsx": "^2.1.1",
26 | "lit": "^3.2.1",
27 | "panzoom": "^9.4.3",
28 | "signal-utils": "^0.21.1"
29 | },
30 | "devDependencies": {
31 | "@babel/plugin-proposal-decorators": "^7.25.9",
32 | "@babel/plugin-syntax-decorators": "^7.25.9",
33 | "@babel/plugin-syntax-typescript": "^7.25.9",
34 | "@custom-elements-manifest/to-markdown": "^0.1.0",
35 | "@eslint/js": "^9.11.1",
36 | "@gracile-labs/jsx": "link:../../@gracile/gracile/packages/labs/jsx",
37 | "@gracile/doc": "link:../../@gracile/website",
38 | "@gracile/gracile": "link:../../@gracile/gracile/packages/gracile",
39 | "@gracile/markdown": "link:../../@gracile/gracile/packages/addons/markdown",
40 | "@gracile/markdown-preset-marked": "link:../../@gracile/gracile/packages/addons/markdown-preset-marked",
41 | "@literals/rollup-plugin-html-css-minifier": "^3.0.1",
42 | "@rollup/plugin-babel": "^6.0.4",
43 | "@rollup/plugin-strip": "^3.0.4",
44 | "@rollup/plugin-terser": "^0.4.4",
45 | "@shikijs/transformers": "^1.26.1",
46 | "@sveltejs/vite-plugin-svelte": "^5.0.3",
47 | "@trivago/prettier-plugin-sort-imports": "^5.2.0",
48 | "@types/d3-selection": "^3.0.11",
49 | "@types/d3-zoom": "^3.0.8",
50 | "@types/hast": "^3.0.4",
51 | "@types/react": "^19.0.4",
52 | "@types/react-dom": "^19.0.3",
53 | "@vitejs/plugin-react": "^4.3.4",
54 | "@vitejs/plugin-vue": "^5.2.1",
55 | "@vtaits/react-signals": "^1.0.2",
56 | "custom-element-react-wrappers": "^1.6.8",
57 | "custom-element-vuejs-integration": "^1.3.3",
58 | "d3-selection": "^3.0.0",
59 | "d3-zoom": "^3.0.0",
60 | "decorator-transforms": "^2.3.0",
61 | "eslint": "^9.17.0",
62 | "eslint-import-resolver-typescript": "^3.6.3",
63 | "eslint-plugin-import-x": "^4.3.1",
64 | "eslint-plugin-jsdoc": "^50.3.1",
65 | "eslint-plugin-simple-import-sort": "^12.1.1",
66 | "eslint-plugin-sort-class-members": "^1.21.0",
67 | "eslint-plugin-unicorn": "^56.0.0",
68 | "globals": "^15.10.0",
69 | "hono": "^4.6.16",
70 | "json-schema-to-ts": "^3.1.1",
71 | "marked": "^15.0.6",
72 | "marked-shiki": "^1.1.1",
73 | "prettier": "^3.4.2",
74 | "react": "^19",
75 | "react-dom": "^19.0.0",
76 | "rollup-plugin-auto-external": "^2.0.0",
77 | "sass": "^1.83.0",
78 | "shiki": "^1.26.1",
79 | "stylelint": "^16.10.0",
80 | "stylelint-config-recess-order": "^5.1.1",
81 | "stylelint-config-standard": "^36.0.1",
82 | "stylelint-config-standard-scss": "^13.1.0",
83 | "stylelint-order": "^6.0.4",
84 | "svelte": "^5.17.3",
85 | "typedoc": "^0.27.6",
86 | "typedoc-plugin-markdown": "^4.4.1",
87 | "typescript": "^5.7.2",
88 | "typescript-eslint": "^8.8.0",
89 | "use-signals": "^0.1.1",
90 | "vite": "^6.0.6",
91 | "vite-plugin-dts": "^4.4.0",
92 | "vite-plugin-inspect": "^0.10.6",
93 | "vite-plugin-static-copy": "^2.2.0",
94 | "vue": "^3.5.13",
95 | "wc-dox": "^1.2.0"
96 | },
97 | "peerDependencies": {
98 | "@jsfe/shoelace": "link:../../@jsfe/jsfe/packages/shoelace",
99 | "@shoelace-style/shoelace": "^2.19.1",
100 | "@types/json-schema": "^7.0.15",
101 | "ajv": "^8.17.1",
102 | "ajv-formats": "^3.0.1"
103 | },
104 | "peerDependenciesMeta": {
105 | "@jsfe/shoelace": {
106 | "optional": true
107 | },
108 | "@shoelace-style/shoelace": {
109 | "optional": true
110 | },
111 | "@types/json-schema": {
112 | "optional": true
113 | },
114 | "ajv": {
115 | "optional": true
116 | },
117 | "ajv-formats": {
118 | "optional": true
119 | }
120 | },
121 | "customElements": "./dist/custom-elements.json",
122 | "overrides": {
123 | "lit": "^3.2.1",
124 | "@shoelace-style/shoelace": "^2.19.1",
125 | "signal-polyfill": "^0.2.1"
126 | }
127 | }
128 |
--------------------------------------------------------------------------------
/server/README.md:
--------------------------------------------------------------------------------
1 | # EXPERIMENTAL - WORK IN PROGRESS
2 |
3 | Trying to sync. two _NFE_ instance (server/client) via WebSocket, using Hono for convenience.
4 |
--------------------------------------------------------------------------------
/server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "server",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "test": "echo \"Error: no test specified\" && exit 1"
8 | },
9 | "keywords": [],
10 | "author": "",
11 | "license": "ISC",
12 | "dependencies": {
13 | "@hono/node-server": "^1.13.7",
14 | "@hono/node-ws": "^1.0.5",
15 | "hono": "^4.6.16"
16 | },
17 | "devDependencies": {
18 | "tsx": "^4.19.2"
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/server/pnpm-lock.yaml:
--------------------------------------------------------------------------------
1 | lockfileVersion: '9.0'
2 |
3 | settings:
4 | autoInstallPeers: true
5 | excludeLinksFromLockfile: false
6 |
7 | importers:
8 |
9 | .:
10 | dependencies:
11 | '@hono/node-server':
12 | specifier: ^1.13.7
13 | version: 1.13.7(hono@4.6.16)
14 | '@hono/node-ws':
15 | specifier: ^1.0.5
16 | version: 1.0.5(@hono/node-server@1.13.7(hono@4.6.16))
17 | hono:
18 | specifier: ^4.6.16
19 | version: 4.6.16
20 | devDependencies:
21 | tsx:
22 | specifier: ^4.19.2
23 | version: 4.19.2
24 |
25 | packages:
26 |
27 | '@esbuild/aix-ppc64@0.23.1':
28 | resolution: {integrity: sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==}
29 | engines: {node: '>=18'}
30 | cpu: [ppc64]
31 | os: [aix]
32 |
33 | '@esbuild/android-arm64@0.23.1':
34 | resolution: {integrity: sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==}
35 | engines: {node: '>=18'}
36 | cpu: [arm64]
37 | os: [android]
38 |
39 | '@esbuild/android-arm@0.23.1':
40 | resolution: {integrity: sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==}
41 | engines: {node: '>=18'}
42 | cpu: [arm]
43 | os: [android]
44 |
45 | '@esbuild/android-x64@0.23.1':
46 | resolution: {integrity: sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==}
47 | engines: {node: '>=18'}
48 | cpu: [x64]
49 | os: [android]
50 |
51 | '@esbuild/darwin-arm64@0.23.1':
52 | resolution: {integrity: sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==}
53 | engines: {node: '>=18'}
54 | cpu: [arm64]
55 | os: [darwin]
56 |
57 | '@esbuild/darwin-x64@0.23.1':
58 | resolution: {integrity: sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==}
59 | engines: {node: '>=18'}
60 | cpu: [x64]
61 | os: [darwin]
62 |
63 | '@esbuild/freebsd-arm64@0.23.1':
64 | resolution: {integrity: sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==}
65 | engines: {node: '>=18'}
66 | cpu: [arm64]
67 | os: [freebsd]
68 |
69 | '@esbuild/freebsd-x64@0.23.1':
70 | resolution: {integrity: sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==}
71 | engines: {node: '>=18'}
72 | cpu: [x64]
73 | os: [freebsd]
74 |
75 | '@esbuild/linux-arm64@0.23.1':
76 | resolution: {integrity: sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==}
77 | engines: {node: '>=18'}
78 | cpu: [arm64]
79 | os: [linux]
80 |
81 | '@esbuild/linux-arm@0.23.1':
82 | resolution: {integrity: sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==}
83 | engines: {node: '>=18'}
84 | cpu: [arm]
85 | os: [linux]
86 |
87 | '@esbuild/linux-ia32@0.23.1':
88 | resolution: {integrity: sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==}
89 | engines: {node: '>=18'}
90 | cpu: [ia32]
91 | os: [linux]
92 |
93 | '@esbuild/linux-loong64@0.23.1':
94 | resolution: {integrity: sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==}
95 | engines: {node: '>=18'}
96 | cpu: [loong64]
97 | os: [linux]
98 |
99 | '@esbuild/linux-mips64el@0.23.1':
100 | resolution: {integrity: sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==}
101 | engines: {node: '>=18'}
102 | cpu: [mips64el]
103 | os: [linux]
104 |
105 | '@esbuild/linux-ppc64@0.23.1':
106 | resolution: {integrity: sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==}
107 | engines: {node: '>=18'}
108 | cpu: [ppc64]
109 | os: [linux]
110 |
111 | '@esbuild/linux-riscv64@0.23.1':
112 | resolution: {integrity: sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==}
113 | engines: {node: '>=18'}
114 | cpu: [riscv64]
115 | os: [linux]
116 |
117 | '@esbuild/linux-s390x@0.23.1':
118 | resolution: {integrity: sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==}
119 | engines: {node: '>=18'}
120 | cpu: [s390x]
121 | os: [linux]
122 |
123 | '@esbuild/linux-x64@0.23.1':
124 | resolution: {integrity: sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ==}
125 | engines: {node: '>=18'}
126 | cpu: [x64]
127 | os: [linux]
128 |
129 | '@esbuild/netbsd-x64@0.23.1':
130 | resolution: {integrity: sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==}
131 | engines: {node: '>=18'}
132 | cpu: [x64]
133 | os: [netbsd]
134 |
135 | '@esbuild/openbsd-arm64@0.23.1':
136 | resolution: {integrity: sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==}
137 | engines: {node: '>=18'}
138 | cpu: [arm64]
139 | os: [openbsd]
140 |
141 | '@esbuild/openbsd-x64@0.23.1':
142 | resolution: {integrity: sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==}
143 | engines: {node: '>=18'}
144 | cpu: [x64]
145 | os: [openbsd]
146 |
147 | '@esbuild/sunos-x64@0.23.1':
148 | resolution: {integrity: sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==}
149 | engines: {node: '>=18'}
150 | cpu: [x64]
151 | os: [sunos]
152 |
153 | '@esbuild/win32-arm64@0.23.1':
154 | resolution: {integrity: sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==}
155 | engines: {node: '>=18'}
156 | cpu: [arm64]
157 | os: [win32]
158 |
159 | '@esbuild/win32-ia32@0.23.1':
160 | resolution: {integrity: sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==}
161 | engines: {node: '>=18'}
162 | cpu: [ia32]
163 | os: [win32]
164 |
165 | '@esbuild/win32-x64@0.23.1':
166 | resolution: {integrity: sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==}
167 | engines: {node: '>=18'}
168 | cpu: [x64]
169 | os: [win32]
170 |
171 | '@hono/node-server@1.13.7':
172 | resolution: {integrity: sha512-kTfUMsoloVKtRA2fLiGSd9qBddmru9KadNyhJCwgKBxTiNkaAJEwkVN9KV/rS4HtmmNRtUh6P+YpmjRMl0d9vQ==}
173 | engines: {node: '>=18.14.1'}
174 | peerDependencies:
175 | hono: ^4
176 |
177 | '@hono/node-ws@1.0.5':
178 | resolution: {integrity: sha512-p1q3Q+ThKZ28xNB0mZa2MAMPBMhX8rbk6CYs/XYbTAhLa/pccsORUoNQjEEXqTB0liRDj4WJpMaSY4qWXsAPuA==}
179 | engines: {node: '>=18.14.1'}
180 | peerDependencies:
181 | '@hono/node-server': ^1.11.1
182 |
183 | esbuild@0.23.1:
184 | resolution: {integrity: sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==}
185 | engines: {node: '>=18'}
186 | hasBin: true
187 |
188 | fsevents@2.3.3:
189 | resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
190 | engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
191 | os: [darwin]
192 |
193 | get-tsconfig@4.8.1:
194 | resolution: {integrity: sha512-k9PN+cFBmaLWtVz29SkUoqU5O0slLuHJXt/2P+tMVFT+phsSGXGkp9t3rQIqdz0e+06EHNGs3oM6ZX1s2zHxRg==}
195 |
196 | hono@4.6.16:
197 | resolution: {integrity: sha512-iE6xOPwDYlfnZFwk6BfIMMIH4WZm3pPhz6rc1uJM/OPew0pjG5K6p8WTLaMBY1/szF/T0TaEjprMpwn16BA0NQ==}
198 | engines: {node: '>=16.9.0'}
199 |
200 | resolve-pkg-maps@1.0.0:
201 | resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
202 |
203 | tsx@4.19.2:
204 | resolution: {integrity: sha512-pOUl6Vo2LUq/bSa8S5q7b91cgNSjctn9ugq/+Mvow99qW6x/UZYwzxy/3NmqoT66eHYfCVvFvACC58UBPFf28g==}
205 | engines: {node: '>=18.0.0'}
206 | hasBin: true
207 |
208 | ws@8.18.0:
209 | resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==}
210 | engines: {node: '>=10.0.0'}
211 | peerDependencies:
212 | bufferutil: ^4.0.1
213 | utf-8-validate: '>=5.0.2'
214 | peerDependenciesMeta:
215 | bufferutil:
216 | optional: true
217 | utf-8-validate:
218 | optional: true
219 |
220 | snapshots:
221 |
222 | '@esbuild/aix-ppc64@0.23.1':
223 | optional: true
224 |
225 | '@esbuild/android-arm64@0.23.1':
226 | optional: true
227 |
228 | '@esbuild/android-arm@0.23.1':
229 | optional: true
230 |
231 | '@esbuild/android-x64@0.23.1':
232 | optional: true
233 |
234 | '@esbuild/darwin-arm64@0.23.1':
235 | optional: true
236 |
237 | '@esbuild/darwin-x64@0.23.1':
238 | optional: true
239 |
240 | '@esbuild/freebsd-arm64@0.23.1':
241 | optional: true
242 |
243 | '@esbuild/freebsd-x64@0.23.1':
244 | optional: true
245 |
246 | '@esbuild/linux-arm64@0.23.1':
247 | optional: true
248 |
249 | '@esbuild/linux-arm@0.23.1':
250 | optional: true
251 |
252 | '@esbuild/linux-ia32@0.23.1':
253 | optional: true
254 |
255 | '@esbuild/linux-loong64@0.23.1':
256 | optional: true
257 |
258 | '@esbuild/linux-mips64el@0.23.1':
259 | optional: true
260 |
261 | '@esbuild/linux-ppc64@0.23.1':
262 | optional: true
263 |
264 | '@esbuild/linux-riscv64@0.23.1':
265 | optional: true
266 |
267 | '@esbuild/linux-s390x@0.23.1':
268 | optional: true
269 |
270 | '@esbuild/linux-x64@0.23.1':
271 | optional: true
272 |
273 | '@esbuild/netbsd-x64@0.23.1':
274 | optional: true
275 |
276 | '@esbuild/openbsd-arm64@0.23.1':
277 | optional: true
278 |
279 | '@esbuild/openbsd-x64@0.23.1':
280 | optional: true
281 |
282 | '@esbuild/sunos-x64@0.23.1':
283 | optional: true
284 |
285 | '@esbuild/win32-arm64@0.23.1':
286 | optional: true
287 |
288 | '@esbuild/win32-ia32@0.23.1':
289 | optional: true
290 |
291 | '@esbuild/win32-x64@0.23.1':
292 | optional: true
293 |
294 | '@hono/node-server@1.13.7(hono@4.6.16)':
295 | dependencies:
296 | hono: 4.6.16
297 |
298 | '@hono/node-ws@1.0.5(@hono/node-server@1.13.7(hono@4.6.16))':
299 | dependencies:
300 | '@hono/node-server': 1.13.7(hono@4.6.16)
301 | ws: 8.18.0
302 | transitivePeerDependencies:
303 | - bufferutil
304 | - utf-8-validate
305 |
306 | esbuild@0.23.1:
307 | optionalDependencies:
308 | '@esbuild/aix-ppc64': 0.23.1
309 | '@esbuild/android-arm': 0.23.1
310 | '@esbuild/android-arm64': 0.23.1
311 | '@esbuild/android-x64': 0.23.1
312 | '@esbuild/darwin-arm64': 0.23.1
313 | '@esbuild/darwin-x64': 0.23.1
314 | '@esbuild/freebsd-arm64': 0.23.1
315 | '@esbuild/freebsd-x64': 0.23.1
316 | '@esbuild/linux-arm': 0.23.1
317 | '@esbuild/linux-arm64': 0.23.1
318 | '@esbuild/linux-ia32': 0.23.1
319 | '@esbuild/linux-loong64': 0.23.1
320 | '@esbuild/linux-mips64el': 0.23.1
321 | '@esbuild/linux-ppc64': 0.23.1
322 | '@esbuild/linux-riscv64': 0.23.1
323 | '@esbuild/linux-s390x': 0.23.1
324 | '@esbuild/linux-x64': 0.23.1
325 | '@esbuild/netbsd-x64': 0.23.1
326 | '@esbuild/openbsd-arm64': 0.23.1
327 | '@esbuild/openbsd-x64': 0.23.1
328 | '@esbuild/sunos-x64': 0.23.1
329 | '@esbuild/win32-arm64': 0.23.1
330 | '@esbuild/win32-ia32': 0.23.1
331 | '@esbuild/win32-x64': 0.23.1
332 |
333 | fsevents@2.3.3:
334 | optional: true
335 |
336 | get-tsconfig@4.8.1:
337 | dependencies:
338 | resolve-pkg-maps: 1.0.0
339 |
340 | hono@4.6.16: {}
341 |
342 | resolve-pkg-maps@1.0.0: {}
343 |
344 | tsx@4.19.2:
345 | dependencies:
346 | esbuild: 0.23.1
347 | get-tsconfig: 4.8.1
348 | optionalDependencies:
349 | fsevents: 2.3.3
350 |
351 | ws@8.18.0: {}
352 |
--------------------------------------------------------------------------------
/server/server.ts:
--------------------------------------------------------------------------------
1 | import { createNodeWebSocket } from '@hono/node-ws';
2 | import { Hono } from 'hono';
3 | import { serve } from '@hono/node-server';
4 |
5 | import { Flow } from '../src/lib/flow.js';
6 |
7 | export const app = new Hono();
8 |
9 | const flow = new Flow();
10 |
11 | console.log({ flow });
12 |
13 | const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app });
14 |
15 | app.get(
16 | '/ws',
17 | upgradeWebSocket((c) => ({
18 | onMessage(event, ws) {
19 | // console.log(`Message from client: ${event.data}`);
20 | console.log(JSON.parse(event.data));
21 | ws.send('Hello from server!');
22 | },
23 | onClose: () => {
24 | console.log('Connection closed');
25 | },
26 | })),
27 | );
28 |
29 | const server = serve({ fetch: app.fetch, port: 8787 });
30 | injectWebSocket(server);
31 |
32 | console.log(server.address());
33 |
34 | export type WebSocketApp = typeof app;
35 |
--------------------------------------------------------------------------------
/src/document.client.ts:
--------------------------------------------------------------------------------
1 | // import '@gracile/gracile/hydration-elements';
2 | // import { createHydrationRoot } from '@gracile/gracile/hydration-full';
3 |
4 | // createHydrationRoot();
5 |
--------------------------------------------------------------------------------
/src/document.scss:
--------------------------------------------------------------------------------
1 | body {
2 | font-family:
3 | system-ui,
4 | -apple-system,
5 | BlinkMacSystemFont,
6 | 'Segoe UI',
7 | Roboto,
8 | Oxygen,
9 | Ubuntu,
10 | Cantarell,
11 | 'Open Sans',
12 | 'Helvetica Neue',
13 | sans-serif;
14 | background-color: var(--primary-color);
15 | }
16 |
--------------------------------------------------------------------------------
/src/document.tsx:
--------------------------------------------------------------------------------
1 | 'use html-server';
2 |
3 | export const document = (options?: { title?: string }) => {
4 | return (
5 |
6 |
7 |
8 |
9 |
10 |
11 | {options?.title ?? 'Node Flow Elements | Documentation'}
12 |
13 |
14 |
15 |
16 |
17 |
18 | );
19 | };
20 |
--------------------------------------------------------------------------------
/src/features/demo-nf-wa.tsx:
--------------------------------------------------------------------------------
1 | 'use html-signal';
2 | import { Flow } from '../lib/index.js';
3 |
4 | import { SignalWatcher } from '@lit-labs/signals';
5 | import { LitElement, css, unsafeCSS } from 'lit';
6 | import { customElement } from 'lit/decorators.js';
7 |
8 | import * as presets from '../lib/themes/webawesome/demo-nodes/presets/index.js';
9 |
10 | import '../lib/themes/webawesome/index.js';
11 | import '../lib/themes/webawesome/demo-nodes/index.js';
12 |
13 | import waTheme from '../lib/themes/webawesome/theme.scss?inline';
14 |
15 | @customElement('demo-nf-wa')
16 | export class DemoNfWa extends SignalWatcher(LitElement) {
17 | static styles = [
18 | unsafeCSS(waTheme),
19 | css`
20 | :host {
21 | display: contents;
22 | }
23 | .wrapper {
24 | height: 100%;
25 | width: 100%;
26 | /* HACK: */
27 | /* will-change: transform; */
28 | /* transform: translateZ(0);
29 | isolation: isolate; */
30 | overflow: hidden;
31 | border-radius: var(--sl-border-radius-x-large);
32 | }
33 | `,
34 | ];
35 |
36 | flow = new Flow(presets.kitchenSink);
37 |
38 | render() {
39 | return (
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 | );
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/features/docs.scss:
--------------------------------------------------------------------------------
1 | @use '@gracile/doc/src/lib/styles/document' as *;
2 | @use '@gracile/doc/src/lib/shoelace/vars' as *;
3 | @import '@gracile/doc/src/lib/shoelace/typography';
4 |
5 | // @import '@gracile/doc/src/lib/shoelace/shiki-syntax';
6 | @import '@gracile/doc/src/lib/shoelace/code';
7 | @import '@shoelace-style/shoelace/dist/themes/light.css';
8 | @import '@shoelace-style/shoelace/dist/themes/dark.css';
9 |
10 | :root {
11 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
12 | font-synthesis: none;
13 | text-rendering: optimizelegibility;
14 | -webkit-font-smoothing: antialiased;
15 | -moz-osx-font-smoothing: grayscale;
16 | text-size-adjust: 100%;
17 | }
18 |
19 | html {
20 | @include fix-border-box;
21 | @include font-smoothing;
22 | }
23 |
24 | .git-only {
25 | display: none;
26 | }
27 |
28 | body {
29 | margin: 0;
30 | background-color: var(--sl-color-neutral-0);
31 | }
32 |
33 | :is(h1, h2, h3, h4, h5, h6) {
34 | scroll-margin-top: 2rem;
35 | }
36 |
37 | .main {
38 | flex-grow: 1;
39 | width: 60%;
40 | padding: 0 4vw;
41 | margin-bottom: 4rem;
42 | }
43 |
44 | .root {
45 | display: flex;
46 | justify-content: space-between;
47 | }
48 |
49 | .side-menu {
50 | position: sticky;
51 | top: 0;
52 | width: fit-content;
53 | height: 100dvh;
54 | padding: 1vw;
55 | padding-bottom: 8rem;
56 | overflow-y: auto;
57 |
58 | .title {
59 | display: block;
60 | margin: 1.5rem 1rem;
61 | font-weight: var(--sl-font-weight-bold);
62 | color: var(--sl-color-primary-800);
63 |
64 | span {
65 | font-weight: var(--sl-font-weight-light);
66 | }
67 | }
68 |
69 | a {
70 | color: currentcolor;
71 | text-decoration: none;
72 |
73 | &:hover {
74 | color: var(--sl-color-primary-700);
75 | text-decoration: underline;
76 | }
77 | }
78 |
79 | li {
80 | padding: 0;
81 | margin: 0.1rem;
82 |
83 | &::marker {
84 | color: color-mix(
85 | in srgb,
86 | var(--sl-color-primary-500) 100%,
87 | transparent 50%
88 | );
89 | }
90 | }
91 |
92 | ul {
93 | padding: 0 1rem;
94 | margin: 0.5rem 0;
95 | }
96 | }
97 |
98 | .shiki {
99 | max-height: 50vh;
100 | padding: var(--sl-spacing-x-large);
101 | margin: 0;
102 | overflow: auto;
103 | font-family: ui-monospace, monospace;
104 | font-size: 16px;
105 | border-radius: var(--sl-border-radius-large);
106 |
107 | // height: 100%;
108 |
109 | .file-title {
110 | &::before {
111 | content: '📄 • ';
112 | }
113 |
114 | display: inline-block;
115 | padding: var(--sl-spacing-small);
116 | margin: var(--sl-spacing-medium) 0;
117 | font-family: system-ui, sans-serif;
118 | background-color: var(--sl-color-neutral-50);
119 | border-radius: var(--sl-border-radius-small);
120 | box-shadow: var(--sl-shadow-medium);
121 | }
122 |
123 | .file-title:first-of-type {
124 | margin-top: 0;
125 | }
126 | }
127 |
128 | .markdown-alert {
129 | width: fit-content;
130 |
131 | .markdown-alert-title {
132 | display: flex;
133 | gap: 0.5rem;
134 | align-items: center;
135 | font-weight: var(--sl-font-weight-bold);
136 |
137 | svg {
138 | fill: currentcolor;
139 | }
140 | }
141 |
142 | &.markdown-alert-caution {
143 | border-color: var(--sl-color-red-100);
144 |
145 | .markdown-alert-title {
146 | color: var(--sl-color-red-600);
147 | }
148 | }
149 |
150 | padding: 0.2rem 1.5rem;
151 | margin: 3vw;
152 | background-color: var(--sl-color-neutral-50);
153 | border: 1px solid var(--sl-color-neutral-600);
154 | border-radius: 1rem;
155 | box-shadow: var(--sl-shadow-large);
156 | }
157 |
--------------------------------------------------------------------------------
/src/features/side-menu.tsx:
--------------------------------------------------------------------------------
1 | export const SideMenu = ({
2 | toc,
3 | title,
4 | }: {
5 | toc: MarkdownModule['meta']['tableOfContents'];
6 | title: string;
7 | }) => (
8 |
59 | );
60 |
--------------------------------------------------------------------------------
/src/features/wa-theme.ts:
--------------------------------------------------------------------------------
1 | import '../lib/flow.el.jsx';
2 |
3 | import '@shoelace-style/shoelace/dist/components/tab/tab.js';
4 | import '@shoelace-style/shoelace/dist/components/tab-panel/tab-panel.js';
5 | import '@shoelace-style/shoelace/dist/components/tab-group/tab-group.js';
6 |
7 | import '../lib/themes/webawesome/index.js';
8 | import '../lib/themes/webawesome/theme.scss';
9 | import '../lib/themes/webawesome/demo-nodes/index.js';
10 |
--------------------------------------------------------------------------------
/src/lib/_typedoc.ts:
--------------------------------------------------------------------------------
1 | export * from './flow.js';
2 | export * from './node.js';
3 | export * from './port.js';
4 |
5 | export * from './flow.el.js';
6 | export * from './node.el.js';
7 | export * from './port.el.js';
8 | export * from './handle.el.js';
9 |
10 | export * from './links.el.js';
11 | export * from './background.el.js';
12 |
13 | export * from './adapters/react.js';
14 |
15 | export type {
16 | NodeList,
17 | NodeType,
18 | Link,
19 | PortDirection,
20 | FlowConstructorParameters,
21 | NodeConstructorParameters,
22 | NodeSerializableOptions,
23 | PortSerializationOptions,
24 | FLOW_SLOT,
25 | FlowSlot,
26 | isHandle,
27 | isNode,
28 | isPort,
29 | } from './types.js';
30 |
31 | export {
32 | loadSerializedEvent,
33 | serializeEvent,
34 | type NfEventDetail,
35 | type NodeFlowEventSuperType,
36 | } from './events.js';
37 |
--------------------------------------------------------------------------------
/src/lib/adapters/react.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useReducer } from 'react';
2 |
3 | import type { GenericFlow, GenericNode, GenericPort } from '../types.js';
4 |
5 | export function useFlow(flow: GenericFlow): void {
6 | const [, forceUpdate] = useReducer((x) => x + 1, 0);
7 |
8 | useEffect(() => {
9 | const aborter = flow.listen((detail) => {
10 | if (detail.type === 'Flow') forceUpdate();
11 | });
12 | return () => aborter.abort();
13 | }, [flow]);
14 | }
15 |
16 | export function useNode(node: GenericNode): void {
17 | const [, forceUpdate] = useReducer((x) => x + 1, 0);
18 |
19 | useEffect(() => {
20 | const aborter = node.flow.listen((detail) => {
21 | if (detail.type === 'Node' && detail.instance === node) forceUpdate();
22 | });
23 | return () => aborter.abort();
24 | }, [node]);
25 | }
26 |
27 | export function usePort(port: GenericPort): void {
28 | const [, forceUpdate] = useReducer((x) => x + 1, 0);
29 |
30 | useEffect(() => {
31 | const aborter = port.flow.listen((detail) => {
32 | if (detail.type === 'Port' && detail.instance === port) forceUpdate();
33 | });
34 | return () => aborter.abort();
35 | }, [port]);
36 | }
37 |
--------------------------------------------------------------------------------
/src/lib/background.el.tsx:
--------------------------------------------------------------------------------
1 | 'use html-signal';
2 | import { LitElement, css, type PropertyValues } from 'lit';
3 | import { ContextConsumer } from '@lit/context';
4 | import { SignalWatcher } from '@lit-labs/signals';
5 | import { createRef } from 'lit/directives/ref.js';
6 | import { reaction } from 'signal-utils/subtle/reaction';
7 |
8 | import { Flow } from './flow.js';
9 | import type { NfFlowElement as Nfe } from './flow.el.jsx';
10 | import type { GenericFlow } from './types.js';
11 |
12 | /**
13 | * @cssproperty [--nf-background-grid-line-colors=#191919]
14 | * @cssproperty [--nf-background-grid-line-width=1]
15 | * @cssproperty [--nf-background-grid-line-spacing=64]
16 | */
17 | export class NfBackgroundElement extends SignalWatcher(LitElement) {
18 | static styles = css`
19 | :host {
20 | position: absolute;
21 | display: block;
22 | /* width: fit-content;
23 | height: fit-content; */
24 |
25 | /* HACK: (updateViewportRect) */
26 | overflow: hidden;
27 |
28 | /* TODO: light-dark(#333b3c, #efefec) */
29 | --_nf-background-grid-line-colors: var(
30 | --nf-background-grid-line-colors,
31 | #191919
32 | );
33 | --_nf-background-grid-line-width: var(--nf-background-grid-line-width, 1);
34 | --_nf-background-grid-line-spacing: var(
35 | --nf-background-grid-line-spacing,
36 | 64
37 | );
38 | }
39 |
40 | canvas {
41 | background: #080808;
42 | width: 100%;
43 | height: 100%;
44 | }
45 | `;
46 |
47 | declare slot: (typeof Nfe)['SLOT']['background'];
48 |
49 | declare flow?: GenericFlow;
50 | static properties = { flow: { attribute: false } };
51 |
52 | readonly #flowProvider = new ContextConsumer(this, {
53 | context: Flow.CONTEXT,
54 | subscribe: true,
55 | });
56 | get #flow(): Flow {
57 | const flow = this.flow || this.#flowProvider.value;
58 | if (!flow) throw new ReferenceError('Missing flow.');
59 | return flow;
60 | }
61 |
62 | // @property({ type: Number }) accessor spacing = 20;
63 | // @property({ type: Number }) accessor lineWidth = 1;
64 |
65 | readonly #canvasRef = createRef();
66 |
67 | protected firstUpdated(): void {
68 | reaction(
69 | () => [
70 | this.#flow.viewportRect.width,
71 | this.#flow.viewportRect.height,
72 | this.#flow.offsetX,
73 | this.#flow.offsetY,
74 | ],
75 | () => this.#updateCanvas(),
76 | );
77 | }
78 |
79 | #updateCanvas() {
80 | console.log('UP1');
81 | const canvas = this.#canvasRef.value;
82 | if (!canvas) return;
83 | const context = canvas.getContext('2d');
84 | if (!context) return;
85 | console.log('UP2');
86 |
87 | if (canvas.width === 0 || canvas.height === 0 || this.#flow.scale === 0)
88 | return;
89 |
90 | console.log('UP3');
91 | const spacing = Number(
92 | getComputedStyle(this).getPropertyValue(
93 | '--_nf-background-grid-line-spacing',
94 | ),
95 | );
96 | const strokeStyle = getComputedStyle(this).getPropertyValue(
97 | '--_nf-background-grid-line-colors',
98 | );
99 | const lineWidth = Number(
100 | getComputedStyle(this).getPropertyValue(
101 | '--_nf-background-grid-line-width',
102 | ),
103 | );
104 |
105 | let step = spacing * this.#flow.scale;
106 |
107 | while (step < 16) step = step * 2;
108 |
109 | if (step === 0) return;
110 |
111 | const left =
112 | 0.5 - Math.ceil(canvas.width / step) * step + this.#flow.offsetX;
113 | const top =
114 | 0.5 - Math.ceil(canvas.height / step) * step + this.#flow.offsetY;
115 |
116 | const right = 2 * canvas.width;
117 | const bottom = 2 * canvas.height;
118 |
119 | context.clearRect(left, top, right - left, bottom - top);
120 | context.beginPath();
121 |
122 | for (let x = left; x < right; x += step) {
123 | context.moveTo(x, top);
124 | context.lineTo(x, bottom);
125 | }
126 | for (let y = top; y < bottom; y += step) {
127 | context.moveTo(left, y);
128 | context.lineTo(right, y);
129 | }
130 |
131 | context.strokeStyle = strokeStyle;
132 | context.lineWidth = lineWidth;
133 |
134 | context.stroke();
135 | }
136 |
137 | public override render(): JSX.LitTemplate {
138 | return (
139 |
144 | );
145 | }
146 | }
147 |
148 | declare global {
149 | interface HTMLElementTagNameMap {
150 | 'nf-background': NfBackgroundElement;
151 | }
152 | }
153 | customElements.define('nf-background', NfBackgroundElement);
154 |
--------------------------------------------------------------------------------
/src/lib/canvas.el.tsx:
--------------------------------------------------------------------------------
1 | 'use html-signal';
2 | import { LitElement, unsafeCSS } from 'lit';
3 | import { createRef } from 'lit/directives/ref.js';
4 | import panzoom from 'panzoom';
5 | import { SignalWatcher } from '@lit-labs/signals';
6 |
7 | import { Flow } from './flow.js';
8 | import styles from './canvas.scss?inline';
9 | import { NfNodeElement } from './node.el.jsx';
10 | import {
11 | isHandle,
12 | isNode,
13 | isPort,
14 | PZ_EVENT,
15 | type Offset,
16 | type PointerData,
17 | } from './types.js';
18 | import type { Port } from './port.js';
19 |
20 | import './node.el.js';
21 | import './handle.el.js';
22 | import './port.el.js';
23 |
24 | export class NfCanvasElement extends SignalWatcher(LitElement) {
25 | static styles = unsafeCSS(styles);
26 |
27 | static override properties = { flow: { attribute: false } };
28 | declare public flow: Flow;
29 |
30 | #pointerMap: Map = new Map();
31 |
32 | #panzoomInnerRef = createRef();
33 | #panzoomWrapperRef = createRef();
34 |
35 | protected override firstUpdated(): void {
36 | if (!this.flow) throw new ReferenceError('Missing flow.');
37 | this.#setupPanZoom();
38 | this.#observeResize();
39 | }
40 |
41 | #setupPanZoom(): void {
42 | this.flow.panzoom = panzoom(this.#panzoomInnerRef.value!, {
43 | // TODO: Parametrize some options, via CSS or JS props?
44 | disableKeyboardInteraction: true,
45 | minZoom: 0.05,
46 | maxZoom: 10,
47 | zoomSpeed: 0.25,
48 | // initialX: this.flow.initial.x,
49 | // initialY: this.flow.initial.y,
50 | // initialZoom: this.flow.initial.zoom,
51 | // bounds: true,
52 | // autocenter: true,
53 | });
54 | this.flow.panzoom.on(PZ_EVENT.panstart, () =>
55 | this.flow.setIsDraggingCanvas(true),
56 | );
57 | this.flow.panzoom.on(PZ_EVENT.panend, () =>
58 | this.flow.setIsDraggingCanvas(false),
59 | );
60 | // FIXME:
61 | // this.flow.panzoom.on(PZ_EVENT.zoom, () =>
62 | // this.flow.setIsZoomingCanvas(true),
63 | // );
64 | // this.flow.panzoom.on(PZ_EVENT.zoomend, () =>
65 | // this.flow.setIsZoomingCanvas(false),
66 | // );
67 | this.flow.panzoom.on(PZ_EVENT.transform, () => {
68 | if (!this.flow.panzoom) return;
69 | const transform = this.flow.panzoom.getTransform();
70 |
71 | this.flow.updateOffset(transform.x, transform.y);
72 | this.flow.updateScale(transform.scale);
73 | });
74 | }
75 |
76 | public override disconnectedCallback(): void {
77 | super.disconnectedCallback();
78 | if (this.flow.panzoom) this.flow.panzoom.dispose();
79 | }
80 |
81 | #observeResize(): void {
82 | const observer = new ResizeObserver(() => this.#updateViewportRect());
83 | observer.observe(this.#panzoomWrapperRef.value!);
84 |
85 | this.#updateViewportRect();
86 | }
87 |
88 | #updateViewportRect(): void {
89 | const rect = this.#panzoomWrapperRef.value?.getBoundingClientRect();
90 | if (!rect) return;
91 |
92 | const scroll = { x: window.scrollX, y: window.scrollY };
93 | this.flow.updateViewportRect({ rect, scroll });
94 | }
95 |
96 | #handleDown(event: PointerEvent): void {
97 | if (event.button === 2 || !(event.target instanceof Element)) return;
98 |
99 | const paths = event.composedPath();
100 |
101 | const portElement = paths.find((element) => isPort(element));
102 | if (portElement?.port) {
103 | this.flow.setConnectingLink(portElement.port as Port);
104 | return;
105 | }
106 |
107 | const nodeElement = paths.find((element) => isNode(element));
108 | if (!nodeElement) return;
109 |
110 | this.flow.selectNode(nodeElement.node);
111 |
112 | const handleElement = paths.find((element) => isHandle(element));
113 | if (!handleElement) return;
114 |
115 | event.target.setPointerCapture(event.pointerId);
116 |
117 | this.#pointerMap.set(event.pointerId, {
118 | id: event.pointerId,
119 | startPos: { x: event.clientX, y: event.clientY },
120 | currentPos: { x: event.clientX, y: event.clientY },
121 | });
122 |
123 | nodeElement.node.setIsDragging(true);
124 | }
125 |
126 | #handleMove(event: PointerEvent): void {
127 | if (this.flow.connectingLink) {
128 | const rect = this.getBoundingClientRect();
129 |
130 | this.flow.updateMousePosition({
131 | x: (event.clientX - rect.x) / this.flow.scale,
132 | y: (event.clientY - rect.y) / this.flow.scale,
133 | });
134 | }
135 |
136 | const saved = this.#pointerMap.get(event.pointerId);
137 | if (!saved) return;
138 |
139 | const paths = event.composedPath();
140 | const nodeElement = paths.find((element) => isNode(element));
141 |
142 | if (!nodeElement) return;
143 | if (nodeElement.node.isDragging === false) return;
144 |
145 | const current = { ...saved.currentPos };
146 | saved.currentPos = { x: event.clientX, y: event.clientY };
147 | const delta = {
148 | y: (saved.currentPos.y - current.y) / this.flow.scale,
149 | x: (saved.currentPos.x - current.x) / this.flow.scale,
150 | };
151 |
152 | this.#moveElement(nodeElement, delta);
153 | }
154 |
155 | #handleUp(event: PointerEvent): void {
156 | const paths = event.composedPath();
157 | const nodeElement = paths.find((element) => isNode(element));
158 | const portElement = paths.find((element) => isPort(element));
159 |
160 | if (this.flow.connectingLink) {
161 | if (portElement?.port)
162 | this.flow.connectingLink.from.connectTo(portElement.port.id);
163 |
164 | this.flow.setConnectingLink(null);
165 | }
166 |
167 | if (!nodeElement) return;
168 |
169 | nodeElement.node.setIsDragging(false);
170 |
171 | this.#pointerMap.delete(event.pointerId);
172 | }
173 |
174 | #moveElement(child: NfNodeElement, delta: Offset): void {
175 | const position = {
176 | x: child.node.x + delta.x,
177 | y: child.node.y + delta.y,
178 | };
179 | const rect = child.getBoundingClientRect();
180 |
181 | child.node.updatePosition(position);
182 | child.node.updateSizeFromDom(rect);
183 | }
184 |
185 | #handleEnter(event: PointerEvent): void {
186 | this.#hoverNode(event, true);
187 | }
188 |
189 | #handleLeave(event: PointerEvent): void {
190 | this.#hoverNode(event, false);
191 | }
192 |
193 | #hoverNode(event: PointerEvent, hovering: boolean): void {
194 | const nodeElement = event.composedPath().find((element) => isNode(element));
195 | if (nodeElement) nodeElement.node.setIsHovering(hovering);
196 | }
197 |
198 | #handleDoubleClick(event: MouseEvent): void {
199 | const paths = event.composedPath();
200 | const nodeElement = paths.find((element) => isNode(element));
201 | const handleElement = paths.find((element) => isHandle(element));
202 | const portElement = paths.find((element) => isPort(element));
203 |
204 | if (portElement || nodeElement || handleElement) event.stopPropagation();
205 | }
206 |
207 | #handleContextMenu(event: MouseEvent): void {
208 | const element = event.composedPath().at(0);
209 | if (
210 | element !== this.#panzoomInnerRef.value &&
211 | element !== this.#panzoomWrapperRef.value
212 | )
213 | return;
214 | event.preventDefault();
215 |
216 | const x =
217 | event.clientX +
218 | window.scrollX +
219 | (event.clientX > 125 ? -105 : 25) -
220 | this.flow.viewportRect.x;
221 | const y =
222 | event.clientY +
223 | window.scrollY +
224 | (event.clientY < 125 ? 25 : -15) -
225 | this.flow.viewportRect.y;
226 |
227 | this.flow.setContextMenuPosition({ x, y });
228 | this.flow.setIsContextMenuVisible(true);
229 | }
230 |
231 | public static readonly SLOT_NAMES = {
232 | background: 'background',
233 | foreground: 'foreground',
234 | } as const;
235 |
236 | render(): JSX.LitTemplate {
237 | return (
238 | this.#handleDown(event)}
247 | on:pointermove={(event) => this.#handleMove(event)}
248 | on:pointerup={(event) => this.#handleUp(event)}
249 | on:pointerover={(event) => this.#handleEnter(event)}
250 | on:pointerout={(event) => this.#handleLeave(event)}
251 | on:dblclick={(event) => this.#handleDoubleClick(event)}
252 | on:contextmenu={(event) => this.#handleContextMenu(event)}
253 | >
254 |
255 |
256 |
257 | event.stopPropagation()}
260 | on:mousedown={(event) => event.stopPropagation()}
261 | on:mouseup={(event) => event.stopPropagation()}
262 | on:keydown={(event) => event.stopPropagation()}
263 | />
264 |
265 |
266 |
267 |
268 | );
269 | }
270 | }
271 |
272 | declare global {
273 | interface HTMLElementTagNameMap {
274 | 'nf-interactive-canvas': NfCanvasElement;
275 | }
276 | }
277 | customElements.define('nf-interactive-canvas', NfCanvasElement);
278 |
--------------------------------------------------------------------------------
/src/lib/canvas.scss:
--------------------------------------------------------------------------------
1 | .wrapper {
2 | position: relative;
3 | display: block;
4 | width: 100%;
5 | height: 100%;
6 | overflow: hidden;
7 |
8 | // TODO: Namespace cursors
9 |
10 | cursor: var(--cursor-grab, grab);
11 |
12 | // HACK: Safari
13 | /* stylelint-disable-next-line property-no-vendor-prefix */
14 | -webkit-user-select: none;
15 | user-select: none;
16 |
17 | &.is-dragging {
18 | cursor: var(--cursor-grabbing, grabbing);
19 | }
20 |
21 | &.is-connecting-port {
22 | cursor: var(--cursor-connecting, crosshair);
23 | }
24 |
25 | &.is-dragging-node {
26 | // FIXME:
27 | cursor: var(--cursor-move, move);
28 | }
29 | }
30 |
31 | ::slotted(.node) {
32 | position: absolute;
33 |
34 | // NOTE: Prevents seams from wrapper `grab` cursor.
35 | cursor: var(--cursor-default, default);
36 | transform: translate(var(--dx), var(--dy));
37 | }
38 |
--------------------------------------------------------------------------------
/src/lib/events.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-explicit-any */
2 | import type { Flow } from './flow.js';
3 | import type { Node } from './node.js';
4 | import type { Port } from './port.js';
5 |
6 | export type NodeFlowEventSuperType = 'Flow' | 'Node' | 'Port';
7 |
8 | export type NodeFlowEventListenerType = 'changed';
9 |
10 | type NodeEvent = ExtractMethods;
11 | type PortEvent = ExtractMethods;
12 | type FlowEvent = ExtractMethods;
13 |
14 | type PickMatching = {
15 | [K in keyof T as T[K] extends V ? K : never]: T[K];
16 | };
17 | type ExtractMethods = PickMatching any>;
18 |
19 | export type NfEventDetail<
20 | _N extends NodeEvent = NodeEvent,
21 | _P extends PortEvent = PortEvent,
22 | _F extends FlowEvent = FlowEvent,
23 | > = {
24 | id: string;
25 | } & (
26 | | {
27 | type: 'Node';
28 | instance: Node;
29 | method: keyof _N;
30 | args: unknown;
31 | }
32 | | {
33 | type: 'Port';
34 | instance: Port;
35 | method: keyof _P;
36 | args: unknown;
37 | }
38 | | {
39 | type: 'Flow';
40 | instance: Flow;
41 | method: keyof _F;
42 | args: unknown;
43 | }
44 | );
45 |
46 | export class NfEvent extends CustomEvent {
47 | constructor(
48 | type: NodeFlowEventListenerType,
49 | options: { detail: NfEventDetail },
50 | ) {
51 | super(type, options);
52 | }
53 | }
54 |
55 | export class NodeFlowEventTarget extends EventTarget {
56 | listen(
57 | callback: {
58 | (event: NfEvent['detail']): void;
59 | },
60 | aborter = new AbortController(),
61 | ): AbortController {
62 | this.addEventListener(
63 | 'changed' satisfies NodeFlowEventListenerType,
64 | (event) => callback((event as NfEvent).detail),
65 | { signal: aborter.signal },
66 | );
67 | return aborter;
68 | }
69 |
70 | public dispatching = true;
71 |
72 | public dispatch(
73 | method: string,
74 | arguments_: unknown,
75 | instance: Port | Flow | Node,
76 | ): void {
77 | if (!this.dispatching) return;
78 | try {
79 | const detail: NfEventDetail = {
80 | id: instance.id,
81 | type: instance.superType,
82 | method,
83 | instance,
84 | args: [...((arguments_ as []) || [])],
85 | } as NfEventDetail;
86 |
87 | this.dispatchEvent(new NfEvent('changed', { detail }));
88 | } catch (error) {
89 | console.error(error);
90 | }
91 | }
92 | }
93 |
94 | export function serializeEvent(detail: NfEvent['detail']): {
95 | type: 'Node' | 'Port' | 'Flow' | 'unknown';
96 | id: string;
97 | method: unknown;
98 | args: unknown;
99 | } {
100 | return {
101 | type: detail.type,
102 | id: detail.id,
103 | method: detail.method,
104 | args: detail.args,
105 | };
106 | }
107 |
108 | export function loadSerializedEvent(
109 | flow: Pick,
110 | data: NfEventDetail,
111 | options?: {
112 | pick: {
113 | Node?: (keyof Node)[];
114 | Flow?: (keyof Flow)[];
115 | Port?: (keyof Port)[];
116 | };
117 | },
118 | ): void {
119 | if (!options?.pick?.[data.type]?.includes(data.method as any)) return;
120 |
121 | switch (data.type) {
122 | case 'Node': {
123 | const node = flow.nodes.find((node) => node.id === data.id);
124 | if (!node) return;
125 |
126 | console.log({ data });
127 | flow.dispatching = false;
128 | // @ts-expect-error catch-all
129 | node[data.method](...(data.args || []));
130 | flow.dispatching = true;
131 | break;
132 | }
133 | case 'Port': {
134 | const port = flow.ports.find((port) => port.id === data.id);
135 | if (!port) return;
136 |
137 | flow.dispatching = false;
138 | // @ts-expect-error catch-all
139 | port[data.method](...(data.args || []));
140 | flow.dispatching = true;
141 | break;
142 | }
143 | case 'Flow': {
144 | flow.dispatching = false;
145 | // @ts-expect-error catch-all
146 | flow[data.method](...(data.args || []));
147 | flow.dispatching = true;
148 | break;
149 | }
150 | }
151 | }
152 |
153 | // export function dispatch(
154 | // value: (..._arguments: any) => any,
155 | // context: ClassMethodDecoratorContext,
156 | // ) {
157 | // if (context.kind === 'method') {
158 | // return function (this: Node | Port | Flow, ..._arguments: any): any {
159 | // const returnValue = value.call(this, ..._arguments);
160 |
161 | // ('flow' in this ? this.flow : this).dispatch(
162 | // context.name.toString(),
163 | // _arguments || [],
164 | // this,
165 | // );
166 |
167 | // return returnValue;
168 | // };
169 | // }
170 | // }
171 |
--------------------------------------------------------------------------------
/src/lib/flow.el.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable jsdoc/check-tag-names */
2 | 'use html-signal';
3 | import { css, LitElement } from 'lit';
4 | import { For } from '@gracile-labs/jsx/components/for';
5 | import { ContextProvider } from '@lit/context';
6 | import { SignalWatcher } from '@lit-labs/signals';
7 |
8 | import { Flow } from './flow.js';
9 | import './node.el.js';
10 | import './canvas.el.js';
11 | import { FLOW_SLOT } from './types.js';
12 |
13 | // import type { GenericFlow } from './types.js';
14 |
15 | /**
16 | * @slot background
17 | * @slot background-interactive
18 | * @slot foreground
19 | * @slot foreground-interactive
20 | */
21 | export class NfFlowElement extends SignalWatcher(LitElement) {
22 | static styles = css`
23 | :host {
24 | display: block;
25 | position: relative;
26 | width: 100%;
27 | height: 100%;
28 | }
29 | `;
30 |
31 | static override properties = { flow: { attribute: false } };
32 |
33 | readonly #provider = new ContextProvider(this, { context: Flow.CONTEXT });
34 | #flow!: Flow;
35 | set flow(value) {
36 | this.#flow = value;
37 | this.#provider.setValue(value);
38 | }
39 | get flow(): Flow {
40 | return this.#flow;
41 | }
42 |
43 | public override connectedCallback(): void {
44 | if (!this.flow) throw new ReferenceError('Missing flow store.');
45 | super.connectedCallback();
46 | }
47 |
48 | public static readonly SLOT = FLOW_SLOT;
49 |
50 | public override render(): JSX.LitTemplate {
51 | return (
52 | <>
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 | >
65 | );
66 | }
67 |
68 | private readonly Nodes = (): JSX.LitTemplate => (
69 | node.id}>
70 | {(node) => (
71 |
80 |
81 | {node.Template ? : null}
82 |
83 |
84 | )}
85 |
86 | );
87 | }
88 |
89 | declare global {
90 | interface HTMLElementTagNameMap {
91 | 'nf-flow': NfFlowElement;
92 | }
93 | }
94 | customElements.define('nf-flow', NfFlowElement);
95 |
--------------------------------------------------------------------------------
/src/lib/flow.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable prefer-rest-params */
2 | /* eslint-disable @typescript-eslint/no-explicit-any */
3 | import { createContext } from '@lit/context';
4 | import type { PanZoom } from 'panzoom';
5 | import { computed, signal } from '@lit-labs/signals';
6 | // import { Ref } from 'lit/directives/ref.js';
7 |
8 | import { Node } from './node.js';
9 | import type {
10 | ConnectingLink,
11 | Coordinates,
12 | FlowConstructorParameters,
13 | GenericFlow,
14 | Link,
15 | MenuItem,
16 | NodeList,
17 | NodeType,
18 | } from './types.js';
19 | import type { Port } from './port.js';
20 | import { NodeFlowEventTarget } from './events.js';
21 |
22 | export class Flow<
23 | _NodeList extends NodeList = NodeList,
24 | TypedNode extends InstanceType<_NodeList[keyof _NodeList]> = InstanceType<
25 | _NodeList[keyof _NodeList]
26 | >,
27 | > extends NodeFlowEventTarget {
28 | public nodeTypes: _NodeList;
29 |
30 | readonly superType = 'Flow';
31 | readonly id;
32 |
33 | public constructor(
34 | options?: FlowConstructorParameters<_NodeList, NodeType<_NodeList>[]>,
35 | ) {
36 | super();
37 | this.nodeTypes = options?.nodeTypes || ({} as _NodeList);
38 |
39 | if (options?.initialNodes)
40 | this.fromJSON({ nodeInfos: options.initialNodes });
41 | if (options?.menu) this.registerMenu(options.menu);
42 |
43 | this.id = options?.id ?? crypto.randomUUID();
44 |
45 | // this.ready2();
46 | }
47 |
48 | // private ready2(): void {
49 | // this.dispatch(this.ready2.name, arguments, this);
50 | // }
51 |
52 | public fromJSON(options: { nodeInfos: NodeType<_NodeList>[] }): void {
53 | for (const nodeInfo of options.nodeInfos) this.addNode(nodeInfo);
54 |
55 | for (const nodeInfo of options.nodeInfos)
56 | for (const portKey in nodeInfo.ports) {
57 | const port = nodeInfo.ports[portKey as keyof typeof nodeInfo.ports];
58 | if (!port) continue;
59 |
60 | const nodeSource = this.nodes.find((n) => n.id === nodeInfo.id);
61 | const portSource =
62 | nodeSource?.ports[portKey as keyof typeof nodeSource.ports];
63 |
64 | if (portSource && nodeSource && 'value' in port)
65 | portSource.updateValue(port.value);
66 |
67 | if (!port.connectedTo) continue;
68 | for (const target of port.connectedTo) {
69 | const nodeTarget = this.nodes.find((n) => n.id === target.node);
70 |
71 | if (nodeTarget && nodeSource) {
72 | const portTarget =
73 | nodeTarget.ports[target.port as keyof typeof nodeTarget.ports];
74 | if (!portSource || !portTarget) continue;
75 |
76 | portSource.connectTo(portTarget.id);
77 | }
78 | }
79 | }
80 | }
81 |
82 | // MARK: Canvas
83 |
84 | public readonly $offsetX = signal(0);
85 | public get offsetX(): number {
86 | return this.$offsetX.get();
87 | }
88 | public readonly $offsetY = signal(0);
89 | public get offsetY(): number {
90 | return this.$offsetY.get();
91 | }
92 |
93 | public updateOffset(x: number, y: number): void {
94 | this.$offsetX.set(x);
95 | this.$offsetY.set(y);
96 |
97 | this.dispatch(this.updateOffset.name, arguments, this);
98 | }
99 |
100 | public readonly $scale = signal(1);
101 | public get scale(): number {
102 | return this.$scale.get();
103 | }
104 |
105 | // public readonly $1 = signal($2)
106 | // public $1 get() {
107 | // return this.$1.get();
108 | // };
109 |
110 | public updateScale(factor: this['scale']): void {
111 | this.$scale.set(factor);
112 | this.dispatch(this.updateScale.name, arguments, this);
113 | }
114 |
115 | public readonly $isDraggingCanvas = signal(false);
116 | public get isDraggingCanvas(): boolean {
117 | return this.$isDraggingCanvas.get();
118 | }
119 |
120 | public setIsDraggingCanvas(state: this['isDraggingCanvas']): void {
121 | this.$isDraggingCanvas.set(state);
122 | }
123 |
124 | public readonly $isZoomingCanvas = signal(false);
125 | public get isZoomingCanvas(): boolean {
126 | return this.$isZoomingCanvas.get();
127 | }
128 |
129 | public setIsZoomingCanvas(state: this['isZoomingCanvas']): void {
130 | this.$isZoomingCanvas.set(state);
131 | }
132 |
133 | public get isDraggingNode(): boolean {
134 | return this.nodes.some((node) => node.isDragging);
135 | }
136 |
137 | public resetOffset(smooth?: boolean): void {
138 | const x = 0;
139 | const y = 0;
140 |
141 | this.updateOffset(x, y);
142 |
143 | if (smooth) this.panzoom?.smoothMoveTo(x, y);
144 | else this.panzoom?.moveTo(x, y);
145 | }
146 |
147 | public resetScale(smooth?: boolean): void {
148 | const x = this.viewportRect.x / 2;
149 | const y = this.viewportRect.y / 2;
150 |
151 | this.updateScale(1);
152 |
153 | if (smooth) this.panzoom?.smoothZoomAbs(x, y, 1);
154 | else this.panzoom?.zoomAbs(x, y, 1);
155 | }
156 |
157 | public resetViewport(smooth: boolean = false): void {
158 | this.resetOffset(smooth);
159 | this.resetScale(smooth);
160 | // HACK: Doing it twice…
161 | this.resetOffset(smooth);
162 | this.resetScale(smooth);
163 | }
164 |
165 | // IDEA:
166 | // public showRectangle(rect: DOMRect): void {
167 | // this.panzoom?.showRectangle({ x: 100, y: 1100 });
168 | // }
169 |
170 | public readonly $viewportRect = signal({ width: 0, height: 0, x: 0, y: 0 });
171 | public get viewportRect() {
172 | return this.$viewportRect.get();
173 | }
174 | public updateViewportRect({
175 | rect,
176 | scroll,
177 | }: {
178 | rect: Flow['viewportRect'];
179 | scroll: Coordinates;
180 | }): void {
181 | this.$viewportRect.set({
182 | width: rect.width,
183 | height: rect.height,
184 | x: rect.x + scroll.x,
185 | y: rect.y + scroll.y,
186 | });
187 |
188 | this.dispatch(this.updateViewportRect.name, arguments, this);
189 | }
190 |
191 | public get isCanvasCentered(): boolean {
192 | return (
193 | this.scale === 1 &&
194 | this.offsetX === this.viewportRect.width / 2 &&
195 | this.offsetY === this.viewportRect.height / 2
196 | );
197 | }
198 |
199 | public readonly $mouseX = signal(0);
200 | public get mouseX(): number | null {
201 | return this.$mouseX.get();
202 | }
203 | public readonly $mouseY = signal(0);
204 | public get mouseY(): number | null {
205 | return this.$mouseY.get();
206 | }
207 | public updateMousePosition(coords: {
208 | x: Flow['mouseX'];
209 | y: Flow['mouseY'];
210 | }): void {
211 | this.$mouseX.set(coords.x);
212 | this.$mouseY.set(coords.y);
213 | }
214 |
215 | public get mouseXScaled(): number | null {
216 | if (!this.mouseX) return null;
217 | return this.mouseX - this.offsetX / this.scale;
218 | }
219 | public get mouseYScaled(): number | null {
220 | if (!this.mouseY) return null;
221 | return this.mouseY - this.offsetY / this.scale;
222 | }
223 |
224 | // MARK: Node
225 |
226 | public readonly $nodes = signal([]);
227 | public get nodes() {
228 | return this.$nodes.get();
229 | }
230 |
231 | public addNode<
232 | AddedType extends keyof _NodeList = keyof _NodeList,
233 | AddedNode extends _NodeList[AddedType] = _NodeList[AddedType],
234 | >(
235 | node: { type?: AddedType | 'Node' } & NodeType<_NodeList>,
236 | ): InstanceType {
237 | const CustomNode =
238 | node.type === 'Node' || node.type === undefined
239 | ? Node
240 | : this.nodeTypes[node.type];
241 |
242 | if (!CustomNode)
243 | throw new ReferenceError(`Missing custom node "${node.type}".`);
244 |
245 | const createdNode = new CustomNode({
246 | flow: this as Flow,
247 | data: { ...node, zIndex: this.nodes.length },
248 | });
249 |
250 | this.selectNode(createdNode);
251 |
252 | this.$nodes.set([...this.nodes, createdNode] as TypedNode[]);
253 |
254 | this.dispatch(this.addNode.name, arguments, this);
255 |
256 | return createdNode as InstanceType;
257 | }
258 |
259 | public readonly $selectedNode = signal(null);
260 | public get selectedNode(): Node | null {
261 | return this.$selectedNode.get();
262 | }
263 |
264 | public selectNode(node: Node): void {
265 | if (this.selectedNode === node) return;
266 | node.updateZIndex(
267 | this.selectedNode?.zIndex
268 | ? this.selectedNode.zIndex + 1
269 | : this.nodes.length,
270 | );
271 |
272 | this.$selectedNode.set(node);
273 | }
274 |
275 | public clearNodes(): void {
276 | this.$nodes.set([]);
277 | this.dispatch(this.clearNodes.name, arguments, this);
278 | }
279 |
280 | // MARK: Links
281 |
282 | public readonly $links = computed(() => {
283 | const links: Link[] = [];
284 |
285 | for (const node of this.$nodes.get())
286 | for (const outlet of Object.values(node.ports).filter(
287 | (port) => port.direction === 'out',
288 | ))
289 | for (const inlet of outlet.$connectedTo.get())
290 | links.push({ from: outlet, to: inlet });
291 |
292 | return links;
293 | });
294 | public get links(): Link[] {
295 | return this.$links.get();
296 | }
297 |
298 | public readonly $ports = computed(() => {
299 | const ports: Port[] = [];
300 |
301 | for (const node of this.nodes)
302 | for (const port of Object.values(node.ports)) ports.push(port);
303 |
304 | return ports;
305 | });
306 | public get ports(): Port[] {
307 | return this.$ports.get();
308 | }
309 |
310 | public panzoom?: PanZoom;
311 | // public panzoomWrapperRef?: Ref;
312 |
313 | public zoom(direction: 'in' | 'out'): void {
314 | if (!this.panzoom) return;
315 |
316 | this.panzoom?.smoothZoom(
317 | this.viewportRect.width / 2,
318 | this.viewportRect.height / 2,
319 | direction === 'in' ? 1.3 : 0.7,
320 | );
321 | this.dispatch(this.zoom.name, arguments, this);
322 | }
323 |
324 | public readonly $connectingLink = signal(null);
325 | public get connectingLink(): ConnectingLink | null {
326 | return this.$connectingLink.get();
327 | }
328 | public setConnectingLink(
329 | from: Port | null = null,
330 | to: Port | null = null,
331 | ): void {
332 | console.log({ from: from });
333 | this.$connectingLink.set(from ? { from, to } : null);
334 | }
335 |
336 | public makeLinkSvgPath(link: ConnectingLink): string {
337 | const ws = this.viewportRect;
338 | const to = link.to || {
339 | x: this.mouseXScaled ?? link.from.x,
340 | y: this.mouseYScaled ?? link.from.y,
341 | };
342 |
343 | const controlPoint =
344 | Math.max(15, Math.min(Math.abs(to.x - link.from.x) / 1.5, 500)) *
345 | // FIXME:
346 | (link.from.direction === 'in' ? -1 : 1);
347 |
348 | const dPath =
349 | `M ${link.from.x - ws.x} ` +
350 | `${link.from.y - ws.y} ` +
351 | //
352 | `C ${link.from.x + controlPoint - ws.x} ` +
353 | `${link.from.y - ws.y}, ` +
354 | //
355 | `${to.x - controlPoint - ws.x} ` +
356 | `${to.y - ws.y} ${to.x - ws.x} ` +
357 | `${to.y - ws.y}`;
358 |
359 | return dPath;
360 | }
361 |
362 | // MARK: Context menu
363 |
364 | public $isContextMenuVisible = signal(false);
365 | public get isContextMenuVisible(): boolean {
366 | return this.$isContextMenuVisible.get();
367 | }
368 | public setIsContextMenuVisible(state: this['isContextMenuVisible']): void {
369 | this.$isContextMenuVisible.set(state);
370 | this.dispatch(this.setIsContextMenuVisible.name, arguments, this);
371 | }
372 |
373 | public $contextMenuPosition = signal({ x: 0, y: 0 });
374 | public get contextMenuPosition(): { x: number; y: number } {
375 | return this.$contextMenuPosition.get();
376 | }
377 | public setContextMenuPosition(state: this['contextMenuPosition']): void {
378 | this.$contextMenuPosition.set(state);
379 | }
380 |
381 | // MARK: Menu
382 |
383 | public readonly $menuItems = signal