├── .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([]); 384 | public get menuItems(): MenuItem[] { 385 | return this.$menuItems.get(); 386 | } 387 | public registerMenu(items: this['menuItems']): void { 388 | this.$menuItems.set(items); 389 | } 390 | 391 | public static readonly CONTEXT = createContext( 392 | Symbol('NFE_FLOW'), 393 | ); 394 | 395 | // MARK: Coordinates 396 | 397 | public readonly $isCoordinatesVisible = signal(false); 398 | public get isCoordinatesVisible(): boolean { 399 | return this.$isCoordinatesVisible.get(); 400 | } 401 | public setIsCoordinatesVisible(state: this['isCoordinatesVisible']): void { 402 | this.$isCoordinatesVisible.set(state); 403 | this.dispatch(this.setIsCoordinatesVisible.name, arguments, this); 404 | } 405 | 406 | public toJSON() { 407 | const { nodes } = this; 408 | 409 | const result = { nodes: nodes.map((node) => node.toJSON()) }; 410 | console.log(result); 411 | return result; 412 | } 413 | } 414 | -------------------------------------------------------------------------------- /src/lib/handle.el.tsx: -------------------------------------------------------------------------------- 1 | 'use html-signal'; 2 | import { css, LitElement } from 'lit'; 3 | import { SignalWatcher } from '@lit-labs/signals'; 4 | import { HANDLE } from './types.js'; 5 | 6 | export class NfHandleElement extends SignalWatcher(LitElement) { 7 | public readonly [HANDLE] = true; 8 | 9 | static styles = css` 10 | :host { 11 | display: contents; 12 | cursor: var(--cursor-move, move); 13 | } 14 | `; 15 | 16 | public override render(): JSX.LitTemplate { 17 | return ; 18 | } 19 | } 20 | 21 | declare global { 22 | interface HTMLElementTagNameMap { 23 | 'nf-handle': NfHandleElement; 24 | } 25 | } 26 | customElements.define('nf-handle', NfHandleElement); 27 | -------------------------------------------------------------------------------- /src/lib/index.ts: -------------------------------------------------------------------------------- 1 | import './flow.el.js'; 2 | import './links.el.js'; 3 | import './background.el.js'; 4 | 5 | export { Flow } from './flow.js'; 6 | export { Node } from './node.js'; 7 | 8 | export type { NodeType, PortDirection, NodeList } from './types.js'; 9 | export * from './events.js'; 10 | -------------------------------------------------------------------------------- /src/lib/links.el.tsx: -------------------------------------------------------------------------------- 1 | 'use html-signal'; 2 | import { LitElement, svg, unsafeCSS } from 'lit'; 3 | import { styleMap } from 'lit/directives/style-map.js'; 4 | // eslint-disable-next-line unicorn/no-keyword-prefix 5 | import { classMap } from 'lit/directives/class-map.js'; 6 | import { animate } from '@lit-labs/motion'; 7 | import { ContextConsumer } from '@lit/context'; 8 | import { SignalWatcher } from '@lit-labs/signals'; 9 | import { For } from '@gracile-labs/jsx/components/for'; 10 | 11 | import { Flow } from './flow.js'; 12 | import type { ConnectingLink, GenericFlow, Link } from './types.js'; 13 | 14 | import styles from './links.scss?inline'; 15 | import type { NfFlowElement as Nfe } from './flow.el.jsx'; 16 | 17 | // IDEA: Parametrize styles via CSS? 18 | export class NfLinksElement extends SignalWatcher(LitElement) { 19 | static styles = unsafeCSS(styles); 20 | 21 | declare slot: (typeof Nfe)['SLOT']['fgInteractive']; 22 | 23 | declare flow?: GenericFlow; 24 | static properties = { flow: { attribute: false } }; 25 | 26 | readonly #flowProvider = new ContextConsumer(this, { 27 | context: Flow.CONTEXT, 28 | subscribe: true, 29 | }); 30 | get #flow(): GenericFlow { 31 | const flow = this.flow || this.#flowProvider.value; 32 | if (!flow) throw new ReferenceError('Missing flow.'); 33 | return flow; 34 | } 35 | 36 | private readonly Cable = ({ 37 | link, 38 | type, 39 | }: { 40 | link: Link | ConnectingLink; 41 | type?: 'overlay' | 'fatty' | 'outline'; 42 | }): JSX.LitTemplate => { 43 | const dPath = this.#flow.makeLinkSvgPath(link); 44 | 45 | const cableStyles = styleMap({ 46 | // NOTE: DISABLED (performance issues). 47 | // IDEA: Might use CSS, not inline styles. 48 | // animation: 'dash 120s linear infinite', 49 | // transition: 'stroke 25ms, stroke-width 25ms ', 50 | }); 51 | 52 | // TODO: Parametrize CSS vars. 53 | const strokeWidth = ( 54 | { 55 | fatty: '10px', 56 | outline: '6px', 57 | overlay: '3px', 58 | none: link.from.isPulsing ? '3px' : '1px', 59 | } as const 60 | )[type ?? 'none']; 61 | 62 | const stroke = ( 63 | { 64 | fatty: 'transparent', 65 | outline: 'var(--_nf-links-grid-stroke-main-outline-color)', 66 | overlay: 'var(--_nf-links-grid-stroke-main-overlay-color)', 67 | main: link.from.isPulsing 68 | ? 'var(--_nf-links-grid-stroke-main-pulsing-color)' 69 | : 'var(--_nf-links-grid-stroke-main-color)', 70 | } as const 71 | )[type ?? 'main']; 72 | 73 | // TODO: Parametrize CSS vars. 74 | const dashArray = type === 'overlay' ? '20 100' : undefined; 75 | const dashOffset = type === 'overlay' ? 955 : undefined; 76 | 77 | return svg` 78 | 88 | `; 89 | }; 90 | 91 | public override render(): JSX.LitTemplate { 92 | return ( 93 |
event.stopPropagation()} 95 | class="links" 96 | style:map={{ 97 | width: `${this.#flow.viewportRect.width}px`, 98 | height: `${this.#flow.viewportRect.height}px`, 99 | 100 | left: `${this.#flow.viewportRect.x}px`, 101 | top: `${this.#flow.viewportRect.y}px`, 102 | 103 | zIndex: this.#flow.isDraggingNode ? this.#flow.nodes.length + 1 : 0, 104 | }} 105 | > 106 | 107 | {this.#flow.connectingLink 108 | ? svg` 109 | ${this.Cable({ link: this.#flow.connectingLink })} 110 | ${this.Cable({ 111 | link: this.#flow.connectingLink, 112 | type: 'overlay', 113 | })} 114 | ` 115 | : null} 116 | 117 | `${link.from.id}_${link.to.id}`} 120 | > 121 | {(link) => { 122 | const animation = animate({ 123 | in: [], 124 | out: [{ opacity: 0 }], 125 | stabilizeOut: true, 126 | properties: ['opacity'], 127 | keyframeOptions: { duration: 150 }, 128 | id: `${link.from.id}_${link.to.id}`, 129 | inId: `in_${link.from.id}_${link.to.id}`, 130 | 131 | skipInitial: true, 132 | }); 133 | const classes = classMap({ 134 | paths: true, 135 | 'is-connecting-port': this.#flow.connectingLink !== null, 136 | }); 137 | 138 | return svg` 139 | link.from.disconnect(link.to.id)} 143 | @mousedown=${(event: MouseEvent): void => 144 | event.stopPropagation()} 145 | > 146 | ${this.Cable({ link, type: 'outline' })} 147 | ${this.Cable({ link })} 148 | ${this.Cable({ link, type: 'overlay' })} 149 | ${this.Cable({ link, type: 'fatty' })} 150 | 151 | `; 152 | }} 153 | 154 | 155 |
156 | ); 157 | } 158 | } 159 | 160 | declare global { 161 | interface HTMLElementTagNameMap { 162 | 'nf-links': NfLinksElement; 163 | } 164 | } 165 | customElements.define('nf-links', NfLinksElement); 166 | -------------------------------------------------------------------------------- /src/lib/links.scss: -------------------------------------------------------------------------------- 1 | .links { 2 | --_nf-links-grid-stroke-main-color: var( 3 | --nf-links-grid-stroke-main-color, 4 | grey 5 | ); 6 | --_nf-links-grid-stroke-main-pulsing-color: var( 7 | --nf-links-grid-stroke-main-pulsing-color, 8 | lightgreen 9 | ); 10 | --_nf-links-grid-stroke-main-overlay-color: var( 11 | --nf-links-grid-stroke-main-overlay-color, 12 | grey 13 | ); 14 | --_nf-links-grid-stroke-main-outline-color: var( 15 | --nf-links-grid-stroke-main-outline-color, 16 | hsl(0, 0%, 0%, 0.15) 17 | ); 18 | 19 | position: absolute; 20 | pointer-events: none; 21 | 22 | & svg { 23 | width: 100%; 24 | height: 100%; 25 | overflow: visible; 26 | } 27 | 28 | & .paths { 29 | pointer-events: initial; 30 | cursor: crosshair; 31 | 32 | &:not(.is-connecting-port) { 33 | cursor: no-drop; 34 | 35 | &:hover { 36 | & path:not(.fatty, .overlay) { 37 | stroke: var(--sl-color-danger-100) !important; 38 | stroke-width: 2px !important; 39 | } 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/lib/node.el.tsx: -------------------------------------------------------------------------------- 1 | 'use html-signal'; 2 | import './port.el.jsx'; 3 | 4 | import { SignalWatcher } from '@lit-labs/signals'; 5 | import { css, LitElement } from 'lit'; 6 | 7 | import { Node } from './node.js'; 8 | import { NODE } from './types.js'; 9 | 10 | export class NfNodeElement extends SignalWatcher(LitElement) { 11 | public readonly [NODE] = true; 12 | 13 | static override properties = { node: { attribute: false } }; 14 | declare public node: Node; 15 | 16 | static styles = css` 17 | :host { 18 | display: block; 19 | } 20 | `; 21 | 22 | public override firstUpdated(): void { 23 | if (!this.node) throw new ReferenceError('Missing node.'); 24 | 25 | const observer = new ResizeObserver(() => this.#onResize()); 26 | observer.observe(this); 27 | } 28 | 29 | #onResize(): void { 30 | const rect = this.getBoundingClientRect(); 31 | this.node.updateSizeFromDom(rect); 32 | } 33 | 34 | public override render(): JSX.LitTemplate { 35 | return ; 36 | } 37 | } 38 | customElements.define('nf-node', NfNodeElement); 39 | 40 | declare global { 41 | interface HTMLElementTagNameMap { 42 | 'nf-node': NfNodeElement; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/lib/node.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | /* eslint-disable unicorn/consistent-function-scoping */ 3 | /* eslint-disable prefer-rest-params */ 4 | import { computed, signal } from '@lit-labs/signals'; 5 | 6 | import type { 7 | AddPortOptions, 8 | AnyNodePorts, 9 | Coordinates, 10 | DefaultNodePorts, 11 | NodeConstructorParameters, 12 | NodeSerializableOptions, 13 | } from './types.js'; 14 | import { Port } from './port.js'; 15 | 16 | export class Node { 17 | public readonly flow; 18 | public readonly superType = 'Node'; 19 | 20 | public readonly type: string = 'Node'; 21 | public id: string; 22 | 23 | public readonly defaultDisplayName: string = 'Node'; 24 | public readonly defaultIcon: string = 'activity'; 25 | public readonly helpText: string | null = null; 26 | 27 | public readonly Template: (() => unknown) | null = null; 28 | 29 | declare readonly ports: AnyNodePorts | DefaultNodePorts; 30 | 31 | constructor(options: NodeConstructorParameters) { 32 | this.flow = options.flow; 33 | 34 | this.id = options.data?.id || `node_${crypto.randomUUID()}`; 35 | 36 | if (options.data) this.fromJSON(options.data); 37 | 38 | setTimeout(() => { 39 | console.log({ ddd: Node.prototype.defaultDisplayName }); 40 | }, 10); 41 | 42 | this.ports = { 43 | input: this.addPort({ direction: 'in' }), 44 | output: this.addPort({ direction: 'out' }), 45 | }; 46 | } 47 | 48 | public get slotName(): string { 49 | return `node_${this.id}`; 50 | } 51 | 52 | public readonly $customDisplayName = signal(null); 53 | public get customDisplayName(): string | null { 54 | return this.$customDisplayName.get(); 55 | } 56 | public updateDisplayName(customName: string): void { 57 | this.$customDisplayName.set(customName); 58 | this.flow.dispatch(this.updateDisplayName.name, arguments, this); 59 | } 60 | public get displayName(): string { 61 | return this.customDisplayName ?? this.defaultDisplayName; 62 | } 63 | 64 | public readonly $isSelected = computed(() => this.flow.selectedNode === this); 65 | public get isSelected(): boolean { 66 | return this.$isSelected.get(); 67 | } 68 | 69 | public readonly $zIndex = signal(null); 70 | public get zIndex(): number | null { 71 | return this.$zIndex.get(); 72 | } 73 | public updateZIndex(value: number): void { 74 | this.$zIndex.set(value); 75 | this.flow.dispatch(this.updateZIndex.name, arguments, this); 76 | } 77 | 78 | public readonly $isDragging = signal(false); 79 | public get isDragging(): boolean { 80 | return this.$isDragging.get(); 81 | } 82 | public setIsDragging(state: boolean): void { 83 | this.$isDragging.set(state); 84 | this.flow.dispatch(this.setIsDragging.name, arguments, this); 85 | } 86 | 87 | public readonly $isHovering = signal(false); 88 | public get isHovering(): boolean { 89 | return this.$isHovering.get(); 90 | } 91 | public setIsHovering(state: boolean): void { 92 | this.$isHovering.set(state); 93 | this.flow.dispatch(this.setIsHovering.name, arguments, this); 94 | } 95 | 96 | public readonly $x = signal(0); 97 | public get x(): number { 98 | return this.$x.get(); 99 | } 100 | public readonly $y = signal(0); 101 | public get y(): number { 102 | return this.$y.get(); 103 | } 104 | 105 | public readonly $width = signal(0); 106 | public get width(): number { 107 | return this.$width.get(); 108 | } 109 | public readonly $height = signal(0); 110 | public get height(): number { 111 | return this.$height.get(); 112 | } 113 | 114 | // realX = 0; 115 | // realY = 0; 116 | 117 | public updatePosition(options: Coordinates): void { 118 | // const gridSize = 16; 119 | 120 | // TODO: 121 | // const roundNearest = (coord, gridSize) => { 122 | // return Math.round(coord / gridSize) * gridSize; 123 | // }; 124 | // const roundNearest = (xx, nearest) => { 125 | // return xx % gridSize ? xx - (xx % gridSize) + gridSize : xx; 126 | // }; 127 | // if (this.realX % gridSize === 0) this.x = x; 128 | // if (this.realY % gridSize === 0) this.y = y; 129 | 130 | // this.realX = x; 131 | // this.realY = y; 132 | 133 | // this.x = roundNearest(x, gridSize); 134 | // this.y = roundNearest(y, gridSize); 135 | 136 | if (options.x) this.$x.set(options.x); 137 | if (options.y) this.$y.set(options.y); 138 | 139 | this.flow.dispatch(this.updatePosition.name, arguments, this); 140 | } 141 | 142 | public updateSizeFromDom({ width, height }: DOMRect): void { 143 | this.$width.set(width / this.flow.scale); 144 | this.$height.set(height / this.flow.scale); 145 | } 146 | 147 | public delete(): void { 148 | for (const port of Object.values(this.ports as AnyNodePorts)) 149 | for (const extensionPort of port.connectedTo) 150 | for (const candidatePort of extensionPort.connectedTo) 151 | extensionPort.$connectedTo.set( 152 | extensionPort.connectedTo.filter((port) => port !== candidatePort), 153 | ); 154 | 155 | // TODO: cleanup connections 156 | this.flow.$nodes.set(this.flow.nodes.filter((node) => node !== this)); 157 | this.flow.dispatch(this.delete.name, arguments, this); 158 | } 159 | 160 | public addPort( 161 | options: AddPortOptions, 162 | ): Port { 163 | const createdPort = new Port(options, this); 164 | 165 | return createdPort; 166 | } 167 | 168 | public fromJSON(options: NodeSerializableOptions): void { 169 | this.id = options.id; 170 | this.$x.set(options.x); 171 | this.$y.set(options.y); 172 | if (options.zIndex) this.$zIndex.set(options.zIndex); 173 | if (options.customDisplayName) 174 | this.$customDisplayName.set(options.customDisplayName); 175 | } 176 | 177 | public toJSON(): NodeSerializableOptions { 178 | const { type, id: id, x, y, displayName, zIndex, ports: _ports } = this; 179 | 180 | const ports = Object.fromEntries( 181 | Object.entries(_ports).map(([name, port]) => [name, port.toJSON()]), 182 | ); 183 | 184 | return { type, id: id, x, y, displayName, zIndex, ports }; 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /src/lib/port.el.tsx: -------------------------------------------------------------------------------- 1 | 'use html-signal'; 2 | import { css, LitElement } from 'lit'; 3 | import { SignalWatcher } from '@lit-labs/signals'; 4 | 5 | import { reaction } from 'signal-utils/subtle/reaction'; 6 | import { PORT, type GenericPort } from './types.js'; 7 | 8 | export class NfPortElement extends SignalWatcher(LitElement) { 9 | public readonly [PORT] = true; 10 | 11 | static override styles = css` 12 | :host { 13 | display: inline-block; 14 | user-select: none; 15 | cursor: var(--cursor-connecting, crosshair); 16 | } 17 | `; 18 | 19 | static override properties = { 20 | port: { attribute: false }, 21 | // TODO: CSS API. 22 | cableOffset: { type: Number }, 23 | }; 24 | declare public port?: GenericPort; 25 | declare public cableOffset?: number; 26 | 27 | public override firstUpdated(): void { 28 | if (!this.port) throw new ReferenceError('No port.'); 29 | 30 | // this.#updatePosition(); 31 | reaction( 32 | () => [ 33 | this.port?.node.x, 34 | this.port?.node.y, 35 | this.port?.node.width, 36 | this.port?.node.height, 37 | // this.port?.flow.viewportRect.x, 38 | // this.port?.flow.viewportRect.y, 39 | ], 40 | () => this.#updatePosition(), 41 | ); 42 | } 43 | 44 | #updatePosition(): void { 45 | if (!this.port) return; 46 | 47 | this.port.updatePositionFromDom({ 48 | rect: this.getBoundingClientRect(), 49 | scroll: { x: window.scrollX, y: window.scrollY }, 50 | cableOffset: this.cableOffset, 51 | }); 52 | } 53 | 54 | public override render(): JSX.LitTemplate { 55 | return ; 56 | } 57 | } 58 | 59 | declare global { 60 | interface HTMLElementTagNameMap { 61 | 'nf-port': NfPortElement; 62 | } 63 | } 64 | customElements.define('nf-port', NfPortElement); 65 | -------------------------------------------------------------------------------- /src/lib/port.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable prefer-rest-params */ 2 | /* eslint-disable @typescript-eslint/no-explicit-any */ 3 | // import { reaction } from 'signal-utils/subtle/reaction'; 4 | import { signal } from '@lit-labs/signals'; 5 | 6 | import { Node } from './node.js'; 7 | import { Flow } from './flow.js'; 8 | import type { AddPortOptions, PortDirection, Validate } from './types.js'; 9 | 10 | export class Port { 11 | public readonly flow: Flow; 12 | 13 | public readonly node: Node; 14 | public readonly superType = 'Port'; 15 | 16 | public get name(): string { 17 | for (const [name, port] of Object.entries(this.node.ports)) 18 | if (port === this) return name; 19 | throw new ReferenceError('Incorrect port mapping.'); 20 | } 21 | public get id(): string { 22 | for (const [name, port] of Object.entries(this.node.ports)) 23 | if (port === this) return `port_${name}__${port.node.id}`; 24 | throw new ReferenceError('Incorrect port mapping.'); 25 | } 26 | 27 | public readonly $direction = signal('both'); 28 | public get direction(): PortDirection { 29 | return this.$direction.get(); 30 | } 31 | 32 | public readonly validate: Validate | null = null; 33 | 34 | public readonly $metadata = signal({} as PortMetadata); 35 | public get metadata(): PortMetadata { 36 | return this.$metadata.get(); 37 | } 38 | 39 | constructor( 40 | options: AddPortOptions, 41 | node: Node, 42 | ) { 43 | this.node = node; 44 | this.flow = node.flow; 45 | 46 | this.fromJSON(options); 47 | 48 | if (options.validate) this.validate = options.validate; 49 | } 50 | 51 | public readonly $connectedTo = signal([]); 52 | public get connectedTo(): Port[] { 53 | return this.$connectedTo.get(); 54 | } 55 | 56 | declare public readonly customValueType?: string; 57 | 58 | public readonly $value = signal(null); 59 | public get value(): Value | null { 60 | return this.$value.get(); 61 | } 62 | public updateValue(value: this['value']): void { 63 | this.$value.set(value); 64 | 65 | for (const port of this.connectedTo) 66 | if (port.direction === 'in') port.updateValue(value); 67 | 68 | this.bang(); 69 | this.flow.dispatch(this.updateValue.name, null, this); 70 | } 71 | 72 | public setValidity(state: boolean): void { 73 | this.$validity.set(state); 74 | setTimeout(() => this.setValidity(state)); 75 | } 76 | 77 | public connectTo(id: string): void { 78 | const foundPort = this.flow.ports.find((p) => p.id === id); 79 | 80 | if (!foundPort) return; 81 | 82 | if ( 83 | foundPort.node !== this.node && 84 | foundPort.direction !== this.direction 85 | ) { 86 | if (foundPort.validate) { 87 | const isValid = foundPort.validate(this.value); 88 | 89 | if (!isValid) { 90 | this.setValidity(false); 91 | return; 92 | } 93 | } 94 | 95 | foundPort.$connectedTo.set([...foundPort.connectedTo, this]); 96 | this.$connectedTo.set([...this.connectedTo, foundPort]); 97 | 98 | if (foundPort.direction === 'in') foundPort.updateValue(this.value); 99 | 100 | this.flow.updateMousePosition({ x: null, y: null }); 101 | } 102 | this.flow.dispatch(this.connectTo.name, arguments, this); 103 | } 104 | 105 | public disconnect(id: string): void { 106 | const connectedPort = this.flow.ports.find((p) => p.id === id); 107 | if (!connectedPort) return; 108 | 109 | this.$connectedTo.set( 110 | this.connectedTo.filter((port) => port !== connectedPort), 111 | ); 112 | connectedPort.$connectedTo.set( 113 | connectedPort.connectedTo.filter((port) => port !== this), 114 | ); 115 | this.setIsDisconnecting(false); 116 | this.flow.dispatch(this.disconnect.name, arguments, this); 117 | } 118 | 119 | public disconnectAll(): void { 120 | for (const port of this.connectedTo) 121 | port.$connectedTo.set(port.connectedTo.filter((port) => port !== this)); 122 | 123 | this.$connectedTo.set([]); 124 | this.flow.dispatch(this.disconnectAll.name, null, this); 125 | } 126 | 127 | public readonly $isDisconnecting = signal(false); 128 | public get isDisconnecting(): boolean { 129 | return this.$isDisconnecting.get(); 130 | } 131 | public setIsDisconnecting(value: boolean): void { 132 | this.$isDisconnecting.set(value); 133 | } 134 | 135 | public readonly $customDisplayName = signal(null); 136 | public get customDisplayName(): string | null { 137 | return this.$customDisplayName.get(); 138 | } 139 | 140 | public get displayName(): string { 141 | return this.customDisplayName ?? this.name; 142 | } 143 | 144 | public updateDisplayName(customName: string): void { 145 | this.$customDisplayName.set(customName); 146 | this.flow.dispatch(this.updateDisplayName.name, arguments, this); 147 | } 148 | 149 | public readonly $validity = signal(true); 150 | public get validity(): boolean { 151 | return this.$validity.get(); 152 | } 153 | 154 | public setIsPulsing(state: boolean): void { 155 | this.$isPulsing.set(state); 156 | } 157 | 158 | public readonly $lastChangeTime = signal(0); 159 | public get lastChangeTime(): number { 160 | return this.$lastChangeTime.get(); 161 | } 162 | 163 | public bang(): void { 164 | this.setIsPulsing(true); 165 | setTimeout(() => this.setIsPulsing(false), 50); 166 | this.$lastChangeTime.set(Date.now()); 167 | this.flow.dispatch(this.bang.name, null, this); 168 | } 169 | 170 | public readonly $isPulsing = signal(false); 171 | public get isPulsing(): boolean { 172 | return this.$isPulsing.get(); 173 | } 174 | 175 | public readonly $x = signal(0); 176 | public get x(): number { 177 | return this.$x.get(); 178 | } 179 | public readonly $y = signal(0); 180 | public get y(): number { 181 | return this.$y.get(); 182 | } 183 | 184 | public updatePositionFromDom({ 185 | rect, 186 | scroll, 187 | cableOffset = 4, 188 | }: { 189 | rect: DOMRect; 190 | scroll: { x: number; y: number }; 191 | cableOffset?: number; 192 | }): void { 193 | const { scale, offsetX, offsetY, viewportRect } = this.flow; 194 | 195 | // NOTE: So the cable is sticking out 196 | const adjustment = cableOffset * (this.direction === 'in' ? -1 : 1); 197 | 198 | this.$x.set( 199 | (rect.x + 200 | scroll.x - 201 | offsetX - 202 | viewportRect.x + 203 | rect.width / 2 + 204 | adjustment) / 205 | scale, 206 | ); 207 | this.$y.set( 208 | (rect.y + 209 | // 210 | scroll.y - 211 | offsetY - 212 | viewportRect.y + 213 | rect.height / 2) / 214 | scale, 215 | ); 216 | } 217 | 218 | public fromJSON(options: AddPortOptions): void { 219 | if (options.value) this.$value.set(options.value); 220 | if (options.direction) this.$direction.set(options.direction); 221 | if (options.metadata) this.$metadata.set(options.metadata); 222 | if (options.customDisplayName) 223 | this.$customDisplayName.set(options.customDisplayName); 224 | } 225 | 226 | // FIXME: 227 | public toJSON() /* : PortSerializationOptions */ { 228 | const { x, y, value, connectedTo: _connectedTo } = this; 229 | 230 | const connectedTo = _connectedTo.map((connectedPort) => { 231 | let portName; 232 | for (const [nodePortName, nodePort] of Object.entries( 233 | connectedPort.node.ports, 234 | )) 235 | if (connectedPort === nodePort) portName = nodePortName; 236 | 237 | if (!portName) throw new ReferenceError('Incorrect port.'); 238 | 239 | return { node: connectedPort.node.id, port: portName }; 240 | }); 241 | 242 | return { x, y, value, connectedTo }; 243 | } 244 | } 245 | -------------------------------------------------------------------------------- /src/lib/themes/lit-renderer.el.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, css } from 'lit'; 2 | 3 | export class LitRenderer extends LitElement { 4 | static styles = [ 5 | css` 6 | :host { 7 | display: contents; 8 | } 9 | `, 10 | ]; 11 | 12 | static override properties = { template: { attribute: false } }; 13 | declare template?: unknown; 14 | 15 | public override render(): JSX.LitTemplate { 16 | return this.template || null; 17 | } 18 | } 19 | customElements.define('lit-renderer', LitRenderer); 20 | -------------------------------------------------------------------------------- /src/lib/themes/webawesome/demo-nodes/broadcast-channel.el.tsx: -------------------------------------------------------------------------------- 1 | import '@shoelace-style/shoelace/dist/components/copy-button/copy-button.js'; 2 | import { css, LitElement } from 'lit'; 3 | import { customElement, property } from 'lit/decorators.js'; 4 | import { SignalWatcher } from '@lit-labs/signals'; 5 | import { signal } from 'signal-utils'; 6 | import { reaction } from 'signal-utils/subtle/reaction'; 7 | 8 | import { Port } from '../../../port.js'; 9 | import { Node } from '../../../node.js'; 10 | 11 | import type { PortsWithSchema } from './schemas.js'; 12 | 13 | export class NfWaBroadcastChannelNode extends Node { 14 | public override readonly type = 'NfWaBroadcastChannelNode'; 15 | 16 | public override readonly defaultDisplayName = 'Broadcast'; 17 | public override readonly defaultIcon = 'broadcast-pin'; 18 | 19 | public override readonly ports = { 20 | messageInput: this.addPort({ 21 | direction: 'in', 22 | customDisplayName: 'TX', 23 | }), 24 | 25 | messageOutput: this.addPort({ 26 | direction: 'out', 27 | customDisplayName: 'RX', 28 | }), 29 | }; 30 | 31 | @signal accessor defaultValue: string = 'main'; 32 | 33 | public override readonly Template = (): JSX.LitTemplate => ( 34 | 35 | 40 | 41 | ); 42 | } 43 | 44 | @customElement('wf-broadcast-channel') 45 | export class NfWaBroadcastChannelElement extends SignalWatcher(LitElement) { 46 | static styles = [ 47 | css` 48 | :host { 49 | display: flex; 50 | justify-content: center; 51 | align-items: center; 52 | } 53 | 54 | .channel-wrapper { 55 | display: flex; 56 | align-items: center; 57 | } 58 | 59 | header { 60 | display: flex; 61 | align-items: center; 62 | justify-content: space-around; 63 | } 64 | 65 | sl-copy-button { 66 | margin-top: 2rem; 67 | margin-right: var(--sl-spacing-medium); 68 | 69 | &::part(button) { 70 | padding: 0; 71 | } 72 | } 73 | `, 74 | ]; 75 | 76 | @property({ attribute: false }) accessor messageInput!: Port; 77 | @property({ attribute: false }) accessor messageOutput!: Port; 78 | 79 | // @property({ attribute: false }) accessor channel = 'main'; 80 | @signal accessor channel = 'main'; 81 | 82 | #broadcastChannel: BroadcastChannel = new BroadcastChannel('default'); 83 | 84 | protected firstUpdated(): void { 85 | this.#reinitListener(); 86 | reaction( 87 | () => [this.channel], 88 | () => { 89 | this.#reinitListener(); 90 | }, 91 | ); 92 | 93 | reaction( 94 | () => [this.messageInput?.value], 95 | () => { 96 | this.#broadcastChannel.postMessage(this.messageInput.value); 97 | }, 98 | ); 99 | 100 | queueMicrotask(() => 101 | this.#broadcastChannel.postMessage(this.messageInput.value), 102 | ); 103 | } 104 | 105 | #listener = (event: MessageEvent) => { 106 | this.messageOutput.updateValue(event.data); 107 | }; 108 | #reinitListener(): void { 109 | this.#broadcastChannel.removeEventListener('message', this.#listener); 110 | this.#broadcastChannel.close(); 111 | this.#broadcastChannel = new BroadcastChannel(this.channel); 112 | this.#broadcastChannel.addEventListener('message', this.#listener); 113 | } 114 | 115 | public override render(): JSX.LitTemplate { 116 | return ( 117 |
118 | {/* @ts-expect-error TODO: generate typings for JSFE. */} 119 | { 132 | this.channel = data.text; 133 | }} 134 | prop:submitButton={false} 135 | /> 136 | 137 |
138 | ); 139 | } 140 | } 141 | 142 | declare global { 143 | interface HTMLElementTagNameMap { 144 | 'wf-broadcast-channel': NfWaBroadcastChannelElement; 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/lib/themes/webawesome/demo-nodes/canvas-color.el.tsx: -------------------------------------------------------------------------------- 1 | import { css, LitElement } from 'lit'; 2 | import { customElement, property } from 'lit/decorators.js'; 3 | import { createRef } from 'lit/directives/ref.js'; 4 | import { SignalWatcher } from '@lit-labs/signals'; 5 | import { reaction } from 'signal-utils/subtle/reaction'; 6 | import type { SlColorPicker } from '@shoelace-style/shoelace'; 7 | 8 | import type { Port } from '../../../port.js'; 9 | 10 | import { 11 | textPortSchema, 12 | type PortsWithSchema, 13 | type TextPort, 14 | } from './schemas.js'; 15 | import { Node } from '../../../node.js'; 16 | 17 | // TODO: add "image node source" before 18 | export class NfWaCanvasColorNode extends Node { 19 | public override readonly type = 'NfWaCanvasColorNode'; 20 | 21 | public override readonly defaultDisplayName = 'Solid color'; 22 | public override readonly defaultIcon = 'alphabet-uppercase'; 23 | 24 | public override readonly ports = { 25 | text: this.addPort({ 26 | direction: 'in', 27 | customDisplayName: 'Text', 28 | metadata: { schema: textPortSchema }, 29 | }), 30 | 31 | canvas: this.addPort({ 32 | direction: 'out', 33 | customDisplayName: 'Canvas', 34 | }), 35 | }; 36 | 37 | public override readonly Template = (): JSX.LitTemplate => ( 38 | 39 | 43 | 44 | ); 45 | } 46 | 47 | @customElement('nf-canvas-color') 48 | export class NfWaCanvasColorElement extends SignalWatcher(LitElement) { 49 | static styles = [ 50 | css` 51 | :host { 52 | display: flex; 53 | flex-direction: column; 54 | justify-content: center; 55 | } 56 | 57 | header { 58 | display: flex; 59 | 60 | justify-content: center; 61 | align-items: center; 62 | gap: var(--sl-spacing-x-large); 63 | padding: var(--sl-spacing-small); 64 | } 65 | `, 66 | ]; 67 | 68 | #canvasRef = createRef(); 69 | 70 | protected firstUpdated(): void { 71 | this.draw(); 72 | 73 | reaction( 74 | () => [this.textIn?.lastChangeTime], 75 | () => { 76 | this.draw(); 77 | }, 78 | ); 79 | } 80 | 81 | draw() { 82 | const canvas = this.#canvasRef.value!; 83 | 84 | const context = canvas.getContext('2d')!; 85 | 86 | context.fillStyle = this.color; 87 | 88 | context.font = '36px system-ui'; 89 | 90 | context.fillRect(0, 0, canvas.width, canvas.height); 91 | 92 | this.canvasOut?.updateValue(canvas); 93 | } 94 | 95 | @property({ attribute: false }) 96 | accessor canvasOut: Port | null = null; 97 | 98 | @property({ attribute: false }) accessor textIn: Port | null = null; 99 | 100 | // TODO: extract to node body initial value with schema (everywhere) 101 | @property({ attribute: false }) accessor color: string = '#45001d'; 102 | 103 | public override render(): JSX.LitTemplate { 104 | return ( 105 | <> 106 |
107 | Color 108 | { 112 | this.color = (event.target as SlColorPicker).value; 113 | this.draw(); 114 | }} 115 | /> 116 | 117 | {this.color} 118 | 119 |
120 | 121 |