├── .gitignore ├── .npmrc ├── .prettierrc.json ├── LICENSE ├── README.md ├── package.json ├── packages └── codeck │ ├── .eslintrc.cjs │ ├── .gitignore │ ├── README.md │ ├── jest.config.js │ ├── package.json │ ├── src │ ├── code │ │ ├── compiler.ts │ │ └── pack.ts │ ├── components │ │ ├── Connection.tsx │ │ ├── ContextMenu.tsx │ │ └── FlowEditor │ │ │ ├── ConnectionLayer.tsx │ │ │ ├── GridLayer.tsx │ │ │ ├── NodeLayer.tsx │ │ │ ├── SelectionLayer.tsx │ │ │ └── index.tsx │ ├── hooks │ │ ├── useEditValue.ts │ │ ├── useNodeData.ts │ │ ├── useNodeInfo.ts │ │ ├── usePinDefinition.ts │ │ ├── useStage.ts │ │ └── useUpdateRef.ts │ ├── index.ts │ ├── nodes │ │ ├── BaseNode.tsx │ │ ├── BaseNodeWrapper.tsx │ │ ├── VariableNode.tsx │ │ ├── VariableSetNode.tsx │ │ ├── __all__.ts │ │ ├── components │ │ │ ├── input │ │ │ │ ├── Base.tsx │ │ │ │ ├── Boolean.tsx │ │ │ │ ├── Number.tsx │ │ │ │ ├── Select.tsx │ │ │ │ ├── Text.tsx │ │ │ │ ├── TextArea.tsx │ │ │ │ └── shared.ts │ │ │ ├── pin │ │ │ │ ├── ExecPin.tsx │ │ │ │ ├── Label.tsx │ │ │ │ ├── PortPin.tsx │ │ │ │ └── index.tsx │ │ │ └── preset │ │ │ │ ├── BooleanInputPreset.tsx │ │ │ │ ├── NumberInputPreset.tsx │ │ │ │ ├── SelectInputPreset.tsx │ │ │ │ ├── TextAreaInputPreset.tsx │ │ │ │ ├── TextInputPreset.tsx │ │ │ │ └── types.ts │ │ ├── definitions │ │ │ ├── core │ │ │ │ ├── alert.tsx │ │ │ │ ├── begin.tsx │ │ │ │ ├── fetch.tsx │ │ │ │ ├── foreach.tsx │ │ │ │ ├── if.tsx │ │ │ │ ├── includes.tsx │ │ │ │ ├── json-stringify.tsx │ │ │ │ ├── length.tsx │ │ │ │ ├── log-error.tsx │ │ │ │ ├── log.tsx │ │ │ │ ├── loop.tsx │ │ │ │ ├── raw-js.tsx │ │ │ │ ├── sleep.tsx │ │ │ │ └── timer.tsx │ │ │ ├── lodash │ │ │ │ └── get.tsx │ │ │ ├── logic │ │ │ │ ├── _utils.tsx │ │ │ │ ├── add.tsx │ │ │ │ ├── anl.tsx │ │ │ │ ├── divided.tsx │ │ │ │ ├── equal.tsx │ │ │ │ ├── gt.tsx │ │ │ │ ├── gte.tsx │ │ │ │ ├── lt.tsx │ │ │ │ ├── lte.tsx │ │ │ │ ├── mod.tsx │ │ │ │ ├── multiply.tsx │ │ │ │ ├── not.tsx │ │ │ │ ├── orl.tsx │ │ │ │ └── subtract.tsx │ │ │ ├── varget.tsx │ │ │ └── varset.tsx │ │ └── hooks │ │ │ └── usePinRender.tsx │ ├── store │ │ ├── __all__.ts │ │ ├── connection.ts │ │ ├── node.ts │ │ ├── stage.ts │ │ ├── ui.ts │ │ └── variable.ts │ └── utils │ │ ├── __tests__ │ │ └── size-helper.spec.ts │ │ ├── color.ts │ │ ├── consts.ts │ │ ├── persist.ts │ │ ├── pointer-helper.ts │ │ ├── size-helper.ts │ │ ├── standard.tsx │ │ └── string-helper.ts │ └── tsconfig.json ├── platform ├── .gitignore ├── .ministarrc.cjs ├── example │ ├── easemob-steam1.codeck │ └── easemob-steam1.png ├── index.html ├── jest.config.js ├── package.json ├── plugins │ └── easemob │ │ ├── package.json │ │ ├── src │ │ ├── const.ts │ │ ├── index.ts │ │ └── nodes │ │ │ ├── connAddEventHandler.tsx │ │ │ ├── createConnection.tsx │ │ │ ├── createConnectionToken.tsx │ │ │ ├── createMessagePayload.tsx │ │ │ ├── parseTextMessagePayload.tsx │ │ │ └── sendMessage.tsx │ │ └── tsconfig.json ├── postcss.config.cjs ├── public │ ├── icon.svg │ ├── icon2.svg │ └── lib │ │ └── console-log-html.min.js ├── src │ ├── App.tsx │ ├── components │ │ ├── CodeEditor │ │ │ └── index.tsx │ │ ├── ManagerPanel │ │ │ ├── index.tsx │ │ │ ├── pack.ts │ │ │ └── persist.ts │ │ └── modal │ │ │ ├── RunCode.less │ │ │ └── RunCode.tsx │ ├── index.css │ ├── index.tsx │ ├── reg.ts │ ├── registry │ │ ├── init.ts │ │ └── store.ts │ ├── tailwind.css │ └── vite-env.d.ts ├── tailwind.config.cjs ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml └── website ├── .gitignore ├── README.md ├── babel.config.js ├── blog ├── 2019-05-28-first-blog-post.md ├── 2019-05-29-long-blog-post.md ├── 2021-08-01-mdx-blog-post.mdx ├── 2021-08-26-welcome │ ├── docusaurus-plushie-banner.jpeg │ └── index.md └── authors.yml ├── docs ├── concept │ ├── _category_.json │ ├── connection.md │ ├── img │ │ ├── connection.png │ │ ├── connection2.png │ │ ├── node.png │ │ └── variable.png │ ├── node.md │ └── variable.md ├── develop │ ├── _category_.json │ ├── base.md │ ├── code-gen.md │ ├── complex.md │ └── img │ │ ├── begin.png │ │ ├── log.png │ │ └── loop.png ├── example.md ├── faq.md ├── intro.mdx └── roadmap.md ├── docusaurus.config.js ├── package.json ├── sidebars.js ├── src ├── components │ └── HomepageFeatures │ │ ├── index.tsx │ │ └── styles.module.css ├── css │ └── custom.css └── pages │ ├── index.module.css │ ├── index.tsx │ └── markdown-page.md ├── static ├── .nojekyll └── img │ ├── docusaurus.png │ ├── favicon.ico │ ├── logo.svg │ ├── undraw_docusaurus_mountain.svg │ ├── undraw_docusaurus_react.svg │ └── undraw_docusaurus_tree.svg └── tsconfig.json /.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-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | .vercel 27 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry = https://registry.npmmirror.com 2 | strict-peer-dependencies=false 3 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": true, 7 | "trailingComma": "es5", 8 | "bracketSpacing": true, 9 | "arrowParens": "always", 10 | "jsxBracketSameLine": false 11 | } 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## codeck 2 | 3 | 一个基于JS的可视化蓝图编程引擎 4 | 5 | 官方网站: [https://codeck.moonrailgun.com/](https://codeck.moonrailgun.com/) 6 | 7 | 8 | ![](./website/docs/concept/img/connection.png) 9 | 10 | 11 | `codeck` 是一款蓝图可视化编程系统,其理念是,在网页中使用基于节点的界面创建任何编程语言能够编程出的脚本。其设计灵感来源于虚幻 4 引擎的蓝图可视化脚本。 12 | 13 | ## 使用场景 14 | 15 | 与一般的编程语言不同的是,`codeck` 的设计方向在于一些需要快速实现的地方,对于一些简单的编程场景,单独开一个项目的成本会相对较高。而基于网页的 `codeck` 则实现了**随用随编程**的理念,将快速验证的成本降低到一个很低的地步。 16 | 17 | 使用 `codeck`, 你甚至不需要了解其背后的细节。我们会将很多内容封装成一个单独的节点,并通过一些 `端点(pin)` 将这些上下文暴露出来。 18 | 19 | 对此感兴趣了?点个Star,我会逐步带你领取可视化编程的魅力。 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "codeck-main", 3 | "private": true, 4 | "version": "1.0.0", 5 | "description": "", 6 | "main": "index.js", 7 | "scripts": { 8 | "dev": "concurrently --kill-others npm:dev:codeck npm:dev:platform", 9 | "dev:codeck": "cd packages/codeck && pnpm dev", 10 | "dev:platform": "cd platform && pnpm dev", 11 | "website": "cd website && pnpm dev", 12 | "build": "pnpm build:platform && pnpm build:website", 13 | "build:platform": "cd platform && pnpm build", 14 | "build:website": "cd website && pnpm build", 15 | "test": "echo \"Error: no test specified\" && exit 1" 16 | }, 17 | "keywords": [], 18 | "author": "moonrailgun ", 19 | "license": "Apache-2.0", 20 | "devDependencies": { 21 | "concurrently": "^7.6.0" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/codeck/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | }, 6 | extends: [ 7 | 'eslint:recommended', 8 | 'plugin:react/recommended', 9 | 'plugin:@typescript-eslint/recommended', 10 | ], 11 | overrides: [], 12 | parser: '@typescript-eslint/parser', 13 | parserOptions: { 14 | ecmaVersion: 'latest', 15 | sourceType: 'module', 16 | }, 17 | plugins: ['react', '@typescript-eslint'], 18 | rules: { 19 | "react/prop-types": "off" 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /packages/codeck/.gitignore: -------------------------------------------------------------------------------- 1 | lib 2 | -------------------------------------------------------------------------------- /packages/codeck/README.md: -------------------------------------------------------------------------------- 1 | ## codeck 2 | 3 | 一个基于JS的可视化蓝图编程引擎 4 | 5 | 官方网站: [https://codeck.moonrailgun.com/](https://codeck.moonrailgun.com/) 6 | 7 | 8 | ![](https://github.com/moonrailgun/codeck/raw/master/website/docs/concept/img/connection.png) 9 | 10 | `codeck` 是一款蓝图可视化编程系统,其理念是,在网页中使用基于节点的界面创建任何编程语言能够编程出的脚本。其设计灵感来源于虚幻 4 引擎的蓝图可视化脚本。 11 | 12 | ## 使用场景 13 | 14 | 与一般的编程语言不同的是,`codeck` 的设计方向在于一些需要快速实现的地方,对于一些简单的编程场景,单独开一个项目的成本会相对较高。而基于网页的 `codeck` 则实现了**随用随编程**的理念,将快速验证的成本降低到一个很低的地步。 15 | 16 | 使用 `codeck`, 你甚至不需要了解其背后的细节。我们会将很多内容封装成一个单独的节点,并通过一些 `端点(pin)` 将这些上下文暴露出来。 17 | 18 | 对此感兴趣了?点个Star,我会逐步带你领取可视化编程的魅力。 19 | -------------------------------------------------------------------------------- /packages/codeck/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | export default { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | }; 6 | -------------------------------------------------------------------------------- /packages/codeck/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "codeck", 3 | "files": [ 4 | "lib" 5 | ], 6 | "version": "1.0.0", 7 | "type": "module", 8 | "main": "./lib/index.js", 9 | "description": "A visual programming engine base on js", 10 | "homepage": "https://codeck.moonrailgun.com/", 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/moonrailgun/codeck", 14 | "directory": "packages/codeck" 15 | }, 16 | "scripts": { 17 | "dev": "tsc --watch", 18 | "build": "tsc", 19 | "prepare": "tsc", 20 | "lint": "eslint ./src/**/*.{ts,tsx}", 21 | "release": "npm publish --registry https://registry.npmjs.com/", 22 | "test": "jest" 23 | }, 24 | "dependencies": { 25 | "ahooks": "^3.7.2", 26 | "fuse.js": "^6.6.2", 27 | "immer": "^9.0.16", 28 | "jszip": "^3.10.1", 29 | "konva": "^8.3.13", 30 | "lodash-es": "^4.17.21", 31 | "nanoid": "^4.0.0", 32 | "react-highlight-words": "^0.18.0", 33 | "react-konva": "^18.2.3", 34 | "react-konva-utils": "^0.3.0", 35 | "styled-components": "^5.3.6", 36 | "zustand": "^4.1.4" 37 | }, 38 | "devDependencies": { 39 | "@arco-design/web-react": "^2.41.1", 40 | "@types/jest": "^29.2.3", 41 | "@types/lodash-es": "^4.17.6", 42 | "@types/react": "^18.0.24", 43 | "@types/react-dom": "^18.0.8", 44 | "@types/react-highlight-words": "^0.16.4", 45 | "@types/styled-components": "^5.1.26", 46 | "@typescript-eslint/eslint-plugin": "^5.45.0", 47 | "@typescript-eslint/parser": "^5.45.0", 48 | "eslint": "^8.29.0", 49 | "eslint-plugin-react": "^7.31.11", 50 | "jest": "^29.3.1", 51 | "react": "^18.2.0", 52 | "react-dom": "^18.2.0", 53 | "ts-jest": "^29.0.3", 54 | "typescript": "^4.7.4" 55 | }, 56 | "peerDependencies": { 57 | "@arco-design/web-react": "^2.41.1", 58 | "react": "^18.2.0", 59 | "react-dom": "^18.2.0" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /packages/codeck/src/code/pack.ts: -------------------------------------------------------------------------------- 1 | import JSZip from 'jszip'; 2 | import { uniq, zipObject } from 'lodash-es'; 3 | import { CodeImportPrepare, CodePrepare } from '../store/node'; 4 | 5 | export async function packRepo(options: { 6 | projectName?: string; 7 | code: string; 8 | prepares: CodePrepare[]; 9 | platform?: 'web' | 'nodejs'; 10 | }): Promise { 11 | const { 12 | projectName = 'codeck-project', 13 | code, 14 | prepares, 15 | platform = 'web', 16 | } = options; 17 | 18 | const zip = new JSZip(); 19 | const repo = zip.folder(projectName); 20 | if (!repo) { 21 | throw new Error('Zip folder create error'); 22 | } 23 | const dependencies = await getAllDependencies(prepares); 24 | 25 | repo.file( 26 | 'README.md', 27 | `## ${projectName} 28 | 29 | Generate by [codeck](https://codeck.moonrailgun.com/) 30 | 31 | ### Usage 32 | 33 | \`\`\`bash 34 | npm install 35 | npm start 36 | \`\`\` 37 | ` 38 | ); 39 | repo.file('index.js', code); 40 | 41 | if (platform === 'web') { 42 | repo.file( 43 | 'index.html', 44 | ` 45 | 46 | 47 | 48 | 49 | ${projectName} 50 | 51 | 52 |
53 |
This project is generated by codeck
54 |
    55 |
    56 | 57 | 60 | 61 | 62 | ` 63 | ); 64 | repo.file( 65 | 'package.json', 66 | JSON.stringify( 67 | { 68 | name: projectName, 69 | private: true, 70 | version: '1.0.0', 71 | description: 'Generated by codeck', 72 | main: 'index.js', 73 | scripts: { 74 | start: 'vite', 75 | build: 'vite dev', 76 | }, 77 | keywords: ['codeck'], 78 | dependencies: { 79 | ...dependencies, 80 | vite: '3.2.5', 81 | }, 82 | }, 83 | null, 84 | 2 85 | ) 86 | ); 87 | } else if (platform === 'nodejs') { 88 | repo.file( 89 | 'package.json', 90 | JSON.stringify( 91 | { 92 | name: projectName, 93 | private: true, 94 | version: '1.0.0', 95 | description: 'Generated by codeck', 96 | main: 'index.js', 97 | scripts: { 98 | start: 'node index.js', 99 | }, 100 | keywords: ['codeck'], 101 | dependencies, 102 | }, 103 | null, 104 | 2 105 | ) 106 | ); 107 | } 108 | 109 | const content = zip.generateAsync({ type: 'blob' }); 110 | 111 | return content; 112 | } 113 | 114 | async function getAllDependencies( 115 | prepares: CodePrepare[] 116 | ): Promise> { 117 | const modules = uniq( 118 | prepares 119 | .filter((p): p is CodeImportPrepare => p.type === 'import') 120 | .map((p) => p.module) 121 | ); 122 | 123 | const versions = await Promise.all( 124 | modules.map((module) => fetchModuleLatestVersion(module)) 125 | ); 126 | 127 | return zipObject(modules, versions); 128 | } 129 | 130 | /** 131 | * 获取npm包最新版本号 132 | */ 133 | async function fetchModuleLatestVersion(packageName: string) { 134 | const res = await fetch(`https://registry.npmjs.org/${packageName}/latest`); 135 | const json = await res.json(); 136 | 137 | return json.version || '*'; 138 | } 139 | -------------------------------------------------------------------------------- /packages/codeck/src/components/Connection.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo, useState } from 'react'; 2 | import Konva from 'konva'; 3 | import { Line } from 'react-konva'; 4 | 5 | interface ConnectionProps { 6 | from: Konva.Vector2d; 7 | to: Konva.Vector2d; 8 | direction: 'out-in' | 'in-out'; 9 | isActive?: boolean; 10 | onClick?: (evt: Konva.KonvaEventObject) => void; 11 | } 12 | 13 | /** 14 | * 连接线 15 | */ 16 | export const Connection: React.FC = React.memo((props) => { 17 | const { from, to, direction, isActive, onClick } = props; 18 | const [hover, setHover] = useState(false); 19 | 20 | const points = useMemo(() => { 21 | const dir = direction === 'out-in' ? 1 : -1; 22 | 23 | let holdLen = (to.x - from.x) / 3; // 确保初始方向正确 24 | const diffY = Math.abs(to.y - from.y); 25 | holdLen = dir * (Math.abs(holdLen) + diffY / 4); 26 | 27 | const mid1 = { 28 | x: from.x + holdLen, 29 | y: from.y, 30 | }; 31 | const mid2 = { 32 | x: to.x - holdLen, 33 | y: to.y, 34 | }; 35 | 36 | return [from.x, from.y, mid1.x, mid1.y, mid2.x, mid2.y, to.x, to.y]; 37 | }, [from, to, direction]); 38 | 39 | return ( 40 | setHover(true)} 47 | onMouseLeave={() => setHover(false)} 48 | onClick={onClick} 49 | /> 50 | ); 51 | }); 52 | Connection.displayName = 'Connection'; 53 | -------------------------------------------------------------------------------- /packages/codeck/src/components/ContextMenu.tsx: -------------------------------------------------------------------------------- 1 | import { entries, groupBy, keys, values } from 'lodash-es'; 2 | import React, { 3 | PropsWithChildren, 4 | useEffect, 5 | useMemo, 6 | useRef, 7 | useState, 8 | } from 'react'; 9 | import { useNodeStore } from '../store/node'; 10 | import { Trigger, Menu, Input, Message, Tree } from '@arco-design/web-react'; 11 | import { useMemoizedFn } from 'ahooks'; 12 | import Highlighter from 'react-highlight-words'; 13 | import Fuse from 'fuse.js'; 14 | import { useStageStore } from '../store/stage'; 15 | import Konva from 'konva'; 16 | import { useConnectionStore } from '../store/connection'; 17 | import { useVariableStore } from '../store/variable'; 18 | import { VarGetNodeDefinition } from '../nodes/definitions/varget'; 19 | import { VarSetNodeDefinition } from '../nodes/definitions/varset'; 20 | 21 | const ContextMenu: React.FC<{ onClose: () => void }> = React.memo((props) => { 22 | const { nodeDefinition, createNode } = useNodeStore(); 23 | const [searchValue, setSearchValue] = useState(''); 24 | const { getRelativePointerPosition } = useStageStore(); 25 | const { variableMap } = useVariableStore(); 26 | const nodeCreatedPosRef = useRef(null); 27 | 28 | useEffect(() => { 29 | nodeCreatedPosRef.current = getRelativePointerPosition(); 30 | }, []); 31 | 32 | const handleCreateNode = useMemoizedFn( 33 | (nodeName: string, data?: Record) => { 34 | if (!nodeName) { 35 | Message.error('Node Name undefined'); 36 | return; 37 | } 38 | 39 | if (!nodeCreatedPosRef.current) { 40 | Message.error('Cannot get pointer position'); 41 | return; 42 | } 43 | 44 | createNode(nodeName, nodeCreatedPosRef.current, data); 45 | props.onClose(); 46 | } 47 | ); 48 | 49 | const list = useMemo( 50 | () => values(nodeDefinition).filter((definiton) => !definiton.hidden), 51 | [nodeDefinition] 52 | ); 53 | 54 | const variableList = useMemo(() => keys(variableMap), [variableMap]); 55 | 56 | const fuse = useMemo( 57 | () => 58 | new Fuse(list, { 59 | keys: ['label'], 60 | }), 61 | [list] 62 | ); 63 | 64 | const variableFuse = useMemo(() => new Fuse(variableList), [variableList]); 65 | 66 | const matchedNode = 67 | searchValue === '' ? list : fuse.search(searchValue).map((res) => res.item); 68 | 69 | const matchedVariable = 70 | searchValue === '' 71 | ? variableList 72 | : variableFuse.search(searchValue).map((res) => res.item); 73 | 74 | return ( 75 |
    81 | 82 | e.stopPropagation()} 88 | /> 89 | 90 |
    91 | 92 | {Array.isArray(matchedVariable) && matchedVariable.length > 0 && ( 93 | 94 | {matchedVariable.map((item) => ( 95 | 102 | } 103 | > 104 | 109 | handleCreateNode(VarGetNodeDefinition.name, { 110 | name: item, 111 | }) 112 | } 113 | > 114 | Get 115 |
    116 | } 117 | /> 118 | 123 | handleCreateNode(VarSetNodeDefinition.name, { 124 | name: item, 125 | }) 126 | } 127 | > 128 | Set 129 |
    130 | } 131 | /> 132 | 133 | ))} 134 | 135 | )} 136 | 137 | {Array.isArray(matchedNode) && 138 | matchedNode.length > 0 && 139 | entries(groupBy(matchedNode, 'category')).map( 140 | ([category, items]) => ( 141 | 142 | {items.map((item) => ( 143 | handleCreateNode(item.name)}> 147 | 151 | 152 | } 153 | /> 154 | ))} 155 | 156 | ) 157 | )} 158 | 159 | 160 | 161 | 162 | ); 163 | }); 164 | ContextMenu.displayName = 'ContextMenu'; 165 | 166 | interface ContextMenuWrapperProps extends PropsWithChildren { 167 | className?: string; 168 | style?: React.CSSProperties; 169 | } 170 | export const ContextMenuWrapper = React.forwardRef< 171 | HTMLDivElement, 172 | ContextMenuWrapperProps 173 | >((props, ref) => { 174 | const [popupVisible, setPopupVisible] = useState(false); 175 | const workingConnection = useConnectionStore( 176 | (state) => state.workingConnection 177 | ); 178 | 179 | return ( 180 | setPopupVisible(false)} />} 182 | alignPoint={true} 183 | escToClose={true} 184 | popupVisible={popupVisible} 185 | onVisibleChange={(v) => setPopupVisible(v)} 186 | position="bl" 187 | popupAlign={{ 188 | bottom: 8, 189 | left: 8, 190 | }} 191 | trigger={['contextMenu']} 192 | disabled={!!workingConnection} 193 | > 194 |
    195 | {props.children} 196 |
    197 |
    198 | ); 199 | }); 200 | ContextMenuWrapper.displayName = 'ContextMenuWrapper'; 201 | -------------------------------------------------------------------------------- /packages/codeck/src/components/FlowEditor/ConnectionLayer.tsx: -------------------------------------------------------------------------------- 1 | import { useUpdate } from 'ahooks'; 2 | import React, { useEffect, useState } from 'react'; 3 | import { Layer } from 'react-konva'; 4 | import { useUpdateRef } from '../../hooks/useUpdateRef'; 5 | import { ConnectInfo, useConnectionStore } from '../../store/connection'; 6 | import { useStageStore } from '../../store/stage'; 7 | import { useStage } from '../../hooks/useStage'; 8 | import { useNodeStore } from '../../store/node'; 9 | import Konva from 'konva'; 10 | import { Connection } from '../Connection'; 11 | import { useUIStore } from '../../store/ui'; 12 | 13 | function getWorkingConnectionFromPos(): Konva.Vector2d { 14 | const { workingConnection } = useConnectionStore.getState(); 15 | 16 | if (!workingConnection) { 17 | return { x: 0, y: 0 }; 18 | } 19 | 20 | return ( 21 | getPinPos( 22 | workingConnection.fromNodeId, 23 | workingConnection.fromNodePinName 24 | ) ?? { x: 0, y: 0 } 25 | ); 26 | } 27 | 28 | function getConnectionFromToPos( 29 | connection: ConnectInfo 30 | ): { from: Konva.Vector2d; to: Konva.Vector2d } | null { 31 | const { fromNodeId, fromNodePinName, toNodeId, toNodePinName } = connection; 32 | 33 | const from = getPinPos(fromNodeId, fromNodePinName); 34 | const to = getPinPos(toNodeId, toNodePinName); 35 | 36 | if (!from || !to) { 37 | return null; 38 | } 39 | 40 | return { 41 | from, 42 | to, 43 | }; 44 | } 45 | 46 | function getPinPos(nodeId: string, nodePinName: string) { 47 | const { nodeMap, getPinDefinitionByName } = useNodeStore.getState(); 48 | 49 | const node = nodeMap[nodeId]; 50 | if (!node) { 51 | return null; 52 | } 53 | 54 | const pinDefinition = getPinDefinitionByName(nodeId, nodePinName); 55 | if (!pinDefinition) { 56 | return null; 57 | } 58 | 59 | return { 60 | x: node.position.x + pinDefinition.position.x, 61 | y: node.position.y + pinDefinition.position.y, 62 | }; 63 | } 64 | 65 | /** 66 | * 仅用于绘制 67 | * 因为线条的刷新频率比较高,所以单独放一个layer 68 | */ 69 | export const ConnectionLayer: React.FC = React.memo(() => { 70 | const { connections, workingConnection, cancelConnect } = 71 | useConnectionStore(); 72 | const { getRelativePointerPosition } = useStageStore(); 73 | useNodeStore(); // 这只是为了确保node位置更新了这个layer也能被渲染 74 | const { selectedConnectionIds } = useUIStore(); 75 | 76 | const updateDraw = useUpdate(); 77 | 78 | const workingConnectionRef = useUpdateRef(workingConnection); 79 | 80 | useStage((stage) => { 81 | const mouseMoveHandler = () => { 82 | if (workingConnectionRef.current) { 83 | updateDraw(); 84 | } 85 | }; 86 | 87 | stage.on('mousemove', mouseMoveHandler); 88 | 89 | return () => { 90 | stage.off('mousemove', mouseMoveHandler); 91 | }; 92 | }); 93 | 94 | useStage((stage) => { 95 | const handleAutoCreateNode = () => { 96 | if (workingConnectionRef.current) { 97 | // 正在选择 98 | // TODO: 自动创建并连接默认入口/出口 99 | console.log('handleAutoCreateNode'); 100 | } 101 | }; 102 | 103 | stage.on('click', handleAutoCreateNode); 104 | 105 | return () => { 106 | stage.off('click', handleAutoCreateNode); 107 | }; 108 | }); 109 | 110 | useEffect(() => { 111 | const handleKeyDown = (e: KeyboardEvent) => { 112 | if (e.key === 'Escape') { 113 | cancelConnect(); 114 | } 115 | }; 116 | 117 | const handleContextMenu = (e: MouseEvent) => { 118 | if (workingConnectionRef.current) { 119 | // 正在选择 120 | cancelConnect(); 121 | } 122 | }; 123 | 124 | window.addEventListener('keydown', handleKeyDown); 125 | window.addEventListener('contextmenu', handleContextMenu); 126 | return () => { 127 | window.removeEventListener('keydown', handleKeyDown); 128 | window.removeEventListener('contextmenu', handleContextMenu); 129 | }; 130 | }, []); 131 | 132 | let workingConnectionEl: React.ReactNode = null; 133 | if (workingConnection) { 134 | const fromPos = getWorkingConnectionFromPos(); 135 | const toPos = getRelativePointerPosition(); 136 | 137 | workingConnectionEl = ( 138 | 143 | ); 144 | } 145 | 146 | return ( 147 | 148 | {/* created connections */} 149 | {connections.map((connection) => { 150 | const info = getConnectionFromToPos(connection); 151 | if (!info) { 152 | console.warn('Connection info not found', connection); 153 | return null; 154 | } 155 | 156 | return ( 157 | { 164 | e.cancelBubble = true; 165 | 166 | if (!e.evt.shiftKey) { 167 | useUIStore.getState().clearSelectedStatus(); 168 | } 169 | 170 | useUIStore.getState().addSelectedConnections([connection.id]); 171 | }} 172 | /> 173 | ); 174 | })} 175 | 176 | {workingConnectionEl} 177 | 178 | ); 179 | }); 180 | ConnectionLayer.displayName = 'ConnectionLayer'; 181 | -------------------------------------------------------------------------------- /packages/codeck/src/components/FlowEditor/GridLayer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Layer, Line } from 'react-konva'; 3 | import { useStageStore } from '../../store/stage'; 4 | 5 | const stepSize = 40; 6 | 7 | export const GridLayer: React.FC = React.memo(() => { 8 | const { width, height, position, scale } = useStageStore((state) => ({ 9 | width: state.width, 10 | height: state.height, 11 | position: state.position, 12 | scale: state.scale, 13 | })); 14 | 15 | const stageRect = { 16 | x1: 0, 17 | y1: 0, 18 | x2: width, 19 | y2: height, 20 | offset: { 21 | x: position.x / scale.x, 22 | y: position.y / scale.y, 23 | }, 24 | }; 25 | 26 | const viewRect = { 27 | x1: -stageRect.offset.x, 28 | y1: -stageRect.offset.y, 29 | x2: width / scale.x - stageRect.offset.x, 30 | y2: height / scale.y - stageRect.offset.y, 31 | }; 32 | 33 | const gridOffset = { 34 | x: Math.ceil(position.x / scale.x / stepSize) * stepSize, 35 | y: Math.ceil(position.y / scale.y / stepSize) * stepSize, 36 | }; 37 | 38 | const gridRect = { 39 | x1: -gridOffset.x, 40 | y1: -gridOffset.y, 41 | x2: width / scale.x - gridOffset.x + stepSize, 42 | y2: height / scale.y - gridOffset.y + stepSize, 43 | }; 44 | 45 | const fullRect = { 46 | x1: Math.min(viewRect.x1, gridRect.x1), 47 | y1: Math.min(viewRect.y1, gridRect.y1), 48 | x2: Math.max(viewRect.x2, gridRect.x2), 49 | y2: Math.max(viewRect.y2, gridRect.y2), 50 | }; 51 | 52 | // find the x & y size of the grid 53 | const xSize = fullRect.x2 - fullRect.x1; 54 | const ySize = fullRect.y2 - fullRect.y1; 55 | 56 | // compute the number of steps required on each axis. 57 | const xSteps = Math.round(xSize / stepSize) + 1; 58 | const ySteps = Math.round(ySize / stepSize) + 1; 59 | 60 | return ( 61 | 68 | {Array.from({ length: xSteps }).map((_, i) => ( 69 | 77 | ))} 78 | 79 | {Array.from({ length: ySteps }).map((_, i) => ( 80 | 88 | ))} 89 | 90 | ); 91 | }); 92 | GridLayer.displayName = 'GridLayer'; 93 | -------------------------------------------------------------------------------- /packages/codeck/src/components/FlowEditor/NodeLayer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Layer } from 'react-konva'; 3 | import { useNodeStore } from '../../store/node'; 4 | import { values } from 'lodash-es'; 5 | 6 | export const NodeLayer: React.FC = React.memo(() => { 7 | const { nodeMap, nodeDefinition } = useNodeStore(); 8 | 9 | return ( 10 | 11 | {values(nodeMap).map((node) => { 12 | const def = nodeDefinition[node.name]; 13 | if (!def) { 14 | console.warn('Not found node:', node.name); 15 | 16 | return null; 17 | } 18 | const component = def.component; 19 | 20 | return React.createElement(component, { key: node.id, id: node.id }); 21 | })} 22 | 23 | ); 24 | }); 25 | NodeLayer.displayName = 'NodeLayer'; 26 | -------------------------------------------------------------------------------- /packages/codeck/src/components/FlowEditor/SelectionLayer.tsx: -------------------------------------------------------------------------------- 1 | import { useStage } from '../../hooks/useStage'; 2 | import { useStageStore } from '../../store/stage'; 3 | import { useUIStore } from '../../store/ui'; 4 | import { SHAPE_NAME_OF_NODE } from '../../utils/consts'; 5 | import Konva from 'konva'; 6 | import React, { useRef, useState } from 'react'; 7 | import { Layer, Rect } from 'react-konva'; 8 | 9 | export const SelectionLayer: React.FC = React.memo(() => { 10 | const [rect, setRect] = useState({ x1: 0, y1: 0, x2: 0, y2: 0 }); 11 | const [visible, setVisible] = useState(false); 12 | const selectionRef = useRef(null); 13 | 14 | useStage((stage) => { 15 | let isSelecting = false; 16 | 17 | const handleMouseDown = ( 18 | e: Konva.KonvaEventObject 19 | ) => { 20 | // do nothing if we mousedown on any shape 21 | if (e.target !== stage) { 22 | return; 23 | } 24 | 25 | if (stage.draggable() === true) { 26 | return; 27 | } 28 | 29 | const pointerPosition = useStageStore 30 | .getState() 31 | .getRelativePointerPosition(); 32 | 33 | if (!pointerPosition) { 34 | return; 35 | } 36 | 37 | setRect({ 38 | x1: pointerPosition.x, 39 | y1: pointerPosition.y, 40 | x2: pointerPosition.x, 41 | y2: pointerPosition.y, 42 | }); 43 | 44 | setVisible(true); 45 | isSelecting = true; 46 | }; 47 | 48 | const handleMouseMove = ( 49 | e: Konva.KonvaEventObject 50 | ) => { 51 | // do nothing if we didn't start selection 52 | if (!isSelecting) { 53 | return; 54 | } 55 | e.evt.preventDefault(); 56 | 57 | // const pointerPosition = stage.getPointerPosition(); 58 | const pointerPosition = useStageStore 59 | .getState() 60 | .getRelativePointerPosition(); 61 | if (!pointerPosition) { 62 | return; 63 | } 64 | 65 | setRect((state) => ({ 66 | ...state, 67 | x2: pointerPosition.x, 68 | y2: pointerPosition.y, 69 | })); 70 | }; 71 | 72 | const handleMouseUp = ( 73 | e: Konva.KonvaEventObject 74 | ) => { 75 | // do nothing if we didn't start selection 76 | if (!isSelecting) { 77 | return; 78 | } 79 | 80 | // update visibility in timeout, so we can check it in click event 81 | setTimeout(() => { 82 | setVisible(false); 83 | isSelecting = false; 84 | }); 85 | 86 | const shapes = stage.find(`.${SHAPE_NAME_OF_NODE}`); 87 | const box = selectionRef.current?.getClientRect(); 88 | if (!box) { 89 | return; 90 | } 91 | 92 | const selected = shapes.filter((shape) => { 93 | const intersected = Konva.Util.haveIntersection( 94 | box, 95 | shape.getClientRect({ 96 | skipShadow: true, 97 | skipStroke: true, 98 | }) 99 | ); 100 | return intersected; 101 | }); 102 | 103 | useUIStore 104 | .getState() 105 | .switchSelectNodes( 106 | selected 107 | .filter((node) => Boolean(node.attrs['nodeId'])) 108 | .map((node) => node.attrs['nodeId']) 109 | ); 110 | }; 111 | 112 | stage.on('mousedown touchstart', handleMouseDown); 113 | stage.on('mousemove touchmove', handleMouseMove); 114 | stage.on('mouseup touchend', handleMouseUp); 115 | stage.on('mouseleave touchcancel', handleMouseUp); 116 | 117 | return () => { 118 | stage.off('mousedown touchstart', handleMouseDown); 119 | stage.off('mousemove touchmove', handleMouseMove); 120 | stage.off('mouseup touchend', handleMouseUp); 121 | stage.off('mouseleave touchcancel', handleMouseUp); 122 | }; 123 | }); 124 | 125 | return ( 126 | 127 | 138 | 139 | ); 140 | }); 141 | SelectionLayer.displayName = 'SelectionLayer'; 142 | -------------------------------------------------------------------------------- /packages/codeck/src/components/FlowEditor/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from 'react'; 2 | import { Stage } from 'react-konva'; 3 | import Konva from 'konva'; 4 | import { GridLayer } from './GridLayer'; 5 | import { useMemoizedFn, useSize } from 'ahooks'; 6 | import { useStageStore } from '../../store/stage'; 7 | import { NodeLayer } from './NodeLayer'; 8 | import { ConnectionLayer } from './ConnectionLayer'; 9 | import { ContextMenuWrapper } from '../ContextMenu'; 10 | import { useStage } from '../../hooks/useStage'; 11 | import { useUIStore } from '../../store/ui'; 12 | import '../../nodes/__all__'; 13 | import { 14 | resetFlowEditorCursorStyle, 15 | setFlowEditorCursorStyle, 16 | } from '../../utils/pointer-helper'; 17 | import { SelectionLayer } from './SelectionLayer'; 18 | import { useConnectionStore } from '../../store/connection'; 19 | 20 | const scaleBy = 1.05; // 缩放系数 21 | 22 | export const FlowEditor: React.FC = React.memo(() => { 23 | const { width, height, scale, setStageRef, setSize, position } = 24 | useStageStore(); 25 | const stageRef = useRef(null); 26 | const containerRef = useRef(null); 27 | 28 | const size = useSize(containerRef.current); 29 | const { draggable, handleWheel, handleUpdatePos } = useStageEventHandler(); 30 | 31 | useEffect(() => { 32 | if (size) { 33 | setSize(size.width, size.height); 34 | } 35 | }, [size]); 36 | 37 | useEffect(() => { 38 | setStageRef(stageRef.current); 39 | }, []); 40 | 41 | return ( 42 | 46 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | ); 67 | }); 68 | FlowEditor.displayName = 'FlowEditor'; 69 | 70 | function useStageEventHandler() { 71 | const { setScale, setPosition } = useStageStore(); 72 | const [draggable, setDraggable] = useState(false); 73 | 74 | const handleWheel = useMemoizedFn((e: Konva.KonvaEventObject) => { 75 | e.evt.preventDefault(); 76 | const stage = e.target as Konva.Stage; 77 | if (!stage) { 78 | return; 79 | } 80 | 81 | const oldScale = stage.scaleX(); 82 | const pointer = stage.getPointerPosition(); 83 | if (!pointer) { 84 | return; 85 | } 86 | 87 | const mousePointTo = { 88 | x: (pointer.x - stage.x()) / oldScale, 89 | y: (pointer.y - stage.y()) / oldScale, 90 | }; 91 | 92 | // how to scale? Zoom in? Or zoom out? 93 | let direction = e.evt.deltaY > 0 ? 1 : -1; 94 | 95 | // when we zoom on trackpad, e.evt.ctrlKey is true 96 | // in that case lets revert direction 97 | if (e.evt.ctrlKey) { 98 | direction = -direction; 99 | } 100 | 101 | const newScale = direction > 0 ? oldScale * scaleBy : oldScale / scaleBy; 102 | 103 | setScale(newScale); 104 | 105 | const newPos = { 106 | x: pointer.x - mousePointTo.x * newScale, 107 | y: pointer.y - mousePointTo.y * newScale, 108 | }; 109 | setPosition(newPos); 110 | }); 111 | 112 | const handleUpdatePos = useMemoizedFn( 113 | (e: Konva.KonvaEventObject) => { 114 | setPosition(e.target.position()); 115 | } 116 | ); 117 | 118 | /** 119 | * Stage 事件 120 | */ 121 | useStage((stage) => { 122 | const handleClick = (e: Konva.KonvaEventObject) => { 123 | useUIStore.getState().clearSelectedStatus(); 124 | }; 125 | 126 | const handleMouseUp = (e: Konva.KonvaEventObject) => { 127 | useConnectionStore.getState().cancelConnect(); 128 | }; 129 | 130 | stage.on('click', handleClick); 131 | stage.on('mouseup', handleMouseUp); 132 | 133 | return () => { 134 | stage.off('click', handleClick); 135 | stage.off('mouseup', handleMouseUp); 136 | }; 137 | }); 138 | 139 | /** 140 | * window事件 141 | */ 142 | useStage((stage) => { 143 | const container = stage.container(); 144 | if (!container) { 145 | return; 146 | } 147 | 148 | const handleKeydown = (e: KeyboardEvent) => { 149 | if (e.code === 'Backspace' || e.code === 'Delete') { 150 | useUIStore.getState().deleteAllSelected(); 151 | return; 152 | } 153 | 154 | if (e.code === 'KeyF') { 155 | useStageStore.getState().focus(); 156 | return; 157 | } 158 | 159 | if (e.code === 'Space') { 160 | setDraggable(true); 161 | setFlowEditorCursorStyle('grab'); 162 | return; 163 | } 164 | }; 165 | 166 | const handleKeyUp = (e: KeyboardEvent) => { 167 | if (e.code === 'Space') { 168 | setDraggable(false); 169 | resetFlowEditorCursorStyle(); 170 | return; 171 | } 172 | }; 173 | 174 | window.addEventListener('keydown', handleKeydown); 175 | window.addEventListener('keyup', handleKeyUp); 176 | 177 | return () => { 178 | window.removeEventListener('keydown', handleKeydown); 179 | window.removeEventListener('keyup', handleKeyUp); 180 | }; 181 | }); 182 | 183 | return { draggable, handleWheel, handleUpdatePos }; 184 | } 185 | -------------------------------------------------------------------------------- /packages/codeck/src/hooks/useEditValue.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from 'react'; 2 | 3 | /** 4 | * 编辑模式的状态管理,外部变化会重置内部状态 5 | */ 6 | export const useEditValue = ( 7 | value: T, 8 | onChange: (val: T) => void 9 | ): [T, (val: T) => void, () => void, () => void] => { 10 | const [innerValue, setInnerValue] = useState(value); 11 | 12 | useEffect(() => { 13 | setInnerValue(value); 14 | }, [value]); 15 | 16 | const submit = useCallback(() => { 17 | onChange(innerValue); 18 | }, [innerValue, onChange]); 19 | 20 | const reset = useCallback(() => { 21 | setInnerValue(value); 22 | }, [value]); 23 | 24 | return [innerValue, setInnerValue, submit, reset]; 25 | }; 26 | -------------------------------------------------------------------------------- /packages/codeck/src/hooks/useNodeData.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import { useNodeStore } from '../store/node'; 3 | import { useNodeInfo } from './useNodeInfo'; 4 | 5 | /** 6 | * 获取节点数据 7 | */ 8 | export function useNodeData(nodeId: string): Record { 9 | return useNodeInfo(nodeId).node?.data ?? {}; 10 | } 11 | 12 | /** 13 | * 编辑节点数据 14 | */ 15 | export function useNodeDataValue( 16 | nodeId: string, 17 | dataKey: string 18 | ): [any, (value: unknown) => void] { 19 | const data = useNodeData(nodeId); 20 | 21 | const value = data[dataKey]; 22 | const setValue = useCallback( 23 | (newValue: unknown) => { 24 | useNodeStore.getState().setNodeData(nodeId, dataKey, newValue); 25 | }, 26 | [nodeId, dataKey] 27 | ); 28 | 29 | return [value, setValue]; 30 | } 31 | -------------------------------------------------------------------------------- /packages/codeck/src/hooks/useNodeInfo.ts: -------------------------------------------------------------------------------- 1 | import { CodeckNode, CodeckNodeDefinition, useNodeStore } from '../store/node'; 2 | 3 | /** 4 | * 获取节点信息 5 | */ 6 | export function useNodeInfo(nodeId: string): { 7 | node: CodeckNode | null; 8 | definition: CodeckNodeDefinition | null; 9 | } { 10 | const { nodeMap, nodeDefinition } = useNodeStore((state) => ({ 11 | nodeMap: state.nodeMap, 12 | nodeDefinition: state.nodeDefinition, 13 | })); 14 | const node = nodeMap[nodeId]; 15 | 16 | if (!node) { 17 | return { 18 | node: null, 19 | definition: null, 20 | }; 21 | } 22 | 23 | return { node, definition: nodeDefinition[node.name] }; 24 | } 25 | -------------------------------------------------------------------------------- /packages/codeck/src/hooks/usePinDefinition.ts: -------------------------------------------------------------------------------- 1 | import { CodeckNodePinDefinition } from '../store/node'; 2 | import { useNodeInfo } from './useNodeInfo'; 3 | 4 | /** 5 | * 获取Pin的定义 6 | */ 7 | export function usePinDefinition( 8 | nodeId: string, 9 | pinName: string 10 | ): CodeckNodePinDefinition | null { 11 | const { definition } = useNodeInfo(nodeId); 12 | 13 | if (!definition) { 14 | return null; 15 | } 16 | 17 | const pinDef = 18 | definition.inputs.find((input) => input.name === pinName) ?? 19 | definition.outputs.find((output) => output.name === pinName) ?? 20 | null; 21 | 22 | return pinDef; 23 | } 24 | -------------------------------------------------------------------------------- /packages/codeck/src/hooks/useStage.ts: -------------------------------------------------------------------------------- 1 | import { useMemoizedFn } from 'ahooks'; 2 | import Konva from 'konva'; 3 | import { useEffect } from 'react'; 4 | import { useStageStore } from '../store/stage'; 5 | 6 | export function useStage( 7 | callback: (stage: Konva.Stage) => ReturnType 8 | ) { 9 | const fn = useMemoizedFn(callback); 10 | const { stageRef } = useStageStore(); 11 | 12 | useEffect(() => { 13 | let ret: ReturnType; 14 | if (stageRef) { 15 | ret = fn(stageRef); 16 | } 17 | 18 | return () => { 19 | if (stageRef && ret) { 20 | ret(); 21 | } 22 | }; 23 | }, [stageRef]); 24 | } 25 | -------------------------------------------------------------------------------- /packages/codeck/src/hooks/useUpdateRef.ts: -------------------------------------------------------------------------------- 1 | import { useRef, MutableRefObject } from 'react'; 2 | 3 | export function useUpdateRef(state: T): MutableRefObject { 4 | const ref = useRef(state); 5 | ref.current = state; 6 | 7 | return ref; 8 | } 9 | -------------------------------------------------------------------------------- /packages/codeck/src/index.ts: -------------------------------------------------------------------------------- 1 | import 'immer'; // 这里是为了确保immer会被打包进去并且类型安全 Reference: https://github.com/microsoft/TypeScript/issues/42873 2 | 3 | export { FlowEditor } from './components/FlowEditor'; 4 | export { regNode } from './store/node'; 5 | export type { CodeckNodeDefinition } from './store/node'; 6 | export { CodeCompiler } from './code/compiler'; 7 | export { 8 | variableTypes, 9 | STANDARD_PIN_EXEC_IN, 10 | STANDARD_PIN_EXEC_OUT, 11 | } from './utils/consts'; 12 | export { 13 | buildPinPosX, 14 | buildPinPosY, 15 | defaultNodeWidth, 16 | buildNodeHeight, 17 | } from './utils/size-helper'; 18 | export { formatFunctionIndent } from './utils/string-helper'; 19 | export { PinLabel, OutputPinLabel } from './nodes/components/pin/Label'; 20 | 21 | export * as persist from './utils/persist'; 22 | export * as standard from './utils/standard'; 23 | export * from './store/__all__'; 24 | export * from './nodes/__all__'; 25 | -------------------------------------------------------------------------------- /packages/codeck/src/nodes/BaseNode.tsx: -------------------------------------------------------------------------------- 1 | import { NODE_TITLE_HEIGHT } from '../utils/consts'; 2 | import React from 'react'; 3 | import { Rect, Text } from 'react-konva'; 4 | import { useNodeInfo } from '../hooks/useNodeInfo'; 5 | import { CodeckNodeComponentProps } from '../store/node'; 6 | import { useUIStore } from '../store/ui'; 7 | import { color } from '../utils/color'; 8 | import { BaseNodeWrapper } from './BaseNodeWrapper'; 9 | import { usePinRender } from './hooks/usePinRender'; 10 | 11 | export const BaseNode: React.FC = React.memo( 12 | (props) => { 13 | const nodeId = props.id; 14 | const { node, definition } = useNodeInfo(nodeId); 15 | const { selectedNodeIds } = useUIStore(); 16 | const pinEl = usePinRender(nodeId); 17 | 18 | if (!node || !definition) { 19 | return null; 20 | } 21 | 22 | const { width, height, label } = definition; 23 | const { x, y } = node.position; 24 | 25 | return ( 26 | 27 | 47 | 48 | 54 | 55 | 66 | 67 | {pinEl} 68 | 69 | ); 70 | } 71 | ); 72 | BaseNode.displayName = 'BaseNode'; 73 | -------------------------------------------------------------------------------- /packages/codeck/src/nodes/BaseNodeWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { SHAPE_NAME_OF_NODE } from '../utils/consts'; 2 | import Konva from 'konva'; 3 | import React, { PropsWithChildren, useRef } from 'react'; 4 | import { Group } from 'react-konva'; 5 | import { useUIStore } from '../store/ui'; 6 | import { useMemoizedFn } from 'ahooks'; 7 | 8 | export const BaseNodeWrapper: React.FC< 9 | PropsWithChildren<{ 10 | x?: number; 11 | y?: number; 12 | nodeId: string; 13 | }> 14 | > = React.memo((props) => { 15 | const prevPosRef = useRef(null); 16 | 17 | const handleClick = useMemoizedFn((e: Konva.KonvaEventObject) => { 18 | e.cancelBubble = true; 19 | 20 | if (!e.evt.shiftKey) { 21 | useUIStore.getState().clearSelectedStatus(); 22 | } 23 | 24 | useUIStore.getState().addSelectedNodes([props.nodeId]); 25 | }); 26 | 27 | const handleDragStart = useMemoizedFn( 28 | (e: Konva.KonvaEventObject) => { 29 | e.cancelBubble = true; 30 | 31 | prevPosRef.current = e.target.getPosition(); 32 | const { selectedNodeIds, clearSelectedStatus, addSelectedNodes } = 33 | useUIStore.getState(); 34 | if (!selectedNodeIds.includes(props.nodeId)) { 35 | clearSelectedStatus(); 36 | addSelectedNodes([props.nodeId]); 37 | } 38 | } 39 | ); 40 | const handleDragMove = useMemoizedFn( 41 | (e: Konva.KonvaEventObject) => { 42 | e.cancelBubble = true; 43 | 44 | if (!prevPosRef.current) { 45 | return; 46 | } 47 | 48 | const currentPos = e.target.getPosition(); 49 | const deltaX = currentPos.x - prevPosRef.current.x; 50 | const deltaY = currentPos.y - prevPosRef.current.y; 51 | useUIStore.getState().moveSelected(deltaX, deltaY); 52 | prevPosRef.current = currentPos; 53 | } 54 | ); 55 | const handleDragEnd = useMemoizedFn( 56 | (e: Konva.KonvaEventObject) => { 57 | e.cancelBubble = true; 58 | 59 | if (!prevPosRef.current) { 60 | return; 61 | } 62 | 63 | const currentPos = e.target.getPosition(); 64 | const deltaX = currentPos.x - prevPosRef.current.x; 65 | const deltaY = currentPos.y - prevPosRef.current.y; 66 | useUIStore.getState().moveSelected(deltaX, deltaY); 67 | prevPosRef.current = null; 68 | } 69 | ); 70 | 71 | return ( 72 | 83 | {props.children} 84 | 85 | ); 86 | }); 87 | BaseNodeWrapper.displayName = 'BaseNodeWrapper'; 88 | -------------------------------------------------------------------------------- /packages/codeck/src/nodes/VariableNode.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Rect, Text } from 'react-konva'; 3 | import { useNodeData } from '../hooks/useNodeData'; 4 | import { useNodeInfo } from '../hooks/useNodeInfo'; 5 | import { CodeckNodeComponentProps } from '../store/node'; 6 | import { useUIStore } from '../store/ui'; 7 | import { color } from '../utils/color'; 8 | import { BaseNodeWrapper } from './BaseNodeWrapper'; 9 | import { usePinRender } from './hooks/usePinRender'; 10 | 11 | export const VariableNode: React.FC = React.memo( 12 | (props) => { 13 | const nodeId = props.id; 14 | const { node, definition } = useNodeInfo(nodeId); 15 | const { name } = useNodeData(nodeId); 16 | const { selectedNodeIds } = useUIStore(); 17 | const pinEl = usePinRender(nodeId); 18 | 19 | if (!node || !definition) { 20 | return null; 21 | } 22 | 23 | const { width, height } = definition; 24 | const { x, y } = node.position; 25 | 26 | return ( 27 | 28 | 48 | 49 | 58 | 59 | {pinEl} 60 | 61 | ); 62 | } 63 | ); 64 | VariableNode.displayName = 'VariableNode'; 65 | -------------------------------------------------------------------------------- /packages/codeck/src/nodes/VariableSetNode.tsx: -------------------------------------------------------------------------------- 1 | import { NODE_TITLE_HEIGHT } from '../utils/consts'; 2 | import React from 'react'; 3 | import { Rect, Text } from 'react-konva'; 4 | import { useNodeInfo } from '../hooks/useNodeInfo'; 5 | import { CodeckNodeComponentProps } from '../store/node'; 6 | import { useUIStore } from '../store/ui'; 7 | import { color } from '../utils/color'; 8 | import { BaseNodeWrapper } from './BaseNodeWrapper'; 9 | import { usePinRender } from './hooks/usePinRender'; 10 | 11 | export const VariableSetNode: React.FC = React.memo( 12 | (props) => { 13 | const nodeId = props.id; 14 | const { node, definition } = useNodeInfo(nodeId); 15 | const { selectedNodeIds } = useUIStore(); 16 | const pinEl = usePinRender(nodeId); 17 | 18 | if (!node || !definition) { 19 | return null; 20 | } 21 | 22 | const { width, height, label } = definition; 23 | const { x, y } = node.position; 24 | const { name } = node.data ?? {}; 25 | 26 | return ( 27 | 28 | 48 | 49 | 55 | 56 | 67 | 68 | {pinEl} 69 | 70 | ); 71 | } 72 | ); 73 | VariableSetNode.displayName = 'VariableSetNode'; 74 | -------------------------------------------------------------------------------- /packages/codeck/src/nodes/__all__.ts: -------------------------------------------------------------------------------- 1 | import { AlertNodeDefinition } from './definitions/core/alert'; 2 | import { BeginNodeDefinition } from './definitions/core/begin'; 3 | import { FetchNodeDefinition } from './definitions/core/fetch'; 4 | import { ForEachNodeDefinition } from './definitions/core/foreach'; 5 | import { IfNodeDefinition } from './definitions/core/if'; 6 | import { IncludesNodeDefinition } from './definitions/core/includes'; 7 | import { JSONStringifyNodeDefinition } from './definitions/core/json-stringify'; 8 | import { LengthNodeDefinition } from './definitions/core/length'; 9 | import { LogNodeDefinition } from './definitions/core/log'; 10 | import { LogErrorNodeDefinition } from './definitions/core/log-error'; 11 | import { LoopNodeDefinition } from './definitions/core/loop'; 12 | import { RawJSNodeDefinition } from './definitions/core/raw-js'; 13 | import { SleepNodeDefinition } from './definitions/core/sleep'; 14 | import { TimerNodeDefinition } from './definitions/core/timer'; 15 | import { LodashGetNodeDefinition } from './definitions/lodash/get'; 16 | import { AddNodeDefinition } from './definitions/logic/add'; 17 | import { AnlNodeDefinition } from './definitions/logic/anl'; 18 | import { DividedNodeDefinition } from './definitions/logic/divided'; 19 | import { EqualNodeDefinition } from './definitions/logic/equal'; 20 | import { GTNodeDefinition } from './definitions/logic/gt'; 21 | import { GTENodeDefinition } from './definitions/logic/gte'; 22 | import { LTNodeDefinition } from './definitions/logic/lt'; 23 | import { LTENodeDefinition } from './definitions/logic/lte'; 24 | import { ModNodeDefinition } from './definitions/logic/mod'; 25 | import { MultiplyNodeDefinition } from './definitions/logic/multiply'; 26 | import { NotNodeDefinition } from './definitions/logic/not'; 27 | import { OrlNodeDefinition } from './definitions/logic/orl'; 28 | import { SubtractNodeDefinition } from './definitions/logic/subtract'; 29 | import { VarGetNodeDefinition } from './definitions/varget'; 30 | import { VarSetNodeDefinition } from './definitions/varset'; 31 | 32 | // definition 33 | export const builtinNodeDefinition = { 34 | // Core 35 | BeginNodeDefinition, 36 | FetchNodeDefinition, 37 | ForEachNodeDefinition, 38 | IfNodeDefinition, 39 | IncludesNodeDefinition, 40 | LengthNodeDefinition, 41 | JSONStringifyNodeDefinition, 42 | LogErrorNodeDefinition, 43 | LogNodeDefinition, 44 | VarGetNodeDefinition, 45 | VarSetNodeDefinition, 46 | AlertNodeDefinition, 47 | LoopNodeDefinition, 48 | RawJSNodeDefinition, 49 | SleepNodeDefinition, 50 | TimerNodeDefinition, 51 | 52 | // Logic 53 | AddNodeDefinition, 54 | SubtractNodeDefinition, 55 | MultiplyNodeDefinition, 56 | DividedNodeDefinition, 57 | EqualNodeDefinition, 58 | GTNodeDefinition, 59 | GTENodeDefinition, 60 | LTNodeDefinition, 61 | LTENodeDefinition, 62 | ModNodeDefinition, 63 | AnlNodeDefinition, 64 | OrlNodeDefinition, 65 | NotNodeDefinition, 66 | 67 | // Lodash 68 | LodashGetNodeDefinition, 69 | }; 70 | 71 | // node 72 | export { BaseNode } from './BaseNode'; 73 | export { BaseNodeWrapper } from './BaseNodeWrapper'; 74 | export { VariableNode } from './VariableNode'; 75 | 76 | // input 77 | export { NodeInputBase } from './components/input/Base'; 78 | export { NodeInputBoolean } from './components/input/Boolean'; 79 | export { NodeInputNumber } from './components/input/Number'; 80 | export { NodeInputText } from './components/input/Text'; 81 | export { NodeInputSelect } from './components/input/Select'; 82 | 83 | // preset 84 | export { BooleanInputPreset } from './components/preset/BooleanInputPreset'; 85 | export { NumberInputPreset } from './components/preset/NumberInputPreset'; 86 | export { TextInputPreset } from './components/preset/TextInputPreset'; 87 | export { SelectInputPreset } from './components/preset/SelectInputPreset'; 88 | -------------------------------------------------------------------------------- /packages/codeck/src/nodes/components/input/Base.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | resetFlowEditorCursorStyle, 3 | setFlowEditorCursorStyle, 4 | } from '../../../utils/pointer-helper'; 5 | import React, { useCallback, useState } from 'react'; 6 | import { Group, Rect, Text } from 'react-konva'; 7 | import { useEditValue } from '../../../hooks/useEditValue'; 8 | 9 | const defaultWidth = 80; 10 | const defaultHeight = 16; 11 | 12 | export interface NodeInputProps { 13 | x?: number; 14 | y?: number; 15 | width?: number; 16 | height?: number; 17 | value: T; 18 | onChange: (text: T) => void; 19 | } 20 | 21 | interface NodeInputBaseProps extends NodeInputProps { 22 | renderEditor: (ctx: { 23 | width: number; 24 | height: number; 25 | handleBlur: () => void; 26 | value: T; 27 | setValue: (val: T) => void; 28 | }) => React.ReactNode; 29 | } 30 | export const NodeInputBase: React.FC = React.memo( 31 | (props) => { 32 | const [isEditing, setIsEditing] = useState(false); 33 | const { x, y, width = defaultWidth, height = defaultHeight } = props; 34 | const [value, setValue, submitValue] = useEditValue( 35 | props.value, 36 | props.onChange 37 | ); 38 | 39 | const handleBlur = useCallback(() => { 40 | resetFlowEditorCursorStyle(); 41 | submitValue(); 42 | setIsEditing(false); 43 | }, [submitValue]); 44 | 45 | const handleMouseEnter = useCallback(() => { 46 | setFlowEditorCursorStyle('text'); 47 | }, []); 48 | 49 | const handleMouseLeave = useCallback(() => { 50 | resetFlowEditorCursorStyle(); 51 | }, []); 52 | 53 | return ( 54 | 60 | 61 | 62 | {isEditing ? ( 63 | props.renderEditor({ 64 | width, 65 | height, 66 | value, 67 | setValue, 68 | handleBlur, 69 | }) 70 | ) : ( 71 | setIsEditing(true)} 79 | /> 80 | )} 81 | 82 | ); 83 | } 84 | ); 85 | NodeInputBase.displayName = 'NodeInputBase'; 86 | -------------------------------------------------------------------------------- /packages/codeck/src/nodes/components/input/Boolean.tsx: -------------------------------------------------------------------------------- 1 | import { color } from '../../../utils/color'; 2 | import React from 'react'; 3 | import { Rect } from 'react-konva'; 4 | import { NodeInputProps } from './Base'; 5 | 6 | const size = 10; 7 | 8 | export const NodeInputBoolean: React.FC = React.memo( 9 | (props) => { 10 | const { x, y, value, onChange } = props; 11 | 12 | return ( 13 | { 22 | e.cancelBubble = true; 23 | onChange(!value); 24 | }} 25 | /> 26 | ); 27 | } 28 | ); 29 | NodeInputBoolean.displayName = 'NodeInputBoolean'; 30 | -------------------------------------------------------------------------------- /packages/codeck/src/nodes/components/input/Number.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Html } from 'react-konva-utils'; 3 | import { NodeInputBase, NodeInputProps } from './Base'; 4 | 5 | export const NodeInputNumber: React.FC = React.memo((props) => { 6 | return ( 7 | ( 10 | 11 | setValue(e.target.value)} 27 | onBlur={handleBlur} 28 | onKeyDown={(e) => e.stopPropagation()} 29 | /> 30 | 31 | )} 32 | /> 33 | ); 34 | }); 35 | NodeInputNumber.displayName = 'NodeInputNumber'; 36 | -------------------------------------------------------------------------------- /packages/codeck/src/nodes/components/input/Select.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Html } from 'react-konva-utils'; 3 | import { NodeInputBase, NodeInputProps } from './Base'; 4 | import { Select } from '@arco-design/web-react'; 5 | import styled from 'styled-components'; 6 | 7 | const SuperMiniSelect = styled(Select)({ 8 | height: 16, 9 | display: 'block', 10 | 11 | '.arco-select-view': { 12 | height: '16px !important', 13 | lineHeight: '14px !important', 14 | }, 15 | }); 16 | 17 | const optionStyle: React.CSSProperties = { 18 | height: 16, 19 | lineHeight: '14px', 20 | fontSize: 10, 21 | }; 22 | 23 | export interface NodeInputSelectProps extends NodeInputProps { 24 | options: { label: string; value: number | string }[]; 25 | } 26 | export const NodeInputSelect: React.FC = React.memo( 27 | (props) => { 28 | const { options, ...others } = props; 29 | const [popupVisible, setPopupVisible] = useState(false); 30 | 31 | return ( 32 | ( 35 | 36 | { 38 | setPopupVisible(true); 39 | }} 40 | style={{ 41 | width, 42 | height, 43 | }} 44 | size="mini" 45 | popupVisible={popupVisible} 46 | onVisibleChange={setPopupVisible} 47 | placeholder={value} 48 | value={value} 49 | onChange={(value) => setValue(value)} 50 | onBlur={handleBlur} 51 | > 52 | {options.map((opt, i) => ( 53 | 58 | {opt.label} 59 | 60 | ))} 61 | 62 | 63 | )} 64 | /> 65 | ); 66 | } 67 | ); 68 | NodeInputSelect.displayName = 'NodeInputSelect'; 69 | -------------------------------------------------------------------------------- /packages/codeck/src/nodes/components/input/Text.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Html } from 'react-konva-utils'; 3 | import { NodeInputBase, NodeInputProps } from './Base'; 4 | import { sharedInputStyle } from './shared'; 5 | 6 | export const NodeInputText: React.FC = React.memo((props) => { 7 | return ( 8 | ( 11 | 12 | setValue(e.target.value)} 22 | onBlur={handleBlur} 23 | onKeyDown={(e) => e.stopPropagation()} 24 | /> 25 | 26 | )} 27 | /> 28 | ); 29 | }); 30 | NodeInputText.displayName = 'NodeInputText'; 31 | -------------------------------------------------------------------------------- /packages/codeck/src/nodes/components/input/TextArea.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Html } from 'react-konva-utils'; 3 | import { NodeInputBase, NodeInputProps } from './Base'; 4 | import { sharedInputStyle } from './shared'; 5 | 6 | export interface NodeInputTextAreaProps extends NodeInputProps { 7 | row: number; 8 | } 9 | export const NodeInputTextArea: React.FC = React.memo( 10 | (props) => { 11 | return ( 12 | ( 16 | 17 |