Repository: bytedance/flowgram.ai Branch: main Commit: 2511ff5cfd65 Files: 3381 Total size: 6.7 MB Directory structure: gitextract_ksplgn6_/ ├── .claude/ │ ├── commands/ │ │ └── add-tests.md │ └── skills/ │ ├── create-node/ │ │ ├── SKILL.md │ │ └── templates/ │ │ ├── README.md │ │ ├── complex-node/ │ │ │ ├── components/ │ │ │ │ └── custom-component.tsx │ │ │ ├── form-meta.tsx │ │ │ ├── index.tsx │ │ │ └── types.tsx │ │ └── simple-node/ │ │ └── index.ts │ ├── material-component-dev/ │ │ └── SKILL.md │ └── material-component-doc/ │ ├── SKILL.md │ └── templates/ │ └── material.mdx ├── .gitattributes ├── .github/ │ ├── CODEOWNERS │ ├── ISSUE_TEMPLATE/ │ │ ├── bug-report.md │ │ └── question.md │ └── workflows/ │ ├── ci.yml │ ├── common-pr-checks.yml │ ├── deploy.yml │ ├── e2e.yml │ ├── publish-alpha.yml │ ├── publish-app-to-version.yml │ ├── publish-app.yml │ ├── publish-minor.yml │ ├── publish-to-version.yml │ ├── publish.yml │ └── sync-screenshot.yml ├── .gitignore ├── .vscode/ │ ├── extentions.json │ └── settings.json ├── AGENTS.md ├── CHANGELOG.md ├── CLAUDE.md ├── CNAME ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── README_DE.md ├── README_ES.md ├── README_JA.md ├── README_PT.md ├── README_RU.md ├── README_ZH.md ├── apps/ │ ├── cli/ │ │ ├── .gitignore │ │ ├── bin/ │ │ │ └── index.js │ │ ├── eslint.config.js │ │ ├── package.json │ │ ├── src/ │ │ │ ├── create-app/ │ │ │ │ └── index.ts │ │ │ ├── find-materials/ │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── materials/ │ │ │ │ ├── copy.ts │ │ │ │ ├── index.ts │ │ │ │ ├── material.ts │ │ │ │ ├── refresh-project-import.ts │ │ │ │ ├── select.ts │ │ │ │ └── types.ts │ │ │ ├── update-version/ │ │ │ │ └── index.ts │ │ │ └── utils/ │ │ │ ├── export.ts │ │ │ ├── file.ts │ │ │ ├── import.ts │ │ │ ├── npm.ts │ │ │ ├── project.ts │ │ │ └── ts-file.ts │ │ ├── tsconfig.json │ │ └── tsup.config.js │ ├── create-app/ │ │ ├── bin/ │ │ │ └── index.js │ │ ├── eslint.config.js │ │ ├── package.json │ │ ├── src/ │ │ │ └── index.ts │ │ └── tsconfig.json │ ├── demo-fixed-layout/ │ │ ├── README.md │ │ ├── README.zh_CN.md │ │ ├── eslint.config.js │ │ ├── index.html │ │ ├── package.json │ │ ├── rsbuild.config.ts │ │ ├── src/ │ │ │ ├── app.tsx │ │ │ ├── assets/ │ │ │ │ ├── icon-mouse.tsx │ │ │ │ └── icon-pad.tsx │ │ │ ├── components/ │ │ │ │ ├── agent-adder/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── agent-label/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── base-node/ │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── styles.tsx │ │ │ │ ├── branch-adder/ │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── styles.tsx │ │ │ │ ├── drag-node/ │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── styles.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── node-adder/ │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── styles.tsx │ │ │ │ │ └── utils.ts │ │ │ │ ├── node-list.tsx │ │ │ │ ├── selector-box-popover/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── sidebar/ │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── sidebar-node-renderer.tsx │ │ │ │ │ └── sidebar-renderer.tsx │ │ │ │ └── tools/ │ │ │ │ ├── download.tsx │ │ │ │ ├── fit-view.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── interactive.tsx │ │ │ │ ├── minimap-switch.tsx │ │ │ │ ├── minimap.tsx │ │ │ │ ├── mouse-pad-selector.less │ │ │ │ ├── mouse-pad-selector.tsx │ │ │ │ ├── readonly.tsx │ │ │ │ ├── run.tsx │ │ │ │ ├── save.tsx │ │ │ │ ├── styles.tsx │ │ │ │ ├── switch-vertical.tsx │ │ │ │ └── zoom-select.tsx │ │ │ ├── context/ │ │ │ │ ├── index.ts │ │ │ │ ├── node-render-context.ts │ │ │ │ └── sidebar-context.ts │ │ │ ├── editor.tsx │ │ │ ├── form-components/ │ │ │ │ ├── feedback.tsx │ │ │ │ ├── form-content/ │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── styles.tsx │ │ │ │ ├── form-header/ │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── styles.tsx │ │ │ │ │ ├── title-input.tsx │ │ │ │ │ └── utils.tsx │ │ │ │ ├── form-inputs/ │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── styles.tsx │ │ │ │ ├── form-item/ │ │ │ │ │ ├── index.css │ │ │ │ │ └── index.tsx │ │ │ │ ├── form-outputs/ │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── styles.tsx │ │ │ │ ├── index.ts │ │ │ │ └── properties-edit/ │ │ │ │ ├── index.tsx │ │ │ │ ├── property-edit.tsx │ │ │ │ └── styles.tsx │ │ │ ├── hooks/ │ │ │ │ ├── index.ts │ │ │ │ ├── use-editor-props.ts │ │ │ │ ├── use-form-value.ts │ │ │ │ ├── use-is-sidebar.ts │ │ │ │ └── use-node-render-context.ts │ │ │ ├── index.ts │ │ │ ├── initial-data.ts │ │ │ ├── nodes/ │ │ │ │ ├── agent/ │ │ │ │ │ ├── agent-llm.ts │ │ │ │ │ ├── agent-memory.ts │ │ │ │ │ ├── agent-tools.ts │ │ │ │ │ ├── agent.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── memory.ts │ │ │ │ │ └── tool.ts │ │ │ │ ├── break-loop/ │ │ │ │ │ ├── form-meta.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── case/ │ │ │ │ │ ├── form-meta.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── case-default/ │ │ │ │ │ ├── form-meta.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── catch-block/ │ │ │ │ │ ├── form-meta.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── default-form-meta.tsx │ │ │ │ ├── end/ │ │ │ │ │ ├── form-meta.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── if/ │ │ │ │ │ └── index.ts │ │ │ │ ├── if-block/ │ │ │ │ │ ├── form-meta.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── llm/ │ │ │ │ │ └── index.ts │ │ │ │ ├── loop/ │ │ │ │ │ ├── form-meta.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── start/ │ │ │ │ │ ├── form-meta.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── switch/ │ │ │ │ │ └── index.ts │ │ │ │ └── trycatch/ │ │ │ │ ├── form-meta.tsx │ │ │ │ └── index.ts │ │ │ ├── plugins/ │ │ │ │ ├── clipboard-plugin/ │ │ │ │ │ └── create-clipboard-plugin.ts │ │ │ │ ├── group-plugin/ │ │ │ │ │ ├── group-box-header.tsx │ │ │ │ │ ├── group-node.tsx │ │ │ │ │ ├── group-note.tsx │ │ │ │ │ ├── group-tools.tsx │ │ │ │ │ ├── icons/ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ └── multilang-textarea-editor/ │ │ │ │ │ ├── base-textarea.tsx │ │ │ │ │ ├── index.css │ │ │ │ │ └── index.tsx │ │ │ │ ├── index.ts │ │ │ │ └── variable-panel-plugin/ │ │ │ │ ├── components/ │ │ │ │ │ ├── full-variable-list.tsx │ │ │ │ │ ├── global-variable-editor.tsx │ │ │ │ │ ├── index.module.less │ │ │ │ │ └── variable-panel.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── variable-panel-layer.tsx │ │ │ │ └── variable-panel-plugin.ts │ │ │ ├── services/ │ │ │ │ ├── custom-service.ts │ │ │ │ └── index.ts │ │ │ ├── shortcuts/ │ │ │ │ ├── constants.ts │ │ │ │ ├── index.ts │ │ │ │ └── utils.ts │ │ │ ├── type.d.ts │ │ │ └── typings/ │ │ │ ├── index.ts │ │ │ ├── json-schema.ts │ │ │ └── node.ts │ │ └── tsconfig.json │ ├── demo-fixed-layout-animation/ │ │ ├── eslint.config.js │ │ ├── index.html │ │ ├── package.json │ │ ├── rsbuild.config.ts │ │ ├── src/ │ │ │ ├── app.tsx │ │ │ ├── components/ │ │ │ │ ├── form-render/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── loading-dots/ │ │ │ │ │ ├── index.less │ │ │ │ │ └── index.tsx │ │ │ │ ├── node-render/ │ │ │ │ │ ├── index.less │ │ │ │ │ └── index.tsx │ │ │ │ ├── thinking-node/ │ │ │ │ │ ├── index.less │ │ │ │ │ └── index.tsx │ │ │ │ ├── tools/ │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── minimap.tsx │ │ │ │ └── update-schema/ │ │ │ │ ├── example-schemas.ts │ │ │ │ ├── example.py │ │ │ │ ├── index.less │ │ │ │ └── index.tsx │ │ │ ├── fields/ │ │ │ │ ├── content-field/ │ │ │ │ │ ├── index.less │ │ │ │ │ └── index.tsx │ │ │ │ ├── thinking-text-field/ │ │ │ │ │ ├── index.less │ │ │ │ │ └── index.tsx │ │ │ │ └── title-field/ │ │ │ │ ├── index.less │ │ │ │ └── index.tsx │ │ │ ├── hooks/ │ │ │ │ ├── use-editor-props.tsx │ │ │ │ └── use-node-loading.tsx │ │ │ ├── nodes/ │ │ │ │ ├── condition/ │ │ │ │ │ └── index.ts │ │ │ │ ├── custom/ │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ └── thinking/ │ │ │ │ └── index.tsx │ │ │ └── services/ │ │ │ ├── index.ts │ │ │ └── load-schema-service/ │ │ │ ├── index.ts │ │ │ ├── type.ts │ │ │ └── utils.ts │ │ └── tsconfig.json │ ├── demo-fixed-layout-simple/ │ │ ├── eslint.config.js │ │ ├── index.html │ │ ├── package.json │ │ ├── rsbuild.config.ts │ │ ├── src/ │ │ │ ├── app.tsx │ │ │ ├── components/ │ │ │ │ ├── base-node.tsx │ │ │ │ ├── branch-adder.tsx │ │ │ │ ├── flow-select.tsx │ │ │ │ ├── minimap.tsx │ │ │ │ ├── node-add-panel.tsx │ │ │ │ ├── node-adder.tsx │ │ │ │ ├── slot-adder.tsx │ │ │ │ └── tools.tsx │ │ │ ├── data/ │ │ │ │ ├── condition.ts │ │ │ │ ├── dynamicSplit.ts │ │ │ │ ├── index.ts │ │ │ │ ├── loop.ts │ │ │ │ ├── mindmap.ts │ │ │ │ ├── multiInputs.ts │ │ │ │ ├── multiOutputs.ts │ │ │ │ ├── slot.ts │ │ │ │ └── tryCatch.ts │ │ │ ├── editor.tsx │ │ │ ├── hooks/ │ │ │ │ ├── use-add-node.tsx │ │ │ │ └── use-editor-props.tsx │ │ │ ├── index.css │ │ │ ├── index.ts │ │ │ ├── initial-data.ts │ │ │ └── node-registries.ts │ │ └── tsconfig.json │ ├── demo-free-layout/ │ │ ├── README.md │ │ ├── README.zh_CN.md │ │ ├── eslint.config.js │ │ ├── index.html │ │ ├── package.json │ │ ├── rsbuild.config.ts │ │ ├── src/ │ │ │ ├── app.tsx │ │ │ ├── assets/ │ │ │ │ ├── icon-auto-layout.tsx │ │ │ │ ├── icon-cancel.tsx │ │ │ │ ├── icon-comment.tsx │ │ │ │ ├── icon-minimap.tsx │ │ │ │ ├── icon-mouse.tsx │ │ │ │ ├── icon-pad.tsx │ │ │ │ ├── icon-success.tsx │ │ │ │ ├── icon-switch-line.tsx │ │ │ │ └── icon-warning.tsx │ │ │ ├── components/ │ │ │ │ ├── add-node/ │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── use-add-node.ts │ │ │ │ ├── base-node/ │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── node-wrapper.tsx │ │ │ │ │ ├── styles.tsx │ │ │ │ │ └── utils.ts │ │ │ │ ├── comment/ │ │ │ │ │ ├── components/ │ │ │ │ │ │ ├── blank-area.tsx │ │ │ │ │ │ ├── border-area.tsx │ │ │ │ │ │ ├── container.tsx │ │ │ │ │ │ ├── content-drag-area.tsx │ │ │ │ │ │ ├── drag-area.tsx │ │ │ │ │ │ ├── editor.tsx │ │ │ │ │ │ ├── index.css │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── more-button.tsx │ │ │ │ │ │ ├── render.tsx │ │ │ │ │ │ └── resize-area.tsx │ │ │ │ │ ├── constant.ts │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── use-model.ts │ │ │ │ │ │ ├── use-overflow.ts │ │ │ │ │ │ ├── use-placeholder.ts │ │ │ │ │ │ └── use-size.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── model.ts │ │ │ │ │ └── type.ts │ │ │ │ ├── group/ │ │ │ │ │ ├── color.ts │ │ │ │ │ ├── components/ │ │ │ │ │ │ ├── background.tsx │ │ │ │ │ │ ├── color.tsx │ │ │ │ │ │ ├── header.tsx │ │ │ │ │ │ ├── icon-group.tsx │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── node-render.tsx │ │ │ │ │ │ ├── tips/ │ │ │ │ │ │ │ ├── global-store.ts │ │ │ │ │ │ │ ├── icon-close.tsx │ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ │ ├── is-mac-os.ts │ │ │ │ │ │ │ ├── style.ts │ │ │ │ │ │ │ └── use-control.ts │ │ │ │ │ │ ├── title.tsx │ │ │ │ │ │ ├── tools.tsx │ │ │ │ │ │ └── ungroup.tsx │ │ │ │ │ ├── constant.ts │ │ │ │ │ ├── index.css │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── line-add-button/ │ │ │ │ │ ├── button.tsx │ │ │ │ │ ├── index.less │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── use-visible.ts │ │ │ │ ├── node-menu/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── node-panel/ │ │ │ │ │ ├── index.less │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── node-list.tsx │ │ │ │ │ └── node-placeholder.tsx │ │ │ │ ├── problem-panel/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── problem-panel.tsx │ │ │ │ │ └── use-watch-validate.ts │ │ │ │ ├── selector-box-popover/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── sidebar/ │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── node-form-panel.tsx │ │ │ │ │ └── sidebar-node-renderer.tsx │ │ │ │ ├── testrun/ │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── use-fields.ts │ │ │ │ │ │ ├── use-form-meta.ts │ │ │ │ │ │ └── use-sync-default.ts │ │ │ │ │ ├── json-value-editor/ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── node-status-bar/ │ │ │ │ │ │ ├── group/ │ │ │ │ │ │ │ ├── index.module.less │ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ │ ├── header/ │ │ │ │ │ │ │ ├── index.module.less │ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ ├── render/ │ │ │ │ │ │ │ ├── index.module.less │ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ │ └── viewer/ │ │ │ │ │ │ ├── index.module.less │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── testrun-button/ │ │ │ │ │ │ ├── index.module.less │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── testrun-form/ │ │ │ │ │ │ ├── index.module.less │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ └── type.ts │ │ │ │ │ ├── testrun-json-input/ │ │ │ │ │ │ ├── index.module.less │ │ │ │ │ │ └── index.tsx │ │ │ │ │ └── testrun-panel/ │ │ │ │ │ ├── index.module.less │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── test-run-panel.tsx │ │ │ │ └── tools/ │ │ │ │ ├── auto-layout.tsx │ │ │ │ ├── comment.tsx │ │ │ │ ├── download.tsx │ │ │ │ ├── fit-view.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── interactive.tsx │ │ │ │ ├── minimap-switch.tsx │ │ │ │ ├── minimap.tsx │ │ │ │ ├── mouse-pad-selector.less │ │ │ │ ├── mouse-pad-selector.tsx │ │ │ │ ├── readonly.tsx │ │ │ │ ├── save.tsx │ │ │ │ ├── styles.tsx │ │ │ │ ├── switch-line.tsx │ │ │ │ └── zoom-select.tsx │ │ │ ├── context/ │ │ │ │ ├── index.ts │ │ │ │ ├── node-render-context.ts │ │ │ │ └── sidebar-context.ts │ │ │ ├── editor.tsx │ │ │ ├── form-components/ │ │ │ │ ├── feedback.tsx │ │ │ │ ├── form-content/ │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── styles.tsx │ │ │ │ ├── form-header/ │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── styles.tsx │ │ │ │ │ ├── title-input.tsx │ │ │ │ │ └── utils.tsx │ │ │ │ ├── form-inputs/ │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── styles.tsx │ │ │ │ ├── form-item/ │ │ │ │ │ ├── index.css │ │ │ │ │ └── index.tsx │ │ │ │ └── index.ts │ │ │ ├── hooks/ │ │ │ │ ├── index.ts │ │ │ │ ├── use-editor-props.tsx │ │ │ │ ├── use-is-sidebar.ts │ │ │ │ ├── use-node-render-context.ts │ │ │ │ └── use-port-click.ts │ │ │ ├── index.ts │ │ │ ├── initial-data.ts │ │ │ ├── nodes/ │ │ │ │ ├── block-end/ │ │ │ │ │ ├── form-meta.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── block-start/ │ │ │ │ │ ├── form-meta.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── break/ │ │ │ │ │ ├── form-meta.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── code/ │ │ │ │ │ ├── components/ │ │ │ │ │ │ ├── code.tsx │ │ │ │ │ │ ├── inputs.tsx │ │ │ │ │ │ └── outputs.tsx │ │ │ │ │ ├── form-meta.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── types.tsx │ │ │ │ ├── comment/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── condition/ │ │ │ │ │ ├── condition-inputs/ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ └── styles.tsx │ │ │ │ │ ├── form-meta.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── constants.ts │ │ │ │ ├── continue/ │ │ │ │ │ ├── form-meta.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── default-form-meta.tsx │ │ │ │ ├── end/ │ │ │ │ │ ├── form-meta.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── group/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── http/ │ │ │ │ │ ├── components/ │ │ │ │ │ │ ├── api.tsx │ │ │ │ │ │ ├── body.tsx │ │ │ │ │ │ ├── headers.tsx │ │ │ │ │ │ ├── params.tsx │ │ │ │ │ │ └── timeout.tsx │ │ │ │ │ ├── form-meta.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── types.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── llm/ │ │ │ │ │ └── index.ts │ │ │ │ ├── loop/ │ │ │ │ │ ├── form-meta.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── multi-condition/ │ │ │ │ │ ├── condition-inputs/ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ └── styles.tsx │ │ │ │ │ ├── form-meta.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── start/ │ │ │ │ │ ├── form-meta.tsx │ │ │ │ │ └── index.ts │ │ │ │ └── variable/ │ │ │ │ ├── form-meta.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── types.tsx │ │ │ ├── plugins/ │ │ │ │ ├── context-menu-plugin/ │ │ │ │ │ ├── context-menu-layer.tsx │ │ │ │ │ ├── context-menu-plugin.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── panel-manager-plugin/ │ │ │ │ │ ├── constants.ts │ │ │ │ │ ├── hooks.ts │ │ │ │ │ └── index.tsx │ │ │ │ ├── runtime-plugin/ │ │ │ │ │ ├── client/ │ │ │ │ │ │ ├── base-client.ts │ │ │ │ │ │ ├── browser-client/ │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── server-client/ │ │ │ │ │ │ ├── constant.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── type.ts │ │ │ │ │ ├── create-runtime-plugin.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── runtime-service/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── type.ts │ │ │ │ └── variable-panel-plugin/ │ │ │ │ ├── components/ │ │ │ │ │ ├── full-variable-list.tsx │ │ │ │ │ ├── global-variable-editor.tsx │ │ │ │ │ ├── index.module.less │ │ │ │ │ └── variable-panel.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── variable-panel-layer.tsx │ │ │ │ └── variable-panel-plugin.ts │ │ │ ├── services/ │ │ │ │ ├── custom-service.ts │ │ │ │ ├── index.ts │ │ │ │ └── validate-service.ts │ │ │ ├── shortcuts/ │ │ │ │ ├── collapse/ │ │ │ │ │ └── index.ts │ │ │ │ ├── constants.ts │ │ │ │ ├── copy/ │ │ │ │ │ └── index.ts │ │ │ │ ├── delete/ │ │ │ │ │ └── index.ts │ │ │ │ ├── expand/ │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── paste/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── traverse.ts │ │ │ │ │ └── unique-workflow.ts │ │ │ │ ├── select-all/ │ │ │ │ │ └── index.ts │ │ │ │ ├── shortcuts.ts │ │ │ │ ├── type.ts │ │ │ │ ├── zoom-in/ │ │ │ │ │ └── index.ts │ │ │ │ └── zoom-out/ │ │ │ │ └── index.ts │ │ │ ├── styles/ │ │ │ │ └── index.css │ │ │ ├── type.d.ts │ │ │ ├── typings/ │ │ │ │ ├── index.ts │ │ │ │ ├── json-schema.ts │ │ │ │ └── node.ts │ │ │ └── utils/ │ │ │ ├── can-contain-node.ts │ │ │ ├── index.ts │ │ │ ├── on-drag-line-end.ts │ │ │ └── toggle-loop-expanded.ts │ │ └── tsconfig.json │ ├── demo-free-layout-simple/ │ │ ├── eslint.config.js │ │ ├── index.html │ │ ├── package.json │ │ ├── rsbuild.config.ts │ │ ├── src/ │ │ │ ├── app.tsx │ │ │ ├── components/ │ │ │ │ ├── minimap.tsx │ │ │ │ ├── node-add-panel.tsx │ │ │ │ └── tools.tsx │ │ │ ├── editor.tsx │ │ │ ├── hooks/ │ │ │ │ └── use-editor-props.tsx │ │ │ ├── index.css │ │ │ ├── index.tsx │ │ │ ├── initial-data.ts │ │ │ └── nodes/ │ │ │ ├── batch/ │ │ │ │ └── index.ts │ │ │ ├── batch-function/ │ │ │ │ ├── create-batch-function-json.ts │ │ │ │ ├── create-batch-function-lines.ts │ │ │ │ ├── create-batch-function.ts │ │ │ │ ├── form-meta.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── registry.ts │ │ │ │ └── relation.ts │ │ │ ├── block-end/ │ │ │ │ ├── form-meta.tsx │ │ │ │ └── index.ts │ │ │ ├── block-start/ │ │ │ │ ├── form-meta.tsx │ │ │ │ └── index.ts │ │ │ ├── chain/ │ │ │ │ └── index.ts │ │ │ ├── condition/ │ │ │ │ ├── form-meta.tsx │ │ │ │ └── index.ts │ │ │ ├── custom/ │ │ │ │ └── index.ts │ │ │ ├── end/ │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── loop/ │ │ │ │ ├── form-meta.tsx │ │ │ │ └── index.ts │ │ │ ├── start/ │ │ │ │ └── index.ts │ │ │ ├── tool/ │ │ │ │ └── index.ts │ │ │ └── twoway/ │ │ │ └── index.ts │ │ └── tsconfig.json │ ├── demo-materials/ │ │ ├── .storybook/ │ │ │ └── main.ts │ │ ├── eslint.config.js │ │ ├── package.json │ │ ├── rsbuild.config.ts │ │ ├── src/ │ │ │ ├── assets/ │ │ │ │ ├── icon-auto-layout.tsx │ │ │ │ ├── icon-cancel.tsx │ │ │ │ ├── icon-comment.tsx │ │ │ │ ├── icon-minimap.tsx │ │ │ │ ├── icon-mouse.tsx │ │ │ │ ├── icon-pad.tsx │ │ │ │ ├── icon-success.tsx │ │ │ │ ├── icon-switch-line.tsx │ │ │ │ └── icon-warning.tsx │ │ │ ├── components/ │ │ │ │ ├── form-header/ │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── styles.tsx │ │ │ │ │ ├── title-input.tsx │ │ │ │ │ └── utils.tsx │ │ │ │ ├── free-editor/ │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ └── use-editor-props.tsx │ │ │ │ │ ├── index.css │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── plugins/ │ │ │ │ │ └── debug-panel-plugin/ │ │ │ │ │ ├── components/ │ │ │ │ │ │ ├── debug-panel.tsx │ │ │ │ │ │ ├── full-variable-list.tsx │ │ │ │ │ │ └── workflow-json-editor.tsx │ │ │ │ │ ├── debug-panel-layer.tsx │ │ │ │ │ ├── debug-panel-plugin.ts │ │ │ │ │ └── index.ts │ │ │ │ └── free-form-meta-story-builder/ │ │ │ │ ├── constants.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── initial-data.tsx │ │ │ │ └── utils.tsx │ │ │ ├── index.tsx │ │ │ ├── stories/ │ │ │ │ ├── components/ │ │ │ │ │ ├── blur-input.stories.tsx │ │ │ │ │ ├── inputs-values-tree.stories.tsx │ │ │ │ │ ├── json-schema-creator.stories.tsx │ │ │ │ │ ├── sql-editor-with-variables.stories.tsx │ │ │ │ │ └── variable-selector.stories.tsx │ │ │ │ └── hello.stories.tsx │ │ │ └── type.d.ts │ │ └── tsconfig.json │ ├── demo-nextjs/ │ │ ├── .gitignore │ │ ├── README.md │ │ ├── eslint.config.js │ │ ├── next.config.ts │ │ ├── package.json │ │ ├── postcss.config.mjs │ │ ├── src/ │ │ │ ├── app/ │ │ │ │ ├── api/ │ │ │ │ │ └── runtime/ │ │ │ │ │ └── route.ts │ │ │ │ ├── globals.css │ │ │ │ ├── layout.tsx │ │ │ │ └── page.tsx │ │ │ ├── editor/ │ │ │ │ ├── components/ │ │ │ │ │ ├── editor-client.tsx │ │ │ │ │ ├── editor.tsx │ │ │ │ │ ├── form-render.tsx │ │ │ │ │ ├── node-render.tsx │ │ │ │ │ └── tools.tsx │ │ │ │ ├── data/ │ │ │ │ │ ├── initial-data.ts │ │ │ │ │ └── node-registries.ts │ │ │ │ ├── hooks/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── use-editor-props.tsx │ │ │ │ ├── index.ts │ │ │ │ └── style/ │ │ │ │ ├── index.css │ │ │ │ ├── theme.css │ │ │ │ └── var.css │ │ │ └── runtime/ │ │ │ ├── index.ts │ │ │ ├── main.ts │ │ │ └── models/ │ │ │ ├── index.ts │ │ │ └── runtime/ │ │ │ ├── index.ts │ │ │ ├── model.ts │ │ │ └── type.ts │ │ └── tsconfig.json │ ├── demo-nextjs-antd/ │ │ ├── .gitignore │ │ ├── README.md │ │ ├── eslint.config.js │ │ ├── next-env.d.ts │ │ ├── next.config.ts │ │ ├── package.json │ │ ├── postcss.config.mjs │ │ ├── src/ │ │ │ ├── app/ │ │ │ │ ├── globals.css │ │ │ │ ├── layout.tsx │ │ │ │ └── page.tsx │ │ │ └── editor/ │ │ │ ├── assets/ │ │ │ │ ├── icon-auto-layout.tsx │ │ │ │ ├── icon-comment.tsx │ │ │ │ ├── icon-minimap.tsx │ │ │ │ ├── icon-mouse.tsx │ │ │ │ ├── icon-pad.tsx │ │ │ │ └── icon-switch-line.tsx │ │ │ ├── components/ │ │ │ │ ├── base-node/ │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── node-wrapper.scss │ │ │ │ │ ├── node-wrapper.tsx │ │ │ │ │ ├── styles.tsx │ │ │ │ │ └── utils.ts │ │ │ │ ├── editor-client.tsx │ │ │ │ ├── editor.tsx │ │ │ │ ├── form-render.tsx │ │ │ │ ├── group/ │ │ │ │ │ ├── color.ts │ │ │ │ │ ├── components/ │ │ │ │ │ │ ├── background.tsx │ │ │ │ │ │ ├── color.tsx │ │ │ │ │ │ ├── header.tsx │ │ │ │ │ │ ├── icon-group.tsx │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── node-render.tsx │ │ │ │ │ │ ├── tips/ │ │ │ │ │ │ │ ├── global-store.ts │ │ │ │ │ │ │ ├── icon-close.tsx │ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ │ ├── is-mac-os.ts │ │ │ │ │ │ │ ├── style.ts │ │ │ │ │ │ │ └── use-control.ts │ │ │ │ │ │ ├── title.tsx │ │ │ │ │ │ ├── tools.tsx │ │ │ │ │ │ └── ungroup.tsx │ │ │ │ │ ├── constant.ts │ │ │ │ │ ├── index.css │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── line-add-button/ │ │ │ │ │ ├── button.tsx │ │ │ │ │ ├── index.scss │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── use-visible.ts │ │ │ │ ├── node-comment/ │ │ │ │ │ ├── components/ │ │ │ │ │ │ ├── blank-area.tsx │ │ │ │ │ │ ├── border-area.tsx │ │ │ │ │ │ ├── container.tsx │ │ │ │ │ │ ├── content-drag-area.tsx │ │ │ │ │ │ ├── drag-area.tsx │ │ │ │ │ │ ├── editor.tsx │ │ │ │ │ │ ├── index.css │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── more-button.tsx │ │ │ │ │ │ ├── render.tsx │ │ │ │ │ │ └── resize-area.tsx │ │ │ │ │ ├── constant.ts │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── use-model.ts │ │ │ │ │ │ ├── use-overflow.ts │ │ │ │ │ │ └── use-size.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── model.ts │ │ │ │ │ └── type.ts │ │ │ │ ├── node-menu/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── node-panel/ │ │ │ │ │ ├── index.scss │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── node-list.tsx │ │ │ │ │ └── node-placeholder.tsx │ │ │ │ ├── node-render.tsx │ │ │ │ ├── selector-box-popover/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── sidebar/ │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── sidebar-node-renderer.tsx │ │ │ │ │ ├── sidebar-provider.tsx │ │ │ │ │ └── sidebar-renderer.tsx │ │ │ │ └── tools.tsx │ │ │ ├── context/ │ │ │ │ ├── index.ts │ │ │ │ ├── node-render-context.ts │ │ │ │ └── sidebar-context.ts │ │ │ ├── data/ │ │ │ │ ├── initial-data.ts │ │ │ │ └── node-registries.ts │ │ │ ├── form-components/ │ │ │ │ ├── feedback.tsx │ │ │ │ ├── form-content/ │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── styles.tsx │ │ │ │ ├── form-header/ │ │ │ │ │ ├── index.scss │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── styles.tsx │ │ │ │ │ ├── title-input.tsx │ │ │ │ │ └── utils.tsx │ │ │ │ ├── form-inputs/ │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── styles.tsx │ │ │ │ ├── form-item/ │ │ │ │ │ ├── index.css │ │ │ │ │ └── index.tsx │ │ │ │ ├── form-outputs/ │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── styles.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── properties-edit/ │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── property-edit.tsx │ │ │ │ │ └── styles.tsx │ │ │ │ ├── type-tag.tsx │ │ │ │ └── value-display/ │ │ │ │ ├── index.tsx │ │ │ │ └── styles.tsx │ │ │ ├── hooks/ │ │ │ │ ├── index.ts │ │ │ │ ├── use-editor-props.tsx │ │ │ │ ├── use-is-sidebar.ts │ │ │ │ └── use-node-render-context.ts │ │ │ ├── index.ts │ │ │ ├── nodes/ │ │ │ │ ├── comment/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── condition/ │ │ │ │ │ ├── condition-inputs/ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ └── styles.tsx │ │ │ │ │ ├── form-meta.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── constants.ts │ │ │ │ ├── default-form-meta.tsx │ │ │ │ ├── end/ │ │ │ │ │ ├── form-meta.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── llm/ │ │ │ │ │ └── index.ts │ │ │ │ ├── loop/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── loop-form-render.tsx │ │ │ │ └── start/ │ │ │ │ ├── form-meta.tsx │ │ │ │ └── index.ts │ │ │ ├── plugins/ │ │ │ │ ├── context-menu-plugin/ │ │ │ │ │ ├── context-menu-layer.tsx │ │ │ │ │ ├── context-menu-plugin.ts │ │ │ │ │ └── index.ts │ │ │ │ └── index.ts │ │ │ ├── shortcuts/ │ │ │ │ ├── collapse/ │ │ │ │ │ └── index.ts │ │ │ │ ├── constants.ts │ │ │ │ ├── copy/ │ │ │ │ │ └── index.ts │ │ │ │ ├── delete/ │ │ │ │ │ └── index.ts │ │ │ │ ├── expand/ │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── paste/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── traverse.ts │ │ │ │ │ └── unique-workflow.ts │ │ │ │ ├── select-all/ │ │ │ │ │ └── index.ts │ │ │ │ ├── shortcuts.ts │ │ │ │ ├── type.ts │ │ │ │ ├── zoom-in/ │ │ │ │ │ └── index.ts │ │ │ │ └── zoom-out/ │ │ │ │ └── index.ts │ │ │ ├── style/ │ │ │ │ ├── index.css │ │ │ │ └── var.css │ │ │ ├── typings/ │ │ │ │ ├── flow-value/ │ │ │ │ │ ├── config.json │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── json-schema/ │ │ │ │ │ ├── config.json │ │ │ │ │ └── index.ts │ │ │ │ └── node.ts │ │ │ └── utils/ │ │ │ ├── index.ts │ │ │ └── on-drag-line-end.ts │ │ └── tsconfig.json │ ├── demo-node-form/ │ │ ├── eslint.config.js │ │ ├── index.html │ │ ├── package.json │ │ ├── rsbuild.config.ts │ │ ├── src/ │ │ │ ├── app.tsx │ │ │ ├── components/ │ │ │ │ ├── field-title.tsx │ │ │ │ ├── field-wrapper.css │ │ │ │ ├── field-wrapper.tsx │ │ │ │ └── index.ts │ │ │ ├── constant.ts │ │ │ ├── editor.tsx │ │ │ ├── form-meta.tsx │ │ │ ├── hooks/ │ │ │ │ └── use-editor-props.tsx │ │ │ ├── index.css │ │ │ ├── index.tsx │ │ │ ├── initial-data.ts │ │ │ └── node-registries.tsx │ │ └── tsconfig.json │ ├── demo-playground/ │ │ ├── eslint.config.js │ │ ├── index.html │ │ ├── package.json │ │ ├── rsbuild.config.ts │ │ ├── src/ │ │ │ ├── app.tsx │ │ │ ├── components/ │ │ │ │ ├── card.tsx │ │ │ │ └── playground-tools.tsx │ │ │ ├── editor.tsx │ │ │ └── index.tsx │ │ └── tsconfig.json │ ├── demo-react-16/ │ │ ├── eslint.config.js │ │ ├── index.html │ │ ├── package.json │ │ ├── rsbuild.config.ts │ │ ├── src/ │ │ │ ├── app.tsx │ │ │ ├── components/ │ │ │ │ ├── minimap.tsx │ │ │ │ ├── node-add-panel.tsx │ │ │ │ ├── node-form-panel/ │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── sidebar-renderer.tsx │ │ │ │ └── tools.tsx │ │ │ ├── editor.tsx │ │ │ ├── hooks/ │ │ │ │ └── use-editor-props.tsx │ │ │ ├── index.css │ │ │ ├── index.tsx │ │ │ ├── initial-data.ts │ │ │ └── node-registries.ts │ │ └── tsconfig.json │ ├── demo-vite/ │ │ ├── eslint.config.js │ │ ├── index.html │ │ ├── package.json │ │ ├── src/ │ │ │ ├── app.tsx │ │ │ ├── components/ │ │ │ │ ├── minimap.tsx │ │ │ │ ├── node-add-panel.tsx │ │ │ │ └── tools.tsx │ │ │ ├── editor.tsx │ │ │ ├── hooks/ │ │ │ │ └── use-editor-props.tsx │ │ │ ├── index.css │ │ │ ├── index.tsx │ │ │ ├── initial-data.ts │ │ │ └── node-registries.ts │ │ ├── tsconfig.json │ │ └── vite.config.js │ └── docs/ │ ├── .gitignore │ ├── README.md │ ├── components/ │ │ ├── code-preview/ │ │ │ └── index.tsx │ │ ├── fixed-examples/ │ │ │ ├── step-1.tsx │ │ │ ├── step-2.tsx │ │ │ ├── step-3.tsx │ │ │ ├── step-4.tsx │ │ │ ├── step-5/ │ │ │ │ ├── adder.tsx │ │ │ │ ├── app.tsx │ │ │ │ ├── initial-data.ts │ │ │ │ ├── node-registries.tsx │ │ │ │ ├── node-render.tsx │ │ │ │ └── use-editor-props.tsx │ │ │ ├── step-6/ │ │ │ │ ├── adder.tsx │ │ │ │ ├── app.tsx │ │ │ │ ├── initial-data.ts │ │ │ │ ├── node-registries.tsx │ │ │ │ ├── node-render.tsx │ │ │ │ └── use-editor-props.tsx │ │ │ └── step-7/ │ │ │ ├── adder.tsx │ │ │ ├── app.tsx │ │ │ ├── initial-data.ts │ │ │ ├── minimap.tsx │ │ │ ├── node-registries.tsx │ │ │ ├── node-render.tsx │ │ │ ├── tools.tsx │ │ │ └── use-editor-props.tsx │ │ ├── fixed-feature-overview/ │ │ │ ├── index.less │ │ │ └── index.tsx │ │ ├── fixed-layout-simple/ │ │ │ ├── composite-nodes-preview.tsx │ │ │ ├── fixed-layout-simple.tsx │ │ │ ├── index.tsx │ │ │ └── preview.tsx │ │ ├── form-materials/ │ │ │ ├── common/ │ │ │ │ ├── disable-declaration-plugin.tsx │ │ │ │ ├── json-schema-preset.css │ │ │ │ └── json-schema-preset.tsx │ │ │ ├── components/ │ │ │ │ ├── assign-row.tsx │ │ │ │ ├── assign-rows.tsx │ │ │ │ ├── batch-outputs.tsx │ │ │ │ ├── batch-variable-selector.tsx │ │ │ │ ├── blur-input.tsx │ │ │ │ ├── code-editor.tsx │ │ │ │ ├── condition-context.tsx │ │ │ │ ├── condition-row.tsx │ │ │ │ ├── constant-inputs.tsx │ │ │ │ ├── db-condition-row.tsx │ │ │ │ ├── display-flow-value.tsx │ │ │ │ ├── display-inputs-values.tsx │ │ │ │ ├── display-outputs.tsx │ │ │ │ ├── display-schema-tag.tsx │ │ │ │ ├── display-schema-tree.tsx │ │ │ │ ├── dynamic-value-input.tsx │ │ │ │ ├── inputs-values-tree.tsx │ │ │ │ ├── inputs-values.tsx │ │ │ │ ├── json-editor-with-variables.tsx │ │ │ │ ├── json-schema-creator.tsx │ │ │ │ ├── json-schema-editor.tsx │ │ │ │ ├── prompt-editor-with-inputs.tsx │ │ │ │ ├── prompt-editor-with-variables.tsx │ │ │ │ ├── prompt-editor.tsx │ │ │ │ ├── sql-editor-with-variables.tsx │ │ │ │ ├── type-selector.tsx │ │ │ │ └── variable-selector.tsx │ │ │ ├── effects/ │ │ │ │ ├── auto-rename-ref.tsx │ │ │ │ ├── listen-ref-schema-change.tsx │ │ │ │ ├── listen-ref-value-change.tsx │ │ │ │ ├── provide-batch-input.tsx │ │ │ │ ├── provide-json-schema-output.tsx │ │ │ │ ├── sync-variable-title.tsx │ │ │ │ └── validate-when-variable-sync.tsx │ │ │ ├── form-plugins/ │ │ │ │ ├── batch-outputs-plugin.tsx │ │ │ │ ├── infer-assign-plugin.tsx │ │ │ │ └── infer-inputs-plugin.tsx │ │ │ └── validate/ │ │ │ └── validate-flow-value.tsx │ │ ├── free-examples/ │ │ │ ├── step-1.tsx │ │ │ ├── step-2.tsx │ │ │ ├── step-3.tsx │ │ │ ├── step-4.tsx │ │ │ ├── step-5/ │ │ │ │ ├── app.tsx │ │ │ │ ├── initial-data.ts │ │ │ │ ├── node-registries.tsx │ │ │ │ ├── node-render.tsx │ │ │ │ └── use-editor-props.tsx │ │ │ ├── step-6/ │ │ │ │ ├── app.tsx │ │ │ │ ├── initial-data.ts │ │ │ │ ├── node-registries.tsx │ │ │ │ ├── node-render.tsx │ │ │ │ └── use-editor-props.tsx │ │ │ └── step-7/ │ │ │ ├── add-node.tsx │ │ │ ├── app.tsx │ │ │ ├── initial-data.ts │ │ │ ├── minimap.tsx │ │ │ ├── node-registries.tsx │ │ │ ├── node-render.tsx │ │ │ ├── tools.tsx │ │ │ └── use-editor-props.tsx │ │ ├── free-feature-overview/ │ │ │ ├── index.less │ │ │ └── index.tsx │ │ ├── free-form-meta-story-builder/ │ │ │ └── index.tsx │ │ ├── free-layout-simple/ │ │ │ ├── index.less │ │ │ ├── index.tsx │ │ │ └── preview.tsx │ │ ├── index.ts │ │ ├── infinite-canvas/ │ │ │ ├── index.less │ │ │ ├── index.tsx │ │ │ ├── infinite-canvas.tsx │ │ │ └── preview.tsx │ │ ├── materials.tsx │ │ ├── node-form/ │ │ │ ├── array/ │ │ │ │ ├── index.css │ │ │ │ ├── node-registry.tsx │ │ │ │ └── preview.tsx │ │ │ ├── basic-preview.tsx │ │ │ ├── dynamic/ │ │ │ │ ├── node-registry.tsx │ │ │ │ └── preview.tsx │ │ │ ├── editor.tsx │ │ │ ├── effect/ │ │ │ │ ├── node-registry.tsx │ │ │ │ └── preview.tsx │ │ │ ├── index.css │ │ │ └── index.ts │ │ ├── preview-editor.tsx │ │ └── tsx-editor.tsx │ ├── eslint.config.js │ ├── global.less │ ├── package.json │ ├── rspress.config.ts │ ├── scripts/ │ │ ├── auto-generate.ts │ │ ├── constants.ts │ │ └── patch.ts │ ├── src/ │ │ ├── en/ │ │ │ ├── _nav.json │ │ │ ├── api/ │ │ │ │ ├── _meta.json │ │ │ │ ├── common-apis.mdx │ │ │ │ ├── components/ │ │ │ │ │ ├── editor-renderer.mdx │ │ │ │ │ ├── fixed-layout-editor-provider.mdx │ │ │ │ │ ├── fixed-layout-editor.mdx │ │ │ │ │ ├── free-layout-editor-provider.mdx │ │ │ │ │ ├── free-layout-editor.mdx │ │ │ │ │ └── workflow-node-renderer.mdx │ │ │ │ ├── core/ │ │ │ │ │ ├── _meta.json │ │ │ │ │ ├── flow-document.mdx │ │ │ │ │ ├── flow-node-entity.mdx │ │ │ │ │ ├── playground.mdx │ │ │ │ │ ├── workflow-document.mdx │ │ │ │ │ ├── workflow-line-entity.mdx │ │ │ │ │ └── workflow-lines-manager.mdx │ │ │ │ ├── hooks/ │ │ │ │ │ ├── use-client-context.mdx │ │ │ │ │ ├── use-node-render.mdx │ │ │ │ │ ├── use-playground-tools.mdx │ │ │ │ │ ├── use-refresh.mdx │ │ │ │ │ └── use-service.mdx │ │ │ │ ├── index.mdx │ │ │ │ ├── plugins.mdx │ │ │ │ ├── services/ │ │ │ │ │ ├── clipboard-service.mdx │ │ │ │ │ ├── command-service.mdx │ │ │ │ │ ├── flow-operation-service.mdx │ │ │ │ │ ├── history-service.mdx │ │ │ │ │ └── selection-service.mdx │ │ │ │ └── utils/ │ │ │ │ ├── disposable-collection.mdx │ │ │ │ ├── disposable.mdx │ │ │ │ ├── emitter.mdx │ │ │ │ └── get-node-form.mdx │ │ │ ├── examples/ │ │ │ │ ├── _meta.json │ │ │ │ ├── fixed-layout/ │ │ │ │ │ ├── _meta.json │ │ │ │ │ ├── fixed-composite-nodes.mdx │ │ │ │ │ ├── fixed-feature-overview.mdx │ │ │ │ │ └── fixed-layout-simple.mdx │ │ │ │ ├── free-layout/ │ │ │ │ │ ├── _meta.json │ │ │ │ │ ├── free-feature-overview.mdx │ │ │ │ │ └── free-layout-simple.mdx │ │ │ │ ├── index.mdx │ │ │ │ ├── node-form/ │ │ │ │ │ ├── _meta.json │ │ │ │ │ ├── array.mdx │ │ │ │ │ ├── basic.mdx │ │ │ │ │ ├── dynamic.mdx │ │ │ │ │ └── effect.mdx │ │ │ │ └── playground.mdx │ │ │ ├── guide/ │ │ │ │ ├── _meta.json │ │ │ │ ├── advanced/ │ │ │ │ │ ├── _meta.json │ │ │ │ │ ├── custom-layer.mdx │ │ │ │ │ ├── custom-plugin.mdx │ │ │ │ │ ├── custom-service.mdx │ │ │ │ │ ├── history.mdx │ │ │ │ │ ├── lines.mdx │ │ │ │ │ ├── shortcuts.mdx │ │ │ │ │ └── zoom-scroll.mdx │ │ │ │ ├── concepts/ │ │ │ │ │ ├── _meta.json │ │ │ │ │ ├── canvas-engine.mdx │ │ │ │ │ ├── ecs.mdx │ │ │ │ │ ├── index.mdx │ │ │ │ │ ├── ioc.mdx │ │ │ │ │ ├── node-engine.mdx │ │ │ │ │ └── reactflow.mdx │ │ │ │ ├── contact-us.mdx │ │ │ │ ├── contributing.mdx │ │ │ │ ├── fixed-layout/ │ │ │ │ │ ├── _meta.json │ │ │ │ │ ├── composite-nodes.mdx │ │ │ │ │ ├── load.mdx │ │ │ │ │ └── node.mdx │ │ │ │ ├── form/ │ │ │ │ │ ├── _meta.json │ │ │ │ │ ├── form-materials.mdx │ │ │ │ │ ├── form.mdx │ │ │ │ │ └── without-form.mdx │ │ │ │ ├── free-layout/ │ │ │ │ │ ├── _meta.json │ │ │ │ │ ├── line.mdx │ │ │ │ │ ├── load.mdx │ │ │ │ │ ├── node.mdx │ │ │ │ │ ├── port.mdx │ │ │ │ │ └── sub-canvas.mdx │ │ │ │ ├── getting-started/ │ │ │ │ │ ├── _meta.json │ │ │ │ │ ├── fixed-layout.mdx │ │ │ │ │ ├── free-layout.mdx │ │ │ │ │ ├── introduction.mdx │ │ │ │ │ └── quick-start.mdx │ │ │ │ ├── plugin/ │ │ │ │ │ ├── _meta.json │ │ │ │ │ ├── background-plugin.mdx │ │ │ │ │ ├── export-plugin.mdx │ │ │ │ │ ├── free-auto-layout-plugin.mdx │ │ │ │ │ ├── free-stack-plugin.mdx │ │ │ │ │ ├── minimap-plugin.mdx │ │ │ │ │ └── panel-manager-plugin.mdx │ │ │ │ ├── runtime/ │ │ │ │ │ ├── _meta.json │ │ │ │ │ ├── api.mdx │ │ │ │ │ ├── introduction.mdx │ │ │ │ │ ├── node.mdx │ │ │ │ │ ├── quick-start.mdx │ │ │ │ │ ├── schema.mdx │ │ │ │ │ └── source-code-guide.mdx │ │ │ │ └── variable/ │ │ │ │ ├── _meta.json │ │ │ │ ├── basic.mdx │ │ │ │ ├── concept.mdx │ │ │ │ ├── custom-scope-chain.mdx │ │ │ │ ├── variable-consume.mdx │ │ │ │ └── variable-output.mdx │ │ │ ├── index.md │ │ │ └── materials/ │ │ │ ├── _meta.json │ │ │ ├── cli.mdx │ │ │ ├── common/ │ │ │ │ ├── _meta.json │ │ │ │ ├── disable-declaration-plugin.mdx │ │ │ │ ├── flow-value.mdx │ │ │ │ ├── inject-material.mdx │ │ │ │ └── json-schema-preset.mdx │ │ │ ├── components/ │ │ │ │ ├── _meta.json │ │ │ │ ├── assign-row.mdx │ │ │ │ ├── assign-rows.mdx │ │ │ │ ├── batch-outputs.mdx │ │ │ │ ├── batch-variable-selector.mdx │ │ │ │ ├── blur-input.mdx │ │ │ │ ├── code-editor.mdx │ │ │ │ ├── condition-context.mdx │ │ │ │ ├── condition-row.mdx │ │ │ │ ├── constant-input.mdx │ │ │ │ ├── coze-editor-extensions.mdx │ │ │ │ ├── db-condition-row.mdx │ │ │ │ ├── display-flow-value.mdx │ │ │ │ ├── display-inputs-values.mdx │ │ │ │ ├── display-outputs.mdx │ │ │ │ ├── display-schema-tag.mdx │ │ │ │ ├── display-schema-tree.mdx │ │ │ │ ├── dynamic-value-input.mdx │ │ │ │ ├── inputs-values-tree.mdx │ │ │ │ ├── inputs-values.mdx │ │ │ │ ├── json-editor-with-variables.mdx │ │ │ │ ├── json-schema-creator.mdx │ │ │ │ ├── json-schema-editor.mdx │ │ │ │ ├── prompt-editor-with-inputs.mdx │ │ │ │ ├── prompt-editor-with-variables.mdx │ │ │ │ ├── prompt-editor.mdx │ │ │ │ ├── sql-editor-with-variables.mdx │ │ │ │ ├── type-selector.mdx │ │ │ │ └── variable-selector.mdx │ │ │ ├── effects/ │ │ │ │ ├── _meta.json │ │ │ │ ├── auto-rename-ref.mdx │ │ │ │ ├── listen-ref-schema-change.mdx │ │ │ │ ├── listen-ref-value-change.mdx │ │ │ │ ├── provide-batch-input.mdx │ │ │ │ ├── provide-json-schema-outputs.mdx │ │ │ │ ├── sync-variable-title.mdx │ │ │ │ └── validate-when-variable-sync.mdx │ │ │ ├── form-plugins/ │ │ │ │ ├── _meta.json │ │ │ │ ├── batch-outputs-plugin.mdx │ │ │ │ ├── infer-assign-plugin.mdx │ │ │ │ └── infer-inputs-plugin.mdx │ │ │ ├── introduction.mdx │ │ │ └── validate/ │ │ │ ├── _meta.json │ │ │ └── validate-flow-value.mdx │ │ ├── global.d.ts │ │ └── zh/ │ │ ├── _nav.json │ │ ├── api/ │ │ │ ├── _meta.json │ │ │ ├── common-apis.mdx │ │ │ ├── components/ │ │ │ │ ├── editor-renderer.mdx │ │ │ │ ├── fixed-layout-editor-provider.mdx │ │ │ │ ├── fixed-layout-editor.mdx │ │ │ │ ├── free-layout-editor-provider.mdx │ │ │ │ ├── free-layout-editor.mdx │ │ │ │ └── workflow-node-renderer.mdx │ │ │ ├── core/ │ │ │ │ ├── _meta.json │ │ │ │ ├── flow-document.mdx │ │ │ │ ├── flow-node-entity.mdx │ │ │ │ ├── playground.mdx │ │ │ │ ├── workflow-document.mdx │ │ │ │ ├── workflow-line-entity.mdx │ │ │ │ └── workflow-lines-manager.mdx │ │ │ ├── hooks/ │ │ │ │ ├── use-client-context.mdx │ │ │ │ ├── use-node-render.mdx │ │ │ │ ├── use-playground-tools.mdx │ │ │ │ ├── use-refresh.mdx │ │ │ │ └── use-service.mdx │ │ │ ├── index.mdx │ │ │ ├── plugins.mdx │ │ │ ├── services/ │ │ │ │ ├── clipboard-service.mdx │ │ │ │ ├── command-service.mdx │ │ │ │ ├── flow-operation-service.mdx │ │ │ │ ├── history-service.mdx │ │ │ │ └── selection-service.mdx │ │ │ └── utils/ │ │ │ ├── disposable-collection.mdx │ │ │ ├── disposable.mdx │ │ │ ├── emitter.mdx │ │ │ └── get-node-form.mdx │ │ ├── examples/ │ │ │ ├── _meta.json │ │ │ ├── fixed-layout/ │ │ │ │ ├── _meta.json │ │ │ │ ├── fixed-composite-nodes.mdx │ │ │ │ ├── fixed-feature-overview.mdx │ │ │ │ └── fixed-layout-simple.mdx │ │ │ ├── free-layout/ │ │ │ │ ├── _meta.json │ │ │ │ ├── free-feature-overview.mdx │ │ │ │ └── free-layout-simple.mdx │ │ │ ├── index.mdx │ │ │ ├── node-form/ │ │ │ │ ├── _meta.json │ │ │ │ ├── array.mdx │ │ │ │ ├── basic.mdx │ │ │ │ ├── dynamic.mdx │ │ │ │ └── effect.mdx │ │ │ └── playground.mdx │ │ ├── guide/ │ │ │ ├── _meta.json │ │ │ ├── advanced/ │ │ │ │ ├── _meta.json │ │ │ │ ├── custom-layer.mdx │ │ │ │ ├── custom-plugin.mdx │ │ │ │ ├── custom-service.mdx │ │ │ │ ├── history.mdx │ │ │ │ ├── shortcuts.mdx │ │ │ │ └── zoom-scroll.mdx │ │ │ ├── concepts/ │ │ │ │ ├── _meta.json │ │ │ │ ├── canvas-engine.mdx │ │ │ │ ├── ecs.mdx │ │ │ │ ├── index.mdx │ │ │ │ ├── ioc.mdx │ │ │ │ ├── node-engine.mdx │ │ │ │ └── reactflow.mdx │ │ │ ├── contact-us.mdx │ │ │ ├── contributing.mdx │ │ │ ├── fixed-layout/ │ │ │ │ ├── _meta.json │ │ │ │ ├── composite-nodes.mdx │ │ │ │ ├── load.mdx │ │ │ │ └── node.mdx │ │ │ ├── form/ │ │ │ │ ├── _meta.json │ │ │ │ ├── form-materials.mdx │ │ │ │ ├── form.mdx │ │ │ │ └── without-form.mdx │ │ │ ├── free-layout/ │ │ │ │ ├── _meta.json │ │ │ │ ├── line.mdx │ │ │ │ ├── load.mdx │ │ │ │ ├── node.mdx │ │ │ │ ├── port.mdx │ │ │ │ └── sub-canvas.mdx │ │ │ ├── getting-started/ │ │ │ │ ├── _meta.json │ │ │ │ ├── fixed-layout.mdx │ │ │ │ ├── free-layout.mdx │ │ │ │ ├── introduction.mdx │ │ │ │ └── quick-start.mdx │ │ │ ├── plugin/ │ │ │ │ ├── _meta.json │ │ │ │ ├── background-plugin.mdx │ │ │ │ ├── export-plugin.mdx │ │ │ │ ├── free-auto-layout-plugin.mdx │ │ │ │ ├── free-stack-plugin.mdx │ │ │ │ ├── minimap-plugin.mdx │ │ │ │ └── panel-manager-plugin.mdx │ │ │ ├── question.mdx │ │ │ ├── runtime/ │ │ │ │ ├── _meta.json │ │ │ │ ├── api.mdx │ │ │ │ ├── introduction.mdx │ │ │ │ ├── node.mdx │ │ │ │ ├── quick-start.mdx │ │ │ │ ├── schema.mdx │ │ │ │ └── source-code-guide.mdx │ │ │ └── variable/ │ │ │ ├── _meta.json │ │ │ ├── basic.mdx │ │ │ ├── cases/ │ │ │ │ ├── _meta.json │ │ │ │ └── case-batch-variable.mdx │ │ │ ├── concept.mdx │ │ │ ├── core-api.mdx │ │ │ ├── core-ast.mdx │ │ │ ├── custom-scope-chain.mdx │ │ │ ├── variable-consume.mdx │ │ │ └── variable-output.mdx │ │ ├── index.md │ │ └── materials/ │ │ ├── _meta.json │ │ ├── cli.mdx │ │ ├── common/ │ │ │ ├── _meta.json │ │ │ ├── disable-declaration-plugin.mdx │ │ │ ├── flow-value.mdx │ │ │ ├── inject-material.mdx │ │ │ └── json-schema-preset.mdx │ │ ├── components/ │ │ │ ├── _meta.json │ │ │ ├── assign-row.mdx │ │ │ ├── assign-rows.mdx │ │ │ ├── batch-outputs.mdx │ │ │ ├── batch-variable-selector.mdx │ │ │ ├── blur-input.mdx │ │ │ ├── code-editor.mdx │ │ │ ├── condition-context.mdx │ │ │ ├── condition-row.mdx │ │ │ ├── constant-input.mdx │ │ │ ├── coze-editor-extensions.mdx │ │ │ ├── db-condition-row.mdx │ │ │ ├── display-flow-value.mdx │ │ │ ├── display-inputs-values.mdx │ │ │ ├── display-outputs.mdx │ │ │ ├── display-schema-tag.mdx │ │ │ ├── display-schema-tree.mdx │ │ │ ├── dynamic-value-input.mdx │ │ │ ├── inputs-values-tree.mdx │ │ │ ├── inputs-values.mdx │ │ │ ├── json-editor-with-variables.mdx │ │ │ ├── json-schema-creator.mdx │ │ │ ├── json-schema-editor.mdx │ │ │ ├── prompt-editor-with-inputs.mdx │ │ │ ├── prompt-editor-with-variables.mdx │ │ │ ├── prompt-editor.mdx │ │ │ ├── sql-editor-with-variables.mdx │ │ │ ├── type-selector.mdx │ │ │ └── variable-selector.mdx │ │ ├── effects/ │ │ │ ├── _meta.json │ │ │ ├── auto-rename-ref.mdx │ │ │ ├── listen-ref-schema-change.mdx │ │ │ ├── listen-ref-value-change.mdx │ │ │ ├── provide-batch-input.mdx │ │ │ ├── provide-json-schema-outputs.mdx │ │ │ ├── sync-variable-title.mdx │ │ │ └── validate-when-variable-sync.mdx │ │ ├── form-plugins/ │ │ │ ├── _meta.json │ │ │ ├── batch-outputs-plugin.mdx │ │ │ ├── infer-assign-plugin.mdx │ │ │ └── infer-inputs-plugin.mdx │ │ ├── introduction.mdx │ │ └── validate/ │ │ ├── _meta.json │ │ └── validate-flow-value.mdx │ ├── theme/ │ │ ├── components/ │ │ │ ├── background/ │ │ │ │ ├── index.css │ │ │ │ └── index.tsx │ │ │ └── logo/ │ │ │ ├── index.less │ │ │ ├── index.tsx │ │ │ ├── initial-data.ts │ │ │ ├── musk.tsx │ │ │ ├── node-color.ts │ │ │ ├── node-registries.tsx │ │ │ ├── node-render.tsx │ │ │ ├── port.tsx │ │ │ ├── position-groups.ts │ │ │ ├── update-position.ts │ │ │ └── use-editor-props.tsx │ │ ├── index.tsx │ │ ├── theme.css │ │ └── use-is-mobile.ts │ └── tsconfig.json ├── common/ │ ├── autoinstallers/ │ │ ├── dep-check/ │ │ │ ├── dep-check.ts │ │ │ ├── index.js │ │ │ └── package.json │ │ ├── license-header/ │ │ │ ├── index.js │ │ │ └── package.json │ │ ├── rush-commands/ │ │ │ ├── check-circular-dependency.mjs │ │ │ └── package.json │ │ ├── rush-commitlint/ │ │ │ ├── .cz-config.js │ │ │ ├── commitlint.config.js │ │ │ ├── package.json │ │ │ └── utils.js │ │ └── rush-lint-staged/ │ │ ├── .lintstagedrc.js │ │ ├── package.json │ │ └── utils.js │ ├── config/ │ │ └── rush/ │ │ ├── .npmrc │ │ ├── .npmrc-publish │ │ ├── .pnpmfile.cjs │ │ ├── artifactory.json │ │ ├── build-cache.json │ │ ├── cobuild.json │ │ ├── command-line.json │ │ ├── common-versions.json │ │ ├── custom-tips.json │ │ ├── experiments.json │ │ ├── pnpm-config.json │ │ ├── repo-state.json │ │ ├── rush-plugins.json │ │ ├── subspaces.json │ │ └── version-policies.json │ ├── git-hooks/ │ │ ├── commit-msg │ │ ├── post-checkout │ │ └── pre-commit │ └── scripts/ │ ├── install-run-rush-pnpm.js │ ├── install-run-rush.js │ ├── install-run-rushx.js │ └── install-run.js ├── config/ │ ├── eslint-config/ │ │ ├── CHANGELOG.json │ │ ├── CHANGELOG.md │ │ ├── eslint.base.config.js │ │ ├── eslint.config.js │ │ ├── eslint.node.config.js │ │ ├── eslint.web.config.js │ │ ├── package.json │ │ ├── src/ │ │ │ ├── defineFlatConfig.js │ │ │ ├── defineFlatConfig.ts │ │ │ └── index.js │ │ └── tsconfig.json │ └── ts-config/ │ ├── eslint.config.js │ ├── global.d.ts │ ├── package.json │ ├── tsconfig.base.json │ ├── tsconfig.flow.base.json │ ├── tsconfig.flow.path.json │ ├── tsconfig.infra.base.json │ ├── tsconfig.infra.node.json │ └── tsconfig.node.json ├── cspell.json ├── doc_build.sh ├── e2e/ │ ├── fixed-layout/ │ │ ├── README.md │ │ ├── eslint.config.js │ │ ├── package.json │ │ ├── playwright.config.ts │ │ ├── tests/ │ │ │ ├── drag.spec.ts │ │ │ ├── drawer.spec.ts │ │ │ ├── layout.spec.ts │ │ │ ├── models/ │ │ │ │ └── index.ts │ │ │ ├── node.spec.ts │ │ │ ├── testrun.spec.ts │ │ │ ├── typings/ │ │ │ │ ├── drag.ts │ │ │ │ └── index.ts │ │ │ ├── validate.spec.ts │ │ │ └── variable.spec.ts │ │ ├── tsconfig.json │ │ └── utils/ │ │ └── index.ts │ └── free-layout/ │ ├── eslint.config.js │ ├── package.json │ ├── playwright.config.ts │ ├── tests/ │ │ ├── layout.spec.ts │ │ ├── models/ │ │ │ └── index.ts │ │ └── node.spec.ts │ └── tsconfig.json ├── packages/ │ ├── canvas-engine/ │ │ ├── core/ │ │ │ ├── __mocks__/ │ │ │ │ ├── create-entity.mock.ts │ │ │ │ ├── layers.mock.tsx │ │ │ │ └── playground-container.mock.ts │ │ │ ├── __tests__/ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ ├── pipeline.spec.tsx.snap │ │ │ │ │ └── playground.test.ts.snap │ │ │ │ ├── core/ │ │ │ │ │ └── layer/ │ │ │ │ │ ├── config/ │ │ │ │ │ │ ├── editor-state-config-entity.spec.ts │ │ │ │ │ │ └── payground-config-entity.spec.ts │ │ │ │ │ └── playground-layer.spec.tsx │ │ │ │ ├── entity.spec.ts │ │ │ │ ├── layer.spec.tsx │ │ │ │ ├── pipeline.spec.tsx │ │ │ │ ├── playground-contribution.spec.tsx │ │ │ │ ├── playground-mock-tools.spec.ts │ │ │ │ ├── playground-react.spec.tsx │ │ │ │ ├── playground.test.ts │ │ │ │ ├── plugin.test.ts │ │ │ │ ├── react-hooks.spec.tsx │ │ │ │ ├── schema.spec.ts │ │ │ │ ├── selection.spec.ts │ │ │ │ ├── services/ │ │ │ │ │ ├── clipboard-service.spec.ts │ │ │ │ │ └── storage-service.spec.ts │ │ │ │ ├── transform-schema.spec.ts │ │ │ │ └── utils.test.ts │ │ │ ├── eslint.config.js │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── common/ │ │ │ │ │ ├── config-entity.ts │ │ │ │ │ ├── entity-data.ts │ │ │ │ │ ├── entity-manager-contribution.ts │ │ │ │ │ ├── entity-manager.ts │ │ │ │ │ ├── entity.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── playground-context.ts │ │ │ │ │ ├── playground-decorator-helper.ts │ │ │ │ │ ├── playground-decorators.ts │ │ │ │ │ ├── playground-schedule.ts │ │ │ │ │ ├── protect-wheel-area.ts │ │ │ │ │ ├── schema/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── node.ts │ │ │ │ │ │ ├── opacity-schema.ts │ │ │ │ │ │ ├── origin-schema.ts │ │ │ │ │ │ ├── position-schema.ts │ │ │ │ │ │ ├── rotation-schema.ts │ │ │ │ │ │ ├── scale-schema.ts │ │ │ │ │ │ ├── size-schema.ts │ │ │ │ │ │ ├── skew-schema.ts │ │ │ │ │ │ └── transform-schema.ts │ │ │ │ │ └── utils/ │ │ │ │ │ ├── bounds.spec.ts │ │ │ │ │ ├── bounds.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── core/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── layer/ │ │ │ │ │ │ ├── config/ │ │ │ │ │ │ │ ├── editor-state-config-entity.ts │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ └── playground-config-entity.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── layer.ts │ │ │ │ │ │ └── playground-layer.ts │ │ │ │ │ ├── pipeline/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── pipeline-entities-selector.ts │ │ │ │ │ │ ├── pipeline-entities.ts │ │ │ │ │ │ ├── pipeline-registry.ts │ │ │ │ │ │ ├── pipeline-renderer.tsx │ │ │ │ │ │ ├── pipeline.ts │ │ │ │ │ │ └── pipline-react-utils.tsx │ │ │ │ │ └── utils/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── inject-provider-decorators.ts │ │ │ │ │ ├── lazy-inject-decorators.ts │ │ │ │ │ ├── mouse-touch-event.ts │ │ │ │ │ ├── playground-drag.ts │ │ │ │ │ ├── playground-gesture.spec.ts │ │ │ │ │ ├── playground-gesture.ts │ │ │ │ │ ├── tween.ts │ │ │ │ │ └── use-gesture/ │ │ │ │ │ ├── core/ │ │ │ │ │ │ ├── Controller.ts │ │ │ │ │ │ ├── EventStore.ts │ │ │ │ │ │ ├── TimeoutStore.ts │ │ │ │ │ │ ├── actions.ts │ │ │ │ │ │ ├── config/ │ │ │ │ │ │ │ ├── commonConfigResolver.ts │ │ │ │ │ │ │ ├── coordinatesConfigResolver.ts │ │ │ │ │ │ │ ├── dragConfigResolver.ts │ │ │ │ │ │ │ ├── hoverConfigResolver.ts │ │ │ │ │ │ │ ├── moveConfigResolver.ts │ │ │ │ │ │ │ ├── pinchConfigResolver.ts │ │ │ │ │ │ │ ├── resolver.ts │ │ │ │ │ │ │ ├── scrollConfigResolver.ts │ │ │ │ │ │ │ ├── sharedConfigResolver.ts │ │ │ │ │ │ │ ├── support.ts │ │ │ │ │ │ │ └── wheelConfigResolver.ts │ │ │ │ │ │ ├── engines/ │ │ │ │ │ │ │ ├── CoordinatesEngine.ts │ │ │ │ │ │ │ ├── DragEngine.ts │ │ │ │ │ │ │ ├── Engine.ts │ │ │ │ │ │ │ ├── HoverEngine.ts │ │ │ │ │ │ │ ├── MoveEngine.ts │ │ │ │ │ │ │ ├── PinchEngine.ts │ │ │ │ │ │ │ ├── ScrollEngine.ts │ │ │ │ │ │ │ └── WheelEngine.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── parser.ts │ │ │ │ │ │ ├── types/ │ │ │ │ │ │ │ ├── action.ts │ │ │ │ │ │ │ ├── config.ts │ │ │ │ │ │ │ ├── handlers.ts │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ ├── internalConfig.ts │ │ │ │ │ │ │ ├── state.ts │ │ │ │ │ │ │ └── utils.ts │ │ │ │ │ │ ├── types.ts │ │ │ │ │ │ ├── utils/ │ │ │ │ │ │ │ ├── events.ts │ │ │ │ │ │ │ ├── fn.ts │ │ │ │ │ │ │ ├── maths.ts │ │ │ │ │ │ │ └── state.ts │ │ │ │ │ │ └── utils.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── vanilla/ │ │ │ │ │ ├── DragGesture.ts │ │ │ │ │ ├── Gesture.ts │ │ │ │ │ ├── HoverGesture.ts │ │ │ │ │ ├── MoveGesture.ts │ │ │ │ │ ├── PinchGesture.ts │ │ │ │ │ ├── Recognizer.ts │ │ │ │ │ ├── ScrollGesture.ts │ │ │ │ │ ├── WheelGesture.ts │ │ │ │ │ ├── createGesture.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── playground-config.ts │ │ │ │ ├── playground-container.ts │ │ │ │ ├── playground-contribution.ts │ │ │ │ ├── playground-mock-tools.ts │ │ │ │ ├── playground.ts │ │ │ │ ├── plugin/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── plugin.ts │ │ │ │ ├── react/ │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── playground-react-context.ts │ │ │ │ │ ├── playground-react-provider.tsx │ │ │ │ │ └── playground-react-renderer.tsx │ │ │ │ ├── react-hooks/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── use-config-entity.ts │ │ │ │ │ ├── use-entities.ts │ │ │ │ │ ├── use-entity-data-from-context.ts │ │ │ │ │ ├── use-entity-from-context.ts │ │ │ │ │ ├── use-listen-events.ts │ │ │ │ │ ├── use-playground-container.ts │ │ │ │ │ ├── use-playground-context.ts │ │ │ │ │ ├── use-playground-drag.ts │ │ │ │ │ ├── use-playground.ts │ │ │ │ │ ├── use-refresh.ts │ │ │ │ │ └── use-service.ts │ │ │ │ └── services/ │ │ │ │ ├── clipboard-service.ts │ │ │ │ ├── context-menu-service.ts │ │ │ │ ├── index.ts │ │ │ │ ├── logger-service.ts │ │ │ │ ├── selection-service.ts │ │ │ │ └── storage-service.ts │ │ │ ├── tsconfig.json │ │ │ ├── vitest.config.ts │ │ │ └── vitest.setup.ts │ │ ├── document/ │ │ │ ├── __tests__/ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ └── flow-document.test.ts.snap │ │ │ │ ├── datas/ │ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ │ └── flow-node-transition-data.spec.ts.snap │ │ │ │ │ ├── flow-node-render-data.spec.ts │ │ │ │ │ └── flow-node-transition-data.spec.ts │ │ │ │ ├── flow-document-container.mock.ts │ │ │ │ ├── flow-document-transformer.test.ts │ │ │ │ ├── flow-document.test.ts │ │ │ │ ├── flow-node-entity.spec.ts │ │ │ │ ├── flow-node-registry.spec.ts │ │ │ │ ├── flow-render-tree.spec.ts │ │ │ │ ├── flow-virtual-tree.spec.ts │ │ │ │ ├── flow.mock.ts │ │ │ │ └── services/ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ └── flow-operation-base-service.test.ts.snap │ │ │ │ └── flow-operation-base-service.test.ts │ │ │ ├── eslint.config.js │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── datas/ │ │ │ │ │ ├── flow-node-render-data.ts │ │ │ │ │ ├── flow-node-transform-data.ts │ │ │ │ │ ├── flow-node-transition-data.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── entities/ │ │ │ │ │ ├── flow-document-transformer-entity.ts │ │ │ │ │ ├── flow-node-entity.ts │ │ │ │ │ ├── flow-renderer-state-entity.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── flow-document-config.ts │ │ │ │ ├── flow-document-container-module.ts │ │ │ │ ├── flow-document-contribution.ts │ │ │ │ ├── flow-document-options.ts │ │ │ │ ├── flow-document.ts │ │ │ │ ├── flow-render-tree.ts │ │ │ │ ├── flow-virtual-tree.ts │ │ │ │ ├── index.ts │ │ │ │ ├── layout/ │ │ │ │ │ ├── horizontal-fixed-layout.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── vertical-fixed-layout.ts │ │ │ │ ├── services/ │ │ │ │ │ ├── flow-drag-service.ts │ │ │ │ │ ├── flow-group-service/ │ │ │ │ │ │ ├── flow-group-controller.ts │ │ │ │ │ │ ├── flow-group-service.ts │ │ │ │ │ │ ├── flow-group-utils.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── flow-operation-base-service.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── typings/ │ │ │ │ │ ├── flow-group.ts │ │ │ │ │ ├── flow-layout.ts │ │ │ │ │ ├── flow-node-register.ts │ │ │ │ │ ├── flow-operation.ts │ │ │ │ │ ├── flow-transition.ts │ │ │ │ │ ├── flow.ts │ │ │ │ │ └── index.ts │ │ │ │ └── utils/ │ │ │ │ ├── get-default-spacing.ts │ │ │ │ └── index.ts │ │ │ ├── tsconfig.json │ │ │ ├── vitest.config.ts │ │ │ └── vitest.setup.ts │ │ ├── fixed-layout-core/ │ │ │ ├── __tests__/ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ └── flow-activities.spec.ts.snap │ │ │ │ ├── flow-activities.mock.ts │ │ │ │ └── flow-activities.spec.ts │ │ │ ├── eslint.config.js │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── activities/ │ │ │ │ │ ├── block-icon.ts │ │ │ │ │ ├── block-order-icon.ts │ │ │ │ │ ├── block.ts │ │ │ │ │ ├── break.ts │ │ │ │ │ ├── dynamic-split.ts │ │ │ │ │ ├── empty.ts │ │ │ │ │ ├── end.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── inline-blocks.ts │ │ │ │ │ ├── input.ts │ │ │ │ │ ├── loop-extends/ │ │ │ │ │ │ ├── constants.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── loop-empty-branch.ts │ │ │ │ │ │ ├── loop-inline-blocks.ts │ │ │ │ │ │ ├── loop-left-empty-block.ts │ │ │ │ │ │ └── loop-right-empty-block.ts │ │ │ │ │ ├── loop.ts │ │ │ │ │ ├── multi-inputs.ts │ │ │ │ │ ├── multi-outputs.ts │ │ │ │ │ ├── output.ts │ │ │ │ │ ├── root.ts │ │ │ │ │ ├── simple-split.ts │ │ │ │ │ ├── slot/ │ │ │ │ │ │ ├── constants.ts │ │ │ │ │ │ ├── extends/ │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ ├── slot-block.ts │ │ │ │ │ │ │ ├── slot-icon.ts │ │ │ │ │ │ │ └── slot-inline-blocks.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── slot.ts │ │ │ │ │ │ ├── typings.ts │ │ │ │ │ │ └── utils/ │ │ │ │ │ │ ├── create.ts │ │ │ │ │ │ ├── layout.ts │ │ │ │ │ │ ├── node.ts │ │ │ │ │ │ └── transition.ts │ │ │ │ │ ├── start.ts │ │ │ │ │ ├── static-split.ts │ │ │ │ │ ├── try-catch-extends/ │ │ │ │ │ │ ├── catch-block.ts │ │ │ │ │ │ ├── catch-inline-blocks.ts │ │ │ │ │ │ ├── constants.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── main-inline-blocks.ts │ │ │ │ │ │ ├── try-block.ts │ │ │ │ │ │ └── try-slot.ts │ │ │ │ │ └── try-catch.ts │ │ │ │ ├── fixed-layout-container-module.ts │ │ │ │ ├── flow-registers.ts │ │ │ │ └── index.ts │ │ │ ├── tsconfig.json │ │ │ ├── vitest.config.ts │ │ │ └── vitest.setup.ts │ │ ├── free-layout-core/ │ │ │ ├── __tests__/ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ └── workflow-lines-manager.test.ts.snap │ │ │ │ ├── hooks/ │ │ │ │ │ ├── use-current-dom-node.test.ts │ │ │ │ │ ├── use-current-entity.test.ts │ │ │ │ │ ├── use-node-render.test.tsx │ │ │ │ │ ├── use-playground-readonly-state.test.ts │ │ │ │ │ └── use-workflow-document.test.ts │ │ │ │ ├── mocks/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── service/ │ │ │ │ │ ├── workflow-drag-service.test.ts │ │ │ │ │ ├── workflow-hover-service.test.ts │ │ │ │ │ └── workflow-select-service.test.ts │ │ │ │ ├── simple-line.ts │ │ │ │ ├── utils/ │ │ │ │ │ └── location-config-to-point.test.ts │ │ │ │ ├── workflow-document.test.ts │ │ │ │ └── workflow-lines-manager.test.ts │ │ │ ├── eslint.config.js │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── constants.ts │ │ │ │ ├── entities/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── workflow-line-entity.ts │ │ │ │ │ ├── workflow-node-entity.ts │ │ │ │ │ └── workflow-port-entity.ts │ │ │ │ ├── entity-datas/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── workflow-line-render-data.ts │ │ │ │ │ ├── workflow-node-lines-data.ts │ │ │ │ │ └── workflow-node-ports-data.ts │ │ │ │ ├── hooks/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── typings.ts │ │ │ │ │ ├── use-current-dom-node.ts │ │ │ │ │ ├── use-current-entity.ts │ │ │ │ │ ├── use-node-render-context.ts │ │ │ │ │ ├── use-node-render.tsx │ │ │ │ │ ├── use-playground-readonly-state.ts │ │ │ │ │ └── use-workflow-document.ts │ │ │ │ ├── index.ts │ │ │ │ ├── layout/ │ │ │ │ │ ├── free-layout.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── service/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── workflow-drag-service.ts │ │ │ │ │ ├── workflow-hover-service.ts │ │ │ │ │ ├── workflow-operation-base-service.ts │ │ │ │ │ ├── workflow-reset-layout-service.ts │ │ │ │ │ └── workflow-select-service.ts │ │ │ │ ├── typings/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── workflow-drag.ts │ │ │ │ │ ├── workflow-edge.ts │ │ │ │ │ ├── workflow-json.ts │ │ │ │ │ ├── workflow-line.ts │ │ │ │ │ ├── workflow-node.ts │ │ │ │ │ ├── workflow-operation.ts │ │ │ │ │ ├── workflow-registry.ts │ │ │ │ │ └── workflow-sub-canvas.ts │ │ │ │ ├── utils/ │ │ │ │ │ ├── build-group-json.ts │ │ │ │ │ ├── compose.ts │ │ │ │ │ ├── fit-view.ts │ │ │ │ │ ├── flow-node-form-data.ts │ │ │ │ │ ├── get-anti-overlap-position.ts │ │ │ │ │ ├── get-line-center.ts │ │ │ │ │ ├── get-url-params.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── layout-to-positions.ts │ │ │ │ │ ├── location-config-to-point.ts │ │ │ │ │ ├── nanoid.ts │ │ │ │ │ └── statics.ts │ │ │ │ ├── workflow-commands.ts │ │ │ │ ├── workflow-document-container-module.ts │ │ │ │ ├── workflow-document-contribution.ts │ │ │ │ ├── workflow-document-option.ts │ │ │ │ ├── workflow-document.ts │ │ │ │ └── workflow-lines-manager.ts │ │ │ ├── tsconfig.json │ │ │ ├── vitest.config.ts │ │ │ └── vitest.setup.ts │ │ └── renderer/ │ │ ├── __mocks__/ │ │ │ ├── flow-document-container.mock.ts │ │ │ ├── flow-drag-entity.ts │ │ │ ├── flow-json.mock.ts │ │ │ ├── flow-labels-mock-register.ts │ │ │ ├── flow-mock-node-json.ts │ │ │ ├── flow-selected-nodes.mock.ts │ │ │ ├── mock-lines.ts │ │ │ ├── renderer.mock.ts │ │ │ └── setup-file.ts │ │ ├── __tests__/ │ │ │ ├── components/ │ │ │ │ ├── Adder.test.tsx │ │ │ │ └── rounded-turning-line.test.tsx │ │ │ ├── entities/ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ └── flow-drag-entities.test.ts.snap │ │ │ │ ├── flow-drag-entities.test.ts │ │ │ │ └── flow-select-config-entity.test.ts │ │ │ ├── flow-renderer.tsx │ │ │ ├── layers/ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ ├── flow-drag-layer.test.ts.snap │ │ │ │ │ ├── flow-label-layer.test.tsx.snap │ │ │ │ │ └── flow-selector-box-layer.test.tsx.snap │ │ │ │ ├── flow-drag-layer.test.ts │ │ │ │ ├── flow-label-layer.test.tsx │ │ │ │ ├── flow-lines-layer.test.ts │ │ │ │ ├── flow-selector-box-layer.test.tsx │ │ │ │ └── flow-transform-layer.test.tsx │ │ │ └── utils/ │ │ │ ├── element.test.ts │ │ │ ├── find-selected-nodes.test.ts │ │ │ ├── get-vertices.test.ts │ │ │ └── scroll-limit.test.ts │ │ ├── eslint.config.js │ │ ├── index.module.less │ │ ├── package.json │ │ ├── src/ │ │ │ ├── components/ │ │ │ │ ├── Adder.tsx │ │ │ │ ├── BranchDraggableRenderer.tsx │ │ │ │ ├── Collapse.tsx │ │ │ │ ├── CollapseAdder.tsx │ │ │ │ ├── CustomLine.tsx │ │ │ │ ├── LabelsRenderer.tsx │ │ │ │ ├── LinesRenderer.tsx │ │ │ │ ├── MarkerActivatedArrow.tsx │ │ │ │ ├── MarkerArrow.tsx │ │ │ │ ├── RoundedTurningLine.tsx │ │ │ │ ├── StraightLine.tsx │ │ │ │ └── utils.tsx │ │ │ ├── entities/ │ │ │ │ ├── README.md │ │ │ │ ├── flow-drag-entity.tsx │ │ │ │ ├── flow-select-config-entity.tsx │ │ │ │ ├── index.ts │ │ │ │ └── selector-box-config-entity.ts │ │ │ ├── flow-renderer-container-module.ts │ │ │ ├── flow-renderer-contribution.ts │ │ │ ├── flow-renderer-registry.ts │ │ │ ├── flow-renderer-resize-observer.ts │ │ │ ├── hooks/ │ │ │ │ └── use-base-color.ts │ │ │ ├── index.ts │ │ │ ├── layers/ │ │ │ │ ├── flow-context-menu-layer.tsx │ │ │ │ ├── flow-debug-layer.tsx │ │ │ │ ├── flow-drag-layer.tsx │ │ │ │ ├── flow-labels-layer.tsx │ │ │ │ ├── flow-lines-layer.tsx │ │ │ │ ├── flow-nodes-content-layer.tsx │ │ │ │ ├── flow-nodes-transform-layer.tsx │ │ │ │ ├── flow-scroll-bar-layer.tsx │ │ │ │ ├── flow-scroll-limit-layer.tsx │ │ │ │ ├── flow-selector-bounds-layer.tsx │ │ │ │ ├── flow-selector-box-layer.tsx │ │ │ │ └── index.ts │ │ │ └── utils/ │ │ │ ├── element.ts │ │ │ ├── find-selected-nodes.ts │ │ │ ├── index.ts │ │ │ ├── scroll-bar-events.tsx │ │ │ └── scroll-limit.ts │ │ ├── tsconfig.json │ │ ├── vitest.config.ts │ │ └── vitest.setup.ts │ ├── client/ │ │ ├── editor/ │ │ │ ├── eslint.config.js │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── clients/ │ │ │ │ │ ├── flow-editor-client-plugins.ts │ │ │ │ │ ├── flow-editor-client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── node-client/ │ │ │ │ │ ├── create-node-client-plugins.ts │ │ │ │ │ ├── highlight/ │ │ │ │ │ │ ├── constants.ts │ │ │ │ │ │ ├── create-node-highlight-plugin.ts │ │ │ │ │ │ ├── highlight-form-item.ts │ │ │ │ │ │ ├── highlight-style.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── use-highlight.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── node-client.ts │ │ │ │ │ └── node-focus-service.ts │ │ │ │ ├── components/ │ │ │ │ │ ├── editor-provider.tsx │ │ │ │ │ ├── editor-renderer.tsx │ │ │ │ │ ├── editor.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── hooks/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── use-flow-editor.ts │ │ │ │ ├── index.ts │ │ │ │ └── preset/ │ │ │ │ ├── editor-default-preset.ts │ │ │ │ ├── editor-props.ts │ │ │ │ └── index.ts │ │ │ ├── tsconfig.json │ │ │ ├── vitest.config.ts │ │ │ └── vitest.setup.ts │ │ ├── fixed-layout-editor/ │ │ │ ├── __mocks__/ │ │ │ │ ├── flow.mock.ts │ │ │ │ └── form.mock.tsx │ │ │ ├── __tests__/ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ └── fixed-layout-preset.test.ts.snap │ │ │ │ ├── create-container.ts │ │ │ │ ├── fixed-layout-preset.test.ts │ │ │ │ ├── services/ │ │ │ │ │ ├── flow-operation-service.test.ts │ │ │ │ │ └── history-operation-service/ │ │ │ │ │ ├── add-block.test.ts │ │ │ │ │ ├── add-from-node.test.ts │ │ │ │ │ ├── add-node.test.ts │ │ │ │ │ ├── apply.test.ts │ │ │ │ │ ├── create-group.test.ts │ │ │ │ │ ├── delete-node.test.ts │ │ │ │ │ ├── delete-nodes.test.ts │ │ │ │ │ ├── move-node.test.ts │ │ │ │ │ ├── set-form-value.test.tsx │ │ │ │ │ ├── transact.test.ts │ │ │ │ │ └── ungroup.test.ts │ │ │ │ └── utils.ts │ │ │ ├── eslint.config.js │ │ │ ├── index.css │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── components/ │ │ │ │ │ ├── fixed-layout-editor-provider.tsx │ │ │ │ │ ├── fixed-layout-editor.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── hooks/ │ │ │ │ │ ├── use-client-context.ts │ │ │ │ │ ├── use-node-render.tsx │ │ │ │ │ └── use-playground-tools.ts │ │ │ │ ├── index.ts │ │ │ │ ├── plugins/ │ │ │ │ │ └── create-operation-plugin.ts │ │ │ │ ├── preset/ │ │ │ │ │ ├── fixed-layout-preset.ts │ │ │ │ │ ├── fixed-layout-props.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── node-serialize.ts │ │ │ │ ├── services/ │ │ │ │ │ ├── flow-operation-service.ts │ │ │ │ │ └── history-operation-service.ts │ │ │ │ ├── types.ts │ │ │ │ └── utils/ │ │ │ │ └── compose.ts │ │ │ ├── tsconfig.json │ │ │ ├── vitest.config.ts │ │ │ └── vitest.setup.ts │ │ ├── free-layout-editor/ │ │ │ ├── __mocks__/ │ │ │ │ └── flow.mocks.ts │ │ │ ├── __tests__/ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ └── free-layout-preset.test.ts.snap │ │ │ │ ├── create-editor.ts │ │ │ │ ├── free-layout-preset.test.ts │ │ │ │ ├── history.test.ts │ │ │ │ ├── use-playground-tools.test.ts │ │ │ │ └── utils.mock.tsx │ │ │ ├── eslint.config.js │ │ │ ├── index.css │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── components/ │ │ │ │ │ ├── free-layout-editor-provider.tsx │ │ │ │ │ ├── free-layout-editor.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ └── workflow-node-renderer.tsx │ │ │ │ ├── hooks/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── use-auto-layout.ts │ │ │ │ │ ├── use-client-context.ts │ │ │ │ │ └── use-playground-tools.ts │ │ │ │ ├── index.ts │ │ │ │ ├── plugins/ │ │ │ │ │ └── create-operation-plugin.ts │ │ │ │ ├── preset/ │ │ │ │ │ ├── free-layout-preset.ts │ │ │ │ │ ├── free-layout-props.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── node-serialize.ts │ │ │ │ ├── services/ │ │ │ │ │ ├── flow-operation-service.ts │ │ │ │ │ └── history-operation-service.ts │ │ │ │ ├── tools/ │ │ │ │ │ ├── auto-layout.ts │ │ │ │ │ └── index.ts │ │ │ │ └── types.ts │ │ │ ├── tsconfig.json │ │ │ ├── vitest.config.ts │ │ │ └── vitest.setup.ts │ │ └── playground-react/ │ │ ├── eslint.config.js │ │ ├── index.css │ │ ├── package.json │ │ ├── src/ │ │ │ ├── components/ │ │ │ │ ├── index.ts │ │ │ │ ├── playground-react-content.tsx │ │ │ │ └── playground-react.tsx │ │ │ ├── hooks/ │ │ │ │ ├── index.ts │ │ │ │ └── use-playground-tools.ts │ │ │ ├── index.ts │ │ │ ├── layers/ │ │ │ │ └── playground-content-layer.tsx │ │ │ └── preset/ │ │ │ ├── index.ts │ │ │ ├── playground-react-preset.ts │ │ │ └── playground-react-props.ts │ │ ├── tsconfig.json │ │ ├── vitest.config.ts │ │ └── vitest.setup.ts │ ├── common/ │ │ ├── command/ │ │ │ ├── eslint.config.js │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── command-container-module.ts │ │ │ │ ├── command-service.ts │ │ │ │ ├── command.ts │ │ │ │ └── index.ts │ │ │ ├── tsconfig.json │ │ │ ├── vitest.config.ts │ │ │ └── vitest.setup.ts │ │ ├── history/ │ │ │ ├── __mocks__/ │ │ │ │ ├── editor.mock.ts │ │ │ │ └── history-container.mock.ts │ │ │ ├── __tests__/ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ ├── history-manager.test.ts.snap │ │ │ │ │ ├── history-service.test.ts.snap │ │ │ │ │ └── undo-redo-service.test.ts.snap │ │ │ │ ├── history-manager.test.ts │ │ │ │ ├── history-service.test.ts │ │ │ │ ├── operation-registry.test.ts │ │ │ │ ├── operation-service.test.ts │ │ │ │ └── undo-redo-service.test.ts │ │ │ ├── eslint.config.js │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── create-history-plugin.ts │ │ │ │ ├── history/ │ │ │ │ │ ├── history-manager.ts │ │ │ │ │ ├── history-service.ts │ │ │ │ │ ├── history-stack.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── stack-operation.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ └── undo-redo-service.ts │ │ │ │ ├── history-config.ts │ │ │ │ ├── history-container-module.ts │ │ │ │ ├── history-context.ts │ │ │ │ ├── index.ts │ │ │ │ └── operation/ │ │ │ │ ├── index.ts │ │ │ │ ├── operation-contribution.ts │ │ │ │ ├── operation-registry.ts │ │ │ │ ├── operation-service.ts │ │ │ │ └── types.ts │ │ │ ├── tsconfig.json │ │ │ ├── vitest.config.ts │ │ │ └── vitest.setup.ts │ │ ├── history-storage/ │ │ │ ├── eslint.config.js │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── __mocks__/ │ │ │ │ │ └── index.ts │ │ │ │ ├── __tests__/ │ │ │ │ │ └── history-database.test.ts │ │ │ │ ├── create-history-storage-plugin.ts │ │ │ │ ├── history-database.ts │ │ │ │ ├── history-storage-container-module.ts │ │ │ │ ├── history-storage-manager.ts │ │ │ │ ├── index.ts │ │ │ │ ├── types.ts │ │ │ │ └── use-storage-hisotry-items.tsx │ │ │ ├── tsconfig.json │ │ │ ├── vitest.config.ts │ │ │ └── vitest.setup.ts │ │ ├── i18n/ │ │ │ ├── __tests__/ │ │ │ │ └── i18n.test.ts │ │ │ ├── eslint.config.js │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── i18n/ │ │ │ │ │ ├── en-US.ts │ │ │ │ │ └── zh-CN.ts │ │ │ │ └── index.ts │ │ │ ├── tsconfig.json │ │ │ └── vitest.config.ts │ │ ├── reactive/ │ │ │ ├── README.md │ │ │ ├── __tests__/ │ │ │ │ ├── hooks.test.tsx │ │ │ │ ├── observe.test.tsx │ │ │ │ ├── reactive-base-state.test.ts │ │ │ │ ├── reactive-state.test.ts │ │ │ │ └── tracker.test.ts │ │ │ ├── eslint.config.js │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── core/ │ │ │ │ │ ├── reactive-base-state.ts │ │ │ │ │ ├── reactive-state.ts │ │ │ │ │ └── tracker.ts │ │ │ │ ├── hooks/ │ │ │ │ │ ├── use-observe.ts │ │ │ │ │ ├── use-reactive-state.ts │ │ │ │ │ └── use-readonly-reactive-state.ts │ │ │ │ ├── index.ts │ │ │ │ ├── react/ │ │ │ │ │ └── observe.tsx │ │ │ │ └── utils/ │ │ │ │ └── create-proxy.ts │ │ │ ├── tsconfig.json │ │ │ └── vitest.config.ts │ │ └── utils/ │ │ ├── eslint.config.js │ │ ├── package.json │ │ ├── src/ │ │ │ ├── add-event-listener.ts │ │ │ ├── array.spec.ts │ │ │ ├── array.ts │ │ │ ├── cache.spec.ts │ │ │ ├── cache.ts │ │ │ ├── cancellation.spec.ts │ │ │ ├── cancellation.ts │ │ │ ├── compare.spec.ts │ │ │ ├── compare.ts │ │ │ ├── compose.ts │ │ │ ├── contribution-provider.ts │ │ │ ├── decoration-style.ts │ │ │ ├── disposable-collection.ts │ │ │ ├── disposable.spec.ts │ │ │ ├── disposable.ts │ │ │ ├── dom-utils.spec.ts │ │ │ ├── dom-utils.ts │ │ │ ├── event.spec.ts │ │ │ ├── event.ts │ │ │ ├── hooks/ │ │ │ │ ├── use-refresh.spec.tsx │ │ │ │ └── use-refresh.ts │ │ │ ├── id.spec.ts │ │ │ ├── id.ts │ │ │ ├── index.spec.ts │ │ │ ├── index.ts │ │ │ ├── inversify-utils.ts │ │ │ ├── logger.spec.ts │ │ │ ├── logger.ts │ │ │ ├── math/ │ │ │ │ ├── IPoint.spec.ts │ │ │ │ ├── IPoint.ts │ │ │ │ ├── Matrix.spec.ts │ │ │ │ ├── Matrix.ts │ │ │ │ ├── ObservablePoint.spec.ts │ │ │ │ ├── ObservablePoint.ts │ │ │ │ ├── Point.spec.ts │ │ │ │ ├── Point.ts │ │ │ │ ├── Transform.spec.ts │ │ │ │ ├── Transform.ts │ │ │ │ ├── Vector2.spec.ts │ │ │ │ ├── Vector2.ts │ │ │ │ ├── angle.spec.ts │ │ │ │ ├── angle.ts │ │ │ │ ├── const.spec.ts │ │ │ │ ├── const.ts │ │ │ │ ├── index.spec.ts │ │ │ │ ├── index.ts │ │ │ │ ├── shapes/ │ │ │ │ │ ├── Circle.spec.ts │ │ │ │ │ ├── Circle.ts │ │ │ │ │ ├── Rectangle.spec.ts │ │ │ │ │ ├── Rectangle.ts │ │ │ │ │ ├── index.spec.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── wrap.spec.ts │ │ │ │ └── wrap.ts │ │ │ ├── objects.spec.ts │ │ │ ├── objects.ts │ │ │ ├── promise-util.spec.ts │ │ │ ├── promise-util.ts │ │ │ ├── request-with-memo.spec.ts │ │ │ ├── request-with-memo.ts │ │ │ ├── schema/ │ │ │ │ ├── index.spec.ts │ │ │ │ ├── index.ts │ │ │ │ ├── schema-base.ts │ │ │ │ ├── schema-transform.ts │ │ │ │ ├── schema.spec.ts │ │ │ │ └── schema.ts │ │ │ ├── types.spec.ts │ │ │ └── types.ts │ │ ├── tsconfig.json │ │ └── vitest.config.ts │ ├── materials/ │ │ ├── coze-editor/ │ │ │ ├── eslint.config.js │ │ │ ├── package.json │ │ │ ├── rslib.config.ts │ │ │ ├── scripts/ │ │ │ │ └── gen.js │ │ │ ├── src/ │ │ │ │ ├── index.ts │ │ │ │ ├── language-json.ts │ │ │ │ ├── language-python.ts │ │ │ │ ├── language-shell.ts │ │ │ │ ├── language-sql.ts │ │ │ │ ├── language-typescript/ │ │ │ │ │ └── worker.ts │ │ │ │ ├── language-typescript.ts │ │ │ │ ├── preset-code.ts │ │ │ │ ├── preset-expression.ts │ │ │ │ ├── preset-none.ts │ │ │ │ ├── preset-prompt.ts │ │ │ │ ├── preset-universal.ts │ │ │ │ ├── preset-variable.ts │ │ │ │ ├── react-merge.ts │ │ │ │ ├── react.ts │ │ │ │ └── vscode.ts │ │ │ ├── tsconfig.json │ │ │ ├── vitest.config.ts │ │ │ └── vitest.setup.ts │ │ ├── fixed-semi-materials/ │ │ │ ├── eslint.config.js │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── assets/ │ │ │ │ │ ├── ellipsis.tsx │ │ │ │ │ ├── icons.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── components/ │ │ │ │ │ ├── adder/ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ └── styles.tsx │ │ │ │ │ ├── branch-adder/ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ └── styles.tsx │ │ │ │ │ ├── collapse/ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ └── styles.tsx │ │ │ │ │ ├── constants.tsx │ │ │ │ │ ├── drag-highlight-adder/ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ └── styles.tsx │ │ │ │ │ ├── drag-node/ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ └── styles.tsx │ │ │ │ │ ├── dragging-adder/ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ └── styles.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── metadata.tsx │ │ │ │ │ ├── nodes/ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ └── styles.tsx │ │ │ │ │ ├── slot-adder.tsx │ │ │ │ │ ├── slot-collapse.tsx │ │ │ │ │ ├── tools.tsx │ │ │ │ │ └── try-catch-collapse.tsx │ │ │ │ └── index.ts │ │ │ ├── tsconfig.json │ │ │ ├── vitest.config.ts │ │ │ └── vitest.setup.ts │ │ ├── form-antd-materials/ │ │ │ ├── eslint.config.js │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── components/ │ │ │ │ │ ├── batch-variable-selector/ │ │ │ │ │ │ ├── config.json │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── condition-row/ │ │ │ │ │ │ ├── config.json │ │ │ │ │ │ ├── constants.ts │ │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ │ ├── styles.tsx │ │ │ │ │ │ │ ├── useOp.tsx │ │ │ │ │ │ │ └── useRule.ts │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ ├── styles.tsx │ │ │ │ │ │ └── types.ts │ │ │ │ │ ├── constant-input/ │ │ │ │ │ │ ├── config.json │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ ├── styles.tsx │ │ │ │ │ │ └── types.ts │ │ │ │ │ ├── dynamic-value-input/ │ │ │ │ │ │ ├── config.json │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ └── styles.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── json-schema-editor/ │ │ │ │ │ │ ├── components/ │ │ │ │ │ │ │ └── blur-input.tsx │ │ │ │ │ │ ├── config.json │ │ │ │ │ │ ├── default-value.tsx │ │ │ │ │ │ ├── hooks.tsx │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ ├── styles.tsx │ │ │ │ │ │ ├── types.ts │ │ │ │ │ │ └── utils.ts │ │ │ │ │ ├── type-selector/ │ │ │ │ │ │ ├── config.json │ │ │ │ │ │ ├── constants.tsx │ │ │ │ │ │ └── index.tsx │ │ │ │ │ └── variable-selector/ │ │ │ │ │ ├── config.json │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── styles.tsx │ │ │ │ │ ├── types.ts │ │ │ │ │ └── use-variable-tree.tsx │ │ │ │ ├── effects/ │ │ │ │ │ ├── auto-rename-ref/ │ │ │ │ │ │ ├── config.json │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── provide-batch-input/ │ │ │ │ │ │ ├── config.json │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── provide-batch-outputs/ │ │ │ │ │ │ ├── config.json │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── provide-json-schema-outputs/ │ │ │ │ │ │ ├── config.json │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── sync-variable-title/ │ │ │ │ │ ├── config.json │ │ │ │ │ └── index.ts │ │ │ │ ├── form-plugins/ │ │ │ │ │ ├── batch-outputs-plugin/ │ │ │ │ │ │ ├── config.json │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── typings/ │ │ │ │ │ ├── flow-value/ │ │ │ │ │ │ ├── config.json │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── json-schema/ │ │ │ │ │ ├── config.json │ │ │ │ │ └── index.ts │ │ │ │ └── utils/ │ │ │ │ ├── format-legacy-refs/ │ │ │ │ │ ├── config.json │ │ │ │ │ ├── index.ts │ │ │ │ │ └── readme.md │ │ │ │ ├── index.ts │ │ │ │ ├── json-schema/ │ │ │ │ │ ├── config.json │ │ │ │ │ └── index.ts │ │ │ │ └── svg-icon/ │ │ │ │ └── index.tsx │ │ │ ├── tsconfig.json │ │ │ ├── vitest.config.ts │ │ │ └── vitest.setup.ts │ │ ├── form-materials/ │ │ │ ├── bin/ │ │ │ │ └── run.sh │ │ │ ├── eslint.config.js │ │ │ ├── package.json │ │ │ ├── rslib.config.ts │ │ │ ├── scripts/ │ │ │ │ └── name-export.js │ │ │ ├── src/ │ │ │ │ ├── components/ │ │ │ │ │ ├── assign-row/ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ └── types.ts │ │ │ │ │ ├── assign-rows/ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── batch-outputs/ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ ├── styles.css │ │ │ │ │ │ └── types.ts │ │ │ │ │ ├── batch-variable-selector/ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── blur-input/ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── code-editor/ │ │ │ │ │ │ ├── editor-all.tsx │ │ │ │ │ │ ├── editor-json.tsx │ │ │ │ │ │ ├── editor-python.tsx │ │ │ │ │ │ ├── editor-shell.tsx │ │ │ │ │ │ ├── editor-sql.tsx │ │ │ │ │ │ ├── editor-ts.tsx │ │ │ │ │ │ ├── editor.tsx │ │ │ │ │ │ ├── factory.tsx │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ ├── styles.css │ │ │ │ │ │ ├── theme/ │ │ │ │ │ │ │ ├── dark.ts │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ └── light.ts │ │ │ │ │ │ └── utils.ts │ │ │ │ │ ├── code-editor-mini/ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── condition-context/ │ │ │ │ │ │ ├── context.tsx │ │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ │ └── use-condition.tsx │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ ├── op.ts │ │ │ │ │ │ └── types.ts │ │ │ │ │ ├── condition-row/ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ ├── styles.css │ │ │ │ │ │ └── types.ts │ │ │ │ │ ├── constant-input/ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ └── types.ts │ │ │ │ │ ├── coze-editor-extensions/ │ │ │ │ │ │ ├── extensions/ │ │ │ │ │ │ │ ├── inputs-tree.tsx │ │ │ │ │ │ │ ├── variable-tag.tsx │ │ │ │ │ │ │ └── variable-tree.tsx │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ └── styles.css │ │ │ │ │ ├── db-condition-row/ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ ├── styles.css │ │ │ │ │ │ └── types.ts │ │ │ │ │ ├── display-flow-value/ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── display-inputs-values/ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ └── styles.css │ │ │ │ │ ├── display-outputs/ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ └── styles.css │ │ │ │ │ ├── display-schema-tag/ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ └── styles.css │ │ │ │ │ ├── display-schema-tree/ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ └── styles.css │ │ │ │ │ ├── dynamic-value-input/ │ │ │ │ │ │ ├── hooks.ts │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ └── styles.css │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── inputs-values/ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ ├── styles.css │ │ │ │ │ │ └── types.ts │ │ │ │ │ ├── inputs-values-tree/ │ │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ │ └── use-child-list.tsx │ │ │ │ │ │ ├── icon.tsx │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ ├── row.tsx │ │ │ │ │ │ ├── styles.css │ │ │ │ │ │ └── types.ts │ │ │ │ │ ├── json-editor-with-variables/ │ │ │ │ │ │ ├── editor.tsx │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── json-schema-creator/ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ ├── json-input-modal.tsx │ │ │ │ │ │ ├── json-schema-creator.tsx │ │ │ │ │ │ └── utils/ │ │ │ │ │ │ └── json-to-schema.ts │ │ │ │ │ ├── json-schema-editor/ │ │ │ │ │ │ ├── default-value.tsx │ │ │ │ │ │ ├── hooks.tsx │ │ │ │ │ │ ├── icon.tsx │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ ├── styles.css │ │ │ │ │ │ └── types.ts │ │ │ │ │ ├── prompt-editor/ │ │ │ │ │ │ ├── editor.tsx │ │ │ │ │ │ ├── extensions/ │ │ │ │ │ │ │ ├── jinja.tsx │ │ │ │ │ │ │ ├── language-support.tsx │ │ │ │ │ │ │ └── markdown.tsx │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ ├── styles.css │ │ │ │ │ │ └── types.tsx │ │ │ │ │ ├── prompt-editor-with-inputs/ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── prompt-editor-with-variables/ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── sql-editor-with-variables/ │ │ │ │ │ │ ├── editor.tsx │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── type-selector/ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ └── variable-selector/ │ │ │ │ │ ├── context.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── styles.css │ │ │ │ │ └── use-variable-tree.tsx │ │ │ │ ├── effects/ │ │ │ │ │ ├── auto-rename-ref/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── listen-ref-schema-change/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── listen-ref-value-change/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── provide-batch-input/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── provide-json-schema-outputs/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── sync-variable-title/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── validate-when-variable-sync/ │ │ │ │ │ └── index.ts │ │ │ │ ├── form-plugins/ │ │ │ │ │ ├── batch-outputs-plugin/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── infer-assign-plugin/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── infer-inputs-plugin/ │ │ │ │ │ └── index.ts │ │ │ │ ├── hooks/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── use-object-list/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── plugins/ │ │ │ │ │ ├── disable-declaration-plugin/ │ │ │ │ │ │ ├── create-disable-declaration-plugin.ts │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ └── json-schema-preset/ │ │ │ │ │ ├── create-type-preset-plugin.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── react.tsx │ │ │ │ │ ├── type-definition/ │ │ │ │ │ │ ├── array.tsx │ │ │ │ │ │ ├── boolean.tsx │ │ │ │ │ │ ├── date-time.tsx │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ ├── integer.tsx │ │ │ │ │ │ ├── map.tsx │ │ │ │ │ │ ├── number.tsx │ │ │ │ │ │ ├── object.tsx │ │ │ │ │ │ └── string.tsx │ │ │ │ │ └── types.ts │ │ │ │ ├── shared/ │ │ │ │ │ ├── flow-value/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── schema.ts │ │ │ │ │ │ ├── types.ts │ │ │ │ │ │ └── utils.ts │ │ │ │ │ ├── format-legacy-refs/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── readme.md │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── inject-material/ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── lazy-suspense/ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ └── polyfill-create-root/ │ │ │ │ │ └── index.tsx │ │ │ │ └── validate/ │ │ │ │ ├── index.ts │ │ │ │ └── validate-flow-value/ │ │ │ │ └── index.tsx │ │ │ ├── tsconfig.json │ │ │ ├── vitest.config.ts │ │ │ └── vitest.setup.ts │ │ └── type-editor/ │ │ ├── eslint.config.js │ │ ├── package.json │ │ ├── src/ │ │ │ ├── components/ │ │ │ │ ├── feedback/ │ │ │ │ │ ├── feedback.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── style.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── index.ts │ │ │ │ ├── type-editor/ │ │ │ │ │ ├── body.tsx │ │ │ │ │ ├── cell.tsx │ │ │ │ │ ├── columns/ │ │ │ │ │ │ ├── default.tsx │ │ │ │ │ │ ├── description.tsx │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── key.tsx │ │ │ │ │ │ ├── operate.tsx │ │ │ │ │ │ ├── private.tsx │ │ │ │ │ │ ├── required.tsx │ │ │ │ │ │ ├── style.ts │ │ │ │ │ │ ├── type.tsx │ │ │ │ │ │ └── value.tsx │ │ │ │ │ ├── common.ts │ │ │ │ │ ├── drop-tip.tsx │ │ │ │ │ ├── error.tsx │ │ │ │ │ ├── formatter/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── header.tsx │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ ├── active-pos.ts │ │ │ │ │ │ ├── blink.ts │ │ │ │ │ │ ├── disabled.ts │ │ │ │ │ │ ├── drag-drop.ts │ │ │ │ │ │ ├── editor-listener.tsx │ │ │ │ │ │ ├── error-cell.ts │ │ │ │ │ │ ├── formatter-value.ts │ │ │ │ │ │ ├── hot-key.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── key-visible.tsx │ │ │ │ │ │ ├── paste-data.ts │ │ │ │ │ │ └── type-edit.ts │ │ │ │ │ ├── indent.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── mode/ │ │ │ │ │ │ ├── declare-assign.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── type-definition.ts │ │ │ │ │ ├── style.ts │ │ │ │ │ ├── table.tsx │ │ │ │ │ ├── tool-bar.tsx │ │ │ │ │ ├── tools/ │ │ │ │ │ │ ├── create-by-data.tsx │ │ │ │ │ │ ├── index.module.less │ │ │ │ │ │ ├── style.ts │ │ │ │ │ │ └── undo-redo.tsx │ │ │ │ │ ├── type-editor.tsx │ │ │ │ │ ├── type.ts │ │ │ │ │ └── utils.ts │ │ │ │ └── type-selector/ │ │ │ │ ├── cascader-v2/ │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── style.ts │ │ │ │ │ ├── trigger.tsx │ │ │ │ │ ├── type-cascader.tsx │ │ │ │ │ └── type-search.tsx │ │ │ │ ├── hooks/ │ │ │ │ │ ├── focus-item.ts │ │ │ │ │ ├── hot-key.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── option-value.ts │ │ │ │ │ ├── root-types.ts │ │ │ │ │ └── use-hight-light.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── type.ts │ │ │ │ └── utils/ │ │ │ │ └── index.tsx │ │ │ ├── contexts/ │ │ │ │ └── index.tsx │ │ │ ├── index.ts │ │ │ ├── preset/ │ │ │ │ ├── index.tsx │ │ │ │ └── object-type-editor/ │ │ │ │ └── index.tsx │ │ │ ├── services/ │ │ │ │ ├── clipboard-service.ts │ │ │ │ ├── index.tsx │ │ │ │ ├── shortcut-service.ts │ │ │ │ ├── type-editor-service.ts │ │ │ │ ├── type-operation-service.ts │ │ │ │ ├── type-registry-manager.ts │ │ │ │ └── utils.ts │ │ │ ├── type-registry/ │ │ │ │ ├── array.tsx │ │ │ │ ├── boolean.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── integer.tsx │ │ │ │ ├── number.tsx │ │ │ │ ├── object.tsx │ │ │ │ └── string.tsx │ │ │ ├── types/ │ │ │ │ ├── index.ts │ │ │ │ ├── registry.ts │ │ │ │ └── type-editor.ts │ │ │ └── utils/ │ │ │ ├── index.ts │ │ │ ├── monitor-data/ │ │ │ │ ├── index.ts │ │ │ │ ├── monitor-data.ts │ │ │ │ └── use-monitor-data.ts │ │ │ └── registry-adapter.tsx │ │ ├── tsconfig.json │ │ ├── vitest.config.ts │ │ └── vitest.setup.ts │ ├── node-engine/ │ │ ├── form/ │ │ │ ├── __tests__/ │ │ │ │ ├── create-form.test.ts │ │ │ │ ├── field-array-model.test.ts │ │ │ │ ├── field-model.test.ts │ │ │ │ ├── form-model.test.ts │ │ │ │ ├── glob.test.ts │ │ │ │ ├── object.test.ts │ │ │ │ ├── path.test.ts │ │ │ │ ├── to-field-array.test.ts │ │ │ │ ├── to-field.test.ts │ │ │ │ ├── to-form.test.ts │ │ │ │ ├── utils.test.ts │ │ │ │ └── validate.test.ts │ │ │ ├── eslint.config.js │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── constants.ts │ │ │ │ ├── core/ │ │ │ │ │ ├── create-form.ts │ │ │ │ │ ├── field-array-model.ts │ │ │ │ │ ├── field-model.ts │ │ │ │ │ ├── form-model.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── path.ts │ │ │ │ │ ├── store.ts │ │ │ │ │ ├── to-field-array.ts │ │ │ │ │ ├── to-field.ts │ │ │ │ │ ├── to-form.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── index.ts │ │ │ │ ├── react/ │ │ │ │ │ ├── context.ts │ │ │ │ │ ├── field-array.tsx │ │ │ │ │ ├── field.tsx │ │ │ │ │ ├── form.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── use-current-field-state.ts │ │ │ │ │ ├── use-current-field.ts │ │ │ │ │ ├── use-field-validate.ts │ │ │ │ │ ├── use-field.ts │ │ │ │ │ ├── use-form-state.ts │ │ │ │ │ ├── use-form.ts │ │ │ │ │ ├── use-watch.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── types/ │ │ │ │ │ ├── common.ts │ │ │ │ │ ├── field.ts │ │ │ │ │ ├── form.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── validate.ts │ │ │ │ └── utils/ │ │ │ │ ├── dom.ts │ │ │ │ ├── event.ts │ │ │ │ ├── glob.ts │ │ │ │ ├── index.ts │ │ │ │ ├── object.ts │ │ │ │ └── validate.ts │ │ │ ├── tsconfig.json │ │ │ └── vitest.config.ts │ │ ├── form-core/ │ │ │ ├── __tests__/ │ │ │ │ ├── form-path-service.test.ts │ │ │ │ └── form.test.ts │ │ │ ├── eslint.config.js │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── client/ │ │ │ │ │ ├── create-node-container-modules.ts │ │ │ │ │ ├── create-node-entity-datas.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── node-material-client.ts │ │ │ │ │ └── node-render.tsx │ │ │ │ ├── error/ │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── error-container-module.ts │ │ │ │ │ ├── error-node-contribution.ts │ │ │ │ │ ├── flow-node-error-data.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── renders/ │ │ │ │ │ │ ├── default-error-render.tsx │ │ │ │ │ │ ├── error-render.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── form/ │ │ │ │ │ ├── abilities/ │ │ │ │ │ │ ├── decorator-ability/ │ │ │ │ │ │ │ ├── decorator-ability.ts │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ └── types.ts │ │ │ │ │ │ ├── default-ability/ │ │ │ │ │ │ │ ├── default-ability.ts │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ └── types.ts │ │ │ │ │ │ ├── effect-ability/ │ │ │ │ │ │ │ ├── effect-ability.ts │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ └── types.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── setter-ability/ │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ ├── setter-ability.ts │ │ │ │ │ │ │ └── types.ts │ │ │ │ │ │ ├── validation-ability/ │ │ │ │ │ │ │ ├── form-validate.types.ts │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ ├── types.ts │ │ │ │ │ │ │ └── validation-ability.ts │ │ │ │ │ │ └── visibility-ability/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── visibility-ability.ts │ │ │ │ │ ├── client/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── flow-node-form-data.ts │ │ │ │ │ ├── form-contribution.ts │ │ │ │ │ ├── form-core-container-module.ts │ │ │ │ │ ├── form-node-contribution.ts │ │ │ │ │ ├── form-render.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── models/ │ │ │ │ │ │ ├── form-ability-extension-registry.ts │ │ │ │ │ │ ├── form-item-ability.ts │ │ │ │ │ │ ├── form-item-material-context.ts │ │ │ │ │ │ ├── form-item.ts │ │ │ │ │ │ ├── form-meta.ts │ │ │ │ │ │ ├── form-model.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── services/ │ │ │ │ │ │ ├── form-context-maker.ts │ │ │ │ │ │ ├── form-manager.ts │ │ │ │ │ │ ├── form-path-service.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── types/ │ │ │ │ │ ├── form-ability.types.ts │ │ │ │ │ ├── form-meta.types.ts │ │ │ │ │ ├── form-model.types.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── node/ │ │ │ │ │ ├── core-materials.ts │ │ │ │ │ ├── core-plugins.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── node-container-module.ts │ │ │ │ │ ├── node-contribution.ts │ │ │ │ │ ├── node-engine-context.ts │ │ │ │ │ ├── node-engine.ts │ │ │ │ │ ├── node-manager.ts │ │ │ │ │ └── types.ts │ │ │ │ └── node-react/ │ │ │ │ ├── context/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── node-engine-react-context.ts │ │ │ │ ├── hooks/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── use-form-Item.ts │ │ │ │ │ └── use-node-engine-context.ts │ │ │ │ └── index.ts │ │ │ ├── tsconfig.json │ │ │ ├── vitest.config.ts │ │ │ └── vitest.setup.ts │ │ └── node/ │ │ ├── __tests__/ │ │ │ ├── form-effects.test.ts │ │ │ ├── form-model-v2.test.ts │ │ │ ├── form-plugins.test.ts │ │ │ └── glob.test.ts │ │ ├── eslint.config.js │ │ ├── package.json │ │ ├── src/ │ │ │ ├── form-model-v2.ts │ │ │ ├── form-plugin.ts │ │ │ ├── form-render.tsx │ │ │ ├── get-node-form.tsx │ │ │ ├── helpers.ts │ │ │ ├── hooks.ts │ │ │ ├── index.ts │ │ │ ├── types.ts │ │ │ └── utils.ts │ │ ├── tsconfig.json │ │ ├── vitest.config.ts │ │ └── vitest.setup.ts │ ├── plugins/ │ │ ├── background-plugin/ │ │ │ ├── eslint.config.js │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── background-layer.tsx │ │ │ │ ├── create-background-plugin.ts │ │ │ │ └── index.ts │ │ │ ├── tsconfig.json │ │ │ ├── vitest.config.ts │ │ │ └── vitest.setup.ts │ │ ├── export-plugin/ │ │ │ ├── eslint.config.js │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── constant.ts │ │ │ │ ├── create-plugin.ts │ │ │ │ ├── download-service/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── service.ts │ │ │ │ │ └── type.ts │ │ │ │ ├── export-image-service/ │ │ │ │ │ ├── constant.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── service.ts │ │ │ │ │ ├── type.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── index.ts │ │ │ │ └── type.ts │ │ │ ├── tsconfig.json │ │ │ ├── vitest.config.ts │ │ │ └── vitest.setup.ts │ │ ├── fixed-drag-plugin/ │ │ │ ├── eslint.config.js │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── create-fixed-drag-plugin.ts │ │ │ │ ├── hooks/ │ │ │ │ │ └── use-start-drag-node.ts │ │ │ │ └── index.ts │ │ │ ├── tsconfig.json │ │ │ ├── vitest.config.ts │ │ │ └── vitest.setup.ts │ │ ├── fixed-history-plugin/ │ │ │ ├── eslint.config.js │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── create-fixed-history-plugin.ts │ │ │ │ ├── fixed-history-config.ts │ │ │ │ ├── fixed-history-registers.ts │ │ │ │ ├── index.ts │ │ │ │ ├── operation-metas/ │ │ │ │ │ ├── add-block.ts │ │ │ │ │ ├── add-child-node.ts │ │ │ │ │ ├── add-from-node.ts │ │ │ │ │ ├── add-node.ts │ │ │ │ │ ├── add-nodes.ts │ │ │ │ │ ├── base.ts │ │ │ │ │ ├── create-group.ts │ │ │ │ │ ├── delete-block.ts │ │ │ │ │ ├── delete-child-node.ts │ │ │ │ │ ├── delete-from-node.ts │ │ │ │ │ ├── delete-node.ts │ │ │ │ │ ├── delete-nodes.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── move-block.ts │ │ │ │ │ ├── move-child-nodes.ts │ │ │ │ │ ├── move-nodes.ts │ │ │ │ │ └── ungroup.ts │ │ │ │ ├── services/ │ │ │ │ │ ├── fixed-history-form-data-service.ts │ │ │ │ │ ├── fixed-history-operation-service.ts │ │ │ │ │ ├── fixed-history-service.ts │ │ │ │ │ └── index.ts │ │ │ │ └── types.ts │ │ │ ├── tsconfig.json │ │ │ ├── vitest.config.ts │ │ │ └── vitest.setup.ts │ │ ├── free-auto-layout-plugin/ │ │ │ ├── eslint.config.js │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── create-auto-layout-plugin.tsx │ │ │ │ ├── dagre-layout/ │ │ │ │ │ ├── acyclic.ts │ │ │ │ │ ├── graph.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── layout.ts │ │ │ │ │ ├── order.ts │ │ │ │ │ ├── rank/ │ │ │ │ │ │ ├── feasible-tree.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── longest-path.ts │ │ │ │ │ │ ├── network-simplex.ts │ │ │ │ │ │ └── normalize-ranks.ts │ │ │ │ │ └── type.ts │ │ │ │ ├── dagre-lib/ │ │ │ │ │ ├── acyclic.js │ │ │ │ │ ├── add-border-segments.js │ │ │ │ │ ├── coordinate-system.js │ │ │ │ │ ├── data/ │ │ │ │ │ │ └── list.js │ │ │ │ │ ├── debug.js │ │ │ │ │ ├── greedy-fas.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── layout.js │ │ │ │ │ ├── nesting-graph.js │ │ │ │ │ ├── normalize.js │ │ │ │ │ ├── order/ │ │ │ │ │ │ ├── add-subgraph-constraints.js │ │ │ │ │ │ ├── barycenter.js │ │ │ │ │ │ ├── build-layer-graph.js │ │ │ │ │ │ ├── cross-count.js │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ ├── init-order.js │ │ │ │ │ │ ├── resolve-conflicts.js │ │ │ │ │ │ ├── sort-subgraph.js │ │ │ │ │ │ └── sort.js │ │ │ │ │ ├── parent-dummy-chains.js │ │ │ │ │ ├── position/ │ │ │ │ │ │ ├── bk.js │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── rank/ │ │ │ │ │ │ ├── feasible-tree.js │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ ├── network-simplex.js │ │ │ │ │ │ └── util.js │ │ │ │ │ ├── util.js │ │ │ │ │ └── version.js │ │ │ │ ├── index.ts │ │ │ │ ├── layout/ │ │ │ │ │ ├── constant.ts │ │ │ │ │ ├── dagre.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── layout.ts │ │ │ │ │ ├── position.ts │ │ │ │ │ ├── store.ts │ │ │ │ │ └── type.ts │ │ │ │ ├── services.ts │ │ │ │ └── type.ts │ │ │ ├── tsconfig.json │ │ │ ├── vitest.config.ts │ │ │ └── vitest.setup.ts │ │ ├── free-container-plugin/ │ │ │ ├── eslint.config.js │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── index.ts │ │ │ │ ├── node-into-container/ │ │ │ │ │ ├── constant.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── plugin.tsx │ │ │ │ │ ├── service.ts │ │ │ │ │ └── type.ts │ │ │ │ ├── sub-canvas/ │ │ │ │ │ ├── components/ │ │ │ │ │ │ ├── background/ │ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ │ └── style.ts │ │ │ │ │ │ ├── border/ │ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ │ └── style.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── render/ │ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ │ └── style.ts │ │ │ │ │ │ └── tips/ │ │ │ │ │ │ ├── global-store.ts │ │ │ │ │ │ ├── icon-close.tsx │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ ├── is-mac-os.ts │ │ │ │ │ │ ├── style.ts │ │ │ │ │ │ └── use-control.ts │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── use-node-size.ts │ │ │ │ │ │ └── use-sync-node-render-size.ts │ │ │ │ │ └── index.ts │ │ │ │ └── utils/ │ │ │ │ ├── adjust-sub-node-position.ts │ │ │ │ ├── get-collision-transform.ts │ │ │ │ ├── get-container-transforms.ts │ │ │ │ ├── index.ts │ │ │ │ ├── is-container.ts │ │ │ │ ├── is-point-in-rect.ts │ │ │ │ ├── is-rect-intersects.ts │ │ │ │ └── next-frame.ts │ │ │ ├── tsconfig.json │ │ │ ├── vitest.config.ts │ │ │ └── vitest.setup.ts │ │ ├── free-group-plugin/ │ │ │ ├── eslint.config.js │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── constant.ts │ │ │ │ ├── create-free-group-plugin.tsx │ │ │ │ ├── group-node.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── shortcuts/ │ │ │ │ │ ├── group.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── ungroup.ts │ │ │ │ ├── type.ts │ │ │ │ ├── utils.ts │ │ │ │ └── workflow-group-service.ts │ │ │ ├── tsconfig.json │ │ │ ├── vitest.config.ts │ │ │ └── vitest.setup.ts │ │ ├── free-history-plugin/ │ │ │ ├── eslint.config.js │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── changes/ │ │ │ │ │ ├── add-line-change.ts │ │ │ │ │ ├── add-node-change.ts │ │ │ │ │ ├── change-line-data.ts │ │ │ │ │ ├── delete-line-change.ts │ │ │ │ │ ├── delete-node-change.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── create-free-history-plugin.ts │ │ │ │ ├── free-history-config.ts │ │ │ │ ├── free-history-manager.ts │ │ │ │ ├── free-history-registers.ts │ │ │ │ ├── handlers/ │ │ │ │ │ ├── change-content-handler.ts │ │ │ │ │ └── drag-nodes-handler.ts │ │ │ │ ├── history-entity-manager.ts │ │ │ │ ├── hooks/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── use-undo-redo.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── operation-metas/ │ │ │ │ │ ├── add-line.ts │ │ │ │ │ ├── add-node.ts │ │ │ │ │ ├── base.ts │ │ │ │ │ ├── change-line-data.ts │ │ │ │ │ ├── delete-line.ts │ │ │ │ │ ├── delete-node.ts │ │ │ │ │ ├── drag-nodes.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── move-child-nodes.ts │ │ │ │ │ └── reset-layout.ts │ │ │ │ └── types.ts │ │ │ ├── tsconfig.json │ │ │ ├── vitest.config.ts │ │ │ └── vitest.setup.ts │ │ ├── free-hover-plugin/ │ │ │ ├── eslint.config.js │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── create-free-hover-plugin.ts │ │ │ │ ├── hover-layer.tsx │ │ │ │ ├── index.ts │ │ │ │ └── selection-utils.ts │ │ │ ├── tsconfig.json │ │ │ ├── vitest.config.ts │ │ │ └── vitest.setup.ts │ │ ├── free-lines-plugin/ │ │ │ ├── eslint.config.js │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── __tests__/ │ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ │ └── bezier-controls.spec.ts.snap │ │ │ │ │ └── bezier-controls.spec.ts │ │ │ │ ├── components/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── workflow-line-render/ │ │ │ │ │ │ ├── arrow.tsx │ │ │ │ │ │ ├── index.style.ts │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ └── line-svg.tsx │ │ │ │ │ └── workflow-port-render/ │ │ │ │ │ ├── cross-hair.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── style.ts │ │ │ │ ├── constants/ │ │ │ │ │ ├── lines.ts │ │ │ │ │ └── points.ts │ │ │ │ ├── contributions/ │ │ │ │ │ ├── bezier/ │ │ │ │ │ │ ├── bezier-controls.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── fold/ │ │ │ │ │ │ ├── fold-line.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── straight/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── point-on-line.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── create-free-lines-plugin.ts │ │ │ │ ├── index.ts │ │ │ │ ├── layer/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── workflow-lines-layer.tsx │ │ │ │ ├── type.ts │ │ │ │ └── types/ │ │ │ │ └── arrow-renderer.ts │ │ │ ├── tsconfig.json │ │ │ ├── vitest.config.ts │ │ │ └── vitest.setup.ts │ │ ├── free-node-panel-plugin/ │ │ │ ├── eslint.config.js │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── component.tsx │ │ │ │ ├── create-plugin.ts │ │ │ │ ├── index.ts │ │ │ │ ├── layer.tsx │ │ │ │ ├── service.ts │ │ │ │ ├── type.ts │ │ │ │ └── utils/ │ │ │ │ ├── adjust-node-position.ts │ │ │ │ ├── build-line.ts │ │ │ │ ├── get-container-node.ts │ │ │ │ ├── get-port-box.ts │ │ │ │ ├── get-sub-nodes.ts │ │ │ │ ├── greater-or-less.ts │ │ │ │ ├── index.ts │ │ │ │ ├── is-container.ts │ │ │ │ ├── rect-distance.ts │ │ │ │ ├── sub-nodes-auto-offset.ts │ │ │ │ ├── sub-position-offset.ts │ │ │ │ ├── update-sub-nodes-position.ts │ │ │ │ └── wait-node-render.ts │ │ │ ├── tsconfig.json │ │ │ ├── vitest.config.ts │ │ │ └── vitest.setup.ts │ │ ├── free-snap-plugin/ │ │ │ ├── eslint.config.js │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── constant.ts │ │ │ │ ├── create-plugin.ts │ │ │ │ ├── index.ts │ │ │ │ ├── layer.tsx │ │ │ │ ├── service.ts │ │ │ │ ├── type.ts │ │ │ │ └── utils.ts │ │ │ ├── tsconfig.json │ │ │ ├── vitest.config.ts │ │ │ └── vitest.setup.ts │ │ ├── free-stack-plugin/ │ │ │ ├── __tests__/ │ │ │ │ ├── computing.test.ts │ │ │ │ ├── manager.test.ts │ │ │ │ ├── type.mock.ts │ │ │ │ └── utils.mock.ts │ │ │ ├── eslint.config.js │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── constant.ts │ │ │ │ ├── create-free-stack-plugin.ts │ │ │ │ ├── index.ts │ │ │ │ ├── manager.ts │ │ │ │ ├── stacking-computing.ts │ │ │ │ └── type.ts │ │ │ ├── tsconfig.json │ │ │ ├── vitest.config.ts │ │ │ └── vitest.setup.ts │ │ ├── group-plugin/ │ │ │ ├── eslint.config.js │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── components/ │ │ │ │ │ ├── group-box.tsx │ │ │ │ │ ├── group-render.tsx │ │ │ │ │ ├── hooks.ts │ │ │ │ │ └── index.tsx │ │ │ │ ├── constant.ts │ │ │ │ ├── create-group-plugin.tsx │ │ │ │ ├── group-node-register.tsx │ │ │ │ ├── groups-layer.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── registers/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── register-clean-groups.ts │ │ │ │ │ ├── register-group-node.ts │ │ │ │ │ ├── register-layer.ts │ │ │ │ │ └── register-render.tsx │ │ │ │ └── type.ts │ │ │ ├── tsconfig.json │ │ │ ├── vitest.config.ts │ │ │ └── vitest.setup.ts │ │ ├── history-node-plugin/ │ │ │ ├── __tests__/ │ │ │ │ ├── create-container.ts │ │ │ │ └── form.test.ts │ │ │ ├── eslint.config.js │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── create-history-node-plugin.ts │ │ │ │ ├── history-node-registers.ts │ │ │ │ ├── index.ts │ │ │ │ ├── operation-metas/ │ │ │ │ │ ├── change-form-values.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── types.ts │ │ │ │ └── utils/ │ │ │ │ └── index.ts │ │ │ ├── tsconfig.json │ │ │ ├── vitest.config.ts │ │ │ └── vitest.setup.ts │ │ ├── i18n-plugin/ │ │ │ ├── eslint.config.js │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── create-i18n-plugin.ts │ │ │ │ └── index.ts │ │ │ ├── tsconfig.json │ │ │ ├── vitest.config.ts │ │ │ └── vitest.setup.ts │ │ ├── materials-plugin/ │ │ │ ├── eslint.config.js │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── create-materials-plugin.ts │ │ │ │ └── index.ts │ │ │ ├── tsconfig.json │ │ │ ├── vitest.config.ts │ │ │ └── vitest.setup.ts │ │ ├── minimap-plugin/ │ │ │ ├── eslint.config.js │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── component.tsx │ │ │ │ ├── constant.ts │ │ │ │ ├── create-plugin.ts │ │ │ │ ├── draw.ts │ │ │ │ ├── index.ts │ │ │ │ ├── layer.tsx │ │ │ │ ├── service.ts │ │ │ │ └── type.ts │ │ │ ├── tsconfig.json │ │ │ ├── vitest.config.ts │ │ │ └── vitest.setup.ts │ │ ├── node-core-plugin/ │ │ │ ├── eslint.config.js │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── create-node-core-plugin.ts │ │ │ │ ├── form-node-contribution.ts │ │ │ │ ├── form-render.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── types.ts │ │ │ │ └── utils.ts │ │ │ ├── tsconfig.json │ │ │ ├── vitest.config.ts │ │ │ └── vitest.setup.ts │ │ ├── node-variable-plugin/ │ │ │ ├── eslint.config.js │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── components/ │ │ │ │ │ ├── PrivateScopeProvider.tsx │ │ │ │ │ └── PublicScopeProvider.tsx │ │ │ │ ├── create-node-variable-plugin.ts │ │ │ │ ├── form-v2/ │ │ │ │ │ ├── create-provider-effect.ts │ │ │ │ │ └── create-variable-provider-plugin.ts │ │ │ │ ├── index.ts │ │ │ │ ├── types.ts │ │ │ │ └── with-node-variables.tsx │ │ │ ├── tsconfig.json │ │ │ ├── vitest.config.ts │ │ │ └── vitest.setup.ts │ │ ├── panel-manager-plugin/ │ │ │ ├── eslint.config.js │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── components/ │ │ │ │ │ ├── panel-layer/ │ │ │ │ │ │ ├── css.ts │ │ │ │ │ │ ├── docked-panel-layer.tsx │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── panel-layer.tsx │ │ │ │ │ │ └── panel.tsx │ │ │ │ │ └── resize-bar/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── contexts.ts │ │ │ │ ├── create-panel-manager-plugin.ts │ │ │ │ ├── hooks/ │ │ │ │ │ ├── use-global-css.ts │ │ │ │ │ ├── use-panel-manager.ts │ │ │ │ │ └── use-panel.ts │ │ │ │ ├── index.ts │ │ │ │ ├── services/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── panel-config.ts │ │ │ │ │ ├── panel-factory.ts │ │ │ │ │ ├── panel-layer.ts │ │ │ │ │ ├── panel-manager.ts │ │ │ │ │ └── panel-restore.ts │ │ │ │ ├── types.ts │ │ │ │ └── utils.ts │ │ │ └── tsconfig.json │ │ ├── redux-devtool-plugin/ │ │ │ ├── eslint.config.js │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── connectors/ │ │ │ │ │ ├── base.ts │ │ │ │ │ ├── ecs-connector.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── variable-connector.ts │ │ │ │ ├── create-redux-devtool-plugin.ts │ │ │ │ └── index.ts │ │ │ ├── tsconfig.json │ │ │ ├── vitest.config.ts │ │ │ └── vitest.setup.ts │ │ ├── select-box-plugin/ │ │ │ ├── eslint.config.js │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── create-select-box-plugin.ts │ │ │ │ └── index.ts │ │ │ ├── tsconfig.json │ │ │ ├── vitest.config.ts │ │ │ └── vitest.setup.ts │ │ ├── shortcuts-plugin/ │ │ │ ├── eslint.config.js │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── create-shortcuts-plugin.ts │ │ │ │ ├── index.ts │ │ │ │ ├── layers/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── shortcuts-layer.tsx │ │ │ │ ├── shortcuts-contribution.ts │ │ │ │ └── shortcuts-utils.ts │ │ │ ├── tsconfig.json │ │ │ ├── vitest.config.ts │ │ │ └── vitest.setup.ts │ │ ├── test-run-plugin/ │ │ │ ├── eslint.config.js │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── create-test-run-plugin.ts │ │ │ │ ├── form-engine/ │ │ │ │ │ ├── contexts.ts │ │ │ │ │ ├── fields/ │ │ │ │ │ │ ├── create-field.tsx │ │ │ │ │ │ ├── general-field.tsx │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── object-field.tsx │ │ │ │ │ │ ├── reactive-field.tsx │ │ │ │ │ │ ├── recursion-field.tsx │ │ │ │ │ │ └── schema-field.tsx │ │ │ │ │ ├── form/ │ │ │ │ │ │ ├── form.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── use-create-form.ts │ │ │ │ │ │ ├── use-field.ts │ │ │ │ │ │ └── use-form.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── model/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── index.ts │ │ │ │ ├── reactive/ │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── use-create-form.ts │ │ │ │ │ │ └── use-test-run-service.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── services/ │ │ │ │ │ ├── config.ts │ │ │ │ │ ├── form/ │ │ │ │ │ │ ├── factory.ts │ │ │ │ │ │ ├── form.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── manager.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── pipeline/ │ │ │ │ │ │ ├── factory.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── pipeline.ts │ │ │ │ │ │ ├── plugin.ts │ │ │ │ │ │ └── tap.ts │ │ │ │ │ ├── store.ts │ │ │ │ │ └── test-run.ts │ │ │ │ └── types.ts │ │ │ └── tsconfig.json │ │ └── variable-plugin/ │ │ ├── eslint.config.js │ │ ├── package.json │ │ ├── src/ │ │ │ ├── create-variable-plugin.ts │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ ├── vitest.config.ts │ │ └── vitest.setup.ts │ ├── runtime/ │ │ ├── interface/ │ │ │ ├── eslint.config.js │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── api/ │ │ │ │ │ ├── constant.ts │ │ │ │ │ ├── define.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── server-info/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── task-cancel/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── task-report/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── task-result/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── task-run/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── task-validate/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── type.ts │ │ │ │ ├── client/ │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── node/ │ │ │ │ │ ├── break/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── code/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── condition/ │ │ │ │ │ │ ├── constant.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── constant.ts │ │ │ │ │ ├── continue/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── end/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── http/ │ │ │ │ │ │ ├── constant.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── llm/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── loop/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── start/ │ │ │ │ │ └── index.ts │ │ │ │ ├── runtime/ │ │ │ │ │ ├── base/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── inputs-outputs.ts │ │ │ │ │ │ ├── invoke.ts │ │ │ │ │ │ └── value-object.ts │ │ │ │ │ ├── cache/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── container/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── context/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── document/ │ │ │ │ │ │ ├── document.ts │ │ │ │ │ │ ├── edge.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── node.ts │ │ │ │ │ │ └── port.ts │ │ │ │ │ ├── engine/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── executor/ │ │ │ │ │ │ ├── executor.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── node-executor.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── io-center/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── message/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── reporter/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── snapshot/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── snapshot-center.ts │ │ │ │ │ │ └── snapshot.ts │ │ │ │ │ ├── state/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── status/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── task/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── validation/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── variable/ │ │ │ │ │ └── index.ts │ │ │ │ └── schema/ │ │ │ │ ├── constant.ts │ │ │ │ ├── edge.ts │ │ │ │ ├── group.ts │ │ │ │ ├── index.ts │ │ │ │ ├── json-schema.ts │ │ │ │ ├── node-meta.ts │ │ │ │ ├── node.ts │ │ │ │ ├── value.ts │ │ │ │ ├── workflow.ts │ │ │ │ └── xy.ts │ │ │ └── tsconfig.json │ │ ├── js-core/ │ │ │ ├── eslint.config.js │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── api/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── task-cancel.ts │ │ │ │ │ ├── task-report.ts │ │ │ │ │ ├── task-result.ts │ │ │ │ │ ├── task-run.ts │ │ │ │ │ └── task-validate.ts │ │ │ │ ├── application/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── workflow.ts │ │ │ │ ├── domain/ │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ ├── config.ts │ │ │ │ │ │ ├── executor/ │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ └── llm.ts │ │ │ │ │ │ ├── schemas/ │ │ │ │ │ │ │ ├── basic.test.ts │ │ │ │ │ │ │ ├── basic.ts │ │ │ │ │ │ │ ├── branch-two-layers.test.ts │ │ │ │ │ │ │ ├── branch-two-layers.ts │ │ │ │ │ │ │ ├── branch.test.ts │ │ │ │ │ │ │ ├── branch.ts │ │ │ │ │ │ │ ├── code.test.ts │ │ │ │ │ │ │ ├── code.ts │ │ │ │ │ │ │ ├── end-constant.test.ts │ │ │ │ │ │ │ ├── end-constant.ts │ │ │ │ │ │ │ ├── global-variable.test.ts │ │ │ │ │ │ │ ├── global-variable.ts │ │ │ │ │ │ │ ├── http-real.test.ts │ │ │ │ │ │ │ ├── http.test.ts │ │ │ │ │ │ │ ├── http.ts │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ ├── llm-real.test.ts │ │ │ │ │ │ │ ├── llm-real.ts │ │ │ │ │ │ │ ├── loop-break-continue.test.ts │ │ │ │ │ │ │ ├── loop-break-continue.ts │ │ │ │ │ │ │ ├── loop.test.ts │ │ │ │ │ │ │ ├── loop.ts │ │ │ │ │ │ │ ├── start-default.test.ts │ │ │ │ │ │ │ ├── start-default.ts │ │ │ │ │ │ │ ├── two-llm.ts │ │ │ │ │ │ │ ├── validate-inputs.test.ts │ │ │ │ │ │ │ └── validate-inputs.ts │ │ │ │ │ │ ├── setup.ts │ │ │ │ │ │ └── utils/ │ │ │ │ │ │ ├── array-vo-data.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── snapshot.ts │ │ │ │ │ ├── cache/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── container/ │ │ │ │ │ │ ├── index.test.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── context/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── document/ │ │ │ │ │ │ ├── document/ │ │ │ │ │ │ │ ├── create-store.test.ts │ │ │ │ │ │ │ ├── create-store.ts │ │ │ │ │ │ │ ├── flat-schema.test.ts │ │ │ │ │ │ │ ├── flat-schema.ts │ │ │ │ │ │ │ ├── index.test.ts │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ ├── entity/ │ │ │ │ │ │ │ ├── edge/ │ │ │ │ │ │ │ │ ├── index.test.ts │ │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ ├── node/ │ │ │ │ │ │ │ │ ├── index.test.ts │ │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ │ └── port/ │ │ │ │ │ │ │ ├── index.test.ts │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── engine/ │ │ │ │ │ │ ├── index.test.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── executor/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── io-center/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── message/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── message-center/ │ │ │ │ │ │ │ ├── index.test.ts │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ └── message-value-object/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── report/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── report-value-object/ │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ └── reporter/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── snapshot/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── snapshot-center/ │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ └── snapshot-entity/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── state/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── status/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── status-center/ │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ └── status-entity/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── task/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── validation/ │ │ │ │ │ │ ├── index.test.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── validators/ │ │ │ │ │ │ ├── cycle-detection.test.ts │ │ │ │ │ │ ├── cycle-detection.ts │ │ │ │ │ │ ├── edge-source-target-exist.test.ts │ │ │ │ │ │ ├── edge-source-target-exist.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── schema-format.test.ts │ │ │ │ │ │ ├── schema-format.ts │ │ │ │ │ │ ├── start-end-node.test.ts │ │ │ │ │ │ └── start-end-node.ts │ │ │ │ │ └── variable/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── variable-store/ │ │ │ │ │ │ ├── index.test.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── variable-value-object/ │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── infrastructure/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── utils/ │ │ │ │ │ ├── compare-node-groups.test.ts │ │ │ │ │ ├── compare-node-groups.ts │ │ │ │ │ ├── delay.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── json-schema-validator.test.ts │ │ │ │ │ ├── json-schema-validator.ts │ │ │ │ │ ├── runtime-type.test.ts │ │ │ │ │ ├── runtime-type.ts │ │ │ │ │ ├── traverse-nodes.test.ts │ │ │ │ │ ├── traverse-nodes.ts │ │ │ │ │ └── uuid.ts │ │ │ │ └── nodes/ │ │ │ │ ├── break/ │ │ │ │ │ └── index.ts │ │ │ │ ├── code/ │ │ │ │ │ └── index.ts │ │ │ │ ├── condition/ │ │ │ │ │ ├── handlers/ │ │ │ │ │ │ ├── array.ts │ │ │ │ │ │ ├── boolean.ts │ │ │ │ │ │ ├── datetime.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── map.ts │ │ │ │ │ │ ├── null.ts │ │ │ │ │ │ ├── number.ts │ │ │ │ │ │ ├── object.ts │ │ │ │ │ │ └── string.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── rules.ts │ │ │ │ │ └── type.ts │ │ │ │ ├── continue/ │ │ │ │ │ └── index.ts │ │ │ │ ├── empty/ │ │ │ │ │ └── index.ts │ │ │ │ ├── end/ │ │ │ │ │ └── index.ts │ │ │ │ ├── http/ │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── llm/ │ │ │ │ │ └── index.ts │ │ │ │ ├── loop/ │ │ │ │ │ └── index.ts │ │ │ │ └── start/ │ │ │ │ └── index.ts │ │ │ ├── tsconfig.json │ │ │ └── vitest.config.ts │ │ └── nodejs/ │ │ ├── .gitignore │ │ ├── README.md │ │ ├── eslint.config.js │ │ ├── package.json │ │ ├── src/ │ │ │ ├── api/ │ │ │ │ ├── create-api.ts │ │ │ │ ├── index.ts │ │ │ │ ├── trpc.ts │ │ │ │ └── type.ts │ │ │ ├── config/ │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ └── server/ │ │ │ ├── context.ts │ │ │ ├── docs.ts │ │ │ ├── index.ts │ │ │ └── type.ts │ │ ├── tsconfig.json │ │ └── vitest.config.ts │ └── variable-engine/ │ ├── json-schema/ │ │ ├── eslint.config.js │ │ ├── package.json │ │ ├── src/ │ │ │ ├── base/ │ │ │ │ ├── base-type-manager.ts │ │ │ │ ├── index.ts │ │ │ │ └── types.ts │ │ │ ├── container-module.tsx │ │ │ ├── context.tsx │ │ │ ├── index.ts │ │ │ └── json-schema/ │ │ │ ├── index.ts │ │ │ ├── json-schema-type-manager.tsx │ │ │ ├── type-definition/ │ │ │ │ ├── array.tsx │ │ │ │ ├── boolean.tsx │ │ │ │ ├── date-time.tsx │ │ │ │ ├── default.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── integer.tsx │ │ │ │ ├── map.tsx │ │ │ │ ├── number.tsx │ │ │ │ ├── object.tsx │ │ │ │ ├── string.tsx │ │ │ │ └── unknown.tsx │ │ │ ├── types.ts │ │ │ └── utils.ts │ │ ├── tsconfig.json │ │ ├── vitest.config.ts │ │ └── vitest.setup.ts │ ├── variable-core/ │ │ ├── __mocks__/ │ │ │ ├── container.ts │ │ │ ├── mock-chain.ts │ │ │ └── variables.ts │ │ ├── __tests__/ │ │ │ ├── ast/ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ ├── key-path-expression-v2.test.ts.snap │ │ │ │ │ ├── variable-declaration.test.ts.snap │ │ │ │ │ └── variable-with-initializer.test.ts.snap │ │ │ │ ├── ast-decorators.test.ts │ │ │ │ ├── key-path-expression-v2.test.ts │ │ │ │ ├── variable-declaration.test.ts │ │ │ │ ├── variable-match.test.ts │ │ │ │ ├── variable-throw-errors.test.ts │ │ │ │ ├── variable-type-equal.test.ts │ │ │ │ └── variable-with-initializer.test.ts │ │ │ ├── case-run-down/ │ │ │ │ ├── blockwise-python-expression.test.ts │ │ │ │ └── variable-rename-listener.test.ts │ │ │ └── scope/ │ │ │ └── variable-engine.test.ts │ │ ├── eslint.config.js │ │ ├── package.json │ │ ├── src/ │ │ │ ├── ast/ │ │ │ │ ├── ast-node.ts │ │ │ │ ├── ast-registers.ts │ │ │ │ ├── common/ │ │ │ │ │ ├── data-node.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── list-node.ts │ │ │ │ │ └── map-node.ts │ │ │ │ ├── declaration/ │ │ │ │ │ ├── base-variable-field.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── property.ts │ │ │ │ │ ├── variable-declaration-list.ts │ │ │ │ │ └── variable-declaration.ts │ │ │ │ ├── expression/ │ │ │ │ │ ├── base-expression.ts │ │ │ │ │ ├── enumerate-expression.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── keypath-expression.ts │ │ │ │ │ ├── legacy-keypath-expression.ts │ │ │ │ │ └── wrap-array-expression.ts │ │ │ │ ├── factory.ts │ │ │ │ ├── flags.ts │ │ │ │ ├── index.ts │ │ │ │ ├── match.ts │ │ │ │ ├── type/ │ │ │ │ │ ├── array.ts │ │ │ │ │ ├── base-type.ts │ │ │ │ │ ├── boolean.ts │ │ │ │ │ ├── custom-type.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── integer.ts │ │ │ │ │ ├── map.ts │ │ │ │ │ ├── number.ts │ │ │ │ │ ├── object.ts │ │ │ │ │ ├── string.ts │ │ │ │ │ └── union.ts │ │ │ │ ├── types.ts │ │ │ │ └── utils/ │ │ │ │ ├── expression.ts │ │ │ │ ├── helpers.ts │ │ │ │ ├── inversify.ts │ │ │ │ ├── observable.ts │ │ │ │ └── variable-field.ts │ │ │ ├── index.ts │ │ │ ├── providers.ts │ │ │ ├── react/ │ │ │ │ ├── context.tsx │ │ │ │ ├── hooks/ │ │ │ │ │ ├── use-available-variables.ts │ │ │ │ │ ├── use-output-variables.ts │ │ │ │ │ └── use-scope-available.ts │ │ │ │ └── index.tsx │ │ │ ├── scope/ │ │ │ │ ├── datas/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── scope-available-data.ts │ │ │ │ │ ├── scope-event-data.ts │ │ │ │ │ └── scope-output-data.ts │ │ │ │ ├── index.ts │ │ │ │ ├── scope-chain.ts │ │ │ │ ├── scope.ts │ │ │ │ ├── types.ts │ │ │ │ └── variable-table.ts │ │ │ ├── services/ │ │ │ │ ├── index.ts │ │ │ │ └── variable-field-key-rename-service.ts │ │ │ ├── utils/ │ │ │ │ ├── memo.ts │ │ │ │ └── toDisposable.tsx │ │ │ ├── variable-container-module.ts │ │ │ └── variable-engine.ts │ │ ├── tsconfig.json │ │ ├── vitest.config.ts │ │ └── vitest.setup.ts │ └── variable-layout/ │ ├── __mocks__/ │ │ ├── container.ts │ │ ├── fixed-layout-specs.ts │ │ ├── free-layout-specs.ts │ │ ├── run-fixed-layout-test.ts │ │ └── run-free-layout-test.ts │ ├── __tests__/ │ │ ├── __snapshots__/ │ │ │ ├── variable-fix-enable-global-scope.test.ts.snap │ │ │ ├── variable-fix-layout-filter-start-end.test.ts.snap │ │ │ ├── variable-fix-layout-group.test.ts.snap │ │ │ ├── variable-fix-layout-no-config.test.ts.snap │ │ │ ├── variable-fix-layout-transform-empty.test.ts.snap │ │ │ ├── variable-fix-layout.test.ts.snap │ │ │ ├── variable-free-enable-global-scope.test.ts.snap │ │ │ ├── variable-free-is-node-children-private.test.ts.snap │ │ │ ├── variable-free-layout-transform-empty.test.ts.snap │ │ │ └── variable-free-layout.test.ts.snap │ │ ├── variable-fix-enable-global-scope.test.ts │ │ ├── variable-fix-layout-filter-start-end.test.ts │ │ ├── variable-fix-layout-group.test.ts │ │ ├── variable-fix-layout-no-config.test.ts │ │ ├── variable-fix-layout-transform-empty.test.ts │ │ ├── variable-fix-layout.test.ts │ │ ├── variable-free-enable-global-scope.test.ts │ │ ├── variable-free-is-node-children-private.test.ts │ │ ├── variable-free-layout-transform-empty.test.ts │ │ └── variable-free-layout.test.ts │ ├── eslint.config.js │ ├── package.json │ ├── src/ │ │ ├── chains/ │ │ │ ├── fixed-layout-scope-chain.ts │ │ │ └── free-layout-scope-chain.ts │ │ ├── flow-node-variable-data.ts │ │ ├── index.ts │ │ ├── scopes/ │ │ │ └── global-scope.ts │ │ ├── services/ │ │ │ └── scope-chain-transform-service.ts │ │ ├── types.ts │ │ ├── utils.ts │ │ └── variable-chain-config.ts │ ├── tsconfig.json │ ├── vitest.config.ts │ └── vitest.setup.ts └── rush.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .claude/commands/add-tests.md ================================================ --- description: 为代码添加单元测试(支持增量和存量代码测试补齐) --- # 单元测试生成 ## 用法 - `/add-tests [dir]` - 为指定模块或文件添加单元测试 - `[dir]` 可选参数:包名(以 @ 开头)、文件路径或目录路径 - 不指定 `[dir]` 则默认为全代码库(会提示用户确认) - 执行后会询问用户选择:增量代码测试(基于 git diff)或存量代码测试补齐 ## 命令说明 此命令用于自动生成和补充单元测试,确保代码质量。FlowGram 使用 **Vitest** 作为测试框架。 ### 测试覆盖率目标 根据包的类型和重要性,测试覆盖率要求如下: - **核心引擎层**(canvas-engine、node-engine、variable-engine、runtime) - 覆盖率目标:≥ 85% - 包括:@flowgram.ai/core、@flowgram.ai/form、@flowgram.ai/variable-core、@flowgram.ai/runtime-js 等 - **插件和客户端层**(plugins、client) - 覆盖率目标:≥ 60% - 包括:@flowgram.ai/editor、各类 plugin 包、@flowgram.ai/fixed-layout-editor 等 - **工具和示例**(common、apps) - 覆盖率目标:尽可能覆盖关键逻辑 - 包括:@flowgram.ai/utils、demo 应用等 ### 测试文件组织 - 测试文件位置: - `__tests__/` 目录(推荐) - 或与源文件同级的 `*.test.ts`/`*.test.tsx` 文件 - 命名规范: - 对于 `src/core/utils.ts`,测试文件为 `__tests__/core/utils.test.ts` 或 `src/core/utils.test.ts` ## 测试生成流程 ### 0. 命令执行和用户确认 1. **确认范围**: - 如果未指定 `[dir]`,询问用户是否要对全代码库操作,还是指定具体目录 - 全代码库操作工作量巨大,需要用户明确确认 2. **选择模式**: - 询问用户选择测试模式: - **增量代码测试**:仅为 git diff 中的新增/修改代码添加测试 - **存量代码测试补齐**:扫描所有代码,补齐缺失或覆盖率不足的测试 ### 1. 识别待测代码 **增量代码模式**: ```bash # 检查 git diff 获取所有修改的文件 git diff --name-only git diff # 查看具体变更 ``` **存量代码模式**: - 扫描指定目录下所有源文件(排除已有完整测试的文件) - 查找缺少测试或覆盖率不足的文件 - 优先处理核心引擎层的文件 ### 2. 确定包信息和覆盖率目标 1. 从最近的 `package.json` 获取包名 2. 使用包名在 `rush.json` 中查找包的分类(projectFolder) 3. 根据包所在目录确定覆盖率目标: - `packages/canvas-engine/`、`packages/node-engine/`、`packages/variable-engine/`、`packages/runtime/` → 85% - `packages/plugins/`、`packages/client/` → 60% - `packages/common/`、`apps/` → 尽可能覆盖 ### 3. 生成测试代码 **测试重点**: - 新增或修改的函数、方法、类 - 分支逻辑(if/else、switch/case) - 边界条件和异常处理 - 依赖注入容器(inversify)的模拟 - 响应式状态(ReactiveState)的行为验证 **Vitest 最佳实践**: ```typescript import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen } from '@testing-library/react'; describe('ModuleName', () => { beforeEach(() => { // 初始化 }); it('should handle specific case', () => { // 测试逻辑 expect(result).toBe(expected); }); }); ``` **React 组件测试**: - 使用 `@testing-library/react` 进行组件测试 - 为关键元素添加 `data-testid` 属性 - 测试用户交互和状态变化 **依赖注入测试**: - 使用 `vi.mock()` 模拟依赖 - 创建测试容器来验证服务注册 ### 4. 执行测试验证 **单包测试**: ```bash cd packages/canvas-engine/core rushx test # 运行测试 rushx test:cov # 生成覆盖率报告 ``` **全局测试**: ```bash rush test # 运行所有包的测试 rush test:cov # 生成所有包的覆盖率报告 ``` **每次添加测试后**: 1. 立即运行测试确保通过 2. 检查覆盖率是否达到目标 3. 修复失败的测试或调整测试用例 4. 继续处理下一个文件 ### 5. 输出测试文件 - 将测试文件保存到 `__tests__/` 目录(优先)或源文件同级 - 保持目录结构与源代码一致 - 添加必要的导入和类型声明 ## 示例命令 ```bash # 为某个包添加测试(执行后会询问增量或存量) /add_tests @flowgram.ai/core # 为特定目录添加测试 /add_tests packages/node-engine/form # 为单个文件添加测试 /add_tests packages/canvas-engine/core/src/core/utils.ts # 全代码库测试(会先确认范围,再询问增量或存量) /add_tests ``` ### 典型使用场景 **场景 1:为新功能添加测试** ```bash /add_tests packages/plugins/my-new-plugin # 选择:增量代码测试 # 结果:仅为 git diff 中的新代码生成测试 ``` **场景 2:提升现有包的测试覆盖率** ```bash /add_tests @flowgram.ai/variable-core # 选择:存量代码测试补齐 # 结果:扫描所有代码,补齐缺失的测试,目标 85% 覆盖率 ``` **场景 3:全面测试检查** ```bash /add_tests # 确认:选择要处理的目录或全代码库 # 选择:存量代码测试补齐 # 结果:系统性地补齐整个项目的测试 ``` ## 注意事项 1. **优先级**:优先为核心引擎层包编写高质量测试 2. **隔离性**:每个测试应该独立,不依赖其他测试的执行顺序 3. **可读性**:测试用例命名应清晰描述测试场景(使用中文或英文皆可) 4. **Mock 策略**: - 外部依赖(网络请求、文件系统)必须 mock - 内部复杂模块可以考虑 mock - 简单工具函数可以直接使用 5. **快照测试**:谨慎使用快照测试,仅用于稳定的 UI 或数据结构 6. **异步测试**:使用 async/await 处理异步操作,确保 Promise 正确解决 ## 工作流程总结 1. **接收命令**:用户执行 `/add_tests [dir]` 2. **确认范围**:如果未指定 dir,询问用户要处理全代码库还是指定目录 3. **选择模式**:询问用户选择增量代码测试或存量代码测试补齐 4. **分析代码**:根据选择的模式识别待测代码 5. **确定目标**:根据包的分类确定覆盖率目标 6. **生成测试**:逐文件生成测试用例 7. **运行验证**:每生成一批测试后立即运行验证 8. **修复问题**:修复失败的测试或调整测试用例 9. **检查覆盖率**:查看覆盖率报告是否达标 10. **继续迭代**:直到达到目标覆盖率或所有文件都有测试 开始生成测试吧! ================================================ FILE: .claude/skills/create-node/SKILL.md ================================================ --- skill_name: create-node description: 用于在 FlowGram demo-free-layout 中创建新的自定义节点,支持简单节点(自动表单)和复杂节点(自定义 UI) version: 1.0.0 tags: [flowgram, node, workflow, custom-node] --- # FlowGram Custom Node Development ## 概述 本 SKILL 用于指导在 FlowGram 项目的 `apps/demo-free-layout/src/nodes` 目录下创建新的自定义工作流节点。 ## 核心概念 ### 节点数据结构 节点数据在保存时会存储到后端,基本结构如下: ```typescript { id: 'node_xxxxx', // 节点 ID type: 'node_type', // 节点类型 data: { title: 'Node Title', // 节点标题 inputsValues: { ... }, // 节点表单字段的初始值(实际的值) inputs: { ... }, // 节点表单的 JSON Schema(定义表单结构) outputs: { ... }, // 节点输出的 JSON Schema(工作流执行时的输出) // ... 其他自定义字段 } } ``` ### 三个核心字段 #### 1. `data.inputsValues` - 节点表单字段的初始值 存储表单中各个字段的实际值,每个字段值包含 `type` 和 `content` 两个属性: ```typescript inputsValues: { url: { type: 'constant', // 常量类型 content: 'https://...', // 实际的值 }, prompt: { type: 'template', // 模板类型(支持变量引用) content: 'Hello {var}', // 可以引用变量 }, } ``` **`type` 的可选值**: - `'constant'`:常量值,不支持变量引用 - `'template'`:模板值,支持 `{variableName}` 语法引用变量 - `'variable'`:变量引用 #### 2. `data.inputs` - 节点表单的 JSON Schema 使用 JSON Schema 定义表单的结构,系统会根据这个 Schema 自动生成表单界面: ```typescript inputs: { type: 'object', required: ['url'], // 必填字段 properties: { url: { type: 'string', }, timeout: { type: 'number', minimum: 0, maximum: 60000, }, prompt: { type: 'string', extra: { formComponent: 'prompt-editor', // 指定自定义组件 }, }, }, } ``` #### 3. `data.outputs` - 节点输出的 JSON Schema 定义节点在工作流执行时的输出数据结构,供下游节点使用: ```typescript outputs: { type: 'object', properties: { body: { type: 'string' }, statusCode: { type: 'number' }, headers: { type: 'object' }, }, } ``` ### 三者的关系 ``` inputs (JSON Schema) → 定义表单结构 inputsValues (实际值) → 存储表单数据 [节点执行] outputs (JSON Schema) → 定义输出结构 ``` ### 字段类型与自动组件映射 在简单节点中,字段类型会自动匹配对应的表单组件: | 字段类型 | `extra.formComponent` | 默认组件 | |---------|---------------------|---------| | `string` | - | Input | | `string` | `'prompt-editor'` | PromptEditorWithVariables | | `number` | - | InputNumber | | `boolean` | - | Switch | | `object` | - | JsonCodeEditor | | `array` | - | JsonCodeEditor | ## 节点开发模式 ### 1. 简单节点(自动表单模式) - **适用场景**:节点配置较为简单,不需要复杂的自定义 UI - **特点**:根据 `inputs` Schema 自动生成表单 - **示例**:LLM 节点 - **文件结构**:只需要 `index.ts` 文件 - **模板位置**:`./templates/simple-node/index.ts` ### 2. 复杂节点(自定义 UI 模式) - **适用场景**:需要自定义表单布局、特殊交互或复杂的 UI 组件 - **特点**:完全控制表单渲染和交互逻辑 - **示例**:HTTP 节点 - **文件结构**: ``` {节点名}/ ├── index.tsx # 节点注册配置 ├── form-meta.tsx # 自定义表单渲染 ├── types.tsx # TypeScript 类型定义 └── components/ # 自定义组件 └── *.tsx ``` - **模板位置**:`./templates/complex-node/` ## 开发流程 ### Step 1: 规划节点 确定节点的核心信息: - **节点类型 ID**:唯一标识,如 `database`、`webhook` - **节点功能**:明确节点要做什么 - **输入参数**:节点需要哪些配置项 - **输出数据**:节点执行后返回什么数据 - **UI 复杂度**:是否需要自定义 UI ### Step 2: 选择开发模式 ``` 是否需要自定义 UI? ├─ 否 → 使用简单节点模式(复制 templates/simple-node/) └─ 是 → 使用复杂节点模式(复制 templates/complex-node/) ``` ### Step 3: 复制模板并修改 #### 简单节点 ```bash # 复制模板 cp .claude/skills/create-node/templates/simple-node/index.ts \ apps/demo-free-layout/src/nodes/{节点名}/index.ts # 修改模板中的 TODO 标记 # - {NODE_NAME} → 节点名(PascalCase) # - {NODE_TYPE} → 节点类型枚举值 # - {node_name} → 节点名(kebab-case) # - {node_type} → 节点类型(小写) ``` #### 复杂节点 ```bash # 复制模板目录 cp -r .claude/skills/create-node/templates/complex-node \ apps/demo-free-layout/src/nodes/{节点名} # 修改所有文件中的 TODO 标记 ``` ### Step 4: 添加节点类型常量 编辑 `apps/demo-free-layout/src/nodes/constants.ts`: ```typescript export enum WorkflowNodeType { // ... 现有节点 {节点类型} = '{节点类型}', } ``` ### Step 5: 注册节点 编辑 `apps/demo-free-layout/src/nodes/index.ts`: ```typescript // 导入节点 export { {节点名}NodeRegistry } from './{节点名}'; // 添加到注册列表 export const nodeRegistries: FlowNodeRegistry[] = [ // ... 现有节点 {节点名}NodeRegistry, ]; ``` ### Step 6: 准备节点图标 在 `apps/demo-free-layout/src/assets/` 目录下添加节点图标(SVG 或 JPG 格式): ``` apps/demo-free-layout/src/assets/icon-{节点名}.svg ``` ### Step 7: 测试验证 ```bash # 启动开发服务器 rush dev:demo-free-layout # 在浏览器中测试节点功能 ``` ## 常用组件和工具 ### FlowGram 组件 从 `@flowgram.ai/form-materials` 导入: ```typescript import { PromptEditorWithVariables, // 带变量的提示词编辑器 VariableSelector, // 变量选择器 JsonCodeEditor, // JSON 代码编辑器 CodeEditor, // 代码编辑器 DisplayOutputs, // 输出字段展示 DynamicValueInput, // 动态值输入 createInferInputsPlugin, // 输入推断插件 } from '@flowgram.ai/form-materials'; ``` ### Semi UI 组件 从 `@douyinfe/semi-ui` 导入: ```typescript import { Input, InputNumber, Select, Switch, Button, Divider, } from '@douyinfe/semi-ui'; ``` ### 表单工具 ```typescript import { Field } from '@flowgram.ai/free-layout-editor'; import { FormItem, FormHeader, FormContent } from '../../form-components'; import { useNodeRenderContext } from '../../hooks'; ``` ## 最佳实践 ### 1. 节点设计 - **单一职责**:一个节点只做一件事 - **清晰的 Schema**:明确定义 inputs 和 outputs - **合理的默认值**:提供有意义的初始配置 - **友好的描述**:为节点和字段提供清晰的描述 ### 2. Schema 设计 ```typescript // ✅ 好的做法:清晰的 Schema inputs: { type: 'object', required: ['url', 'method'], properties: { url: { type: 'string', description: 'API endpoint URL', }, method: { type: 'string', enum: ['GET', 'POST', 'PUT', 'DELETE'], }, }, } // ❌ 不好的做法:缺少约束 inputs: { type: 'object', properties: { url: { type: 'string' }, method: { type: 'string' }, }, } ``` ### 3. 表单组件使用 ```typescript // ✅ 好的做法:使用 Field 绑定表单状态 name="api.url"> {({ field }) => ( field.onChange(value)} /> )} // ❌ 不好的做法:手动管理状态 const [url, setUrl] = useState(''); ``` ### 4. 只读状态处理 ```typescript export function CustomComponent() { const { readonly } = useNodeRenderContext(); return ( ); } ``` ## 常见问题 ### Q1: 如何选择简单节点还是复杂节点? **判断标准**: - 字段简单 + 默认布局满足需求 → 简单节点 - 需要自定义布局/特殊交互 → 复杂节点 ### Q2: 如何使用变量功能? 在 `inputs` Schema 中使用 `formComponent: 'prompt-editor'`,并在 `inputsValues` 中使用 `type: 'template'`。 ### Q3: 如何定义必填字段? 在 `inputs` Schema 的 `required` 数组中列出必填字段名。 ### Q4: `inputsValues` 和 `inputs` 必须一致吗? 是的。`inputsValues` 中的字段必须在 `inputs.properties` 中有对应的定义。 ### Q5: 节点图标支持什么格式? 支持 SVG、JPG、PNG 格式,推荐使用 SVG。 ### Q6: 如何调试节点? 1. 使用浏览器开发者工具查看 console.log 2. 在 FormRender 组件中添加 `console.log(form.getValues())` 3. 使用 React DevTools 查看组件状态 ## 参考资源 ### 代码示例 - **简单节点**: `apps/demo-free-layout/src/nodes/llm/` - **复杂节点**: `apps/demo-free-layout/src/nodes/http/` - **表单组件**: `apps/demo-free-layout/src/form-components/` - **默认表单**: `apps/demo-free-layout/src/nodes/default-form-meta.tsx` ### 模板文件 - **简单节点模板**: `.claude/skills/create-node/templates/simple-node/` - **复杂节点模板**: `.claude/skills/create-node/templates/complex-node/` ### 相关文档 - FlowGram 官方文档: https://flowgram.ai - JSON Schema 规范: https://json-schema.org/ - Semi UI 组件库: https://semi.design/ ### 开发命令 ```bash # 启动开发服务器 rush dev:demo-free-layout # 构建项目 rush build # 类型检查 rush ts-check # 代码检查 rush lint ``` ## 快速开始检查清单 创建新节点时,按照此检查清单执行: - [ ] 规划节点功能和数据结构 - [ ] 选择开发模式(简单 vs 复杂) - [ ] 复制对应的模板文件 - [ ] 修改模板中的 TODO 标记 - [ ] 在 `constants.ts` 中添加节点类型 - [ ] 在 `index.ts` 中注册节点 - [ ] 准备节点图标文件 - [ ] 启动开发服务器测试 - [ ] 验证节点功能正常 ================================================ FILE: .claude/skills/create-node/templates/README.md ================================================ # Node Templates 这些是创建新节点的模板文件,使用时需要替换其中的占位符。 ## 占位符说明 在使用模板时,需要将以下占位符替换为实际值: | 占位符 | 说明 | 示例 | |-------|------|------| | `{NODE_NAME}` | 节点名称(PascalCase) | `Database`, `Webhook`, `EmailSender` | | `{NODE_TYPE}` | 节点类型枚举值(SCREAMING_SNAKE_CASE) | `DATABASE`, `WEBHOOK`, `EMAIL_SENDER` | | `{node_name}` | 节点名称(kebab-case,用于 ID 前缀) | `database`, `webhook`, `email_sender` | | `{node_type}` | 节点类型(小写,用于 type 字段) | `database`, `webhook`, `email_sender` | | `{节点功能描述}` | 节点的功能描述(中文) | `发送邮件`, `查询数据库`, `调用 Webhook` | ## 使用方法 ### 简单节点 ```bash # 1. 复制模板 cp .claude/skills/create-node/templates/simple-node/index.ts \ apps/demo-free-layout/src/nodes/database/index.ts # 2. 替换占位符 # {NODE_NAME} → Database # {NODE_TYPE} → DATABASE # {node_name} → database # {node_type} → database # {节点功能描述} → 查询数据库 ``` ### 复杂节点 ```bash # 1. 复制模板目录 cp -r .claude/skills/create-node/templates/complex-node \ apps/demo-free-layout/src/nodes/webhook # 2. 替换所有文件中的占位符 # {NODE_NAME} → Webhook # {NODE_TYPE} → WEBHOOK # {node_name} → webhook # {node_type} → webhook # {节点功能描述} → 调用 Webhook ``` ## 快速替换脚本(可选) 如果需要批量替换,可以使用以下命令(macOS/Linux): ```bash # 设置变量 NODE_NAME="Database" NODE_TYPE="DATABASE" node_name="database" node_type="database" description="查询数据库" # 批量替换 find apps/demo-free-layout/src/nodes/database -type f -name "*.ts*" -exec sed -i '' \ -e "s/{NODE_NAME}/$NODE_NAME/g" \ -e "s/{NODE_TYPE}/$NODE_TYPE/g" \ -e "s/{node_name}/$node_name/g" \ -e "s/{node_type}/$node_type/g" \ -e "s/{节点功能描述}/$description/g" \ {} + ``` ================================================ FILE: .claude/skills/create-node/templates/complex-node/components/custom-component.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { Field } from '@flowgram.ai/free-layout-editor'; import { Input, Select } from '@douyinfe/semi-ui'; import { useNodeRenderContext } from '../../../hooks'; import { FormItem } from '../../../form-components'; /** * 自定义表单组件 */ export function CustomComponent() { const { readonly } = useNodeRenderContext(); return (
name="customConfig.key" defaultValue=""> {({ field }) => ( field.onChange(value)} disabled={readonly} placeholder="请输入..." /> )} {/* TODO: 添加更多表单字段 */} name="customConfig.option" defaultValue="option1"> {({ field }) => ( updateTitleEdit(false)} /> ) : ( {value} )}
)} ); } ================================================ FILE: apps/demo-fixed-layout/src/form-components/form-header/utils.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { type FlowNodeEntity } from '@flowgram.ai/fixed-layout-editor'; import { FlowNodeRegistry } from '../../typings'; import { Icon } from './styles'; export const getIcon = (node: FlowNodeEntity) => { const icon = node.getNodeRegistry().info?.icon; if (!icon) return null; return ; }; ================================================ FILE: apps/demo-fixed-layout/src/form-components/form-inputs/index.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { DynamicValueInput, PromptEditorWithVariables } from '@flowgram.ai/form-materials'; import { Field } from '@flowgram.ai/fixed-layout-editor'; import { FormItem } from '../form-item'; import { Feedback } from '../feedback'; import { JsonSchema } from '../../typings'; import { useNodeRenderContext } from '../../hooks'; export function FormInputs() { const { readonly } = useNodeRenderContext(); return ( name="inputs"> {({ field: inputsField }) => { const required = inputsField.value?.required || []; const properties = inputsField.value?.properties; if (!properties) { return <>; } const content = Object.keys(properties).map((key) => { const property = properties[key]; const formComponent = property.extra?.formComponent; const vertical = ['prompt-editor'].includes(formComponent || ''); return ( {({ field, fieldState }) => ( {formComponent === 'prompt-editor' && ( 0} /> )} {!formComponent && ( 0} schema={property} /> )} )} ); }); return <>{content}; }} ); } ================================================ FILE: apps/demo-fixed-layout/src/form-components/form-inputs/styles.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ // import styled from 'styled-components'; // TODO ================================================ FILE: apps/demo-fixed-layout/src/form-components/form-item/index.css ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ .form-item-type-tag { color: inherit; padding: 0 2px; height: 18px; width: 18px; vertical-align: middle; flex-shrink: 0; flex-grow: 0; } ================================================ FILE: apps/demo-fixed-layout/src/form-components/form-item/index.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React, { useCallback } from 'react'; import { DisplaySchemaTag } from '@flowgram.ai/form-materials'; import { Typography, Tooltip } from '@douyinfe/semi-ui'; import './index.css'; const { Text } = Typography; interface FormItemProps { children: React.ReactNode; name: string; type: string; required?: boolean; description?: string; labelWidth?: number; vertical?: boolean; } export function FormItem({ children, name, required, description, type, labelWidth, vertical, }: FormItemProps): JSX.Element { const renderTitle = useCallback( (showTooltip?: boolean) => (
{name} {required && *}
), [] ); return (
{description ? {renderTitle()} : renderTitle(true)}
{children}
); } ================================================ FILE: apps/demo-fixed-layout/src/form-components/form-outputs/index.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { DisplayOutputs } from '@flowgram.ai/form-materials'; import { useIsSidebar } from '../../hooks'; export function FormOutputs() { const isSidebar = useIsSidebar(); if (isSidebar) { return null; } return ; } ================================================ FILE: apps/demo-fixed-layout/src/form-components/form-outputs/styles.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import styled from 'styled-components'; export const FormOutputsContainer = styled.div` display: flex; gap: 6px; flex-wrap: wrap; border-top: 1px solid var(--semi-color-border); padding: 8px 0 0; width: 100%; :global(.semi-tag .semi-tag-content) { font-size: 10px; } `; ================================================ FILE: apps/demo-fixed-layout/src/form-components/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export * from './feedback'; export * from './form-content'; export * from './form-outputs'; export * from './form-inputs'; export * from './form-header'; export * from './form-item'; export * from './properties-edit'; ================================================ FILE: apps/demo-fixed-layout/src/form-components/properties-edit/index.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React, { useContext, useState } from 'react'; import { Button } from '@douyinfe/semi-ui'; import { IconPlus } from '@douyinfe/semi-icons'; import { JsonSchema } from '../../typings'; import { NodeRenderContext } from '../../context'; import { PropertyEdit } from './property-edit'; export interface PropertiesEditProps { value?: Record; onChange: (value: Record) => void; useFx?: boolean; } export const PropertiesEdit: React.FC = (props) => { const value = (props.value || {}) as Record; const { readonly } = useContext(NodeRenderContext); const [newProperty, updateNewPropertyFromCache] = useState<{ key: string; value: JsonSchema }>({ key: '', value: { type: 'string' }, }); const [newPropertyVisible, setNewPropertyVisible] = useState(); const clearCache = () => { updateNewPropertyFromCache({ key: '', value: { type: 'string' } }); setNewPropertyVisible(false); }; // 替换对象的key时,保持顺序 const replaceKeyAtPosition = ( obj: Record, oldKey: string, newKey: string, newValue: any ) => { const keys = Object.keys(obj); const index = keys.indexOf(oldKey); if (index === -1) { // 如果 oldKey 不存在,直接添加到末尾 return { ...obj, [newKey]: newValue }; } // 在原位置替换 const newKeys = [...keys.slice(0, index), newKey, ...keys.slice(index + 1)]; return newKeys.reduce((acc, key) => { if (key === newKey) { acc[key] = newValue; } else { acc[key] = obj[key]; } return acc; }, {} as Record); }; const updateProperty = ( propertyValue: JsonSchema, propertyKey: string, newPropertyKey?: string ) => { if (newPropertyKey) { const orderedValue = replaceKeyAtPosition(value, propertyKey, newPropertyKey, propertyValue); props.onChange(orderedValue); } else { const newValue = { ...value }; newValue[propertyKey] = propertyValue; props.onChange(newValue); } }; const updateNewProperty = ( propertyValue: JsonSchema, propertyKey: string, newPropertyKey?: string ) => { // const newValue = { ...value } if (newPropertyKey) { if (!(newPropertyKey in value)) { updateProperty(propertyValue, propertyKey, newPropertyKey); } clearCache(); } else { updateNewPropertyFromCache({ key: newPropertyKey || propertyKey, value: propertyValue, }); } }; return ( <> {Object.keys(props.value || {}).map((key) => { const property = (value[key] || {}) as JsonSchema; return ( { const newValue = { ...value }; delete newValue[key]; props.onChange(newValue); }} /> ); })} {newPropertyVisible && ( { const key = newProperty.key; // after onblur setTimeout(() => { const newValue = { ...value }; delete newValue[key]; props.onChange(newValue); clearCache(); }, 10); }} /> )} {!readonly && (
)} ); }; ================================================ FILE: apps/demo-fixed-layout/src/form-components/properties-edit/property-edit.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React, { useState, useLayoutEffect } from 'react'; import { TypeSelector, DynamicValueInput } from '@flowgram.ai/form-materials'; import { Input, Button } from '@douyinfe/semi-ui'; import { IconCrossCircleStroked } from '@douyinfe/semi-icons'; import { JsonSchema } from '../../typings'; import { LeftColumn, Row } from './styles'; export interface PropertyEditProps { propertyKey: string; value: JsonSchema; useFx?: boolean; disabled?: boolean; onChange: (value: JsonSchema, propertyKey: string, newPropertyKey?: string) => void; onDelete?: () => void; } export const PropertyEdit: React.FC = (props) => { const { value, disabled } = props; const [inputKey, updateKey] = useState(props.propertyKey); const updateProperty = (key: keyof JsonSchema, val: any) => { value[key] = val; props.onChange(value, props.propertyKey); }; const partialUpdateProperty = (val?: Partial) => { props.onChange({ ...value, ...val }, props.propertyKey); }; useLayoutEffect(() => { updateKey(props.propertyKey); }, [props.propertyKey]); return ( partialUpdateProperty(val)} /> updateKey(v.trim())} onBlur={() => { if (inputKey !== '') { props.onChange(value, props.propertyKey, inputKey); } else { updateKey(props.propertyKey); } }} style={{ paddingLeft: 26 }} /> { updateProperty('default', val)} schema={value} style={{ flexGrow: 1 }} /> } {props.onDelete && !disabled && ( ; describe('use-node-render', () => { let container: interfaces.Container; let doc: WorkflowDocument; let wrapper: any; let node: WorkflowNodeEntity; let domNode: HTMLDivElement; beforeEach(async () => { container = (await createDocument()).container; doc = container.get(WorkflowDocument)!; node = doc.getNode('start_0')!; domNode = node.getData(FlowNodeRenderData).node!; wrapper = createHookWrapper(container); }); it('select node and listen change', async () => { // 初始化 const { result } = renderHook(() => useNodeRender(), { wrapper, }); expect(result.current.selected).toEqual(false); expect(result.current.activated).toEqual(false); // 选中 result.current.selectNode(createEvent('click', domNode) as any); const { result: result2 } = renderHook(() => useNodeRender(), { wrapper, }); expect(result2.current.selected).toEqual(true); expect(result2.current.activated).toEqual(true); // 清除选中 container.get(WorkflowSelectService).clear(); const { result: result3 } = renderHook(() => useNodeRender(), { wrapper, }); expect(result3.current.selected).toEqual(false); expect(result3.current.activated).toEqual(false); }); it('toggle select', async () => { const { result } = renderHook(() => useNodeRender(), { wrapper, }); result.current.selectNode(new MouseEvent('click', { shiftKey: true }) as any); const { result: result2 } = renderHook(() => useNodeRender(), { wrapper, }); expect(result2.current.selected).toEqual(true); result.current.selectNode(new MouseEvent('click', { shiftKey: true }) as any); const { result: result3 } = renderHook(() => useNodeRender(), { wrapper, }); expect(result3.current.selected).toEqual(false); }); it('delete node', async () => { const wrapper = createHookWrapper(container); const { result } = renderHook(() => useNodeRender(), { wrapper, }); result.current.deleteNode(); expect(node.disposed).toEqual(true); }); it('start drag', async () => { const wrapper = createHookWrapper(container); const { result } = renderHook(() => useNodeRender(), { wrapper, }); render(); // start Drag fireEvent.click(screen.getByText(/click me/i)); // start mousemove fireEvent( document, new MouseEvent('mousemove', { bubbles: true, cancelable: true, clientX: 100, clientY: 100, }) ); const { result: result2 } = renderHook(() => useNodeRender(), { wrapper, }); expect(result2.current.selected).toEqual(true); expect(node.getData(PositionData).toJSON()).toEqual({ x: 100, y: 100 }); result.current.selectNode(new MouseEvent('click', { shiftKey: true }) as any); const { result: result3 } = renderHook(() => useNodeRender(), { wrapper, }); // 拖拽时候无法再次触发选中事件 expect(result3.current.selected).toEqual(true); fireEvent( document, new MouseEvent('mouseup', { bubbles: true, cancelable: true, clientX: 100, clientY: 100, }) ); await delay(10); // 拖拽结束可以取消选中 result.current.selectNode(new MouseEvent('click', { shiftKey: true }) as any); expect(result.current.selected).toEqual(false); }); it('start drag input', async () => { const wrapper = createHookWrapper(container); const { result } = renderHook(() => useNodeRender(), { wrapper, }); render(); // start Drag fireEvent.click(screen.getByRole('input')); const { result: result2 } = renderHook(() => useNodeRender(), { wrapper, }); expect(result2.current.selected).toEqual(true); fireEvent( document, new MouseEvent('mousemove', { bubbles: true, cancelable: true, clientX: 100, clientY: 100, }) ); // input 无法拖拽 expect(node.getData(PositionData).toJSON()).toEqual({ x: 0, y: 0 }); }); }); ================================================ FILE: packages/canvas-engine/free-layout-core/__tests__/hooks/use-playground-readonly-state.test.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { renderHook } from '@testing-library/react-hooks'; import { PlaygroundConfigEntity } from '@flowgram.ai/core'; import { createDocument, createHookWrapper } from '../mocks'; import { usePlaygroundReadonlyState } from '../../src'; describe('use-workflow-document', () => { it('base', async () => { const { container } = await createDocument(); const wrapper = createHookWrapper(container); const { result } = renderHook(() => usePlaygroundReadonlyState(), { wrapper, }); expect(result.current).toEqual(false); container.get(PlaygroundConfigEntity).readonly = true; // 没有监听不会更新 expect(result.current).toEqual(false); }); it('listen change', async () => { const { container } = await createDocument(); const wrapper = createHookWrapper(container); const { result } = renderHook(() => usePlaygroundReadonlyState(true), { wrapper, }); expect(result.current).toEqual(false); container.get(PlaygroundConfigEntity).readonly = true; expect(result.current).toEqual(true); }); }); ================================================ FILE: packages/canvas-engine/free-layout-core/__tests__/hooks/use-workflow-document.test.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { renderHook } from '@testing-library/react-hooks'; import { createDocument, createHookWrapper } from '../mocks'; import { useWorkflowDocument } from '../../src'; describe('use-workflow-document', () => { it('base', async () => { const { container, document } = await createDocument(); const wrapper = createHookWrapper(container); const { result } = renderHook(() => useWorkflowDocument(), { wrapper, }); expect(result.current).toEqual(document); }); }); ================================================ FILE: packages/canvas-engine/free-layout-core/__tests__/mocks/index.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React from 'react'; import { interfaces } from 'inversify'; import { FlowDocumentContainerModule, FlowNodeBaseType } from '@flowgram.ai/document'; import { PlaygroundMockTools, PlaygroundReactProvider, PlaygroundEntityContext, } from '@flowgram.ai/core'; import { WorkflowSimpleLineContribution } from '../simple-line'; import { WorkflowDocument, WorkflowDocumentContainerModule, WorkflowJSON, WorkflowLinesManager, } from '../../src'; /** * 创建基本的 Container */ export function createWorkflowContainer(): interfaces.Container { const container = PlaygroundMockTools.createContainer([ FlowDocumentContainerModule, WorkflowDocumentContainerModule, ]); const linesManager = container.get(WorkflowLinesManager); linesManager.registerContribution(WorkflowSimpleLineContribution); linesManager.switchLineType(WorkflowSimpleLineContribution.type); return container; } export const baseJSON: WorkflowJSON = { nodes: [ { id: 'start_0', type: 'start', meta: { position: { x: 0, y: 0 }, }, data: undefined, }, { id: 'condition_0', type: 'condition', meta: { position: { x: 400, y: 0 }, }, data: undefined, }, { id: 'end_0', type: 'end', meta: { position: { x: 800, y: 0 }, }, data: undefined, }, ], edges: [ { sourceNodeID: 'start_0', targetNodeID: 'condition_0', data: { a: 33 }, }, { sourceNodeID: 'condition_0', sourcePortID: 'if', targetNodeID: 'end_0', }, { sourceNodeID: 'condition_0', sourcePortID: 'else', targetNodeID: 'end_0', }, ], }; export const nestJSON: WorkflowJSON = { nodes: [ ...baseJSON.nodes, { id: 'loop_0', type: 'loop', meta: { position: { x: 1200, y: 0 }, }, data: undefined, blocks: [ { id: 'break_0', type: 'break', meta: { position: { x: 0, y: 0 }, }, data: undefined, }, { id: 'variable_0', type: 'variable', meta: { position: { x: 400, y: 0 }, }, data: undefined, }, ], edges: [ { sourceNodeID: 'break_0', targetNodeID: 'variable_0', data: { a: 33 }, }, ], }, ], edges: [...baseJSON.edges], }; export function createDocument(data: WorkflowJSON = baseJSON) { const container = createWorkflowContainer(); const document = container.get(WorkflowDocument); document.fromJSON(data); return { document, container, }; } export function createHookWrapper( container: interfaces.Container, entityId: string = 'start_0' ): any { // eslint-disable-next-line react/display-name return ({ children }: any) => ( {children} ); } export function createSubCanvasNodes(document: WorkflowDocument) { document.fromJSON({ nodes: [], edges: [] }); const loopNode = document.createWorkflowNode({ id: 'loop_0', type: 'loop', meta: { position: { x: -100, y: 0 }, subCanvas: () => { const parentNode = document.getNode('loop_0'); const canvasNode = document.getNode('subCanvas_0'); if (!parentNode || !canvasNode) { return; } return { isCanvas: false, parentNode, canvasNode, }; }, }, }); const subCanvasNode = document.createWorkflowNode({ id: 'subCanvas_0', type: FlowNodeBaseType.SUB_CANVAS, meta: { isContainer: true, position: { x: 100, y: 0 }, subCanvas: () => ({ isCanvas: true, parentNode: document.getNode('loop_0')!, canvasNode: document.getNode('subCanvas_0')!, }), }, }); document.linesManager.createLine({ from: loopNode.id, to: subCanvasNode.id, }); const variableNode = document.createWorkflowNode( { id: 'variable_0', type: 'variable', meta: { position: { x: 0, y: 0 }, }, }, false, subCanvasNode.id ); return { loopNode, subCanvasNode, variableNode, }; } ================================================ FILE: packages/canvas-engine/free-layout-core/__tests__/service/workflow-drag-service.test.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { interfaces } from 'inversify'; import { fireEvent, waitFor } from '@testing-library/react'; import { IPoint } from '@flowgram.ai/utils'; import { FlowNodeBaseType } from '@flowgram.ai/document'; import { PlaygroundConfigEntity, PositionData } from '@flowgram.ai/core'; import { TransformData } from '@flowgram.ai/core'; import { createWorkflowContainer, baseJSON, nestJSON } from '../mocks'; import { WorkflowDragService, WorkflowDocument, WorkflowNodePortsData, WorkflowLineEntity, WorkflowSelectService, WorkflowNodeEntity, WorkflowLinesManager, WorkflowPortEntity, } from '../../src'; async function fireMouseEvent(type: string, point: IPoint): Promise { fireEvent( document, new MouseEvent(type, { bubbles: true, cancelable: true, clientX: point.x, clientY: point.y, }) ); await waitFor(() => {}, { timeout: 100 }); } describe('workflow-drag-service', () => { let dragService: WorkflowDragService; let container: interfaces.Container; let document: WorkflowDocument; let startNode: WorkflowNodeEntity; let endNode: WorkflowNodeEntity; let startPorts: WorkflowNodePortsData; let endPorts: WorkflowNodePortsData; let conditionPorts: WorkflowNodePortsData; async function drawingLine( point: IPoint, originLine?: WorkflowLineEntity ): Promise<{ dragSuccess?: boolean; // 是否拖拽成功,不成功则为选择节点 newLine?: WorkflowLineEntity; // 新的线条 }> { const promise = dragService.startDrawingLine( startPorts.outputPorts[0]!, { clientX: 0, clientY: 0 }, originLine ); await fireMouseEvent('mousemove', point); await fireMouseEvent('mouseup', point); return promise; } async function drawingLineBetweenNodes(params: { from?: WorkflowNodeEntity; to?: WorkflowNodeEntity; fromPort?: WorkflowPortEntity; toPoint?: IPoint; middlePoints?: IPoint[]; }): Promise<{ dragSuccess?: boolean; // 是否拖拽成功,不成功则为选择节点 newLine?: WorkflowLineEntity; // 新的线条 }> { const { from, to, middlePoints } = params; const fromPortsData = from?.ports!; const toPortsData = to?.ports!; const fromPort = fromPortsData?.outputPorts?.[0] ?? params.fromPort; const fromPoint = fromPortsData?.getOutputPoint(); const toPoint = toPortsData?.getInputPoint() ?? params.toPoint; if (!fromPort || !toPoint || !fromPoint) { return { dragSuccess: false, }; } const promise = dragService.startDrawingLine(fromPortsData.outputPorts[0]!, { clientX: 0, clientY: 0, }); const middlePoint: IPoint = { x: (fromPoint.x + toPoint.x) / 2, y: (fromPoint.y + toPoint.y) / 2, }; // 开始拖拽 await fireMouseEvent('mousemove', fromPoint); // 经过中间节点 if (middlePoints?.length) { for (const point of middlePoints) { await fireMouseEvent('mousemove', point); } } else { await fireMouseEvent('mousemove', middlePoint); } // 结束拖拽 await fireMouseEvent('mousemove', toPoint); await fireMouseEvent('mouseup', toPoint); return promise; } async function dragNodes( nodes: WorkflowNodeEntity[], point: IPoint, mouseConfig?: any ): Promise { container.get(WorkflowSelectService).selection = nodes; const promise = dragService.startDragSelectedNodes({ clientX: 0, clientY: 0, ...mouseConfig, }); await fireMouseEvent('mousemove', point); await fireMouseEvent('mouseup', point); return promise; } beforeEach(async () => { container = createWorkflowContainer(); dragService = container.get(WorkflowDragService); document = container.get(WorkflowDocument); await document.fromJSON({ nodes: baseJSON.nodes, edges: [], }); startNode = document.getNode('start_0')!; endNode = document.getNode('end_0')!; startPorts = startNode.ports!; endPorts = endNode.ports!; conditionPorts = document.getNode('condition_0')!.ports!; }); it('startDrawingLine', async () => { // 连接到 end 节点 const drawToEnd = await drawingLine(endPorts.getInputPoint()); expect(drawToEnd.newLine!.id).toMatch('end_0'); expect(document.linesManager.getAllLines().length).toEqual(1); // 连接到已有的线 const drawToSame = await drawingLine(endPorts.getInputPoint()); expect(drawToSame.newLine).toEqual(drawToEnd.newLine); // 连接到未知节点 const drawUnknown = await drawingLine({ x: 9999, y: 9999 }); expect(drawUnknown.newLine).toEqual(undefined); // 连接到输出点 (不能连接) // 该 case 下等同于拖拽到节点连线,注释 case // const drawToOutputPoint = await drawingLine(endPorts.getOutputPoint()); // expect(drawToOutputPoint.newLine).toEqual(undefined); }); it('startDrawingLine when readonly', async () => { container.get(PlaygroundConfigEntity).readonly = true; const drawToEnd = await drawingLine(endPorts.getInputPoint()); expect(drawToEnd.dragSuccess).toEqual(false); expect(drawToEnd.newLine).toEqual(undefined); }); it('startDrawingLine with originLine', async () => { const onDragLineEndCaller = vi.fn(); dragService.onDragLineEnd(async () => { onDragLineEndCaller(); }); const startToEndLine = (await drawingLine(endPorts.getInputPoint())).newLine!; // 鼠标没有偏移 const drawToZero = await drawingLine({ x: 0, y: 0 }, startToEndLine); expect(drawToZero.dragSuccess).toEqual(false); // 连到同一个点 const drawToEnd = await drawingLine(endPorts.getInputPoint(), startToEndLine); expect(drawToEnd.dragSuccess).toEqual(true); expect(drawToEnd.newLine).toEqual(undefined); // 连到空白未知时候会把原来线条删除 const drawUnknown = await drawingLine({ x: 999, y: 999 }, startToEndLine); expect(drawUnknown.dragSuccess).toEqual(true); expect(drawUnknown.newLine).toEqual(undefined); expect(startToEndLine.disposed).toEqual(true); expect(document.linesManager.getAllLines().length).toEqual(0); // 创建新的线条 const newStartToEndLine = (await drawingLine(endPorts.getInputPoint())).newLine!; expect(document.linesManager.getAllLines().length).toEqual(1); expect(newStartToEndLine.id).toEqual(newStartToEndLine.id); expect(startToEndLine !== newStartToEndLine).toEqual(true); // 将线条重连到另外的未知 const drawToOther = await drawingLine(conditionPorts.getInputPoint(), newStartToEndLine); expect(drawToOther.dragSuccess).toEqual(true); expect(drawToOther.newLine!.id).toMatch('condition_0'); expect(newStartToEndLine.disposed).toEqual(true); expect(document.linesManager.getAllLines().length).toEqual(1); expect(onDragLineEndCaller).toHaveBeenCalledTimes(6); }); it('startDrawingLine inside sub canvas', async () => { const linesManager = container.get(WorkflowLinesManager); vi.spyOn(linesManager, 'canAddLine').mockImplementation( (fromPort: WorkflowPortEntity, toPort: WorkflowPortEntity, silent?: boolean) => { if (toPort?.node.flowNodeType === FlowNodeBaseType.SUB_CANVAS) { return false; } return true; } ); await document.fromJSON({ nodes: [ { id: 'sub_canvas_0', type: FlowNodeBaseType.SUB_CANVAS, meta: { isContainer: true, position: { x: 0, y: 0, }, size: { width: 1000, height: 1000, }, }, blocks: [ { id: 'from_0', type: 'from', meta: { position: { x: 100, y: 100, }, size: { width: 50, height: 50, }, }, }, { id: 'to_0', type: 'to', meta: { position: { x: 700, y: 700, }, size: { width: 50, height: 50, }, }, }, ], edges: [], }, ], edges: [], }); const fromNodeId = 'from_0'; const toNodeId = 'to_0'; const fromNode = document.getNode(fromNodeId)!; const toNode = document.getNode(toNodeId)!; expect(linesManager.getAllLines().length).toBe(0); const { dragSuccess, newLine } = await drawingLineBetweenNodes({ from: fromNode, to: toNode, }); const line = newLine as WorkflowLineEntity; expect(linesManager.getAllLines().length).toBe(1); expect(line.inContainer).toBeTruthy(); expect(line.from?.id).toBe(fromNodeId); expect(line.to?.id).toBe(toNodeId); expect(dragSuccess).toBeTruthy(); expect(line.id).toBe(`${fromNodeId}_-${toNodeId}_`); }); it('resetLine', async () => { const selectService = container.get(WorkflowSelectService); const startToEndLine = (await drawingLine(endPorts.getInputPoint())).newLine!; // 点到同一个位置则选中线条 dragService.resetLine(startToEndLine, { clientX: 0, clientY: 0, } as MouseEvent); await fireMouseEvent('mousemove', { x: 0, y: 0 }); await fireMouseEvent('mouseup', { x: 0, y: 0 }); expect(startToEndLine.isDrawing).toBeFalsy(); expect(selectService.selection).toEqual([startToEndLine]); selectService.selection = []; // 点到不同位置 dragService.resetLine(startToEndLine, { clientX: conditionPorts.getInputPoint().x, clientY: conditionPorts.getInputPoint().y, } as MouseEvent); await fireMouseEvent('mousemove', { x: 0, y: 0 }); await fireMouseEvent('mouseup', { x: 0, y: 0 }); // 交互定义,如果本来已经连线,拖拽的目标点不可连线,则重置连线位置。 expect(startToEndLine.disposed).toEqual(false); expect(selectService.selection).toEqual([]); }); it('canResetLine', async () => { document.options.canResetLine = () => false; const startToEndLine = (await drawingLine(endPorts.getInputPoint())).newLine!; const drawToOther = await drawingLine(conditionPorts.getInputPoint(), startToEndLine); expect(startToEndLine.disposed).toEqual(false); expect(drawToOther.newLine).toEqual(undefined); }); it('canDeleteLine', async () => { document.options.canDeleteLine = () => false; const startToEndLine = (await drawingLine(endPorts.getInputPoint())).newLine!; await drawingLine({ x: 999, y: 999 }, startToEndLine); expect(startToEndLine.disposed).toEqual(false); document.options.canDeleteLine = () => true; await drawingLine({ x: 999, y: 999 }, startToEndLine); expect(startToEndLine.disposed).toEqual(true); }); it('startDragSelectedNodes empty', async () => { const dragEmpty = await dragNodes([], { x: 0, y: 0 }); expect(dragEmpty).toEqual(false); }); it('startDragSelectedNodes', async () => { const dragResult = await dragNodes([startNode, endNode], { x: 100, y: 100, }); expect(dragResult).toEqual(true); expect(startNode.getData(PositionData).toJSON()).toEqual({ x: 100, y: 100, }); expect(endNode.getData(PositionData).toJSON()).toEqual({ x: 900, y: 100 }); }); it('startDragSelectedNodes with same parent', async () => { await document.fromJSON({ nodes: nestJSON.nodes, edges: [], }); const loopNode = document.getNode('loop_0')!; const breakNode = document.getNode('break_0')!; const variableNode = document.getNode('variable_0')!; const dragResult = await dragNodes([breakNode, variableNode], { x: 100, y: 100, }); expect(dragResult).toEqual(true); expect(breakNode.getData(PositionData).toJSON()).toEqual({ x: 140, y: 0, }); expect(variableNode.getData(PositionData).toJSON()).toEqual({ x: 540, y: 0, }); expect(loopNode.getData(PositionData).toJSON()).toEqual({ x: 1160, y: 100, }); }); it('startDragSelectedNodes with different parent', async () => { await document.fromJSON({ nodes: nestJSON.nodes, edges: [], }); const breakNode = document.getNode('break_0')!; const dragResult = await dragNodes([breakNode, startNode], { x: 100, y: 100, }); expect(dragResult).toEqual(true); expect(breakNode.getData(PositionData).toJSON()).toEqual({ x: 100, y: 100, }); }); it('startDragCard', async () => { // 需要在 viewport 区域 document.playgroundConfig.updateConfig({ width: 1000, height: 1000, }); const domNode = global.document.createElement('div'); const promise = dragService.startDragCard( 'mockType', { clientX: 0, clientY: 0, currentTarget: domNode } as any, {} ); await fireMouseEvent('mousemove', { x: 100, y: 100 }); await fireMouseEvent('mouseup', { x: 100, y: 100 }); const result = await promise; expect(result!.flowNodeType).toEqual('mockType'); expect(result!.getData(PositionData).toJSON()).toEqual({ x: 100, y: 100 }); }); it('startDragCard with cloneNode', async () => { // 需要在 viewport 区域 document.playgroundConfig.updateConfig({ width: 1000, height: 1000, }); const domNode = global.document.createElement('div'); const promise = dragService.startDragCard( 'mockType', { clientX: 0, clientY: 0, currentTarget: domNode } as any, {}, (e) => domNode.cloneNode(true) as HTMLDivElement ); await fireMouseEvent('mousemove', { x: 100, y: 100 }); await fireMouseEvent('mouseup', { x: 100, y: 100 }); const result = await promise; expect(result!.flowNodeType).toEqual('mockType'); expect(result!.getData(PositionData).toJSON()).toEqual({ x: 100, y: 100 }); }); it('startDragCard fail', async () => { document.playgroundConfig.updateConfig({ width: 1000, height: 1000, }); const domNode = global.document.createElement('div'); const promise = dragService.startDragCard( 'mockType', { clientX: 0, clientY: 0, currentTarget: domNode } as any, {} ); await fireMouseEvent('mousemove', { x: -100, y: -100 }); await fireMouseEvent('mouseup', { x: -100, y: -100 }); const result = await promise; expect(result).toEqual(undefined); }); it('dropCard', async () => { await document.fromJSON({ nodes: nestJSON.nodes, edges: [], }); // 需要在 viewport 区域 document.playgroundConfig.updateConfig({ width: 1000, height: 1000, }); const domNode = global.document.createElement('div'); const node = await dragService.dropCard( 'loop', { clientX: 0, clientY: 0, currentTarget: domNode } as any, {} ); expect(node!.flowNodeType).toEqual('loop'); expect(node!.getData(PositionData).toJSON()).toEqual({ x: 0, y: 0 }); }); it('dropCard to parent node', async () => { await document.fromJSON({ nodes: nestJSON.nodes, edges: [], }); // 需要在 viewport 区域 document.playgroundConfig.updateConfig({ width: 1000, height: 1000, }); const domNode = global.document.createElement('div'); const node = await dragService.dropCard( 'break', { clientX: 0, clientY: 0, currentTarget: domNode } as any, {}, document.getNode('loop_0')! ); expect(node!.flowNodeType).toEqual('break'); expect(node!.getData(PositionData).toJSON()).toEqual({ x: -1200, y: 0 }); }); it('startDragCard and drop to container node', async () => { await document.fromJSON({ nodes: [ { id: 'loop_0', type: 'loop', meta: { position: { x: 0, y: 500 }, size: { width: 100, height: 100 }, selectable: () => true, }, data: undefined, }, { id: 'sub_canvas_0', type: FlowNodeBaseType.SUB_CANVAS, meta: { isContainer: true, position: { x: 0, y: -500 }, size: { width: 1000, height: 1000 }, selectable: true, }, data: undefined, }, ], edges: [], }); const loopNode = document.getNode('loop_0')!; const subCanvas = document.getNode('sub_canvas_0')!; subCanvas.originParent = loopNode; const subCanvasTrans = subCanvas.getData(TransformData); subCanvasTrans.update({ position: { x: 0, y: -500 }, size: { width: 1000, height: 1000 }, }); subCanvasTrans.fireChange(); // 需要在 viewport 区域 document.playgroundConfig.updateConfig({ width: 5000, height: 5000, }); const domNode = global.document.createElement('div'); const promise = dragService.startDragCard( 'variable', { clientX: 0, clientY: 0, currentTarget: domNode } as any, {} ); await fireMouseEvent('mousemove', { x: 0, y: 0 }); expect((dragService as any)._droppableTransforms.length).toEqual(1); expect((dragService as any)._dropNode?.id).toEqual('sub_canvas_0'); await fireMouseEvent('mousemove', { x: -2000, y: 0 }); expect((dragService as any)._dropNode?.id).toBeUndefined(); await fireMouseEvent('mousemove', { x: 10, y: 10 }); await fireMouseEvent('mousemove', { x: 0, y: 0 }); await fireMouseEvent('mouseup', { x: 0, y: 0 }); const node = (await promise) as WorkflowNodeEntity; expect(node.parent?.id).toEqual('sub_canvas_0'); expect(node.flowNodeType).toEqual('variable'); expect(node.getData(PositionData).toJSON()).toEqual({ x: 0, y: 0 }); }); it('dispose', () => { dragService.dispose(); }); it('adjustSubNodePosition failed', () => { const pos = dragService.adjustSubNodePosition('variable', document.root); expect(pos).toStrictEqual({ x: 0, y: 0 }); }); }); ================================================ FILE: packages/canvas-engine/free-layout-core/__tests__/service/workflow-hover-service.test.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { vi } from 'vitest'; import { interfaces } from 'inversify'; // import { Playground, PositionData } from '@flowgram.ai/core' import { createWorkflowContainer, baseJSON } from '../mocks'; import { WorkflowHoverService, WorkflowDocument } from '../../src'; describe('workflow-hover-service', () => { let hoverService: WorkflowHoverService; let container: interfaces.Container; let document: WorkflowDocument; beforeEach(async () => { container = createWorkflowContainer(); hoverService = container.get(WorkflowHoverService); document = container.get(WorkflowDocument); await document.fromJSON(baseJSON); }); it('base hover', () => { const fn = vi.fn(); hoverService.onHoveredChange(fn); expect(hoverService.isSomeHovered()).toEqual(false); expect(hoverService.hoveredKey).toEqual(''); expect(hoverService.isHovered('start_0')).toEqual(false); expect(hoverService.hoveredNode).toEqual(undefined); hoverService.updateHoveredKey('start_0'); expect(hoverService.isSomeHovered()).toEqual(true); expect(hoverService.hoveredKey).toEqual('start_0'); expect(hoverService.isHovered('start_0')).toEqual(true); expect(hoverService.hoveredNode).toEqual(document.getNode('start_0')); expect(fn.mock.calls.length).toEqual(1); // duplicate hover hoverService.updateHoveredKey('start_0'); expect(fn.mock.calls.length).toEqual(1); hoverService.clearHovered(); expect(hoverService.hoveredKey).toEqual(''); expect(hoverService.isHovered('start_0')).toEqual(false); expect(hoverService.hoveredNode).toEqual(undefined); }); }); ================================================ FILE: packages/canvas-engine/free-layout-core/__tests__/service/workflow-select-service.test.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { vi } from 'vitest'; import { interfaces } from 'inversify'; import { Playground, PositionData } from '@flowgram.ai/core'; import { createWorkflowContainer, baseJSON } from '../mocks'; import { WorkflowSelectService, WorkflowDocument } from '../../src'; describe('workflow-select-service', () => { let selectService: WorkflowSelectService; let container: interfaces.Container; let document: WorkflowDocument; beforeEach(async () => { container = createWorkflowContainer(); selectService = container.get(WorkflowSelectService); document = container.get(WorkflowDocument); await document.fromJSON(baseJSON); }); it('selectNode and clear', () => { const fn = vi.fn(); expect(selectService.selection).toEqual([]); expect(selectService.activatedNode).toEqual(undefined); selectService.onSelectionChanged(fn); const node = document.getNode('start_0')!; selectService.selectNode(node); expect(selectService.selection).toEqual([node]); expect(selectService.selectedNodes).toEqual([node]); expect(selectService.isSelected('start_0')).toEqual(true); expect(selectService.isActivated('start_0')).toEqual(true); expect(selectService.activatedNode).toEqual(node); expect(fn.mock.calls.length).toEqual(1); selectService.clear(); expect(selectService.isSelected('start_0')).toEqual(false); expect(selectService.selection).toEqual([]); expect(fn.mock.calls.length).toEqual(2); }); it('set selection', () => { const node = document.getNode('start_0')!; selectService.selection = [node]; expect(selectService.selection).toEqual([node]); }); it('set select', () => { const node = document.getNode('start_0')!; selectService.select(node); expect(selectService.selection).toEqual([node]); }); it('toggleSelect', () => { selectService.toggleSelect(document.getNode('start_0')!); expect(selectService.selectedNodes).toEqual([document.getNode('start_0')!]); selectService.toggleSelect(document.getNode('start_0')!); expect(selectService.selectedNodes).toEqual([]); }); it('select and focus', () => { const playground = container.get(Playground); global.document.body.appendChild(playground.node); const node = document.getNode('start_0')!; selectService.selectNodeAndFocus(node); expect(selectService.selection).toEqual([node]); expect(playground.focused).toEqual(true); }); it('selectNodeAndScrollToView', async () => { const node = document.getNode('start_0')!; const playground = container.get(Playground); global.document.body.appendChild(playground.node); node.updateData(PositionData, { x: -999, y: -999, }); await selectService.selectNodeAndScrollToView(node); expect(playground.config.scrollData.scrollX).toEqual(-999); }); }); ================================================ FILE: packages/canvas-engine/free-layout-core/__tests__/simple-line.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { IPoint, Point, Rectangle } from '@flowgram.ai/utils'; import { getLineCenter } from '../src/utils/get-line-center'; import { LineCenterPoint, WorkflowLineRenderContribution } from '../src/typings'; import { POINT_RADIUS, WorkflowLineEntity } from '../src/entities'; const LINE_PADDING = 12; export interface StraightData { points: IPoint[]; path: string; bbox: Rectangle; center: LineCenterPoint; } export class WorkflowSimpleLineContribution implements WorkflowLineRenderContribution { public static type = 'SimpleLine'; public entity: WorkflowLineEntity; constructor(entity: WorkflowLineEntity) { this.entity = entity; } private data?: StraightData; public get path(): string { return this.data?.path ?? ''; } public calcDistance(pos: IPoint): number { if (!this.data) { return Number.MAX_SAFE_INTEGER; } const [start, end] = this.data.points; return Point.getDistance(pos, this.projectPointOnLine(pos, start, end)); } public get bounds(): Rectangle { if (!this.data) { return new Rectangle(); } return this.data.bbox; } get center() { return this.data!.center; } public update(params: { fromPos: IPoint; toPos: IPoint }): void { const { fromPos, toPos } = params; const { vertical } = this.entity; // 根据方向预先计算源点和目标点的偏移 const sourceOffset = { x: vertical ? 0 : POINT_RADIUS, y: vertical ? POINT_RADIUS : 0, }; const targetOffset = { x: vertical ? 0 : -POINT_RADIUS, y: vertical ? -POINT_RADIUS : 0, }; const points = [ { x: fromPos.x + sourceOffset.x, y: fromPos.y + sourceOffset.y, }, { x: toPos.x + targetOffset.x, y: toPos.y + targetOffset.y, }, ]; const bbox = Rectangle.createRectangleWithTwoPoints(points[0], points[1]); // 调整所有点到 SVG 视口坐标系 const adjustedPoints = points.map((p) => ({ x: p.x - bbox.x + LINE_PADDING, y: p.y - bbox.y + LINE_PADDING, })); // 生成直线路径 const path = `M ${adjustedPoints[0].x} ${adjustedPoints[0].y} L ${adjustedPoints[1].x} ${adjustedPoints[1].y}`; this.data = { points, path, bbox, center: getLineCenter(fromPos, toPos, bbox, LINE_PADDING), }; } private projectPointOnLine(point: IPoint, lineStart: IPoint, lineEnd: IPoint): IPoint { const dx = lineEnd.x - lineStart.x; const dy = lineEnd.y - lineStart.y; // 如果是垂直线 if (dx === 0) { return { x: lineStart.x, y: point.y }; } // 如果是水平线 if (dy === 0) { return { x: point.x, y: lineStart.y }; } const t = ((point.x - lineStart.x) * dx + (point.y - lineStart.y) * dy) / (dx * dx + dy * dy); const clampedT = Math.max(0, Math.min(1, t)); return { x: lineStart.x + clampedT * dx, y: lineStart.y + clampedT * dy, }; } } ================================================ FILE: packages/canvas-engine/free-layout-core/__tests__/utils/location-config-to-point.test.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { expect } from 'vitest'; import { Rectangle } from '@flowgram.ai/utils'; import { locationConfigToPoint } from '../../src/utils/location-config-to-point'; test('locationConfigToPoint', () => { const bounds = new Rectangle(10, 10, 100, 100); expect(locationConfigToPoint(bounds, { left: 0, top: 0 })).toEqual(bounds.leftTop); expect(locationConfigToPoint(bounds, { left: 0, bottom: 0 })).toEqual(bounds.leftBottom); expect(locationConfigToPoint(bounds, { right: 0, bottom: 0 })).toEqual(bounds.rightBottom); expect(locationConfigToPoint(bounds, { right: 0, top: 0 })).toEqual(bounds.rightTop); expect(locationConfigToPoint(bounds, { left: 0, top: '0%' })).toEqual(bounds.leftTop); expect(locationConfigToPoint(bounds, { right: 0, bottom: '0%' })).toEqual(bounds.rightBottom); expect(locationConfigToPoint(bounds, { left: 0, top: '50%' })).toEqual(bounds.leftCenter); expect(locationConfigToPoint(bounds, { right: 0, bottom: '50%' })).toEqual(bounds.rightCenter); expect(locationConfigToPoint(bounds, { left: '50%', bottom: 0 })).toEqual(bounds.bottomCenter); expect(locationConfigToPoint(bounds, { right: '50%', top: 0 })).toEqual(bounds.topCenter); expect(locationConfigToPoint(bounds, { left: '50%', top: '50%' })).toEqual(bounds.center); expect(locationConfigToPoint(bounds, { right: '50%', bottom: '50%' })).toEqual(bounds.center); expect(locationConfigToPoint(bounds, { left: 11, top: 11 })).toEqual({ x: 21, y: 21 }); expect(locationConfigToPoint(bounds, { right: 11, bottom: 11 })).toEqual({ x: 10 + 100 - 11, y: 10 + 100 - 11, }); // with offset expect(locationConfigToPoint(bounds, { left: 11, top: 11 }, { x: 100, y: 100 })).toEqual({ x: 121, y: 121, }); }); ================================================ FILE: packages/canvas-engine/free-layout-core/__tests__/workflow-document.test.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { vi } from 'vitest'; import { interfaces } from 'inversify'; import { FlowNodeBaseType, FlowNodeRegistry, FlowNodeTransformData } from '@flowgram.ai/document'; import { PlaygroundConfigEntity } from '@flowgram.ai/core'; import { delay, WorkflowContentChangeEvent, WorkflowContentChangeType, WorkflowDocument, WorkflowJSON, WorkflowLinesManager, WorkflowNodeJSON, WorkflowSubCanvas, } from '../src'; import { baseJSON, createSubCanvasNodes, createWorkflowContainer, nestJSON } from './mocks'; let container: interfaces.Container; let document: WorkflowDocument; beforeEach(() => { container = createWorkflowContainer(); document = container.get(WorkflowDocument); }); describe('workflow-document', () => { it('load', async () => { const fn = vi.fn(); document.onLoaded(fn); await document.load(); expect(fn.mock.calls.length).toEqual(1); }); it('base fromJSON and toJSON', () => { document.fromJSON(baseJSON); expect(document.toJSON()).toEqual(baseJSON); }); it('nested fromJSON and toJSON', () => { document.fromJSON(nestJSON); expect(document.toJSON()).toEqual(nestJSON); }); it('reload json', async () => { document.fromJSON(baseJSON); const newJSON = { nodes: [ { id: 'start_0', type: 'start', data: undefined, meta: { position: { x: 10, y: 10 }, }, }, ], edges: [], }; await document.reload(newJSON); expect(document.toJSON()).toEqual(newJSON); }); it('dispose', () => { document.dispose(); expect((document as any).disposed).toEqual(true); document.dispose(); expect((document as any).disposed).toEqual(true); }); it('fitView', async () => { const config = container.get(PlaygroundConfigEntity); config.updateConfig({ width: 1000, height: 800, }); document.addNode({ id: 'start_0', type: 'start', meta: { position: { x: -1000, y: -1000 }, }, }); await document.fitView(false); expect(config.scrollData).toEqual({ scrollX: -500, scrollY: -400, }); }); it('getNodeDefaultPosition', () => { const variableNodeType = 'variable'; const variableNodeRegister: FlowNodeRegistry = { type: variableNodeType, meta: { position: { x: 10, y: 10 }, size: { width: 100, height: 100 }, }, }; document.registerFlowNodes(variableNodeRegister); expect(document.getNodeDefaultPosition(variableNodeType)).toEqual({ x: 0, y: -50, }); }); it('createWorkflowNodeByType', () => { const node = document.createWorkflowNodeByType('start', { x: 10, y: 10, }); const nodeTransData = node.getData(FlowNodeTransformData); expect(nodeTransData.position).toEqual({ x: 10, y: 10 }); expect(node.flowNodeType).toEqual('start'); }); it('createWorkflowNodeByType with id', () => { const node = document.createWorkflowNodeByType( 'start', { x: 10, y: 10, }, { id: 'start_0' } ); expect(node.id).toEqual('start_0'); const nodeTransData = node.getData(FlowNodeTransformData); expect(nodeTransData.position).toEqual({ x: 10, y: 10 }); expect(node.flowNodeType).toEqual('start'); let error: string = ''; try { document.createWorkflowNodeByType( 'start', { x: 10, y: 10, }, { id: 'start_0' } ); } catch (e: any) { error = e.message; } expect(error).toMatch('duplicated'); }); it('getAllNodes', () => { document.fromJSON(nestJSON); const allNodeIds = document.getAllNodes().map((n) => n.id); expect(allNodeIds).toEqual([ 'start_0', 'condition_0', 'end_0', 'loop_0', 'break_0', 'variable_0', ]); }); it('getAssociatedNodes', () => { const startNodeRegister: FlowNodeRegistry = { type: 'start', meta: { isStart: true, }, }; const endNodeRegister: FlowNodeRegistry = { type: 'end', meta: { isNodeEnd: true, }, }; document.registerFlowNodes(startNodeRegister, endNodeRegister); document.fromJSON({ nodes: [ ...baseJSON.nodes, { id: 'sun_canvas_0', type: FlowNodeBaseType.SUB_CANVAS, meta: { isContainer: true, position: { x: 10, y: 10 }, }, blocks: [ { id: 'variable_0', type: 'variable', meta: { position: { x: -10, y: 0 }, }, }, { id: 'variable_1', type: 'variable', meta: { position: { x: 10, y: 0 }, }, }, ], edges: [], }, ], edges: [], }); const endNode = document.getNode('end_0')!; (endNode as any)._metaCache['isNodeEnd'] = true; const associatedNodeIds = document.getAssociatedNodes().map((n) => n.id); expect(associatedNodeIds).toEqual(['start_0', 'end_0', 'variable_0', 'variable_1']); }); it('fireRender', () => { expect(document.fireRender()).toBeUndefined(); }); it('fireContentChange', () => { document.fromJSON(nestJSON); const loopNode = document.getNode('loop_0')!; const event: WorkflowContentChangeEvent = { type: WorkflowContentChangeType.MOVE_NODE, toJSON: () => document.toNodeJSON(loopNode), entity: loopNode, }; const fn = vi.fn(); document.onContentChange(fn); document.fireContentChange(event); expect(fn.mock.calls.length).toEqual(1); }); it('toNodeJSON', () => { const variableJSON = { id: 'variable_0', type: 'variable', meta: { position: { x: 0, y: 0 } }, }; const variableNode = document.createWorkflowNode(variableJSON); const variableToJSON = document.toNodeJSON(variableNode); expect(variableToJSON).toEqual(variableJSON); }); it('copyNode', () => { document.fromJSON(nestJSON); const loopNode = document.getNode('loop_0')!; const copyFormat = (json: WorkflowNodeJSON) => ({ ...json, meta: { ...json.meta, testFormat: true }, }); const newLoopNode = document.copyNode(loopNode, 'loop_1', copyFormat, { x: -100, y: -100, }); const newLoopTransData = newLoopNode.getData(FlowNodeTransformData); expect(newLoopNode.id).toEqual('loop_1'); expect(newLoopNode.flowNodeType).toEqual('loop'); expect(newLoopTransData.position).toEqual({ x: -100, y: -100 }); expect(newLoopNode.getNodeMeta().testFormat).toEqual(true); }); it('copyNodeFromJSON', () => { document.fromJSON(nestJSON); const variableNode = document.copyNodeFromJSON( 'variable', { id: 'variable_0', type: 'variable', meta: { position: { x: 10, y: 10 } }, }, 'variable_1', { x: -50, y: -50 }, 'loop_0' ); const variableTransData = variableNode.getData(FlowNodeTransformData); expect(variableTransData.position).toEqual({ x: -50, y: -50 }); expect(variableNode.id).toEqual('variable_1'); expect(variableNode.flowNodeType).toEqual('variable'); expect(variableNode.parent?.id).toEqual('loop_0'); }); it('copyNodeFromJSON with default position', () => { document.fromJSON(nestJSON); const variableNode = document.copyNodeFromJSON( 'variable', { id: 'variable_0', type: 'variable', meta: { position: { x: 0, y: 0 } }, }, 'variable_1' ); const variableTransData = variableNode.getData(FlowNodeTransformData); expect(variableTransData.position).toEqual({ x: 30, y: 30 }); expect(variableNode.id).toEqual('variable_1'); expect(variableNode.flowNodeType).toEqual('variable'); expect(variableNode.parent?.id).toEqual('root'); }); it('canRemove', () => { document.fromJSON(baseJSON); const startNode = document.getNode('end_0')!; (startNode as any)._metaCache['deleteDisable'] = true; expect(document.canRemove(startNode)).toEqual(false); }); it('fromJSON with empty json parameter', () => { const expectedEmptyJSON: WorkflowJSON = { nodes: [], edges: [], }; // no nodes or edges document.fromJSON({}); expect(document.toJSON()).toEqual(expectedEmptyJSON); // no edges document.fromJSON({ nodes: [] }); expect(document.toJSON()).toEqual(expectedEmptyJSON); // no nodes document.fromJSON({ edges: [] }); expect(document.toJSON()).toEqual(expectedEmptyJSON); }); }); describe('workflow-document createWorkflowNode', () => { it('createWorkflowNode basic function', () => { const node = document.createWorkflowNode({ id: 'start_0', type: 'start', meta: { position: { x: 10, y: 10 }, }, }); const nodeTransData = node.getData(FlowNodeTransformData); expect(nodeTransData.position).toEqual({ x: 10, y: 10 }); expect(node.id).toEqual('start_0'); expect(node.flowNodeType).toEqual('start'); }); it('createWorkflowNode without position', () => { const node = document.createWorkflowNode({ id: 'start_0', type: 'start', meta: {}, }); const nodeTrans = node.getData(FlowNodeTransformData); expect(nodeTrans.position).toEqual({ x: 0, y: -30 }); expect(node.id).toEqual('start_0'); expect(node.flowNodeType).toEqual('start'); }); it('createWorkflowNode with form', () => { const variableNodeType = 'variable'; const variableNodeRegister: FlowNodeRegistry = { type: variableNodeType, meta: { position: { x: 10, y: 10 }, }, formMeta: { root: { name: 'root', type: 'object', children: [ { name: 'nodeDescription', type: 'form-void', title: '', abilities: [ { type: 'setter', options: { key: 'Text', text: '我是Variable节点', }, }, ], }, ], }, }, }; document.registerFlowNodes(variableNodeRegister); const node = document.createWorkflowNode({ id: 'variable_0', type: variableNodeType, meta: { position: { x: 10, y: 10 }, }, }); const nodeTransData = node.getData(FlowNodeTransformData); expect(nodeTransData.position).toEqual({ x: 10, y: 10 }); expect(node.id).toEqual('variable_0'); expect(node.flowNodeType).toEqual(variableNodeType); }); }); describe('workflow-document with nestedJSON & subCanvas', () => { it('subCanvas parentNode dispose', () => { const { loopNode, subCanvasNode } = createSubCanvasNodes(document); loopNode.dispose(); expect(loopNode.disposed).toEqual(true); expect(subCanvasNode.disposed).toEqual(true); }); it('subCanvas canvasNode dispose', () => { const { loopNode, subCanvasNode } = createSubCanvasNodes(document); subCanvasNode.dispose(); expect(loopNode.disposed).toEqual(true); expect(subCanvasNode.disposed).toEqual(true); }); it('createWorkflowNode with subCanvas', () => { const variableSchema = { id: 'variable_0', type: 'variable', meta: { position: { x: 0, y: 0 } }, }; const loopSchema = { id: 'loop_0', type: 'loop', meta: { position: { x: -100, y: 0 }, canvasPosition: { x: 100, y: 0 }, }, blocks: [variableSchema], }; const { loopNode, subCanvasNode, variableNode } = createSubCanvasNodes(document); expect(document.toNodeJSON(variableNode)).toEqual(variableSchema); expect(document.toNodeJSON(loopNode)).toEqual(loopSchema); expect(document.toNodeJSON(subCanvasNode)).toEqual(loopSchema); expect(document.toJSON()).toEqual({ nodes: [loopSchema], edges: [], }); }); const subCanvasInlinePortSchema = { nodes: [ { id: 'loop_0', type: 'loop', meta: { position: { x: -100, y: 0 }, canvasPosition: { x: 100, y: 0 }, }, blocks: [ { id: 'variable_0', type: 'variable', meta: { position: { x: 0, y: 0 } }, }, { id: 'variable_1', type: 'variable', meta: { position: { x: 0, y: 0 } }, }, ], edges: [ { sourceNodeID: 'loop_0', targetNodeID: 'variable_0' }, { sourceNodeID: 'variable_0', targetNodeID: 'variable_1' }, { sourceNodeID: 'variable_0', targetNodeID: 'loop_0' }, { sourceNodeID: 'loop_0', targetNodeID: 'variable_1' }, { sourceNodeID: 'variable_1', targetNodeID: 'loop_0' }, ], }, ], edges: [], }; it('toJSON with subCanvas inline port', () => { const linesManager = container.get(WorkflowLinesManager); const { subCanvasNode, variableNode: variableANode } = createSubCanvasNodes(document); const variableBNode = document.createWorkflowNode( { id: 'variable_1', type: 'variable', meta: { position: { x: 0, y: 0 }, }, }, false, subCanvasNode.id ); linesManager.createLine({ from: subCanvasNode.id, to: variableANode.id, }); linesManager.createLine({ from: subCanvasNode.id, to: variableBNode.id, }); linesManager.createLine({ from: variableANode.id, to: variableBNode.id, }); linesManager.createLine({ from: variableANode.id, to: subCanvasNode.id, }); linesManager.createLine({ from: variableBNode.id, to: subCanvasNode.id, }); const json = document.toJSON(); expect(json).toEqual(subCanvasInlinePortSchema); }); it('fromJSON with subCanvas inline port', async () => { const createCall = vi.fn(); const createCanvas = () => { createCall(); document.createWorkflowNode({ id: 'subCanvas_0', type: FlowNodeBaseType.SUB_CANVAS, meta: { isContainer: true, position: { x: 100, y: 0 }, subCanvas: () => ({ isCanvas: true, parentNode: document.getNode('loop_0')!, canvasNode: document.getNode('subCanvas_0')!, }), }, }); }; const loopNodeRegister: FlowNodeRegistry = { type: 'loop', meta: { subCanvas: () => { const parentNode = document.getNode('loop_0'); const canvasNode = document.getNode('subCanvas_0'); if (!parentNode || !canvasNode) { return; } return { isCanvas: false, parentNode, canvasNode, }; }, }, onCreate: (node, json) => { createCanvas(); }, }; document.registerFlowNodes(loopNodeRegister); document.fromJSON(subCanvasInlinePortSchema); await delay(10); expect(createCall).toHaveBeenCalledTimes(1); const loopNode = document.getNode('loop_0')!; const subCanvas: WorkflowSubCanvas = loopNode?.getNodeMeta().subCanvas(loopNode); expect(subCanvas).toBeDefined(); const canvasNode = subCanvas.canvasNode; expect(canvasNode.id).toEqual('subCanvas_0'); expect(canvasNode.collapsedChildren.length).toEqual(2); expect(document.toJSON()).toEqual(subCanvasInlinePortSchema); }); it('document is disposed and call toJSON should throw error', () => { document.dispose(); expect(() => document.toJSON()).toThrowError(/disposed/); }); it('lineData change trigger onContentChange', () => { document.fromJSON(baseJSON); let contentChangeEvent: WorkflowContentChangeEvent; document.onContentChange((e) => { contentChangeEvent = e; }); const line = document.linesManager.getLine({ from: 'start_0', to: 'condition_0', })!; line.lineData = { b: 33 }; expect(document.toJSON().edges[0].data).toEqual({ b: 33 }); expect(contentChangeEvent!.type).toEqual(WorkflowContentChangeType.LINE_DATA_CHANGE); expect(contentChangeEvent!.toJSON()).toEqual({ sourceNodeID: 'start_0', targetNodeID: 'condition_0', data: { b: 33 }, }); }); }); ================================================ FILE: packages/canvas-engine/free-layout-core/__tests__/workflow-lines-manager.test.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { interfaces } from 'inversify'; import { WorkflowLinesManager, WorkflowDocument, WorkflowDocumentOptions, WorkflowLineRenderData, LineColors, } from '../src'; import { WorkflowSimpleLineContribution } from './simple-line'; import { createWorkflowContainer } from './mocks'; describe('workflow-lines-manager', () => { let linesManager: WorkflowLinesManager; let container: interfaces.Container; let document: WorkflowDocument; beforeEach(() => { container = createWorkflowContainer(); document = container.get(WorkflowDocument); linesManager = container.get(WorkflowLinesManager); linesManager.init(document); document.createWorkflowNode({ id: 'start_0', type: 'start', meta: { position: { x: 0, y: 0 }, }, }); document.createWorkflowNode({ id: 'end_0', type: 'end', meta: { position: { x: 800, y: 0 }, }, }); }); it('base create and dispose', async () => { expect(linesManager.toJSON()).toEqual([]); const line = linesManager.createLine({ from: 'start_0', to: 'end_0', })!; const startNode = document.getNode('start_0')!.lines; const endNode = document.getNode('end_0')!.lines; expect(startNode.outputLines.length).toEqual(1); expect(startNode.allLines.length).toEqual(1); expect(endNode.inputLines.length).toEqual(1); expect(endNode.allLines.length).toEqual(1); expect(startNode.allOutputNodes.length).toEqual(1); expect(endNode.allInputNodes.length).toEqual(1); expect(line.id).toBe('start_0_-end_0_'); expect(linesManager.toJSON()).toEqual([{ sourceNodeID: 'start_0', targetNodeID: 'end_0' }]); // line destroy line.dispose(); expect(startNode.outputLines.length).toEqual(0); expect(startNode.allLines.length).toEqual(0); expect(endNode.inputLines.length).toEqual(0); expect(endNode.allLines.length).toEqual(0); expect(startNode.allOutputNodes.length).toEqual(0); expect(endNode.allInputNodes.length).toEqual(0); linesManager.createLine({ from: 'start_0', to: 'end_0', })!; expect(startNode.outputLines.length).toEqual(1); // node destroy endNode.entity.dispose(); expect(startNode.outputLines.length).toEqual(0); expect(startNode.allLines.length).toEqual(0); }); it('base create drawing line or hidden line', async () => { const line = linesManager.createLine({ from: 'start_0', drawingTo: { x: 0, y: 0, location: 'right' }, })!; const startNode = document.getNode('start_0')!.lines; expect(startNode.outputLines.length).toEqual(1); expect(startNode.availableLines.length).toEqual(0); line.dispose(); expect(startNode.outputLines.length).toEqual(0); expect(startNode.availableLines.length).toEqual(0); const line2 = linesManager.createLine({ from: 'start_0', to: 'end_0', })!; line2.updateUIState({ highlightColor: line2.linesManager.lineColor.hidden, }); expect(startNode.outputLines.length).toEqual(1); expect(startNode.availableLines.length).toEqual(0); line2.updateUIState({ highlightColor: '', }); expect(startNode.outputLines.length).toEqual(1); expect(startNode.availableLines.length).toEqual(1); }); it('test base create line node', async () => { expect(linesManager.toJSON()).toEqual([]); const line = linesManager.createLine({ from: 'start_0', to: 'end_0', })!; const lineNode = line.node; expect(lineNode.dataset.testid).toBe('sdk.workflow.canvas.line'); expect(lineNode.dataset.lineId).toBe('start_0_-end_0_'); expect(lineNode.dataset.fromNodeId).toBe('start_0'); expect(lineNode.dataset.fromPortId).toBe('port_output_start_0_'); expect(lineNode.dataset.toNodeId).toBe('end_0'); expect(lineNode.dataset.toPortId).toBe('port_input_end_0_'); expect(lineNode.dataset.hasError).toBe('false'); }); it('test base create line bezier', async () => { expect(linesManager.toJSON()).toEqual([]); const line = linesManager.createLine({ from: 'start_0', to: 'end_0', })!; const lineRenderData = line.getData(WorkflowLineRenderData); expect(lineRenderData.position.from).toEqual({ x: 0, y: 0, location: 'right' }); expect(lineRenderData.position.to).toEqual({ x: 660, y: 30, location: 'left' }); expect(lineRenderData.path).toEqual('M 12 12 L 652 42'); }); it('test get all node inputs and outputs', async () => { linesManager.createLine({ from: 'start_0', to: 'end_0', }); const allNodeLineData = document.getAllNodes().map((_node) => _node.lines); expect( allNodeLineData.map((_line) => ({ allInputs: _line.allInputNodes, allOutput: _line.allOutputNodes, })) ).toMatchSnapshot(); }); it('create without to node', () => { const line = linesManager.createLine({ from: 'start_0', to: '', drawingTo: { x: 0, y: 0, location: 'left' }, }); expect(line!.isDrawing).toEqual(true); expect(linesManager.toJSON()).toEqual([]); }); it('create without from node', () => { const line = linesManager.createLine({ from: '', to: 'end_0', drawingFrom: { x: 0, y: 0, location: 'right' }, }); expect(line!.isDrawing).toEqual(true); expect(linesManager.toJSON()).toEqual([]); }); it('create without from node and to node', () => { const line = linesManager.createLine({ from: '', to: '', }); expect(line).toBeUndefined(); expect(linesManager.toJSON()).toEqual([]); }); it('test document line options', () => { const documentOptions = container.get(WorkflowDocumentOptions); documentOptions.isErrorLine = () => true; documentOptions.isReverseLine = () => true; documentOptions.isHideArrowLine = () => true; documentOptions.isFlowingLine = () => true; documentOptions.isDisabledLine = () => true; documentOptions.setLineClassName = () => 'custom-line-class'; documentOptions.setLineRenderType = () => WorkflowSimpleLineContribution.type; documentOptions.lineColor = { default: '#000', error: '#000', }; const line = linesManager.createLine({ from: 'start_0', to: 'end_0', }); line?.fireRender(); expect(line?.reverse).toBeTruthy(); expect(line?.hideArrow).toBeTruthy(); expect(line?.flowing).toBeTruthy(); expect(line?.disabled).toBeTruthy(); expect(line?.hasError).toBeTruthy(); expect(line?.renderType).toBe(WorkflowSimpleLineContribution.type); expect(line?.className).toBe('custom-line-class'); expect(line?.color).toBe('#000'); }); it('test set line state', () => { const line = linesManager.createLine({ from: 'start_0', to: 'end_0', }); if (!line) { expect.fail('line is not created'); } expect(line.reverse).toBeFalsy(); line.processing = true; expect(line.processing).toBeTruthy(); expect(line.hasError).toBeFalsy(); line.hasError = true; line.fireRender(); expect(line.hasError).toBeTruthy(); try { line.setToPort(line.toPort); // 如果没有抛出错误,测试应该失败 expect.fail('Expected an error to be thrown'); } catch (e) { expect((e as Error).message).toBe('[setToPort] only support drawing line.'); } }); describe('flowing line support', () => { it('should return flowing color when line is flowing', () => { const documentOptions: WorkflowDocumentOptions = { lineColor: { flowing: '#ff0000', // 自定义流动颜色 }, isFlowingLine: () => true, }; Object.assign(linesManager, { options: documentOptions }); const line = linesManager.createLine({ from: 'start_0', to: 'end_0', }); expect(line).toBeDefined(); expect(linesManager.isFlowingLine(line!)).toBe(true); expect(linesManager.getLineColor(line!)).toBe('#ff0000'); }); it('should use default flowing color when no custom color provided', () => { const documentOptions: WorkflowDocumentOptions = { isFlowingLine: () => true, }; Object.assign(linesManager, { options: documentOptions }); const line = linesManager.createLine({ from: 'start_0', to: 'end_0', }); expect(line).toBeDefined(); expect(linesManager.isFlowingLine(line!)).toBe(true); expect(linesManager.getLineColor(line!)).toBe(LineColors.FLOWING); }); it('should prioritize selected/hovered over flowing', () => { const documentOptions: WorkflowDocumentOptions = { lineColor: { flowing: '#ff0000', selected: '#00ff00', }, isFlowingLine: () => true, }; Object.assign(linesManager, { options: documentOptions }); const line = linesManager.createLine({ from: 'start_0', to: 'end_0', }); // 模拟选中状态 linesManager.selectService.select(line!); expect(line).toBeDefined(); expect(linesManager.isFlowingLine(line!)).toBe(true); // 选中状态应该优先于流动状态 expect(linesManager.getLineColor(line!)).toBe('#00ff00'); }); it('line data change', () => { const line = linesManager.createLine({ from: 'start_0', to: 'end_0', data: { a: 1 }, })!; expect(line.toJSON()).toEqual({ sourceNodeID: 'start_0', targetNodeID: 'end_0', data: { a: 1 }, }); line.lineData = { a: 2 }; expect(line.toJSON()).toEqual({ sourceNodeID: 'start_0', targetNodeID: 'end_0', data: { a: 2 }, }); }); }); }); ================================================ FILE: packages/canvas-engine/free-layout-core/eslint.config.js ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ const { defineFlatConfig } = require('@flowgram.ai/eslint-config'); module.exports = defineFlatConfig({ preset: 'web', packageRoot: __dirname, }); ================================================ FILE: packages/canvas-engine/free-layout-core/package.json ================================================ { "name": "@flowgram.ai/free-layout-core", "version": "0.1.8", "homepage": "https://flowgram.ai/", "repository": "https://github.com/bytedance/flowgram.ai", "license": "MIT", "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/esm/index.js", "require": "./dist/index.js" }, "./typings": { "types": "./dist/typings/index.d.ts", "import": "./dist/esm/typings/index.js", "require": "./dist/typings/index.js" } }, "main": "./dist/index.js", "module": "./dist/esm/index.js", "types": "./dist/index.d.ts", "typesVersions": { "*": { "typings": [ "./dist/typings/index.d.ts" ] } }, "files": [ "dist" ], "scripts": { "build": "npm run build:fast -- --dts-resolve", "build:fast": "tsup src/index.ts src/typings --format cjs,esm --sourcemap --legacy-output", "build:watch": "npm run build:fast -- --dts-resolve", "clean": "rimraf dist", "test": "vitest run", "test:cov": "vitest run --coverage", "ts-check": "tsc --noEmit", "watch": "npm run build:fast -- --dts-resolve --watch --ignore-watch dist" }, "dependencies": { "@flowgram.ai/core": "workspace:*", "@flowgram.ai/document": "workspace:*", "@flowgram.ai/form-core": "workspace:*", "@flowgram.ai/node": "workspace:*", "@flowgram.ai/reactive": "workspace:*", "@flowgram.ai/utils": "workspace:*", "inversify": "^6.0.1", "reflect-metadata": "~0.2.2", "lodash-es": "^4.17.21", "nanoid": "^5.0.9" }, "devDependencies": { "@flowgram.ai/eslint-config": "workspace:*", "@flowgram.ai/ts-config": "workspace:*", "@testing-library/react": "^12", "@testing-library/react-hooks": "^8.0.1", "@types/bezier-js": "4.1.3", "@types/lodash-es": "^4.17.12", "@types/react": "^18", "@types/react-dom": "^18", "@vitest/coverage-v8": "^3.2.4", "eslint": "^9.0.0", "tsup": "^8.0.1", "typescript": "^5.8.3", "vitest": "^3.2.4" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" }, "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" } } ================================================ FILE: packages/canvas-engine/free-layout-core/src/constants.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export enum EditorCursorState { GRAB = 'GRAB', SELECT = 'SELECT', } export enum InteractiveType { /** 鼠标优先交互模式 */ MOUSE = 'MOUSE', /** 触控板优先交互模式 */ PAD = 'PAD', } ================================================ FILE: packages/canvas-engine/free-layout-core/src/entities/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export * from './workflow-node-entity'; export * from './workflow-line-entity'; export * from './workflow-port-entity'; ================================================ FILE: packages/canvas-engine/free-layout-core/src/entities/workflow-line-entity.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { isEqual } from 'lodash-es'; import { domUtils, type IPoint, Rectangle, Emitter } from '@flowgram.ai/utils'; import { Entity, type EntityOpts } from '@flowgram.ai/core'; import { type WorkflowLinesManager } from '../workflow-lines-manager'; import { type WorkflowDocument } from '../workflow-document'; import { WORKFLOW_LINE_ENTITY } from '../utils/statics'; import { LineRenderType, type LinePosition, LinePoint, LineCenterPoint, } from '../typings/workflow-line'; import { type WorkflowEdgeJSON } from '../typings'; import { WorkflowLineRenderData } from '../entity-datas'; import { type WorkflowPortEntity } from './workflow-port-entity'; import { type WorkflowNodeEntity } from './workflow-node-entity'; export const LINE_HOVER_DISTANCE = 8; // 线条 hover 的最小检测距离 export const POINT_RADIUS = 10; export interface WorkflowLinePortInfo { from?: string; // 前置节点 id to?: string; // 后置节点 id fromPort?: string | number; // 连线的 port 位置 toPort?: string | number; // 连线的 port 位置 data?: any; } export interface WorkflowLineEntityOpts extends EntityOpts, WorkflowLinePortInfo { document: WorkflowDocument; linesManager: WorkflowLinesManager; drawingTo?: LinePoint; drawingFrom?: LinePoint; } export interface WorkflowLineInfo extends WorkflowLinePortInfo { drawingTo?: LinePoint; // 正在画中的元素 drawingFrom?: LinePoint; } export interface WorkflowLineUIState { /** * 是否出错 */ hasError: boolean; /** * 流动 */ flowing: boolean; /** * 禁用 */ disabled: boolean; /** * 箭头反转 */ reverse: boolean; /** * 隐藏箭头 */ hideArrow: boolean; /** * 线条宽度 * @default 2 */ strokeWidth?: number; /** * 选中后的线条宽度 * @default 3 */ strokeWidthSelected?: number; /** * 收缩 * @default 10 */ shrink: number; /** * @deprecated use `lockedColor` instead */ highlightColor: string; /** * 曲率 * only for Bezier, * @default 0.25 */ curvature: number; /** * Line locked color */ lockedColor: string; /** * React className */ className?: string; /** * React style */ style?: React.CSSProperties; } /** * 线条 */ export class WorkflowLineEntity extends Entity { static type = WORKFLOW_LINE_ENTITY; /** * 转成线条 id * @param info */ static portInfoToLineId(info: WorkflowLinePortInfo): string { const { from, to, fromPort, toPort } = info; return `${from}_${fromPort || ''}-${to || ''}_${toPort || ''}`; } private _onLineDataChangeEmitter = new Emitter<{ oldValue: any; newValue: any }>(); readonly document: WorkflowDocument; readonly linesManager: WorkflowLinesManager; readonly onLineDataChange = this._onLineDataChangeEmitter.event; private _from?: WorkflowNodeEntity; private _to?: WorkflowNodeEntity; private _lineData: any; private _uiState: WorkflowLineUIState = { hasError: false, flowing: false, disabled: false, hideArrow: false, reverse: false, shrink: 10, curvature: 0.25, highlightColor: '', lockedColor: '', }; /** * 线条的 UI 状态 */ get uiState(): WorkflowLineUIState { return this._uiState; } /** * 更新线条的 ui 状态 * @param newState */ updateUIState(newState: Partial): void { let changed = false; Object.keys(newState).forEach((key: string) => { const value: any = newState[key as keyof WorkflowLineUIState] as any; if (this._uiState[key as keyof WorkflowLineUIState] !== value) { (this._uiState as any)[key as keyof WorkflowLineUIState] = value; changed = true; } }); if (changed) { this.fireChange(); } } /** * 线条的扩展数据 */ get lineData(): any { return this._lineData; } /** * 更新线条扩展数据 * @param data */ set lineData(newValue: any) { const oldValue = this._lineData; if (!isEqual(oldValue, newValue)) { this._lineData = newValue; this._onLineDataChangeEmitter.fire({ oldValue, newValue }); this.fireChange(); } } public stackIndex = 0; /** * 线条数据 */ info: WorkflowLineInfo = { from: '', }; readonly isDrawing: boolean; /** * 线条 Portal 挂载的 div */ private _node?: HTMLDivElement; constructor(opts: WorkflowLineEntityOpts) { super(opts); this.document = opts.document; this.linesManager = opts.linesManager; // 初始化 this.initInfo({ from: opts.from, to: opts.to, drawingTo: opts.drawingTo, fromPort: opts.fromPort, drawingFrom: opts.drawingFrom, toPort: opts.toPort, data: opts.data, }); if (opts.drawingTo || opts.drawingFrom) { this.isDrawing = true; } this.onEntityChange(() => { this.fromPort?.validate(); this.toPort?.validate(); }); this.onDispose(() => { this.fromPort?.validate(); this.toPort?.validate(); }); this.toDispose.push(this._onLineDataChangeEmitter); // this.onDispose(() => { // this._infoDispose.dispose(); // }); } /** * 获取线条的前置节点 */ get from(): WorkflowNodeEntity | undefined { return this._from; } /** * 获取线条的后置节点 */ get to(): WorkflowNodeEntity | undefined { return this._to; } get isHidden(): boolean { return this.highlightColor === this.linesManager.lineColor.hidden; } get inContainer(): boolean { const nodeInContainer = (node?: WorkflowNodeEntity) => !!node?.parent && node.parent.flowNodeType !== 'root'; return nodeInContainer(this.from) || nodeInContainer(this.to); } /** * 获取是否 testrun processing * @deprecated use `flowing` instead */ get processing(): boolean { return this._uiState.flowing; } /** * 设置 testrun processing 状态 * @deprecated use `flowing` instead */ set processing(status: boolean) { this.flowing = status; } // 获取连线是否为错误态 get hasError() { return this.uiState.hasError; } // 设置连线的错误态 set hasError(hasError: boolean) { this.updateUIState({ hasError, }); if (this._node) { this._node.dataset.hasError = this.hasError ? 'true' : 'false'; } } /** * 设置线条的后置节点 */ setToPort(toPort?: WorkflowPortEntity) { // 只有绘制中的线条才允许设置 port, 主要用于吸附到点 if (!this.isDrawing) { throw new Error('[setToPort] only support drawing line.'); } if (this.toPort === toPort) { return; } const prePort = this.toPort; if ( toPort && toPort.portType === 'input' && this.linesManager.canAddLine(this.fromPort!, toPort, true) ) { const { node, portID } = toPort; this._to = node; this.info.drawingTo = undefined; this.info.to = node.id; this.info.toPort = portID; } else { this._to = undefined; this.info.to = undefined; this.info.toPort = ''; } /** * 移动到端口又快速移出,需要更新 prePort 的状态 */ if (prePort) { prePort.validate(); } this.fireChange(); } setFromPort(fromPort?: WorkflowPortEntity) { // 只有绘制中的线条才允许设置 port, 主要用于吸附到点 if (!this.isDrawing) { throw new Error('[setFromPort] only support drawing line.'); } if (this.fromPort === fromPort) { return; } const prePort = this.fromPort; if ( fromPort && fromPort.portType === 'output' && this.linesManager.canAddLine(fromPort, this.toPort!, true) ) { const { node, portID } = fromPort; this._from = node; this.info.drawingFrom = undefined; this.info.from = node.id; this.info.fromPort = portID; } else { this._from = undefined; this.info.from = undefined; this.info.fromPort = ''; } /** * 移动到端口又快速移出,需要更新 prePort 的状态 */ if (prePort) { prePort.validate(); } this.fireChange(); } /** * 设置线条画线时的目标位置 */ set drawingTo(pos: LinePoint | undefined) { const oldDrawingTo = this.info.drawingTo; if (!pos) { this.info.drawingTo = undefined; this.fireChange(); return; } if (!oldDrawingTo || pos.x !== oldDrawingTo.x || pos.y !== oldDrawingTo.y) { this.info.to = undefined; this.info.drawingTo = pos; this.fireChange(); } } set drawingFrom(pos: LinePoint | undefined) { const oldDrawingFrom = this.info.drawingFrom; if (!pos) { this.info.drawingFrom = undefined; this.fireChange(); return; } if (!oldDrawingFrom || pos.x !== oldDrawingFrom.x || pos.y !== oldDrawingFrom.y) { this.info.from = undefined; this.info.drawingFrom = pos; this.fireChange(); } } get drawingFrom(): LinePoint | undefined { return this.info.drawingFrom; } /** * 获取线条正在画线的位置 */ get drawingTo(): LinePoint | undefined { return this.info.drawingTo; } get highlightColor(): string { return this.uiState.highlightColor || ''; } set highlightColor(highlightColor) { this.updateUIState({ highlightColor, }); } get lockedColor(): string { return this.uiState.lockedColor; } set lockedColor(lockedColor: string) { this.updateUIState({ lockedColor, }); } /** * 获取线条的边框位置大小 */ get bounds(): Rectangle { return this.getData(WorkflowLineRenderData).bounds; } get center(): LineCenterPoint { return this.getData(WorkflowLineRenderData).center; } /** * 获取点和线最接近的距离 */ getHoverDist(pos: IPoint): number { return this.getData(WorkflowLineRenderData).calcDistance(pos); } get fromPort(): WorkflowPortEntity | undefined { if (!this.from) { return undefined; } return this.from.ports.getPortEntityByKey('output', this.info.fromPort); } get toPort(): WorkflowPortEntity | undefined { if (!this.to) { return undefined; } return this.to.ports.getPortEntityByKey('input', this.info.toPort); } /** * 获取线条真实的输入输出节点坐标 */ get position(): LinePosition { return this.getData(WorkflowLineRenderData).position; } /** 是否反转箭头 */ get reverse(): boolean { return this.linesManager.isReverseLine(this, this.uiState.reverse); } /** 是否隐藏箭头 */ get hideArrow(): boolean { return this.linesManager.isHideArrowLine(this, this.uiState.hideArrow); } /** 是否流动 */ get flowing(): boolean { return this.linesManager.isFlowingLine(this, this.uiState.flowing); } set flowing(flowing: boolean) { if (this._uiState.flowing !== flowing) { this._uiState.flowing = flowing; this.fireChange(); } } /** 是否禁用 */ get disabled(): boolean { return this.linesManager.isDisabledLine(this, this.uiState.disabled); } /** * @deprecated */ get vertical(): boolean { const fromLocation = this.fromPort?.location; const toLocation = this.toPort?.location; if (toLocation) { return toLocation === 'top'; } else { return fromLocation === 'bottom'; } } /** 获取线条渲染器类型 */ get renderType(): LineRenderType | undefined { return this.linesManager.setLineRenderType(this); } /** 获取线条样式 */ get className(): string { return [this.linesManager.setLineClassName(this), this._uiState.className] .filter((s) => !!s) .join(' '); } get color(): string | undefined { return this.linesManager.getLineColor(this); } /** * 初始化线条 * @param info 线条信息 */ protected initInfo(info: WorkflowLineInfo): void { if (!isEqual(info, this.info)) { this.info = info; this._from = info.from ? this.document.getNode(info.from) : undefined; this._to = info.to ? this.document.getNode(info.to) : undefined; this._lineData = info.data; this.fireChange(); } } // 校验连线是否为错误态 validate() { this.validateSelf(); } /** * use `validate` instead * @deprecated */ protected validateSelf() { const { fromPort, toPort } = this; if (fromPort) { this.hasError = this.linesManager.isErrorLine(fromPort, toPort, this.uiState.hasError); } } is(line: WorkflowLineEntity | WorkflowLinePortInfo): boolean { if (line instanceof WorkflowLineEntity) { return this === line; } return WorkflowLineEntity.portInfoToLineId(line as WorkflowLinePortInfo) === this.id; } canRemove(newLineInfo?: Required): boolean { return this.linesManager.canRemove(this, newLineInfo); } get node(): HTMLDivElement { if (this._node) return this._node; this._node = domUtils.createDivWithClass('gedit-flow-activity-line'); this._node.dataset.testid = 'sdk.workflow.canvas.line'; this._node.dataset.lineId = this.id; this._node.dataset.fromNodeId = this.from?.id ?? ''; this._node.dataset.fromPortId = this.fromPort?.id ?? ''; this._node.dataset.toNodeId = this.to?.id ?? ''; this._node.dataset.toPortId = this.toPort?.id ?? ''; this._node.dataset.hasError = this.hasError ? 'true' : 'false'; return this._node; } toJSON(): WorkflowEdgeJSON { const json: WorkflowEdgeJSON = { sourceNodeID: this.info.from!, targetNodeID: this.info.to!, sourcePortID: this.info.fromPort, targetPortID: this.info.toPort, }; if (this._lineData !== undefined) { json.data = this._lineData; } if (!json.sourcePortID) { delete json.sourcePortID; } if (!json.targetPortID) { delete json.targetPortID; } return json; } /** 触发线条渲染 */ fireRender(): void { this.fireChange(); } } ================================================ FILE: packages/canvas-engine/free-layout-core/src/entities/workflow-node-entity.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { FlowNodeEntity } from '@flowgram.ai/document'; import type { WorkflowNodeLinesData, WorkflowNodePortsData } from '../entity-datas'; declare module '@flowgram.ai/document' { interface FlowNodeEntity { lines: WorkflowNodeLinesData; ports: WorkflowNodePortsData; } } export type WorkflowNodeEntity = FlowNodeEntity; export const WorkflowNodeEntity = FlowNodeEntity; ================================================ FILE: packages/canvas-engine/free-layout-core/src/entities/workflow-port-entity.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { type IPoint, Rectangle, Emitter, Compare } from '@flowgram.ai/utils'; import { FlowNodeTransformData } from '@flowgram.ai/document'; import { Entity, type EntityOpts, PlaygroundConfigEntity, TransformData, type EntityRegistry, } from '@flowgram.ai/core'; import { type WorkflowDocument } from '../workflow-document'; import { type WorkflowPortType, getPortEntityId, WORKFLOW_LINE_ENTITY, domReactToBounds, } from '../utils/statics'; import { locationConfigToPoint } from '../utils/location-config-to-point'; import { type WorkflowNodeMeta, LinePointLocation, LinePoint } from '../typings'; import { type WorkflowNodeEntity } from './workflow-node-entity'; import { type WorkflowLineEntity } from './workflow-line-entity'; // port 的宽度 export const PORT_SIZE = 24; export interface WorkflowPort { /** * 没有代表 默认连接点,默认 input 类型 为最左边中心,output 类型为最右边中心 */ portID?: string | number; /** * 输入或者输出点 */ type: WorkflowPortType; /** * 端口位置 */ location?: LinePointLocation; /** * 端口位置配置 * @example * // bottom-center * { * left: '50%', * bottom: 0 * } * // right-center * { * right: 0, * top: '50%' * } */ locationConfig?: { left?: string | number; top?: string | number; right?: string | number; bottom?: string | number; }; /** * 相对于 location 的偏移 */ offset?: IPoint; /** * 端口热区大小 */ size?: { width: number; height: number }; /** * 禁用端口 */ disabled?: boolean; /** * 将点位渲染到该父节点上 */ targetElement?: HTMLElement; } export type WorkflowPorts = WorkflowPort[]; export interface WorkflowPortEntityOpts extends EntityOpts, WorkflowPort { /** * port 属于哪个节点 */ node: WorkflowNodeEntity; } /** * Port 抽象的 Entity */ export class WorkflowPortEntity extends Entity { static type = 'WorkflowPortEntity'; readonly node: WorkflowNodeEntity; readonly portID: string | number = ''; readonly portType: WorkflowPortType; private _disabled?: boolean; private _hasError = false; private _location?: LinePointLocation; private _locationConfig?: WorkflowPort['locationConfig']; private _size?: { width: number; height: number }; private _offset?: IPoint; protected readonly _onErrorChangedEmitter = new Emitter(); onErrorChanged = this._onErrorChangedEmitter.event; targetElement?: HTMLElement; static getPortEntityId( node: WorkflowNodeEntity, portType: WorkflowPortType, portID: string | number = '' ): string { return getPortEntityId(node, portType, portID); } get position(): LinePointLocation | undefined { return this._location; } constructor(opts: WorkflowPortEntityOpts) { super(opts); this.portID = opts.portID || ''; this.portType = opts.type; this._disabled = opts.disabled; this._offset = opts.offset; this._locationConfig = opts.locationConfig; this._location = opts.location; this._size = opts.size; this.node = opts.node; this.updateTargetElement(opts.targetElement); this.toDispose.push(this.node.getData(TransformData)!.onDataChange(() => this.fireChange())); this.toDispose.push(this.node.onDispose(this.dispose.bind(this))); } // 获取连线是否为错误态 get hasError() { return this._hasError; } // 设置连线的错误态,外部应使用 validate 进行更新 set hasError(hasError: boolean) { if (hasError !== this._hasError) { this._hasError = hasError; this._onErrorChangedEmitter.fire(); } } validate() { // 一个端口可能连接很多线,需要保证所有的连线都不包含错误 const anyLineHasError = this.allLines.some((line) => { // 忽略已销毁和被隐藏的线 if (line.disposed || line.isHidden) { return false; } return line.hasError; }); // 如果没有连线错误,需校验端口自身错误 const isPortHasError = (this.node.document as WorkflowDocument).isErrorPort(this); this.hasError = anyLineHasError || isPortHasError; } isErrorPort() { return (this.node.document as WorkflowDocument).isErrorPort(this, this.hasError); } get location(): LinePointLocation { if (this._location) { return this._location; } if (this.portType === 'input') { return 'left'; } return 'right'; } get point(): LinePoint { const { targetElement, _locationConfig } = this; const { bounds } = this.node.getData(FlowNodeTransformData)!; const location = this.location; if (targetElement) { const pos = domReactToBounds(targetElement.getBoundingClientRect()).center; const point = this.entityManager .getEntity(PlaygroundConfigEntity)! .getPosFromMouseEvent({ clientX: pos.x, clientY: pos.y, }); return { x: point.x, y: point.y, location, }; } if (_locationConfig) { return { ...locationConfigToPoint(bounds, _locationConfig, this._offset), location, }; } const offset = this._offset || { x: 0, y: 0 }; let point = { x: 0, y: 0 }; switch (location) { case 'left': point = bounds.leftCenter; break; case 'top': point = bounds.topCenter; break; case 'right': point = bounds.rightCenter; break; case 'bottom': point = bounds.bottomCenter; break; } return { x: point.x + offset.x, y: point.y + offset.y, location, }; } /** * 端口热区 */ get bounds(): Rectangle { const { point } = this; const size = this._size || { width: PORT_SIZE, height: PORT_SIZE }; return new Rectangle( point.x - size.width / 2, point.y - size.height / 2, size.width, size.height ); } isHovered(x: number, y: number): boolean { return this.bounds.contains(x, y); } /** * 相对节点左上角的位置 */ get relativePosition(): IPoint { const { point } = this; const { bounds } = this.node.getData(FlowNodeTransformData)!; return { x: point.x - bounds.x, y: point.y - bounds.y, }; } updateTargetElement(el?: HTMLElement): void { if (el !== this.targetElement) { this.targetElement = el; this.fireChange(); } } /** * 是否被禁用 */ get disabled(): boolean { const document = this.node.document as WorkflowDocument; if (typeof document.options.isDisabledPort === 'function') { return document.options.isDisabledPort(this); } if (this._disabled) { return true; } const meta = this.node.getNodeMeta(); if (this.portType === 'input') { return !!meta.inputDisable; } return !!meta.outputDisable; } /** * 当前点位上连接的线条 * @deprecated use `availableLines` instead */ get lines(): WorkflowLineEntity[] { return this.allLines.filter((line) => !line.isDrawing); } /** * 当前有效的线条,不包含正在画的线条和隐藏的线条(这个出现在线条重连会先把原来的线条隐藏) */ get availableLines(): WorkflowLineEntity[] { return this.allLines.filter((line) => !line.isDrawing && !line.isHidden); } /** * 当前点位上连接的线条(包含 isDrawing === true 的线条) */ get allLines() { const lines: WorkflowLineEntity[] = []; // TODO: 后续 sdk 支持 getEntitiesByType 单独根据 type 获取功能后修改 // eslint-disable-next-line @typescript-eslint/consistent-type-assertions const allLines = this.entityManager.getEntities({ type: WORKFLOW_LINE_ENTITY, } as EntityRegistry); allLines.forEach((line) => { // 不包含 drawing 的线条 if (line.toPort === this || line.fromPort === this) { lines.push(line); } }); return lines; } update(data: Exclude) { let changed = false; if (data.targetElement !== this.targetElement) { this.targetElement = data.targetElement; changed = true; } if (data.location !== this._location) { this._location = data.location; changed = true; } if (Compare.isChanged(data.offset, this._offset)) { this._offset = data.offset; changed = true; } if (Compare.isChanged(data.locationConfig, this._locationConfig)) { this._locationConfig = data.locationConfig; changed = true; } if (Compare.isChanged(data.size, this._size)) { this._size = data.size; changed = true; } if (data.disabled !== this._disabled) { this._disabled = data.disabled; changed = true; } if (changed) { this.fireChange(); } } dispose(): void { // 点位被删除,对应的线条也要删除 this.lines.forEach((l) => l.dispose()); super.dispose(); } } ================================================ FILE: packages/canvas-engine/free-layout-core/src/entity-datas/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export * from './workflow-node-ports-data'; export * from './workflow-node-lines-data'; export * from './workflow-line-render-data'; ================================================ FILE: packages/canvas-engine/free-layout-core/src/entity-datas/workflow-line-render-data.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { IPoint, Rectangle } from '@flowgram.ai/utils'; import { EntityData } from '@flowgram.ai/core'; import { LineCenterPoint, LinePosition, LineRenderType, WorkflowLineRenderContribution, WorkflowLineRenderContributionFactory, } from '../typings'; import { WorkflowLineEntity } from '../entities'; export interface WorkflowLineRenderDataSchema { version: string; contributions: Map; position: LinePosition; } export class WorkflowLineRenderData extends EntityData { static type = 'WorkflowLineRenderData'; declare entity: WorkflowLineEntity; constructor(entity: WorkflowLineEntity) { super(entity); this.syncContributions(); } public getDefaultData(): WorkflowLineRenderDataSchema { return { version: '', contributions: new Map(), position: { from: { x: 0, y: 0, location: 'right' }, to: { x: 0, y: 0, location: 'left' }, }, }; } public get renderVersion(): string { return this.data.version; } public get position(): LinePosition { return this.data.position; } public get path(): string { return this.currentLine?.path ?? ''; } public calcDistance(pos: IPoint): number { return this.currentLine?.calcDistance(pos) ?? Number.MAX_SAFE_INTEGER; } public get bounds(): Rectangle { return this.currentLine?.bounds ?? new Rectangle(); } /** * 更新数据 * WARNING: 这个方法,必须在 requestAnimationFrame / useLayoutEffect 中调用,否则会引起浏览器强制重排 */ public update(): void { this.syncContributions(); const oldVersion = this.data.version; this.updatePosition(); const newVersion = this.data.version; if (oldVersion === newVersion) { return; } this.data.version = newVersion; this.currentLine?.update({ fromPos: this.data.position.from, toPos: this.data.position.to, }); } private get lineType(): LineRenderType { return this.entity.renderType ?? this.entity.linesManager.lineType; } /** * 获取 center 位置 */ get center(): LineCenterPoint { return this.currentLine?.center || { x: 0, y: 0, labelX: 0, labelY: 0 }; } /** * 更新版本 * WARNING: 这个方法,必须在 requestAnimationFrame / useLayoutEffect 中调用,否则会引起浏览器强制重排 */ private updatePosition(): void { this.data.position.from = this.entity.drawingFrom || this.entity.fromPort!.point; this.data.position.to = this.entity.drawingTo || this.entity.toPort!.point; this.data.version = [ this.lineType, this.data.position.from.x, this.data.position.from.y, this.data.position.from.location, this.data.position.to.x, this.data.position.to.y, this.data.position.to.location, this.entity.uiState.shrink, // 这个会影响线条的变化 this.entity.uiState.curvature, ].join('-'); } private get currentLine(): WorkflowLineRenderContribution | undefined { return this.data.contributions.get(this.lineType); } private syncContributions(): void { if (this.entity.linesManager.contributionFactories.length === this.data.contributions.size) { return; } this.entity.linesManager.contributionFactories.forEach((factory) => { this.registerContribution(factory); }); } private registerContribution(contributionFactory: WorkflowLineRenderContributionFactory): void { if (this.data.contributions.has(contributionFactory.type)) { return; } const contribution = new contributionFactory(this.entity); this.data.contributions.set(contributionFactory.type, contribution); } } ================================================ FILE: packages/canvas-engine/free-layout-core/src/entity-datas/workflow-node-lines-data.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { Disposable } from '@flowgram.ai/utils'; import { EntityData } from '@flowgram.ai/core'; import { type WorkflowLineEntity, type WorkflowNodeEntity } from '../entities'; export interface WorkflowNodeLines { inputLines: WorkflowLineEntity[]; outputLines: WorkflowLineEntity[]; } /** * 节点的关联的线条 */ export class WorkflowNodeLinesData extends EntityData { static type = 'WorkflowNodeLinesData'; entity: WorkflowNodeEntity; getDefaultData(): WorkflowNodeLines { return { inputLines: [], outputLines: [], }; } constructor(entity: WorkflowNodeEntity) { super(entity); this.entity = entity; this.entity.preDispose.push( Disposable.create(() => { this.inputLines.slice().forEach((line) => line.dispose()); this.outputLines.slice().forEach((line) => line.dispose()); }) ); } /** * 输入线条 */ get inputLines(): WorkflowLineEntity[] { return this.data.inputLines; } /** * 输出线条 */ get outputLines(): WorkflowLineEntity[] { return this.data.outputLines; } get allLines(): WorkflowLineEntity[] { return this.data.inputLines.concat(this.data.outputLines); } get availableLines(): WorkflowLineEntity[] { return this.allLines.filter((line) => !line.isDrawing && !line.isHidden); } /** * 输入节点 */ get inputNodes(): WorkflowNodeEntity[] { return this.inputLines.map((l) => l.from!).filter(Boolean); } /** * 所有输入节点 */ get allInputNodes(): WorkflowNodeEntity[] { const nodeSet: Set = new Set(); const handleNode = (node: WorkflowNodeEntity): void => { if (nodeSet.has(node)) { return; } nodeSet.add(node); const { inputNodes } = node.getData(WorkflowNodeLinesData)!; if (!inputNodes || !inputNodes.length) { return; } inputNodes.forEach((inputNode: WorkflowNodeEntity) => { // 如果 outputNode 和当前 node 是父子节点,则不向下遍历 if (inputNode?.parent === node || node?.parent === inputNode) { return; } handleNode(inputNode); }); }; handleNode(this.entity); nodeSet.delete(this.entity); return Array.from(nodeSet); } /** * 输出节点 */ get outputNodes(): WorkflowNodeEntity[] { return this.outputLines.map((l) => l.to!).filter(Boolean); } /** * 输入输出节点 */ get allOutputNodes(): WorkflowNodeEntity[] { const nodeSet: Set = new Set(); const handleNode = (node: WorkflowNodeEntity): void => { if (nodeSet.has(node)) { return; } nodeSet.add(node); const { outputNodes } = node.getData(WorkflowNodeLinesData)!; if (!outputNodes || !outputNodes.length) { return; } outputNodes.forEach((outputNode: WorkflowNodeEntity) => { // 如果 outputNode 和当前 node 是父子节点,则不向下遍历 if (outputNode?.parent === node || node?.parent === outputNode) { return; } handleNode(outputNode); }); }; handleNode(this.entity); nodeSet.delete(this.entity); return Array.from(nodeSet); } addLine(line: WorkflowLineEntity): void { if (line.from === this.entity) { this.outputLines.push(line); } else { this.inputLines.push(line); } this.fireChange(); } removeLine(line: WorkflowLineEntity): void { const { inputLines, outputLines } = this; const inputIndex = inputLines.indexOf(line); const outputIndex = outputLines.indexOf(line); if (inputIndex !== -1) { inputLines.splice(inputIndex, 1); this.fireChange(); } if (outputIndex !== -1) { outputLines.splice(outputIndex, 1); this.fireChange(); } } } ================================================ FILE: packages/canvas-engine/free-layout-core/src/entity-datas/workflow-node-ports-data.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { isEqual } from 'lodash-es'; import { FlowNodeRenderData } from '@flowgram.ai/document'; import { EntityData, SizeData } from '@flowgram.ai/core'; import { type WorkflowPortType, getPortEntityId } from '../utils/statics'; import { type LinePoint, LinePointLocation, type WorkflowNodeMeta } from '../typings'; import { WorkflowPortEntity } from '../entities/workflow-port-entity'; import { type WorkflowNodeEntity, type WorkflowPort, type WorkflowPorts } from '../entities'; /** * 节点的点位信息 * portsData 只监听点位的数目和类型,不监听点位的 position 变化 */ export class WorkflowNodePortsData extends EntityData { public static readonly type = 'WorkflowNodePortsData'; public readonly entity: WorkflowNodeEntity; /** 静态的 ports 数据 */ protected _staticPorts: WorkflowPorts = []; /** 存储 port 实体的 id,用于判断 port 是否存在 */ protected _portIDSet = new Set(); /** 上一次的 ports 数据,用于判断 ports 是否发生变化 */ protected _prePorts: WorkflowPorts; constructor(entity: WorkflowNodeEntity) { super(entity); this.entity = entity; const meta = entity.getNodeMeta(); // 动态模式默认为空, 非动态模式默认左右两个点位 const defaultPorts: WorkflowPorts = meta.useDynamicPort ? [] : [{ type: 'input' }, { type: 'output' }]; this._staticPorts = meta.defaultPorts?.slice() || defaultPorts; this.updatePorts(this._staticPorts); if (meta.useDynamicPort) { this.toDispose.push( // 只需要监听节点的大小,因为算的是相对位置 entity.getData!(SizeData)!.onDataChange(() => { // 有可能节点被销毁了 if (entity.getData!(SizeData).width && entity.getData!(SizeData).height) { this.updateDynamicPorts(); } }) ); } this.onDispose(() => { this.allPorts.forEach((port) => port.dispose()); }); } public getDefaultData(): any { return {}; } /** * Update all ports data, includes static ports and dynamic ports * @param ports */ public updateAllPorts(ports?: WorkflowPorts) { const meta = this.entity.getNodeMeta(); if (ports) { this._staticPorts = ports; } if (meta.useDynamicPort) { this.updateDynamicPorts(); } else { this.updatePorts(this._staticPorts); } } /** * @deprecated use `updateAllPorts` instead */ public updateStaticPorts(ports: WorkflowPorts): void { this.updateAllPorts(ports); } /** * 动态计算点位,通过 dom 的 data-port-key */ public updateDynamicPorts(): void { const domNode = this.entity.getData(FlowNodeRenderData)!.node; const elements = domNode.querySelectorAll('[data-port-id]'); const staticPorts: WorkflowPorts = this._staticPorts; const dynamicPorts: WorkflowPorts = []; if (elements.length > 0) { dynamicPorts.push( ...Array.from(elements).map((element) => ({ portID: element.getAttribute('data-port-id')!, type: element.getAttribute('data-port-type')! as WorkflowPortType, location: element.getAttribute('data-port-location')! as LinePointLocation, targetElement: element, })) ); } this.updatePorts(staticPorts.concat(dynamicPorts)); } /** * 根据 key 获取 port 实体 */ public getPortEntityByKey( portType: WorkflowPortType, portKey?: string | number ): WorkflowPortEntity { const entity = this.getOrCreatePortEntity({ type: portType, portID: portKey, }); return entity; } /** * 更新 ports 数据 */ protected updatePorts(ports: WorkflowPorts): void { if (!isEqual(this._prePorts, ports)) { const portKeys = ports.map((port) => this.getPortId(port.type, port.portID)); this._portIDSet.forEach((portId) => { if (!portKeys.includes(portId)) { this.getPortEntity(portId)?.dispose(); } }); ports.forEach((port) => this.updatePortEntity(port)); this._prePorts = ports; this.fireChange(); } // Note: 为什么调用 port.validate 不够,需要调用 line.validate // 原因:假设有这样的连线:dynamic port → end 节点。 // line.validate 时,line.fromPort 可能为 undefined(未创建实体),导致 end 节点上的 port 未正确校验 // 所以需要在所有 port entities 准备完成后,通过再次调用 line.validate 来触发连线另一端的 port 更新 this.allPorts.forEach((port) => { port.allLines.forEach((line) => { line.validate(); }); }); } /** * 获取所有 port entities */ public get allPorts(): WorkflowPortEntity[] { return Array.from(this._portIDSet) .map((portId) => this.getPortEntity(portId)!) .filter(Boolean); // dispose 时,会获取不到 port } /** * 获取输入点位 */ public get inputPorts(): WorkflowPortEntity[] { return this.allPorts.filter((port) => port.portType === 'input'); } /** * 获取输出点位 */ public get outputPorts(): WorkflowPortEntity[] { return this.allPorts.filter((port) => port.portType === 'output'); } /** * 获取输入点位置 */ public get inputPoints(): LinePoint[] { return this.inputPorts.map((port) => port.point); } /** * 获取输出点位置 */ public get outputPoints(): LinePoint[] { return this.inputPorts.map((port) => port.point); } /** * 根据 key 获取 输入点位置 */ public getInputPoint(key?: string | number): LinePoint { return this.getPortEntityByKey('input', key).point; } /** * 根据 key 获取输出点位置 */ public getOutputPoint(key?: string | number): LinePoint { return this.getPortEntityByKey('output', key).point; } /** * 获取 port 实体 */ protected getPortEntity(portId: string): WorkflowPortEntity | undefined { if (!this._portIDSet.has(portId)) { // 如果不是自身创建的 port,则返回 undefined return undefined; } return this.entity.entityManager.getEntityById(portId)!; } /** * 拼接 port 实体的 id */ protected getPortId(portType: WorkflowPortType, portKey: string | number = ''): string { return getPortEntityId(this.entity, portType, portKey); } /** * 创建 port 实体 */ protected createPortEntity(portInfo: WorkflowPort): WorkflowPortEntity { const id = this.getPortId(portInfo.type, portInfo.portID); let portEntity = this.entity.entityManager.getEntityById(id); if (!portEntity) { portEntity = this.entity.entityManager.createEntity(WorkflowPortEntity, { id, node: this.entity, ...portInfo, }); } portEntity.onDispose(() => { this._portIDSet.delete(id); }); this._portIDSet.add(id); return portEntity; } /** * 获取或创建 port 实体 */ protected getOrCreatePortEntity(portInfo: WorkflowPort): WorkflowPortEntity { const id = this.getPortId(portInfo.type, portInfo.portID); return this.getPortEntity(id) ?? this.createPortEntity(portInfo); } /** * 更新 port 实体 */ protected updatePortEntity(portInfo: WorkflowPort): WorkflowPortEntity { const portEntity = this.getOrCreatePortEntity(portInfo); portEntity.update(portInfo); return portEntity; } } ================================================ FILE: packages/canvas-engine/free-layout-core/src/hooks/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export { useConfigEntity, useService, usePlayground, useListenEvents, usePlaygroundContainer, usePlaygroundContext, useEntities, useEntityFromContext, useEntityDataFromContext, useRefresh, } from '@flowgram.ai/core'; export * from './typings'; export * from './use-node-render'; export * from './use-current-dom-node'; export * from './use-current-entity'; export * from './use-workflow-document'; export * from './use-playground-readonly-state'; ================================================ FILE: packages/canvas-engine/free-layout-core/src/hooks/typings.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { type NodeFormProps } from '@flowgram.ai/node'; import { FlowNodeEntity } from '@flowgram.ai/document'; import { type WorkflowPortEntity } from '../entities'; export interface NodeRenderReturnType { id: string; type: string | number; /** * 当前节点 */ node: FlowNodeEntity; /** * 节点 data 数据 */ data: any; /** * 更新节点 data 数据 */ updateData: (newData: any) => void; /** * 节点选中 */ selected: boolean; /** * 节点激活 */ activated: boolean; /** * 节点展开 */ expanded: boolean; /** * 触发拖拽 * @param e */ startDrag: (e: React.MouseEvent) => void; /** * 当前节点的点位信息 */ ports: WorkflowPortEntity[]; /** * 删除节点 */ deleteNode: () => void; /** * 选中节点 * @param e */ selectNode: (e: React.MouseEvent) => void; /** * 全局 readonly 状态 */ readonly: boolean; /** * 拖拽线条的目标 node id */ linkingNodeId: string; /** * 节点 ref */ nodeRef: React.MutableRefObject; /** * 节点 focus 事件 */ onFocus: () => void; /** * 节点 blur 事件 */ onBlur: () => void; /** * 渲染表单,只有节点引擎开启才能使用 */ form: NodeFormProps | undefined; /** * 获取节点的扩展数据 */ getExtInfo(): T; /** * 更新节点的扩展数据 * @param extInfo */ updateExtInfo(extInfo: T, fullUpdate?: boolean): void; /** * 展开/收起节点 * @param expanded */ toggleExpand(): void; } ================================================ FILE: packages/canvas-engine/free-layout-core/src/hooks/use-current-dom-node.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { FlowNodeRenderData } from '@flowgram.ai/document'; import { useEntityFromContext } from '@flowgram.ai/core'; import { type WorkflowNodeEntity } from '../entities'; /** * 获取当前渲染的 dom 节点 */ export function useCurrentDomNode(): HTMLDivElement { const entity = useEntityFromContext(); const renderData = entity.getData(FlowNodeRenderData)!; return renderData.node; } ================================================ FILE: packages/canvas-engine/free-layout-core/src/hooks/use-current-entity.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { useEntityFromContext } from '@flowgram.ai/core'; import { type WorkflowNodeEntity } from '../entities'; /** * 获取当前节点 */ export function useCurrentEntity(): WorkflowNodeEntity { return useEntityFromContext(); } ================================================ FILE: packages/canvas-engine/free-layout-core/src/hooks/use-node-render-context.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React from 'react'; import { NodeRenderReturnType } from './typings'; export const NodeRenderContext = React.createContext({} as any); ================================================ FILE: packages/canvas-engine/free-layout-core/src/hooks/use-node-render.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import type React from 'react'; import { useCallback, useEffect, useRef, useState, useContext, useMemo } from 'react'; import { useObserve } from '@flowgram.ai/reactive'; import { getNodeForm } from '@flowgram.ai/node'; import { FlowNodeRenderData } from '@flowgram.ai/document'; import { MouseTouchEvent, PlaygroundEntityContext, useListenEvents, useService, } from '@flowgram.ai/core'; import { WorkflowDragService, WorkflowSelectService } from '../service'; import { type WorkflowNodeEntity } from '../entities'; import { usePlaygroundReadonlyState } from './use-playground-readonly-state'; import { type NodeRenderReturnType } from './typings'; function checkTargetDraggable(el: any): boolean { return ( el && el.tagName !== 'INPUT' && el.tagName !== 'TEXTAREA' && !el.closest('.flow-canvas-not-draggable') ); } /** * - 下面的 firefox 为了修复一个 bug * - firefox 下 draggable 属性会影响节点 input 内容 focus:https://jsfiddle.net/Aydar/ztsvbyep/3/ * - 该 bug 在 firefox 浏览器上存在了很久,需要作兼容:https://bugzilla.mozilla.org/show_bug.cgi?id=739071 */ const isFirefox = typeof navigator !== 'undefined' && navigator?.userAgent?.includes?.('Firefox'); export function useNodeRender(nodeFromProps?: WorkflowNodeEntity): NodeRenderReturnType { const node = nodeFromProps || useContext(PlaygroundEntityContext); const renderData = node.getData(FlowNodeRenderData)!; const portsData = node.ports!; const readonly = usePlaygroundReadonlyState(); const dragService = useService(WorkflowDragService); const selectionService = useService(WorkflowSelectService); const isDragging = useRef(false); const [formValueVersion, updateFormValueVersion] = useState(0); const formValueDependRef = useRef(false); formValueDependRef.current = false; const nodeRef = useRef(null); const [linkingNodeId, setLinkingNodeId] = useState(''); useEffect(() => { const disposable = dragService.onDragLineEventChange(({ type, onDragNodeId }) => { if (type === 'onDrag') { setLinkingNodeId(onDragNodeId || ''); } else { setLinkingNodeId(''); } }); return () => { disposable.dispose(); }; }, []); const startDrag = useCallback( (e: React.MouseEvent) => { MouseTouchEvent.preventDefault(e); if (!selectionService.isSelected(node.id)) { selectNode(e); } if (!MouseTouchEvent.isTouchEvent(e as unknown as React.TouchEvent)) { // 输入框不能拖拽 if (!checkTargetDraggable(e.target) || !checkTargetDraggable(document.activeElement)) { return; } } isDragging.current = true; // 拖拽选中的节点 dragService.startDragSelectedNodes(e)?.finally(() => setTimeout(() => { isDragging.current = false; }) ); }, [dragService, node] ); /** * 单选节点 */ const selectNode = useCallback( (e: React.MouseEvent) => { // 触发了拖拽就不要再触发单选 if (isDragging.current) { return; } // 追加选择 if (e.shiftKey) { selectionService.toggleSelect(node); } else { selectionService.selectNode(node); } if (e.target) { (e.target as HTMLDivElement).focus(); } }, [node] ); const deleteNode = useCallback(() => node.dispose(), [node]); // 监听端口变化 useListenEvents(portsData.onDataChange); const onFocus = useCallback(() => { if (isFirefox) { nodeRef.current?.setAttribute('draggable', 'false'); } }, []); const onBlur = useCallback(() => { if (isFirefox) { nodeRef.current?.setAttribute('draggable', 'true'); } }, []); const getExtInfo = useCallback(() => node.getExtInfo() as any, [node]); const updateExtInfo = useCallback( (data: any, fullUpdate?: boolean) => { node.updateExtInfo(data, fullUpdate); }, [node] ); const form = useMemo(() => getNodeForm(node), [node]); // Listen FormState change const formState = useObserve(form?.state); const toggleExpand = useCallback(() => { renderData.toggleExpand(); }, [renderData]); const selected = selectionService.isSelected(node.id); const activated = selectionService.isActivated(node.id); const expanded = renderData.expanded; useEffect(() => { const toDispose = form?.onFormValuesChange(() => { if (formValueDependRef.current) { updateFormValueVersion((v) => v + 1); } }); return () => toDispose?.dispose(); }, [form]); return useMemo( () => ({ id: node.id, type: node.flowNodeType, get data() { if (form) { formValueDependRef.current = true; return form.values; } return getExtInfo(); }, updateData(values: any) { if (form) { form.updateFormValues(values); } else { updateExtInfo(values, true); } }, node, selected, activated, expanded, startDrag, get ports() { return portsData.allPorts; }, deleteNode, selectNode, readonly, linkingNodeId, nodeRef, onFocus, onBlur, getExtInfo, updateExtInfo, toggleExpand, get form() { if (!form) return undefined; return { ...form, get values() { formValueDependRef.current = true; return form.values!; }, get state() { return formState; }, }; }, }), [ node, selected, activated, expanded, startDrag, deleteNode, selectNode, readonly, linkingNodeId, nodeRef, onFocus, onBlur, getExtInfo, updateExtInfo, toggleExpand, formValueVersion, ] ); } ================================================ FILE: packages/canvas-engine/free-layout-core/src/hooks/use-playground-readonly-state.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { useEffect } from 'react'; import { usePlayground, useRefresh } from '@flowgram.ai/core'; import { type Disposable } from '@flowgram.ai/utils'; /** * 获取 readonly 状态 */ export function usePlaygroundReadonlyState(listenChange?: boolean): boolean { const playground = usePlayground(); const refresh = useRefresh(); useEffect(() => { let dispose: Disposable | undefined = undefined; if (listenChange) { dispose = playground.config.onReadonlyOrDisabledChange(() => refresh()); } return () => dispose?.dispose(); }, [listenChange]); return playground.config.readonly; } ================================================ FILE: packages/canvas-engine/free-layout-core/src/hooks/use-workflow-document.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { useService } from '@flowgram.ai/core'; import { WorkflowDocument } from '../workflow-document'; export function useWorkflowDocument(): WorkflowDocument { return useService(WorkflowDocument); } ================================================ FILE: packages/canvas-engine/free-layout-core/src/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export * from './workflow-commands'; export * from './hooks'; export * from './utils'; export * from './typings'; export * from './entities'; export * from './constants'; export * from './entity-datas'; export * from './service'; export * from './workflow-document'; export * from './workflow-document-container-module'; export * from './workflow-lines-manager'; export * from './workflow-document-option'; ================================================ FILE: packages/canvas-engine/free-layout-core/src/layout/free-layout.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { inject, injectable } from 'inversify'; import { type IPoint, PaddingSchema, Rectangle, type ScrollSchema, SizeSchema, } from '@flowgram.ai/utils'; import { type FlowDocument, type FlowLayout, type FlowNodeEntity, FlowDocumentProvider, FlowNodeTransformData, } from '@flowgram.ai/document'; import { PlaygroundConfigEntity, TransformData } from '@flowgram.ai/core'; export const FREE_LAYOUT_KEY = 'free-layout'; /** * 自由画布布局 */ @injectable() export class FreeLayout implements FlowLayout { name = FREE_LAYOUT_KEY; @inject(PlaygroundConfigEntity) playgroundConfig: PlaygroundConfigEntity; @inject(FlowDocumentProvider) protected documentProvider: FlowDocumentProvider; get document(): FlowDocument { return this.documentProvider(); } /** * 更新布局 */ update(): void { if (this.document.root.getData(FlowNodeTransformData)?.localDirty) { this.document.root.clearMemoGlobal(); // this.document.root.getData(FlowNodeTransformData)!.localDirty = false } // 自由画布同步同步大小, TODO 这个移动到 createWorkflowNode // this.document.root.allChildren.forEach(this.syncTransform.bind(this)) } syncTransform(node: FlowNodeEntity): void { const transform = node.getData(FlowNodeTransformData)!; if (!transform.localDirty) { return; } node.clearMemoGlobal(); node.clearMemoLocal(); // 同步 size 给原始的 transform transform.transform.update({ size: transform.data.size, }); if (!node.parent) { return; } node.parent.clearMemoGlobal(); node.parent.clearMemoLocal(); const parentTransform = node.parent.getData(FlowNodeTransformData); parentTransform.transform.fireChange(); } /** * 更新所有受影响的上下游节点 */ updateAffectedTransform(node: FlowNodeEntity): void { const transformData = node.transform; if (!transformData.localDirty) { return; } const allParents = this.getAllParents(node); const allBlocks = this.getAllBlocks(node).reverse(); const affectedNodes = [...allBlocks, ...allParents]; affectedNodes.forEach((node) => { this.fireChange(node); }); } /** * 获取节点的 padding 数据 * @param node */ getPadding(node: FlowNodeEntity): PaddingSchema { const { padding } = node.getNodeMeta(); const transform = node.getData(FlowNodeTransformData); if (padding) { return typeof padding === 'function' ? padding(transform) : padding; } return PaddingSchema.empty(); } /** * 默认滚动到 fitview 区域 * @param contentSize */ getInitScroll(contentSize: SizeSchema): ScrollSchema { const bounds = Rectangle.enlarge( this.document.getAllNodes().map((node) => node.getData(TransformData).bounds) ).pad(30, 30); // 留出 30 像素的边界 const viewport = this.playgroundConfig.getViewport(false); const zoom = SizeSchema.fixSize(bounds, viewport); return { scrollX: (bounds.x + bounds.width / 2) * zoom - this.playgroundConfig.config.width / 2, scrollY: (bounds.y + bounds.height / 2) * zoom - this.playgroundConfig.config.height / 2, }; } /** * 获取默认输入点 */ getDefaultInputPoint(node: FlowNodeEntity): IPoint { return node.getData(TransformData)!.bounds.leftCenter; } /** * 获取默认输出点 */ getDefaultOutputPoint(node: FlowNodeEntity): IPoint { return node.getData(TransformData)!.bounds.rightCenter; } /** * 水平中心点 */ getDefaultNodeOrigin(): IPoint { return { x: 0.5, y: 0 }; } private getAllParents(node: FlowNodeEntity): FlowNodeEntity[] { const parents: FlowNodeEntity[] = []; let current = node.parent; while (current) { parents.push(current); current = current.parent; } return parents; } private getAllBlocks(node: FlowNodeEntity): FlowNodeEntity[] { return node.blocks.reduce( (acc, child) => [...acc, ...this.getAllBlocks(child)], [node] ); } private fireChange(node?: FlowNodeEntity): void { const transformData = node?.transform; if (!node || !transformData?.localDirty) { return; } node.clearMemoGlobal(); node.clearMemoLocal(); transformData.transform.fireChange(); } } ================================================ FILE: packages/canvas-engine/free-layout-core/src/layout/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export * from './free-layout'; ================================================ FILE: packages/canvas-engine/free-layout-core/src/service/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export * from './workflow-select-service'; export * from './workflow-hover-service'; export * from './workflow-drag-service'; export * from './workflow-reset-layout-service'; export * from './workflow-operation-base-service'; ================================================ FILE: packages/canvas-engine/free-layout-core/src/service/workflow-drag-service.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import type React from 'react'; import { nanoid } from 'nanoid'; import { inject, injectable, postConstruct } from 'inversify'; import { domUtils, type IPoint, PromiseDeferred, Emitter, type PositionSchema, DisposableCollection, Rectangle, delay, Disposable, Point, } from '@flowgram.ai/utils'; import { FlowNodeTransformData, FlowNodeType, type FlowNodeEntity } from '@flowgram.ai/document'; import { FlowNodeBaseType } from '@flowgram.ai/document'; import { CommandService, MouseTouchEvent, PlaygroundConfigEntity, PlaygroundDrag, type PlaygroundDragEvent, TransformData, } from '@flowgram.ai/core'; import { WorkflowLinesManager } from '../workflow-lines-manager'; import { WorkflowDocumentOptions } from '../workflow-document-option'; import { WorkflowDocument } from '../workflow-document'; import { LineEventProps, NodesDragEvent, OnDragLineEnd } from '../typings/workflow-drag'; import { LinePointLocation, WorkflowOperationBaseService, type WorkflowNodeJSON, type WorkflowNodeMeta, } from '../typings'; import { type WorkflowLineEntity, type WorkflowLinePortInfo, type WorkflowNodeEntity, type WorkflowPortEntity, } from '../entities'; import { WorkflowSelectService } from './workflow-select-service'; import { WorkflowHoverService } from './workflow-hover-service'; import { WorkflowPortType } from '../utils'; const DRAG_TIMEOUT = 100; const DRAG_MIN_DELTA = 5; function checkDragSuccess( time: number, e: PlaygroundDragEvent, originLine?: WorkflowLineEntity ): boolean { if ( !originLine || time > DRAG_TIMEOUT || Math.abs(e.endPos.x - e.startPos.x) >= DRAG_MIN_DELTA || Math.abs(e.endPos.y - e.startPos.y) >= DRAG_MIN_DELTA ) { return true; } return false; } function reverseLocation(sourceLocation: LinePointLocation): LinePointLocation { switch (sourceLocation) { case 'bottom': return 'top'; case 'left': return 'right'; case 'top': return 'bottom'; case 'right': return 'left'; } } @injectable() export class WorkflowDragService { @inject(PlaygroundConfigEntity) protected playgroundConfig: PlaygroundConfigEntity; @inject(WorkflowHoverService) protected hoverService: WorkflowHoverService; @inject(WorkflowDocument) protected document: WorkflowDocument; @inject(WorkflowLinesManager) protected linesManager: WorkflowLinesManager; @inject(CommandService) protected commandService: CommandService; @inject(WorkflowSelectService) protected selectService: WorkflowSelectService; @inject(WorkflowOperationBaseService) protected operationService: WorkflowOperationBaseService; @inject(WorkflowDocumentOptions) readonly options: WorkflowDocumentOptions; private _onDragLineEventEmitter = new Emitter(); readonly onDragLineEventChange = this._onDragLineEventEmitter.event; isDragging = false; private _nodesDragEmitter = new Emitter(); readonly onNodesDrag = this._nodesDragEmitter.event; protected _toDispose = new DisposableCollection(); private _droppableTransforms: FlowNodeTransformData[] = []; private _dropNode?: FlowNodeEntity; private posAdjusters: Set< (params: { selectedNodes: WorkflowNodeEntity[]; position: IPoint }) => IPoint > = new Set(); private _onDragLineEndCallbacks: Map = new Map(); @postConstruct() init() { this._toDispose.pushAll([this._onDragLineEventEmitter, this._nodesDragEmitter]); if (this.options.onDragLineEnd) { this._toDispose.push(this.onDragLineEnd(this.options.onDragLineEnd)); } } dispose() { this._toDispose.dispose(); } /** * 拖拽选中节点 * @param triggerEvent */ async startDragSelectedNodes(triggerEvent: MouseEvent | React.MouseEvent): Promise { let { selectedNodes } = this.selectService; if (selectedNodes.length === 0 || this.isDragging) { return Promise.resolve(false); } if ( !this.document.options.enableReadonlyNodeDragging && (this.playgroundConfig.readonly || this.playgroundConfig.disabled) ) { return Promise.resolve(false); } this.isDragging = true; // 节点整体开始位置 let startPosition = this.getNodesPosition(selectedNodes); // 单个节点开始位置 let startPositions = selectedNodes.map((node) => { const transform = node.getData(TransformData); return { x: transform.position.x, y: transform.position.y }; }); let dragSuccess = false; const startTime = Date.now(); const dragger = new PlaygroundDrag({ onDragStart: (dragEvent) => { this._nodesDragEmitter.fire({ type: 'onDragStart', nodes: selectedNodes, startPositions, dragEvent, triggerEvent, dragger, }); }, onDrag: (dragEvent) => { if (!dragSuccess && checkDragSuccess(Date.now() - startTime, dragEvent)) { dragSuccess = true; } // 计算拖拽偏移量 const offset: IPoint = this.getDragPosOffset({ event: dragEvent, selectedNodes, startPosition, }); const positions: PositionSchema[] = []; selectedNodes.forEach((node, index) => { const transform = node.getData(TransformData); const nodeStartPosition = startPositions[index]; const newPosition = { x: nodeStartPosition.x + offset.x, y: nodeStartPosition.y + offset.y, }; transform.update({ position: newPosition, }); this.document.layout.updateAffectedTransform(node); positions.push(newPosition); }); this._nodesDragEmitter.fire({ type: 'onDragging', nodes: selectedNodes, startPositions, positions, dragEvent, triggerEvent, dragger, }); }, onDragEnd: (dragEvent) => { this.isDragging = false; this._nodesDragEmitter.fire({ type: 'onDragEnd', nodes: selectedNodes, startPositions, dragEvent, triggerEvent, dragger, }); this.resetContainerInternalPosition(selectedNodes); }, }); const { clientX, clientY } = MouseTouchEvent.getEventCoord(triggerEvent); return dragger.start(clientX, clientY, this.playgroundConfig)?.then(() => dragSuccess); } /** * 通过拖入卡片添加 * @param type * @param event * @param data 节点数据 */ async dropCard( type: string, event: { clientX: number; clientY: number }, data?: Partial, parent?: WorkflowNodeEntity ): Promise { const mousePos = this.playgroundConfig.getPosFromMouseEvent(event); if (!this.playgroundConfig.getViewport().contains(mousePos.x, mousePos.y)) { // 鼠标范围不在画布之内 return; } const position = this.adjustSubNodePosition(type, parent, mousePos); const node: WorkflowNodeEntity = await this.document.createWorkflowNodeByType( type, position, data, parent?.id ); return node; } /** * 拖拽卡片到画布 * 返回创建结果 * @param type * @param event */ async startDragCard( type: string, event: React.MouseEvent, data: Partial, cloneNode?: (e: PlaygroundDragEvent) => HTMLDivElement // 创建拖拽的dom ): Promise { let domNode: HTMLDivElement; let startPos: IPoint = { x: 0, y: 0 }; const deferred = new PromiseDeferred(); const dragger = new PlaygroundDrag({ onDragStart: (e) => { const targetNode = event.currentTarget as HTMLDivElement; domNode = cloneNode ? cloneNode(e) : (targetNode.cloneNode(true) as HTMLDivElement); const bounds = targetNode.getBoundingClientRect(); startPos = { x: bounds.left + window.scrollX, y: bounds.top + window.scrollY }; domUtils.setStyle(domNode, { zIndex: 1000, position: 'absolute', left: startPos.x, top: startPos.y, boxShadow: '0 6px 8px 0 rgba(28, 31, 35, .2)', }); document.body.appendChild(domNode); this.updateDroppableTransforms(); }, onDrag: (e) => { const deltaX = e.endPos.x - e.startPos.x; const deltaY = e.endPos.y - e.startPos.y; const left = startPos.x + deltaX; const right = startPos.y + deltaY; domNode.style.left = `${left}px`; domNode.style.top = `${right}px`; // 节点类型拖拽碰撞检测 const { x, y } = this.playgroundConfig.getPosFromMouseEvent(e); const draggingRect = new Rectangle(x, y, 170, 90); const collisionTransform = this._droppableTransforms.find((transform) => { const { bounds, entity } = transform; const padding = this.document.layout.getPadding(entity); const transformRect = new Rectangle( bounds.x + padding.left + padding.right, bounds.y, bounds.width, bounds.height ); // 检测两个正方形是否相互碰撞 return Rectangle.intersects(draggingRect, transformRect); }); this.updateDropNode(collisionTransform?.entity); }, onDragEnd: async (e) => { const dropNode = this._dropNode; const { allowDrop } = this.canDropToNode({ dragNodeType: type, dropNodeType: dropNode?.flowNodeType, dropNode, }); const dragNode = allowDrop ? await this.dropCard(type, e, data, dropNode) : undefined; this.clearDrop(); if (dragNode) { domNode.remove(); deferred.resolve(dragNode); } else { domNode.style.transition = 'all ease .2s'; domNode.style.left = `${startPos.x}px`; domNode.style.top = `${startPos.y}px`; const TIMEOUT = 200; await delay(TIMEOUT); domNode.remove(); deferred.resolve(); } }, }); await dragger.start(event.clientX, event.clientY); return deferred.promise; } /** * 如果存在容器节点,且传入鼠标坐标,需要用容器的坐标减去传入的鼠标坐标 */ public adjustSubNodePosition( subNodeType?: string, containerNode?: WorkflowNodeEntity, mousePos?: IPoint ): IPoint { if (!mousePos) { return { x: 0, y: 0 }; } if (!subNodeType || !containerNode || containerNode.flowNodeType === FlowNodeBaseType.ROOT) { return mousePos; } const isParentEmpty = !containerNode.children || containerNode.children.length === 0; const parentPadding = this.document.layout.getPadding(containerNode); const containerWorldTransform = containerNode.transform.transform.worldTransform; if (isParentEmpty) { // 确保空容器节点不偏移 return { x: 0, y: parentPadding.top, }; } else { return { x: mousePos.x - containerWorldTransform.tx, y: mousePos.y - containerWorldTransform.ty, }; } } /** * 注册位置调整 */ public registerPosAdjuster( adjuster: (params: { selectedNodes: WorkflowNodeEntity[]; position: IPoint }) => IPoint ) { this.posAdjusters.add(adjuster); return { dispose: () => this.posAdjusters.delete(adjuster), }; } /** * 判断是否可以放置节点 */ public canDropToNode(params: { dragNodeType?: FlowNodeType; dragNode?: WorkflowNodeEntity; dropNode?: WorkflowNodeEntity; dropNodeType?: FlowNodeType; }): { allowDrop: boolean; message?: string; dropNode?: WorkflowNodeEntity; } { const { canDropToNode } = this.document.options; const { dragNodeType, dropNode } = params; if (canDropToNode) { const result = canDropToNode(params); if (result) { return { allowDrop: true, dropNode, }; } return { allowDrop: false, }; } if (!dragNodeType) { return { allowDrop: false, message: 'Please select a node to drop', }; } return { allowDrop: true, dropNode, }; } /** * 获取拖拽偏移 */ private getDragPosOffset(params: { event: PlaygroundDragEvent; selectedNodes: WorkflowNodeEntity[]; startPosition: IPoint; }) { const { event, selectedNodes, startPosition } = params; const { finalScale } = this.playgroundConfig; const mouseOffset: IPoint = { x: (event.endPos.x - event.startPos.x) / finalScale, y: (event.endPos.y - event.startPos.y) / finalScale, }; const wholePosition: IPoint = { x: startPosition.x + mouseOffset.x, y: startPosition.y + mouseOffset.y, }; const adjustedOffsets: IPoint[] = Array.from(this.posAdjusters.values()).map((adjuster) => adjuster({ selectedNodes, position: wholePosition, }) ); const offset: IPoint = adjustedOffsets.reduce( (offset, adjustOffset) => ({ x: offset.x + adjustOffset.x, y: offset.y + adjustOffset.y, }), mouseOffset ); return offset; } private updateDroppableTransforms() { this._droppableTransforms = this.document .getRenderDatas(FlowNodeTransformData, false) .filter((transform) => { const { entity } = transform; if (entity.originParent) { return this.nodeSelectable(entity) && this.nodeSelectable(entity.originParent); } return this.nodeSelectable(entity); }) .filter((transform) => this.isContainer(transform.entity)); } /** 是否容器节点 */ private isContainer(node?: WorkflowNodeEntity): boolean { return node?.getNodeMeta().isContainer ?? false; } /** * 获取节点整体位置 */ private getNodesPosition(nodes: WorkflowNodeEntity[]): IPoint { const selectedBounds = Rectangle.enlarge( nodes.map((n) => n.getData(FlowNodeTransformData)!.bounds) ); const position: IPoint = { x: selectedBounds.x, y: selectedBounds.y, }; return position; } private nodeSelectable(node: FlowNodeEntity) { const selectable = node.getNodeMeta().selectable; if (typeof selectable === 'function') { return selectable(node); } else { return selectable; } } private updateDropNode(node?: FlowNodeEntity) { if (this._dropNode) { if (this._dropNode.id === node?.id) { return; } this.selectService.clear(); } if (node) { this.selectService.selectNode(node); } this._dropNode = node; } private clearDrop() { if (this._dropNode) { this.selectService.clear(); } this._dropNode = undefined; this._droppableTransforms = []; } private setLineColor(line: WorkflowLineEntity, color: string) { line.highlightColor = color; this.hoverService.clearHovered(); } private checkDraggingPort( isDrawingTo: boolean, line: WorkflowLineEntity, draggingNode: WorkflowNodeEntity, draggingPort?: WorkflowPortEntity, originLine?: WorkflowLineEntity ): { hasError: boolean; } { let successDrawing = false; if (isDrawingTo) { successDrawing = !!( draggingPort && // 同一条线条则不用在判断 canAddLine (originLine?.toPort === draggingPort || (draggingPort.portType === 'input' && this.linesManager.canAddLine(line.fromPort!, draggingPort, true))) ); } else { successDrawing = !!( draggingPort && // 同一条线条则不用在判断 canAddLine (originLine?.fromPort === draggingPort || (draggingPort.portType === 'output' && this.linesManager.canAddLine(draggingPort, line.toPort!, true))) ); } if (successDrawing) { this.hoverService.updateHoveredKey(draggingPort!.id); if (isDrawingTo) { line.setToPort(draggingPort!); } else { line.setFromPort(draggingPort!); } this._onDragLineEventEmitter.fire({ type: 'onDrag', onDragNodeId: draggingNode.id, }); return { hasError: false, }; } else if (this.isContainer(draggingNode)) { // 在容器内进行连线的情况,需忽略 return { hasError: false, }; } else { this.setLineColor(line, this.linesManager.lineColor.error); return { hasError: true, }; } } /** * 容器内子节点总体位置重置为0 */ private resetContainerInternalPosition(nodes: WorkflowNodeEntity[]) { const container = this.childrenOfContainer(nodes); if (!container) { return; } const bounds: Rectangle = Rectangle.enlarge( container.blocks.map((node) => { const x = node.transform.position.x - node.transform.bounds.width / 2; const y = node.transform.position.y; const width = node.transform.bounds.width; const height = node.transform.bounds.height; return new Rectangle(x, y, width, height); }) ); const containerTransform = container.getData(TransformData); this.operationService.updateNodePosition(container, { x: containerTransform.position.x + bounds.x, y: containerTransform.position.y + bounds.y, }); this.document.layout.updateAffectedTransform(container); container.blocks.forEach((node) => { const transform = node.getData(TransformData); this.operationService.updateNodePosition(node, { x: transform.position.x - bounds.x, y: transform.position.y - bounds.y, }); this.document.layout.updateAffectedTransform(node); }); } private childrenOfContainer(nodes: WorkflowNodeEntity[]): WorkflowNodeEntity | undefined { if (nodes.length === 0) { return; } const sourceContainer = nodes[0]?.parent; if (!sourceContainer || sourceContainer.flowNodeType === FlowNodeBaseType.ROOT) { return; } const valid = nodes.every((node) => node?.parent === sourceContainer); if (!valid) { return; } return sourceContainer; } /** * 绘制线条 * @param opts * @param event */ async startDrawingLine( port: WorkflowPortEntity, event: { clientX: number; clientY: number }, originLine?: WorkflowLineEntity ): Promise<{ dragSuccess?: boolean; // 是否拖拽成功,不成功则为选择节点 newLine?: WorkflowLineEntity; // 新的线条 }> { const isDrawingTo = port.portType === 'output'; const isInActivePort = !originLine && port.isErrorPort() && port.disabled; if ( originLine?.disabled || isInActivePort || this.playgroundConfig.readonly || this.playgroundConfig.disabled ) { return { dragSuccess: false, newLine: undefined }; } this.selectService.clear(); const config = this.playgroundConfig; const deferred = new PromiseDeferred<{ dragSuccess?: boolean; newLine?: WorkflowLineEntity; // 新的线条 }>(); const preCursor = config.cursor; let line: WorkflowLineEntity | undefined; let newLineInfo: { fromPort?: WorkflowPortEntity; toPort?: WorkflowPortEntity; hasError: boolean; }; const startTime = Date.now(); let dragSuccess = false; const dragger = new PlaygroundDrag({ onDrag: (e) => { if (!line && checkDragSuccess(Date.now() - startTime, e, originLine)) { // 隐藏原来的线条 if (originLine) { originLine.highlightColor = this.linesManager.lineColor.hidden; } dragSuccess = true; const pos = config.getPosFromMouseEvent(event); // 创建临时的线条 if (isDrawingTo) { line = this.linesManager.createLine({ from: port.node.id, fromPort: port.portID, data: originLine?.lineData, drawingTo: { x: pos.x, y: pos.y, location: port.location === 'right' ? 'left' : 'top', }, }); } else { line = this.linesManager.createLine({ to: port.node.id, toPort: port.portID, data: originLine?.lineData, drawingFrom: { x: pos.x, y: pos.y, location: port.location === 'left' ? 'right' : 'bottom', }, }); } if (!line) { return; } config.updateCursor('grab'); line.highlightColor = originLine?.lockedColor || this.linesManager.lineColor.drawing; this.hoverService.updateHoveredKey(''); } if (!line) { return; } const dragPos = config.getPosFromMouseEvent(e); newLineInfo = this.updateDrawingLine(isDrawingTo, line, dragPos, originLine); }, onDragEnd: async (e) => { const dragPos = config.getPosFromMouseEvent(e); const onDragLineEndCallbacks = Array.from(this._onDragLineEndCallbacks.values()); config.updateCursor(preCursor); const { fromPort, toPort, hasError } = newLineInfo || {}; await Promise.all( onDragLineEndCallbacks.map((callback) => callback({ fromPort, toPort, mousePos: dragPos, line, originLine, event: e, }) ) ); line?.dispose(); this._onDragLineEventEmitter.fire({ type: 'onDragEnd', }); // 清除选中状态 if (originLine) { originLine.highlightColor = ''; } const end = () => { originLine?.validate(); deferred.resolve({ dragSuccess }); }; if (dragSuccess) { // Step 1: check same line if (originLine && originLine.toPort === toPort && originLine.fromPort === fromPort) { // 线条没变化则直接返回,不做处理 return end(); } // 非 input 节点不能连接 if ( (toPort && toPort.portType !== 'input') || (fromPort && fromPort.portType !== 'output') ) { return end(); } const newLinePortInfo: Required | undefined = toPort && fromPort ? { from: fromPort.node.id, fromPort: fromPort.portID, to: toPort.node.id, toPort: toPort.portID, data: originLine?.lineData, } : undefined; // Step2: 检测 reset const isReset = originLine && newLinePortInfo; if (isReset && !this.linesManager.canReset(originLine, newLinePortInfo)) { return end(); } // Step 3: delete line if ( originLine && (!this.linesManager.canRemove(originLine, newLinePortInfo, false) || hasError) ) { // 线条无法删除则返回,不再触发 canAddLine return end(); } else { originLine?.dispose(); } // Step 4: add line if (!newLinePortInfo || !this.linesManager.canAddLine(fromPort!, toPort!, false)) { // 无法添加成功 return end(); } const newLine = this.linesManager.createLine(newLinePortInfo); if (!newLine) { end(); } deferred.resolve({ dragSuccess, newLine, }); } else { end(); } }, }); const { clientX, clientY } = MouseTouchEvent.getEventCoord(event); await dragger.start(clientX, clientY, config); return deferred.promise; } private updateDrawingLine( isDrawingTo: boolean, line: WorkflowLineEntity, dragPos: IPoint, originLine?: WorkflowLineEntity ): { fromPort?: WorkflowPortEntity; toPort?: WorkflowPortEntity; hasError: boolean } { let hasError = false; const mouseNode = this.linesManager.getNodeFromMousePos(dragPos); let toNode: WorkflowNodeEntity | undefined; let toPort: WorkflowPortEntity | undefined; let fromPort: WorkflowPortEntity | undefined; let fromNode: WorkflowNodeEntity | undefined; if (isDrawingTo) { fromPort = line.fromPort!; toNode = mouseNode; toPort = this.linesManager.getPortFromMousePos(dragPos, 'input'); if (toNode && this.canBuildContainerLine(toNode, dragPos)) { // 如果鼠标 hover 在 node 中的时候,默认连线到这个 node 的初始位置 toPort = this.getNearestPort(toNode, dragPos, 'input'); hasError = this.checkDraggingPort(isDrawingTo, line, toNode, toPort, originLine).hasError; } if (!toPort) { line.setToPort(undefined); } else if (!this.linesManager.canAddLine(fromPort, toPort, true)) { hasError = true; line.setToPort(undefined); } else { line.setToPort(toPort); } if (line.toPort) { line.drawingTo = { x: line.toPort.point.x, y: line.toPort.point.y, location: line.toPort.location, }; } else { line.drawingTo = { x: dragPos.x, y: dragPos.y, location: reverseLocation(line.fromPort!.location), }; } } else { toPort = line.toPort!; fromNode = mouseNode; fromPort = this.linesManager.getPortFromMousePos(dragPos, 'output'); if (fromNode && this.canBuildContainerLine(fromNode, dragPos)) { // 如果鼠标 hover 在 node 中的时候,默认连线到这个 node 的初始位置 fromPort = this.getNearestPort(fromNode, dragPos, 'output'); hasError = this.checkDraggingPort( isDrawingTo, line, fromNode, fromPort, originLine ).hasError; } if (!fromPort) { line.setFromPort(undefined); } else if (!this.linesManager.canAddLine(fromPort, toPort, true)) { hasError = true; line.setFromPort(undefined); } else { line.setFromPort(fromPort); } if (line.fromPort) { line.drawingFrom = { x: line.fromPort.point.x, y: line.fromPort.point.y, location: line.fromPort.location, }; } else { line.drawingFrom = { x: dragPos.x, y: dragPos.y, location: reverseLocation(line.toPort!.location), }; } } this._onDragLineEventEmitter.fire({ type: 'onDrag', }); if (hasError) { this.setLineColor(line, this.linesManager.lineColor.error); } else { this.setLineColor(line, originLine?.lockedColor || this.linesManager.lineColor.drawing); } // 触发原 toPort 的校验 originLine?.validate(); line.validate(); return { fromPort: fromPort, toPort: toPort, hasError, }; } /** * 重新连接线条 * @param line * @param e */ async resetLine(line: WorkflowLineEntity, e: MouseEvent): Promise { const { fromPort, toPort } = line; const mousePos = this.playgroundConfig.getPosFromMouseEvent(e); const distanceFrom = Point.getDistance(fromPort!.point, mousePos); const distanceTo = Point.getDistance(toPort!.point, mousePos); const { dragSuccess } = await this.startDrawingLine( distanceTo <= distanceFrom || !this.document.options.twoWayConnection ? fromPort! : toPort!, e, line ); if (!dragSuccess) { // 没有拖拽成功则表示为选中节点 this.selectService.select(line); } } /** 线条拖拽结束 */ public onDragLineEnd(callback: OnDragLineEnd): Disposable { const id = nanoid(); this._onDragLineEndCallbacks.set(id, callback); return { dispose: () => { this._onDragLineEndCallbacks.delete(id); }, }; } /** 能否建立容器连线 */ private canBuildContainerLine(node: WorkflowNodeEntity, mousePos: IPoint): boolean { const isContainer = this.isContainer(node); if (!isContainer) { return true; } const { padding, bounds } = node.transform; const DEFAULT_DELTA = 10; const leftDelta = (padding.left * 2) / 3 || DEFAULT_DELTA; const rightDelta = (padding.right * 2) / 3 || DEFAULT_DELTA; const bottomDelta = (padding.bottom * 2) / 3 || DEFAULT_DELTA; const topDelta = (padding.top * 2) / 3 || DEFAULT_DELTA; const rectangles = [ new Rectangle(bounds.x, bounds.y, leftDelta, bounds.height), // left new Rectangle(bounds.x, bounds.y, bounds.width, topDelta), // top new Rectangle(bounds.x, bounds.y + bounds.height - bottomDelta, bounds.width, bottomDelta), // bottom new Rectangle(bounds.x + bounds.width - rightDelta, bounds.y, rightDelta, bounds.height), // right ]; return rectangles.some((rect) => rect.contains(mousePos.x, mousePos.y)); } /** 获取最近的 port */ private getNearestPort( node: WorkflowNodeEntity, mousePos: IPoint, portType: WorkflowPortType = 'input' ): WorkflowPortEntity { const portsData = node.ports!; const distanceSortedPorts = ( portType === 'input' ? portsData.inputPorts : portsData.outputPorts ).sort((a, b) => Point.getDistance(mousePos, a.point) - Point.getDistance(mousePos, b.point)); return distanceSortedPorts[0]; } } ================================================ FILE: packages/canvas-engine/free-layout-core/src/service/workflow-hover-service.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { inject, injectable } from 'inversify'; import { Emitter, type PositionSchema } from '@flowgram.ai/utils'; import { EntityManager } from '@flowgram.ai/core'; import { type WorkflowLineEntity, type WorkflowNodeEntity, type WorkflowPortEntity, } from '../entities'; /** * 可 Hover 的节点 类型 */ export type WorkflowEntityHoverable = WorkflowNodeEntity | WorkflowLineEntity | WorkflowPortEntity; export interface HoverPosition { position: PositionSchema; target?: HTMLElement; } /** @deprecated */ export type WorkfloEntityHoverable = WorkflowEntityHoverable; /** * hover 状态管理 */ @injectable() export class WorkflowHoverService { @inject(EntityManager) protected entityManager: EntityManager; protected onHoveredChangeEmitter = new Emitter(); protected onUpdateHoverPositionEmitter = new Emitter(); readonly onHoveredChange = this.onHoveredChangeEmitter.event; readonly onUpdateHoverPosition = this.onUpdateHoverPositionEmitter.event; // 当前鼠标 hover 位置 hoveredPos: PositionSchema = { x: 0, y: 0 }; /** * 当前 hovered 的 节点或者线条或者点 * 1: nodeId / lineId (节点 / 线条) * 2: nodeId:portKey (节点连接点) */ hoveredKey = ''; /** * 更新 hover 的内容 * @param hoveredKey hovered key */ updateHoveredKey(hoveredKey: string): void { if (this.hoveredKey !== hoveredKey) { this.hoveredKey = hoveredKey; this.onHoveredChangeEmitter.fire(hoveredKey); } } updateHoverPosition(position: PositionSchema, target?: HTMLElement): void { this.hoveredPos = position; this.onUpdateHoverPositionEmitter.fire({ position, target, }); } /** * 清空 hover 内容 */ clearHovered(): void { this.updateHoveredKey(''); } /** * 判断是否 hover * @param nodeId hoveredKey * @returns 是否 hover */ isHovered(nodeId: string): boolean { return nodeId === this.hoveredKey; } isSomeHovered(): boolean { return !!this.hoveredKey; } /** * 获取被 hover 的节点或线条 * @deprecated use 'someHovered' instead */ get hoveredNode(): WorkflowEntityHoverable | undefined { return this.entityManager.getEntityById(this.hoveredKey); } /** * 获取被 hover 的节点或线条 */ get someHovered(): WorkflowEntityHoverable | undefined { return this.entityManager.getEntityById(this.hoveredKey); } } ================================================ FILE: packages/canvas-engine/free-layout-core/src/service/workflow-operation-base-service.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { inject } from 'inversify'; import { IPoint, Emitter } from '@flowgram.ai/utils'; import { FlowNodeEntityOrId, FlowOperationBaseServiceImpl } from '@flowgram.ai/document'; import { TransformData } from '@flowgram.ai/core'; import { WorkflowLinesManager } from '../workflow-lines-manager'; import { WorkflowDocument } from '../workflow-document'; import { NodePostionUpdateEvent, WorkflowOperationBaseService, } from '../typings/workflow-operation'; import { WorkflowJSON } from '../typings'; import { WorkflowNodeEntity, WorkflowLineEntity } from '../entities'; export class WorkflowOperationBaseServiceImpl extends FlowOperationBaseServiceImpl implements WorkflowOperationBaseService { @inject(WorkflowDocument) protected declare document: WorkflowDocument; @inject(WorkflowLinesManager) linesManager: WorkflowLinesManager; private onNodePostionUpdateEmitter = new Emitter(); public readonly onNodePostionUpdate = this.onNodePostionUpdateEmitter.event; updateNodePosition(nodeOrId: FlowNodeEntityOrId, position: IPoint): void { const node = this.toNodeEntity(nodeOrId); if (!node) { return; } const transformData = node.getData(TransformData); const oldPosition = { x: transformData.position.x, y: transformData.position.y, }; transformData.update({ position, }); this.onNodePostionUpdateEmitter.fire({ node, oldPosition, newPosition: position, }); } fromJSON(json: WorkflowJSON) { if (this.document.disposed) return; const workflowJSON: WorkflowJSON = { nodes: json.nodes ?? [], edges: json.edges ?? [], }; const oldNodes = this.document.getAllNodes(); const oldEdges = this.linesManager.getAllLines(); const oldPositionMap = new Map( oldNodes.map((node) => [ node.id, { x: node.transform.transform.position.x, y: node.transform.transform.position.y, }, ]) ); const newNodes: WorkflowNodeEntity[] = []; const newEdges: WorkflowLineEntity[] = []; // 逐层渲染 this.document.batchAddFromJSON(workflowJSON, { onNodeCreated: (node) => newNodes.push(node), onEdgeCreated: (edge) => newEdges.push(edge), }); const newEdgeIDSet = new Set(newEdges.map((edge) => edge.id)); oldEdges.forEach((edge) => { // 清空旧线条 if (!newEdgeIDSet.has(edge.id)) { edge.dispose(); return; } }); const newNodeIDSet = new Set(newNodes.map((node) => node.id)); oldNodes.forEach((node) => { // 清空旧节点 if (!newNodeIDSet.has(node.id)) { node.dispose(); return; } // 记录现有节点位置变更 const oldPosition = oldPositionMap.get(node.id); const newPosition = { x: node.transform.transform.position.x, y: node.transform.transform.position.y, }; if (oldPosition && (oldPosition.x !== newPosition.x || oldPosition.y !== newPosition.y)) { this.onNodePostionUpdateEmitter.fire({ node, oldPosition, newPosition, }); } }); // 批量触发画布更新 this.document.fireRender(); } } ================================================ FILE: packages/canvas-engine/free-layout-core/src/service/workflow-reset-layout-service.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { inject, injectable, postConstruct } from 'inversify'; import { PlaygroundConfigEntity } from '@flowgram.ai/core'; import { EntityManager } from '@flowgram.ai/core'; import { DisposableCollection, Emitter, type IPoint } from '@flowgram.ai/utils'; import { WorkflowDocument } from '../workflow-document'; import { layoutToPositions } from '../utils/layout-to-positions'; import { fitView } from '../utils'; import { WorkflowNodeEntity } from '../entities'; export type PositionMap = Record; /** * 重置布局服务 */ @injectable() export class WorkflowResetLayoutService { @inject(PlaygroundConfigEntity) private _config: PlaygroundConfigEntity; @inject(WorkflowDocument) private _document: WorkflowDocument; @inject(EntityManager) private _entityManager: EntityManager; private _resetLayoutEmitter = new Emitter<{ nodeIds: string[]; positionMap: PositionMap; oldPositionMap: PositionMap; }>(); /** * reset layout事件 */ readonly onResetLayout = this._resetLayoutEmitter.event; private _toDispose = new DisposableCollection(); /** * 初始化 */ @postConstruct() init() { this._toDispose.push(this._resetLayoutEmitter); } /** * 触发重置布局 * @param nodeIds 节点id * @param positionMap 新布局数据 * @param oldPositionMap 老布局数据 */ fireResetLayout(nodeIds: string[], positionMap: PositionMap, oldPositionMap: PositionMap) { this._resetLayoutEmitter.fire({ nodeIds, positionMap, oldPositionMap, }); } /** * 根据数据重新布局 * @param positionMap * @returns */ async layoutToPositions(nodeIds: string[], positionMap: PositionMap) { const nodes = nodeIds .map(id => this._entityManager.getEntityById(id)) .filter(Boolean) as WorkflowNodeEntity[]; const positions = await layoutToPositions(nodes, positionMap); fitView(this._document, this._config, true); return positions; } /** * 销毁 */ dispose() { this._toDispose.dispose(); } } ================================================ FILE: packages/canvas-engine/free-layout-core/src/service/workflow-select-service.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { inject, injectable } from 'inversify'; import { type Entity, Playground, SelectionService, TransformData, type PlaygroundConfigRevealOpts, } from '@flowgram.ai/core'; import { type Event, Rectangle, SizeSchema } from '@flowgram.ai/utils'; import { delay } from '../utils'; import { WorkflowNodeEntity } from '../entities'; import { type WorkfloEntityHoverable } from './workflow-hover-service'; @injectable() export class WorkflowSelectService { @inject(SelectionService) protected selectionService: SelectionService; @inject(Playground) protected playground: Playground; get onSelectionChanged(): Event { return this.selectionService.onSelectionChanged; } get selection(): Entity[] { return this.selectionService.selection; } set selection(entities: Entity[]) { this.selectionService.selection = entities; } /** * 当前激活的节点只能有一个 */ get activatedNode(): WorkflowNodeEntity | undefined { const { selectedNodes } = this; if (selectedNodes.length !== 1) { return undefined; } return selectedNodes[0]; } isSelected(id: string): boolean { return this.selectionService.selection.some(s => s.id === id); } isActivated(id: string): boolean { return this.activatedNode?.id === id; } /** * 选中的节点 */ get selectedNodes(): WorkflowNodeEntity[] { return this.selectionService.selection.filter( n => n instanceof WorkflowNodeEntity, ) as WorkflowNodeEntity[]; } /** * 选中 * @param node */ selectNode(node: WorkflowNodeEntity): void { this.selectionService.selection = [node]; } toggleSelect(node: WorkflowNodeEntity): void { if (this.selectionService.selection.includes(node)) { this.selectionService.selection = this.selectionService.selection.filter(n => n !== node); } else { this.selectionService.selection = this.selectionService.selection.concat(node); } } select(node: WorkfloEntityHoverable): void { this.selectionService.selection = [node]; } clear(): void { this.selectionService.selection = []; } /** * 选中并滚动到节点 * @param node */ async selectNodeAndScrollToView(node: WorkflowNodeEntity, fitView?: boolean): Promise { this.selectNodeAndFocus(node); const DELAY_TIME = 30; // 等待节点渲染完成(一般用于刚添加的节点) await delay(DELAY_TIME); const scrollConfig: PlaygroundConfigRevealOpts = { entities: [node], }; if (fitView) { const bounds = Rectangle.enlarge([node.getData(TransformData).bounds]).pad( 30, 30, ); // 留出 30 像素的边界 const viewport = this.playground.config.getViewport(false); const zoom = SizeSchema.fixSize(bounds, viewport); scrollConfig.zoom = zoom; scrollConfig.scrollToCenter = true; scrollConfig.easing = true; } return this.playground.config.scrollToView(scrollConfig); } selectNodeAndFocus(node: WorkflowNodeEntity): void { // 新添加的节点需要被选中 this.select(node); // 拖进来需要让画布聚焦, 才能使用快捷键删除 this.playground.node.focus(); } } ================================================ FILE: packages/canvas-engine/free-layout-core/src/typings/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export * from './workflow-json'; export * from './workflow-edge'; export * from './workflow-node'; export * from './workflow-registry'; export * from './workflow-line'; export * from './workflow-sub-canvas'; export * from './workflow-operation'; export * from './workflow-drag'; export const URLParams = Symbol(''); export interface URLParams { [key: string]: string; } ================================================ FILE: packages/canvas-engine/free-layout-core/src/typings/workflow-drag.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import type React from 'react'; import { type PositionSchema } from '@flowgram.ai/utils'; import { type FlowNodeEntity } from '@flowgram.ai/document'; import { PlaygroundDrag, type PlaygroundDragEvent } from '@flowgram.ai/core'; import { type WorkflowLineEntity, type WorkflowPortEntity } from '../entities'; export interface LineEventProps { type: 'onDrag' | 'onDragEnd'; onDragNodeId?: string; event?: MouseEvent; } interface INodesDragEvent { type: string; nodes: FlowNodeEntity[]; startPositions: PositionSchema[]; dragEvent: PlaygroundDragEvent; triggerEvent: MouseEvent | React.MouseEvent; dragger: PlaygroundDrag; } export interface NodesDragStartEvent extends INodesDragEvent { type: 'onDragStart'; } export interface NodesDragEndEvent extends INodesDragEvent { type: 'onDragEnd'; } export interface NodesDraggingEvent extends INodesDragEvent { type: 'onDragging'; positions: PositionSchema[]; } export type NodesDragEvent = NodesDragStartEvent | NodesDraggingEvent | NodesDragEndEvent; export type onDragLineEndParams = { fromPort?: WorkflowPortEntity; toPort?: WorkflowPortEntity; mousePos: PositionSchema; line?: WorkflowLineEntity; originLine?: WorkflowLineEntity; event: PlaygroundDragEvent; }; export type OnDragLineEnd = (params: onDragLineEndParams) => Promise; ================================================ FILE: packages/canvas-engine/free-layout-core/src/typings/workflow-edge.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ /** * 边数据 */ export interface WorkflowEdgeJSON { sourceNodeID: string; targetNodeID: string; sourcePortID?: string | number; targetPortID?: string | number; data?: any; } ================================================ FILE: packages/canvas-engine/free-layout-core/src/typings/workflow-json.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { type WorkflowLineEntity, type WorkflowNodeEntity } from '../entities'; import { type WorkflowNodeJSON } from './workflow-node'; import { type WorkflowEdgeJSON } from './workflow-edge'; export interface WorkflowJSON { nodes: WorkflowNodeJSON[]; edges: WorkflowEdgeJSON[]; } export enum WorkflowContentChangeType { /** * 添加节点 */ ADD_NODE = 'ADD_NODE', /** * 删除节点 */ DELETE_NODE = 'DELETE_NODE', /** * 移动节点 */ MOVE_NODE = 'MOVE_NODE', /** * 节点数据更新 (表单引擎数据 或者 extInfo 数据) */ NODE_DATA_CHANGE = 'NODE_DATA_CHANGE', /** * 添加线条 */ ADD_LINE = 'ADD_LINE', /** * 删除线条 */ DELETE_LINE = 'DELETE_LINE', /** * 线条数据修改 */ LINE_DATA_CHANGE = 'LINE_DATA_CHANGE', /** * 节点Meta信息变更 */ META_CHANGE = 'META_CHANGE', } export interface WorkflowContentChangeEvent { type: WorkflowContentChangeType; /** * 当前触发的元素的json数据,toJSON 需要主动触发 */ toJSON: () => any; /** * oldValue */ oldValue?: any; /* * 当前的事件的 entity */ entity: WorkflowNodeEntity | WorkflowLineEntity; } ================================================ FILE: packages/canvas-engine/free-layout-core/src/typings/workflow-line.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import type { Rectangle, IPoint } from '@flowgram.ai/utils'; import { type WorkflowLineEntity } from '../entities'; export enum LineType { BEZIER, // 贝塞尔曲线 LINE_CHART, // 折叠线 STRAIGHT, // 直线 } export type LineRenderType = LineType | string; export type LinePointLocation = 'left' | 'top' | 'right' | 'bottom'; export interface LinePoint { x: number; y: number; location: LinePointLocation; } export interface LinePosition { from: LinePoint; to: LinePoint; } export interface LineColor { hidden: string; default: string; drawing: string; hovered: string; selected: string; error: string; flowing: string; } export enum LineColors { HIDDEN = 'var(--g-workflow-line-color-hidden,transparent)', // 隐藏线条 DEFUALT = 'var(--g-workflow-line-color-default,#4d53e8)', DRAWING = 'var(--g-workflow-line-color-drawing, #5DD6E3)', // '#b5bbf8', // '#9197F1', HOVER = 'var(--g-workflow-line-color-hover,#37d0ff)', SELECTED = 'var(--g-workflow-line-color-selected,#37d0ff)', ERROR = 'var(--g-workflow-line-color-error,red)', FLOWING = 'var(--g-workflow-line-color-flowing,#4d53e8)', // 流动线条,默认使用主题色 } export interface LineCenterPoint { x: number; y: number; labelX: number; // Relative to where the line begins labelY: number; // Relative to where the line begins } export interface WorkflowLineRenderContribution { entity: WorkflowLineEntity; path: string; center?: LineCenterPoint; bounds: Rectangle; update: (params: { fromPos: LinePoint; toPos: LinePoint }) => void; calcDistance: (pos: IPoint) => number; } export type WorkflowLineRenderContributionFactory = (new ( entity: WorkflowLineEntity ) => WorkflowLineRenderContribution) & { type: LineRenderType; }; ================================================ FILE: packages/canvas-engine/free-layout-core/src/typings/workflow-node.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import type { IPoint } from '@flowgram.ai/utils'; import type { FlowNodeJSON, FlowNodeMeta } from '@flowgram.ai/document'; import type { WorkflowNodeEntity, WorkflowPorts } from '../entities'; import type { WorkflowSubCanvas } from './workflow-sub-canvas'; import type { WorkflowEdgeJSON } from './workflow-edge'; /** * 节点 meta 信息 */ export interface WorkflowNodeMeta extends FlowNodeMeta { position?: IPoint; canvasPosition?: IPoint; // 子画布位置 deleteDisable?: boolean; // 是否禁用删除 copyDisable?: boolean; // 禁用复制 inputDisable?: boolean; // 禁用输入点 outputDisable?: boolean; // 禁用输出点 defaultPorts?: WorkflowPorts; // 默认点位 useDynamicPort?: boolean; // 使用动态点位,会计算 data-port-key subCanvas?: (node: WorkflowNodeEntity) => WorkflowSubCanvas | undefined; isContainer?: boolean; // 是否容器节点 } /** * 节点数据 */ export interface WorkflowNodeJSON extends FlowNodeJSON { id: string; type: string | number; /** * ui 数据 */ meta?: WorkflowNodeMeta; /** * 表单数据 */ data?: any; /** * 子节点 */ blocks?: WorkflowNodeJSON[]; /** * 子节点间连线 */ edges?: WorkflowEdgeJSON[]; } ================================================ FILE: packages/canvas-engine/free-layout-core/src/typings/workflow-operation.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { IPoint, Event } from '@flowgram.ai/utils'; import { FlowNodeEntity, FlowNodeEntityOrId, FlowOperationBaseService, } from '@flowgram.ai/document'; import { WorkflowJSON } from './workflow-json'; export interface NodePostionUpdateEvent { node: FlowNodeEntity; oldPosition: IPoint; newPosition: IPoint; } export interface WorkflowOperationBaseService extends FlowOperationBaseService { /** * 节点位置更新事件 */ readonly onNodePostionUpdate: Event; /** * 更新节点位置 * @param nodeOrId * @param position * @returns */ updateNodePosition(nodeOrId: FlowNodeEntityOrId, position: IPoint): void; /** * 更新节点与线条 */ fromJSON(json: WorkflowJSON): void; } export const WorkflowOperationBaseService = Symbol('WorkflowOperationBaseService'); ================================================ FILE: packages/canvas-engine/free-layout-core/src/typings/workflow-registry.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import type { FormMeta } from '@flowgram.ai/node'; import type { FormMetaOrFormMetaGenerator } from '@flowgram.ai/form-core'; import type { FlowNodeRegistry } from '@flowgram.ai/document'; import type { WorkflowNodeEntity, WorkflowPortEntity } from '../entities'; import type { WorkflowNodeMeta } from './workflow-node'; import type { WorkflowLinesManager } from '../workflow-lines-manager'; /** * 节点表单引擎配置 */ export type WorkflowNodeFormMeta = FormMetaOrFormMetaGenerator | FormMeta; /** * 节点注册 */ export interface WorkflowNodeRegistry extends FlowNodeRegistry { formMeta?: WorkflowNodeFormMeta; canAddLine?: ( fromPort: WorkflowPortEntity, toPort: WorkflowPortEntity, lines: WorkflowLinesManager, silent?: boolean ) => boolean; } export interface WorkflowNodeRenderProps { node: WorkflowNodeEntity; } ================================================ FILE: packages/canvas-engine/free-layout-core/src/typings/workflow-sub-canvas.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import type { WorkflowNodeEntity } from '../entities'; /** * 子画布配置 */ export type WorkflowSubCanvas = { isCanvas: boolean; // 是否画布节点 parentNode: WorkflowNodeEntity; // 父节点 canvasNode: WorkflowNodeEntity; // 画布节点 }; ================================================ FILE: packages/canvas-engine/free-layout-core/src/utils/build-group-json.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { FlowNodeBaseType } from '@flowgram.ai/document'; import { WorkflowJSON, WorkflowNodeJSON } from '../typings'; interface WorkflowGroupJSON extends WorkflowNodeJSON { data: { parentID?: string; blockIDs?: string[]; }; } export const buildGroupJSON = (json: WorkflowJSON): WorkflowJSON => { const { nodes, edges } = json; const groupJSONs = nodes.filter( (nodeJSON) => nodeJSON.type === FlowNodeBaseType.GROUP ) as WorkflowGroupJSON[]; const nodeJSONMap = new Map(nodes.map((n) => [n.id, n])); const groupNodeJSONs = groupJSONs.map((groupJSON): WorkflowNodeJSON => { const groupBlocks = (groupJSON.data.blockIDs ?? []) .map((blockID) => nodeJSONMap.get(blockID)) .filter(Boolean) as WorkflowNodeJSON[]; const groupEdges = edges?.filter((edge) => groupBlocks.some((block) => block.id === edge.sourceNodeID || block.id === edge.targetNodeID) ); const groupNodeJSON: WorkflowNodeJSON = { ...groupJSON, blocks: groupBlocks, edges: groupEdges, }; return groupNodeJSON; }); const groupBlockSet = new Set(groupJSONs.map((groupJSON) => groupJSON.data.blockIDs).flat()); const processedNodes = nodes .filter((nodeJSON) => !groupBlockSet.has(nodeJSON.id)) .concat(groupNodeJSONs); return { nodes: processedNodes, edges, }; }; ================================================ FILE: packages/canvas-engine/free-layout-core/src/utils/compose.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export { compose, composeAsync } from '@flowgram.ai/utils'; ================================================ FILE: packages/canvas-engine/free-layout-core/src/utils/fit-view.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { Rectangle } from '@flowgram.ai/utils'; import { type PlaygroundConfigEntity, TransformData } from '@flowgram.ai/core'; import { type WorkflowDocument } from '../workflow-document'; export const fitView = ( doc: WorkflowDocument, playgroundConfig: PlaygroundConfigEntity, easing = true ) => { const bounds = Rectangle.enlarge( doc.getAllNodes().map((node) => node.getData(TransformData).bounds) ); // 留出 30 像素的边界 return playgroundConfig.fitView(bounds, easing, 30); }; ================================================ FILE: packages/canvas-engine/free-layout-core/src/utils/flow-node-form-data.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { FlowNodeFormData } from '@flowgram.ai/form-core'; import { FlowNodeEntity, FlowNodeJSON } from '@flowgram.ai/document'; import { type WorkflowDocument } from '../workflow-document'; import { WorkflowContentChangeType, type WorkflowNodeRegistry } from '../typings'; export function getFlowNodeFormData(node: FlowNodeEntity) { return node.getData(FlowNodeFormData) as FlowNodeFormData; } export function toFormJSON(node: FlowNodeEntity) { const formData = node.getData(FlowNodeFormData) as FlowNodeFormData; if (!formData || !(node.getNodeRegistry() as WorkflowNodeRegistry).formMeta) return undefined; return formData.toJSON(); } export function initFormDataFromJSON( node: FlowNodeEntity, json: FlowNodeJSON, isFirstCreate: boolean ) { const formData = node.getData(FlowNodeFormData)!; const registry = node.getNodeRegistry(); const { formMeta } = registry; if (formData && formMeta) { if (isFirstCreate) { formData.createForm(formMeta, json.data); formData.onDataChange(() => { (node.document as WorkflowDocument).fireContentChange({ type: WorkflowContentChangeType.NODE_DATA_CHANGE, toJSON: () => formData.toJSON(), entity: node, }); }); } else { formData.updateFormValues(json.data); } } } ================================================ FILE: packages/canvas-engine/free-layout-core/src/utils/get-anti-overlap-position.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { TransformData } from '@flowgram.ai/core'; import { type IPoint } from '@flowgram.ai/utils'; import { type WorkflowDocument } from '../workflow-document'; import { WorkflowNodeEntity } from '../entities'; /** * 获取没有碰撞的位置 * 距离很小时,xy 各偏移 30 * @param position */ export function getAntiOverlapPosition( doc: WorkflowDocument, position: IPoint, containerNode?: WorkflowNodeEntity, ): IPoint { let { x, y } = position; const nodes = containerNode ? containerNode.collapsedChildren : doc.getAllNodes(); const positions = nodes .map(n => { const transform = n.getData(TransformData)!; return { x: transform.position.x, y: transform.position.y }; }) .sort((a, b) => a.y - b.y); const minDistance = 3; for (const pos of positions) { const { x: posX, y: posY } = pos; if (y - posY < -minDistance) { break; } const deltaX = Math.abs(x - posX); const deltaY = Math.abs(y - posY); if (deltaX <= minDistance && deltaY <= minDistance) { x += 30; y += 30; } } return { x, y }; } ================================================ FILE: packages/canvas-engine/free-layout-core/src/utils/get-line-center.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { IPoint, Rectangle } from '@flowgram.ai/utils'; import { LineCenterPoint } from '../typings'; export function getLineCenter( from: IPoint, to: IPoint, bbox: Rectangle, linePadding: number ): LineCenterPoint { return { x: bbox.center.x, y: bbox.center.y, labelX: bbox.center.x - bbox.x + linePadding, labelY: bbox.center.y - bbox.y + linePadding, }; } ================================================ FILE: packages/canvas-engine/free-layout-core/src/utils/get-url-params.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export function getUrlParams(): Record { const paramsMap = new Map(); location.search .replace(/^\?/, '') .split('&') .forEach((key) => { if (!key) return; const [k, v] = key.split('='); if (k) { // Decode URL-encoded parameter names and values const decodedKey = decodeURIComponent(k.trim()); const decodedValue = v ? decodeURIComponent(v.trim()) : ''; // Prevent prototype pollution by filtering dangerous property names const dangerousProps = [ '__proto__', 'constructor', 'prototype', '__defineGetter__', '__defineSetter__', '__lookupGetter__', '__lookupSetter__', 'hasOwnProperty', 'isPrototypeOf', 'propertyIsEnumerable', 'toString', 'valueOf', 'toLocaleString', ]; if (dangerousProps.includes(decodedKey.toLowerCase())) { return; } // Use Map to prevent prototype pollution paramsMap.set(decodedKey, decodedValue); } }); // Convert Map to plain object while maintaining API compatibility return Object.fromEntries(paramsMap); } ================================================ FILE: packages/canvas-engine/free-layout-core/src/utils/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { bindConfigEntity } from '@flowgram.ai/core'; export { delay } from '@flowgram.ai/utils'; /** * 让 entity 可以注入到类中 * * @example * ``` * class SomeClass { * @inject(PlaygroundConfigEntity) playgroundConfig: PlaygroundConfigEntity * } * ``` * @param bind * @param entityRegistry */ export { bindConfigEntity }; export { buildGroupJSON } from './build-group-json'; export { getLineCenter } from './get-line-center'; export * from './nanoid'; export * from './compose'; export * from './fit-view'; export * from './get-anti-overlap-position'; export * from './statics'; ================================================ FILE: packages/canvas-engine/free-layout-core/src/utils/layout-to-positions.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { type IPoint } from '@flowgram.ai/utils'; import { FlowNodeTransformData } from '@flowgram.ai/document'; import { TransformData, startTween } from '@flowgram.ai/core'; import { type WorkflowDocument } from '../workflow-document'; import { type WorkflowNodeEntity } from '../entities'; /** * Coze 中节点坐标,以卡片顶部中间为原点。 * autoLayout 计算出来的对齐的坐标以节点正中为原点,需要上移当前节点一般高度。 * 即: newPosition.y - transform.bounds.height / 2 * bounds 的原点坐标为左上角。 */ export const layoutToPositions = async ( nodes: WorkflowNodeEntity[], nodePositionMap: Record ): Promise> => { // 缓存上次位置,用来还原位置 const newNodePositionMap: Record = {}; nodes.forEach((node) => { const transform = node.getData(TransformData); const nodeTransform = node.getData(FlowNodeTransformData); newNodePositionMap[node.id] = { x: transform.position.x, y: transform.position.y + nodeTransform.bounds.height / 2, }; }); return new Promise((resolve) => { startTween({ from: { d: 0 }, to: { d: 100 }, duration: 300, onUpdate: (v) => { nodes.forEach((node) => { const transform = node.getData(TransformData); const deltaX = ((nodePositionMap[node.id].x - transform.position.x) * v.d) / 100; const deltaY = ((nodePositionMap[node.id].y - transform.bounds.height / 2 - transform.position.y) * v.d) / 100; transform.update({ position: { x: transform.position.x + deltaX, y: transform.position.y + deltaY, }, }); const document = node.document as WorkflowDocument; document.layout.updateAffectedTransform(node); }); }, onComplete: () => { resolve(newNodePositionMap); }, }); }); }; ================================================ FILE: packages/canvas-engine/free-layout-core/src/utils/location-config-to-point.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { Rectangle, IPoint } from '@flowgram.ai/utils'; import { WorkflowPort } from '../entities'; export function locationConfigToPoint( bounds: Rectangle, config: Required['locationConfig'], _offset: IPoint = { x: 0, y: 0 } ): IPoint { const offset = { ..._offset }; if (config.left !== undefined) { offset.x += typeof config.left === 'string' ? parseFloat(config.left) * 0.01 * bounds.width : config.left; } else if (config.right !== undefined) { offset.x += bounds.width - (typeof config.right === 'string' ? parseFloat(config.right) * 0.01 * bounds.width : config.right); } if (config.top !== undefined) { offset.y += typeof config.top === 'string' ? parseFloat(config.top) * 0.01 * bounds.height : config.top; } else if (config.bottom !== undefined) { offset.y += bounds.height - (typeof config.bottom === 'string' ? parseFloat(config.bottom) * 0.01 * bounds.height : config.bottom); } return { x: bounds.x + offset.x, y: bounds.y + offset.y, }; } ================================================ FILE: packages/canvas-engine/free-layout-core/src/utils/nanoid.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { nanoid as nanoidOrigin } from 'nanoid'; export function nanoid(n?: number): string { return nanoidOrigin(n); } ================================================ FILE: packages/canvas-engine/free-layout-core/src/utils/statics.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { Rectangle } from '@flowgram.ai/utils'; import { type WorkflowNodeEntity } from '../entities/workflow-node-entity'; export type WorkflowPortType = 'input' | 'output'; export const getPortEntityId = ( node: WorkflowNodeEntity, portType: WorkflowPortType, portID: string | number = '', ): string => `port_${portType}_${node.id}_${portID}`; export const WORKFLOW_LINE_ENTITY = 'WorkflowLineEntity'; export function domReactToBounds(react: DOMRect): Rectangle { return new Rectangle(react.x, react.y, react.width, react.height); } ================================================ FILE: packages/canvas-engine/free-layout-core/src/workflow-commands.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export enum WorkflowCommands { DELETE_NODES = 'DELETE_NODES', COPY_NODES = 'COPY_NODES', PASTE_NODES = 'PASTE_NODES', ZOOM_IN = 'ZOOM_IN', ZOOM_OUT = 'ZOOM_OUT', UNDO = 'UNDO', REDO = 'REDO', } ================================================ FILE: packages/canvas-engine/free-layout-core/src/workflow-document-container-module.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { ContainerModule } from 'inversify'; import { bindContributions } from '@flowgram.ai/utils'; import { FlowDocument, FlowDocumentContribution } from '@flowgram.ai/document'; import { WorkflowLinesManager } from './workflow-lines-manager'; import { WorkflowDocumentOptions, WorkflowDocumentOptionsDefault, } from './workflow-document-option'; import { WorkflowDocumentContribution } from './workflow-document-contribution'; import { WorkflowDocument, WorkflowDocumentProvider } from './workflow-document'; import { getUrlParams } from './utils/get-url-params'; import { URLParams, WorkflowOperationBaseService } from './typings'; import { WorkflowDragService, WorkflowHoverService, WorkflowSelectService, WorkflowResetLayoutService, WorkflowOperationBaseServiceImpl, } from './service'; import { FreeLayout } from './layout'; export const WorkflowDocumentContainerModule = new ContainerModule( (bind, unbind, isBound, rebind) => { bind(WorkflowDocument).toSelf().inSingletonScope(); bind(WorkflowLinesManager).toSelf().inSingletonScope(); bind(FreeLayout).toSelf().inSingletonScope(); bind(WorkflowDragService).toSelf().inSingletonScope(); bind(WorkflowSelectService).toSelf().inSingletonScope(); bind(WorkflowHoverService).toSelf().inSingletonScope(); bind(WorkflowResetLayoutService).toSelf().inSingletonScope(); bind(WorkflowOperationBaseService).to(WorkflowOperationBaseServiceImpl).inSingletonScope(); bind(URLParams) .toDynamicValue(() => getUrlParams()) .inSingletonScope(); bindContributions(bind, WorkflowDocumentContribution, [FlowDocumentContribution]); bind(WorkflowDocumentOptions).toConstantValue({ ...WorkflowDocumentOptionsDefault, }); rebind(FlowDocument).toService(WorkflowDocument); bind(WorkflowDocumentProvider) .toDynamicValue((ctx) => () => ctx.container.get(WorkflowDocument)) .inSingletonScope(); } ); ================================================ FILE: packages/canvas-engine/free-layout-core/src/workflow-document-contribution.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { injectable, inject } from 'inversify'; import { type FlowDocumentContribution, FlowNodeRenderData, FlowNodeTransformData, } from '@flowgram.ai/document'; import { WorkflowDocument } from './workflow-document'; import { FreeLayout } from './layout'; import { WorkflowNodeLinesData, WorkflowNodePortsData } from './entity-datas'; @injectable() export class WorkflowDocumentContribution implements FlowDocumentContribution { @inject(FreeLayout) freeLayout: FreeLayout; registerDocument(document: WorkflowDocument): void { // 注册节点数据 document.registerNodeDatas( FlowNodeTransformData, FlowNodeRenderData, WorkflowNodePortsData, WorkflowNodeLinesData, ); document.registerLayout(this.freeLayout); } } ================================================ FILE: packages/canvas-engine/free-layout-core/src/workflow-document-option.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { FlowNodeErrorData } from '@flowgram.ai/form-core'; import { FlowDocumentOptions, FlowNodeTransformData, FlowNodeType } from '@flowgram.ai/document'; import { TransformData } from '@flowgram.ai/core'; import { type WorkflowLinesManager } from './workflow-lines-manager'; import { initFormDataFromJSON, toFormJSON } from './utils/flow-node-form-data'; import { LineColor, LineRenderType, onDragLineEndParams, WorkflowNodeJSON, WorkflowNodeMeta, } from './typings'; import { type WorkflowLineEntity, type WorkflowLinePortInfo, type WorkflowNodeEntity, type WorkflowPortEntity, } from './entities'; export const WorkflowDocumentOptions = Symbol('WorkflowDocumentOptions'); /** * 线条配置 */ export interface WorkflowDocumentOptions extends FlowDocumentOptions { cursors?: { grab?: string; grabbing?: string; }; /** 双向连接 */ twoWayConnection?: boolean; /** 允许拖拽只读节点 */ enableReadonlyNodeDragging?: boolean; /** 线条颜色 */ lineColor?: Partial; /** 是否显示错误线条 */ isErrorLine?: ( fromPort: WorkflowPortEntity | undefined, toPort: WorkflowPortEntity | undefined, lines: WorkflowLinesManager ) => boolean; /** 是否错误端口 */ isErrorPort?: (port: WorkflowPortEntity) => boolean; /** 是否禁用端口 */ isDisabledPort?: (port: WorkflowPortEntity) => boolean; /** 是否反转线条箭头 */ isReverseLine?: (line: WorkflowLineEntity) => boolean; /** 是否隐藏线条箭头 */ isHideArrowLine?: (line: WorkflowLineEntity) => boolean; /** 是否流动线条 */ isFlowingLine?: (line: WorkflowLineEntity) => boolean; /** 是否禁用线条 */ isDisabledLine?: (line: WorkflowLineEntity) => boolean; /** 拖拽线条结束 */ onDragLineEnd?: (params: onDragLineEndParams) => Promise; /** 获取线条渲染器 */ setLineRenderType?: (line: WorkflowLineEntity) => LineRenderType | undefined; /** 设置线条样式 */ setLineClassName?: (line: WorkflowLineEntity) => string | undefined; /** 能否添加线条 */ canAddLine?: ( fromPort: WorkflowPortEntity, toPort: WorkflowPortEntity, lines: WorkflowLinesManager, silent?: boolean ) => boolean; /** 能否删除节点 */ canDeleteNode?: (node: WorkflowNodeEntity, silent?: boolean) => boolean; /** 能否删除线条 */ canDeleteLine?: ( line: WorkflowLineEntity, newLineInfo?: Required>, silent?: boolean ) => boolean; /** * @param fromPort - 开始点 * @param oldToPort - 旧的连接点 * @param newToPort - 新的连接点 * @param lines - 线条管理器 */ canResetLine?: ( oldLine: WorkflowLineEntity, newLineInfo: Required, lines: WorkflowLinesManager ) => boolean; /** * 是否允许拖入子画布 (loop or group) * Whether to allow dragging into the sub-canvas (loop or group) * @param params */ canDropToNode?: (params: { dragNodeType?: FlowNodeType; dragNode?: WorkflowNodeEntity; dropNode?: WorkflowNodeEntity; dropNodeType?: FlowNodeType; }) => boolean; } export const WorkflowDocumentOptionsDefault: WorkflowDocumentOptions = { // cursors: { // grab: 'url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjEiIHZpZXdCb3g9IjAgMCAyMCAyMSIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xMC40ODczIDIuNjIzNzhDOS45MDczMSAyLjYyMzc4IDkuNDM3MTMgMy4wOTM5NiA5LjQzNzEzIDMuNjczOTZWNS4xNDM3NkM5LjM5NDI4IDQuNDAyNzQgOC43Nzk3OCAzLjgxNTA0IDguMDI4MDIgMy44MTUwNEM3LjI0ODQ4IDMuODE1MDQgNi42MTY1MyA0LjQ0Njk5IDYuNjE2NTMgNS4yMjY1M1YxMS44Mjg5TDUuNjc0MTggMTEuMDA0OUM1LjE1NDg3IDEwLjU1MDkgNC40MDk1IDEwLjQ2MzYgMy43OTkzOCAxMC43ODU1TDMuNjk2OTQgMTAuODM5NkMzLjA2MjE3IDExLjE3NDUgMi45MjI2IDEyLjAyMjggMy40MTY2MiAxMi41NDM0TDcuMzM5NTkgMTYuNjc3NVYxNy4zMjU5QzcuMzM5NTkgMTcuNzg2MiA3LjcxMjY5IDE4LjE1OTMgOC4xNzI5MiAxOC4xNTkzSDEzLjgwODRDMTQuMjY4NyAxOC4xNTkzIDE0LjY0MTcgMTcuNzg2MiAxNC42NDE3IDE3LjMyNTlWMTYuNzkzNUMxNS44MDk0IDE1LjY0ODUgMTYuNDY3MyAxNC4wODE5IDE2LjQ2NzMgMTIuNDQ2NVYxMS40OTY3TDE2LjQ2NzEgNi42MzY4NUMxNi40NjcxIDUuOTU2MyAxNS45MTU0IDUuNDA0NjEgMTUuMjM0OCA1LjQwNDYxQzE0LjU1NDMgNS40MDQ2MSAxNC4wMDI2IDUuOTU2MyAxNC4wMDI2IDYuNjM2ODVMMTQuMDAyMSA1LjA0NzI4QzE0LjAwMjEgNC4zNjY3MyAxMy40NTA0IDMuODE1MDQgMTIuNzY5OCAzLjgxNTA0QzEyLjA4OTMgMy44MTUwNCAxMS41Mzc2IDQuMzY2NzMgMTEuNTM3NiA1LjA0NzI4TDExLjUzNzUgMy42NzM5NUMxMS41Mzc1IDMuMDkzOTYgMTEuMDY3MyAyLjYyMzc4IDEwLjQ4NzMgMi42MjM3OFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTAuNDg3NCAxLjM3NDAyQzExLjM2MTIgMS4zNzQwMiAxMi4xMjExIDEuODYxMTggMTIuNTEwNSAyLjU3ODY4QzEyLjU5NTggMi41Njk4MyAxMi42ODIzIDIuNTY1MjggMTIuNzcgMi41NjUyOEMxMy44Mjc4IDIuNTY1MjggMTQuNzMxMSAzLjIyNzAxIDE1LjA4ODUgNC4xNTkxMUMxNS4xMzcgNC4xNTYyOSAxNS4xODU4IDQuMTU0ODYgMTUuMjM1IDQuMTU0ODZDMTYuNjA1OSA0LjE1NDg2IDE3LjcxNzIgNS4yNjYxOSAxNy43MTcyIDYuNjM3MDlMMTcuNzE3NCAxMi40NDY3QzE3LjcxNzQgMTQuMjM1NSAxNy4wNjQ0IDE1Ljk1NTkgMTUuODkxOSAxNy4yOTA0VjE3LjMyNjJDMTUuODkxOSAxOC40NzY4IDE0Ljk1OTEgMTkuNDA5NSAxMy44MDg1IDE5LjQwOTVIOC4xNzMwNkM3LjAyMjQ3IDE5LjQwOTUgNi4wODk3MyAxOC40NzY4IDYuMDg5NzMgMTcuMzI2MlYxNy4xNzY0TDIuNTEwMDMgMTMuNDA0MUMxLjQ0NTk5IDEyLjI4MjggMS43NDY2IDEwLjQ1NTUgMy4xMTM3OSA5LjczNDI0TDMuMjE2MjQgOS42ODAxOUMzLjg5MTY4IDkuMzIzODMgNC42NjE4NSA5LjI1NDAxIDUuMzY2NjYgOS40NTE5OFY1LjIyNjc4QzUuMzY2NjYgMy43NTY4NyA2LjU1ODI2IDIuNTY1MjggOC4wMjgxNiAyLjU2NTI4QzguMTcyOTMgMi41NjUyOCA4LjMxNDk5IDIuNTc2ODQgOC40NTM0NyAyLjU5OTA3QzguODM5NDMgMS44NzA0MiA5LjYwNTQ2IDEuMzc0MDIgMTAuNDg3NCAxLjM3NDAyWk0xMi40NDc2IDMuODU3ODdWOS40NzY0NkMxMi40NDc2IDkuNzI4NTIgMTIuMjQzMyA5LjkzMjg1IDExLjk5MTMgOS45MzI4NUMxMS43MzkyIDkuOTMyODUgMTEuNTM0OSA5LjcyODUyIDExLjUzNDkgOS40NzY0NlYzLjc5NjU1QzExLjUzNDkgMy43Nzk1NiAxMS41MzU4IDMuNzYyNzcgMTEuNTM3NiAzLjc0NjI2VjMuNjc0MkMxMS41Mzc2IDMuNDMyODIgMTEuNDU2MiAzLjIxMDQ2IDExLjMxOTMgMy4wMzMwOUMxMS4xMjcyIDIuNzg0MjggMTAuODI2MSAyLjYyNDAyIDEwLjQ4NzQgMi42MjQwMkMxMC4xMjM4IDIuNjI0MDIgOS44MDMzMiAyLjgwODg2IDkuNjE0ODMgMy4wODk3QzkuNTAyNjkgMy4yNTY3OSA5LjQzNzI2IDMuNDU3ODUgOS40MzcyNiAzLjY3NDJWMy43ODU3M0M5LjQzNzM1IDMuNzg5MzMgOS40MzczOSAzLjc5Mjk0IDkuNDM3MzkgMy43OTY1NVY5LjkwMTdDOS40MzczOSAxMC4xNTM3IDkuMjMzMDYgMTAuMzU4MSA4Ljk4MTAxIDEwLjM1ODFDOC43Mjg5NSAxMC4zNTgxIDguNTI0NjIgMTAuMTUzNyA4LjUyNDYyIDkuOTAxN1YzLjkwNTA3QzguNDE3NzMgMy44NjQ5IDguMzA0NjggMy44MzczMiA4LjE4NzI2IDMuODI0MTVDOC4xMzUwNCAzLjgxODI5IDguMDgxOTUgMy44MTUyOCA4LjAyODE2IDMuODE1MjhDNy4yNDg2MSAzLjgxNTI4IDYuNjE2NjYgNC40NDcyMyA2LjYxNjY2IDUuMjI2NzhWMTEuODI5Mkw1LjY3NDMxIDExLjAwNTJDNS41Nzg2OCAxMC45MjE2IDUuNDc1MzcgMTAuODUwNCA1LjM2NjY2IDEwLjc5MTlDNC44ODUwNiAxMC41MzI5IDQuMjk3MjggMTAuNTIzMSAzLjc5OTUyIDEwLjc4NThMMy42OTcwNyAxMC44Mzk4QzMuMDYyMzEgMTEuMTc0NyAyLjkyMjczIDEyLjAyMzEgMy40MTY3NSAxMi41NDM3TDcuMzM5NzMgMTYuNjc3N1YxNy4zMjYyQzcuMzM5NzMgMTcuNzg2NCA3LjcxMjgyIDE4LjE1OTUgOC4xNzMwNiAxOC4xNTk1SDEzLjgwODVDMTQuMjY4OCAxOC4xNTk1IDE0LjY0MTkgMTcuNzg2NCAxNC42NDE5IDE3LjMyNjJWMTYuNzkzOEMxNS43Mzc5IDE1LjcxOSAxNi4zODQ3IDE0LjI3MjggMTYuNDYgMTIuNzQ3QzE2LjQ2NDEgMTIuNjY0MSAxNi40NjY1IDEyLjU4MDkgMTYuNDY3MiAxMi40OTc1TDE2LjQ2NzQgMTIuNDQ2N0wxNi40NjcyIDYuNjM3MDlDMTYuNDY3MiA1Ljk2MjMgMTUuOTI0OCA1LjQxNDE5IDE1LjI1MjIgNS40MDQ5N0wxNS4yMzUgNS40MDQ4NkMxNS4xMjQ2IDUuNDA0ODYgMTUuMDE3NyA1LjQxOTM2IDE0LjkxNTkgNS40NDY1NlY5LjYwMjI2QzE0LjkxNTkgOS44NTQzMSAxNC43MTE2IDEwLjA1ODYgMTQuNDU5NSAxMC4wNTg2QzE0LjIwNzUgMTAuMDU4NiAxNC4wMDMxIDkuODU0MzEgMTQuMDAzMSA5LjYwMjI2VjYuNjA1MTRDMTQuMDAyOSA2LjYxNTc2IDE0LjAwMjcgNi42MjY0MSAxNC4wMDI3IDYuNjM3MDlWOS4yNzcwNUwxNC4wMDIyIDUuMDQ3NTJDMTQuMDAyMiA0Ljg2OTEzIDEzLjk2NDMgNC42OTk2IDEzLjg5NjEgNC41NDY1M0MxMy43MDY0IDQuMTIwNzIgMTMuMjgyMiAzLjgyMjM1IDEyLjc4NzYgMy44MTU0MUwxMi43NyAzLjgxNTI4QzEyLjY1ODQgMy44MTUyOCAxMi41NTA0IDMuODMwMSAxMi40NDc2IDMuODU3ODdaIiBmaWxsPSIjMUQxQzIzIi8+Cjwvc3ZnPg=="), auto', // grabbing: // 'url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjEiIHZpZXdCb3g9IjAgMCAyMCAyMSIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik02LjYxODE3IDUuNTk4NzVDNi42MTgxNyA0LjgxOTIgNy4yNTAxMiA0LjE4NzI2IDguMDI5NjcgNC4xODcyNkM4Ljc3ODczIDQuMTg3MjYgOS4zOTE1MiA0Ljc3MDc1IDkuNDM4MjkgNS41MDgwMUM5LjQ1OTkyIDQuOTQ3MSA5LjkyMTQ3IDQuNDk5MDIgMTAuNDg3NyA0LjQ5OTAyQzExLjA2NzcgNC40OTkwMiAxMS41Mzc4IDQuOTY5MiAxMS41Mzc4IDUuNTQ5MTlWOC43NjI0NkwxMS41Mzc5IDYuNzExNUMxMS41Mzc5IDYuMDMwOTUgMTIuMDg5NiA1LjQ3OTI2IDEyLjc3MDIgNS40NzkyNkMxMy40NTA3IDUuNDc5MjYgMTQuMDAyNCA2LjAzMDk1IDE0LjAwMjQgNi43MTE1TDE0LjAwMjQgOC43NjI0NkwxNC4wMDI5IDguMDE5ODNDMTQuMDAyOSA3LjMzOTI5IDE0LjU1NDYgNi43ODc1OSAxNS4yMzUyIDYuNzg3NTlDMTUuOTE1NyA2Ljc4NzU5IDE2LjQ2NzQgNy4zMzkyOCAxNi40Njc0IDguMDE5ODNWMTEuNDk3TDE2LjQ2NzUgMTIuNDQ2N0MxNi40Njc1IDE0LjA4MjEgMTUuODA5NiAxNS42NDg3IDE0LjY0MiAxNi43OTM4VjE3LjMyNjJDMTQuNjQyIDE3Ljc4NjQgMTQuMjY4OSAxOC4xNTk1IDEzLjgwODcgMTguMTU5NUg4LjE3MzE3QzcuNzEyOTMgMTguMTU5NSA3LjMzOTg0IDE3Ljc4NjQgNy4zMzk4NCAxNy4zMjYyVjE1Ljk0MjRMNS4zNDU2MiAxNC43NTM0QzQuNTg5MjQgMTQuMzAyNCA0LjEyNTkxIDEzLjQ4NjcgNC4xMjU4OSAxMi42MDYxTDQuMTI1ODMgOS4yODM4M0M0LjEyNTgyIDguOTU0MjcgNC4zMjAwMyA4LjY1NTY2IDQuNjIxMjkgOC41MjIwNUw2LjYxODE3IDcuNjM2MzRWNS41OTg3NVoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTAuNDg3OCAzLjI0OTAyQzExLjI3OTYgMy4yNDkwMiAxMS45Nzc4IDMuNjQ5MDIgMTIuMzkxNyA0LjI1Nzk2QzEyLjUxNTEgNC4yMzkwNiAxMi42NDE2IDQuMjI5MjYgMTIuNzcwMyA0LjIyOTI2QzEzLjcyMjQgNC4yMjkyNiAxNC41NDkzIDQuNzY1MzEgMTQuOTY1NyA1LjU1MjA3QzE1LjA1NDMgNS41NDI1IDE1LjE0NDIgNS41Mzc1OSAxNS4yMzUzIDUuNTM3NTlDMTYuNjA2MiA1LjUzNzU5IDE3LjcxNzYgNi42NDg5MyAxNy43MTc2IDguMDE5ODNMMTcuNzE3NyAxMi40NDY3QzE3LjcxNzcgMTQuMjM1NSAxNy4wNjQ3IDE1Ljk1NTkgMTUuODkyMSAxNy4yOTA0VjE3LjMyNjJDMTUuODkyMSAxOC40NzY4IDE0Ljk1OTQgMTkuNDA5NSAxMy44MDg4IDE5LjQwOTVIOC4xNzMzMkM3LjAyMjczIDE5LjQwOTUgNi4wODk5OCAxOC40NzY4IDYuMDg5OTggMTcuMzI2MlYxNi42NTI0TDQuNzA1NjMgMTUuODI3QzMuNTcxMDYgMTUuMTUwNSAyLjg3NjA3IDEzLjkyNzEgMi44NzYwNCAxMi42MDYxTDIuODc1OTggOS4yODM4NUMyLjg3NTk2IDguNDU5OTYgMy4zNjE0OSA3LjcxMzQ1IDQuMTE0NjIgNy4zNzk0TDUuMzY4MzIgNi44MjMzM1Y1LjU5ODc1QzUuMzY4MzIgNC4xMjg4NSA2LjU1OTkxIDIuOTM3MjYgOC4wMjk4MiAyLjkzNzI2QzguNjA4MzEgMi45MzcyNiA5LjE0MzU1IDMuMTIxNyA5LjU4MDA1IDMuNDM1MDVDOS44NTg1MyAzLjMxNTMyIDEwLjE2NTQgMy4yNDkwMiAxMC40ODc4IDMuMjQ5MDJaTTEyLjQ0NzkgNS41MjE4NlY5LjQ3NTU3QzEyLjQ0NzkgOS43Mjc2MiAxMi4yNDM2IDkuOTMxOTUgMTEuOTkxNiA5LjkzMTk1QzExLjc1NjggOS45MzE5NSAxMS41NjM0IDkuNzU0NjUgMTEuNTM4IDkuNTI2NjNDMTEuNTM2MSA5LjUwOTg3IDExLjUzNTIgOS40OTI4MyAxMS41MzUyIDkuNDc1NTdWNS40NzE1OEMxMS41MTU0IDUuMjAwODMgMTEuMzkzIDQuOTU4NTggMTEuMjA2NiA0Ljc4MzU4QzExLjAxODggNC42MDcxMSAxMC43NjU5IDQuNDk5MDIgMTAuNDg3OCA0LjQ5OTAyQzEwLjQ3NjYgNC40OTkwMiAxMC40NjU0IDQuNDk5MTkgMTAuNDU0MiA0LjQ5OTU0QzkuOTAzNDcgNC41MTY4NCA5LjQ1OTY0IDQuOTU4MjQgOS40Mzg0NCA1LjUwODAxQzkuNDM4MiA1LjUwNDMgOS40Mzc5NSA1LjUwMDU4IDkuNDM3NjkgNS40OTY4OFY5LjkwMjQzQzkuNDM3NjkgMTAuMTU0NSA5LjIzMzM2IDEwLjM1ODggOC45ODEzMSAxMC4zNTg4QzguNzI5MjUgMTAuMzU4OCA4LjUyNDkyIDEwLjE1NDUgOC41MjQ5MiA5LjkwMjQzVjQuMjc2NTNDOC4zNzA4NiA0LjIxODgyIDguMjA0MDIgNC4xODcyNiA4LjAyOTgyIDQuMTg3MjZDNy4yNTAyNyA0LjE4NzI2IDYuNjE4MzIgNC44MTkyIDYuNjE4MzIgNS41OTg3NUw2LjYxODI3IDkuOTc1OTlDNi42MTgyNyAxMC4yMjggNi40MTM5NCAxMC40MzI0IDYuMTYxODkgMTAuNDMyNEM1LjkwOTgzIDEwLjQzMjQgNS43MDU1IDEwLjIyOCA1LjcwNTUgOS45NzU5OVY4LjA0MTIyTDQuNjIxNDQgOC41MjIwNUM0LjMyMDE4IDguNjU1NjYgNC4xMjU5NyA4Ljk1NDI3IDQuMTI1OTggOS4yODM4M0w0LjEyNjA0IDEyLjYwNjFDNC4xMjYwNiAxMy40ODY3IDQuNTg5MzkgMTQuMzAyNCA1LjM0NTc2IDE0Ljc1MzRMNy4zMzk5OCAxNS45NDI0VjE3LjMyNjJDNy4zMzk5OCAxNy43ODY0IDcuNzEzMDggMTguMTU5NSA4LjE3MzMyIDE4LjE1OTVIMTMuODA4OEMxNC4yNjkgMTguMTU5NSAxNC42NDIxIDE3Ljc4NjQgMTQuNjQyMSAxNy4zMjYyVjE2Ljc5MzhDMTUuNzM4MSAxNS43MTkgMTYuMzg1IDE0LjI3MjggMTYuNDYwMyAxMi43NDdDMTYuNDY0NiAxMi42NiAxNi40NjcgMTIuNTcyOCAxNi40Njc2IDEyLjQ4NTRMMTYuNDY3NyAxMi40NDY3TDE2LjQ2NzYgOC4wMTk4M0MxNi40Njc2IDcuMzQ1MDQgMTUuOTI1MiA2Ljc5NjkzIDE1LjI1MjUgNi43ODc3MUwxNS4yMzUzIDYuNzg3NTlDMTUuMTI1IDYuNzg3NTkgMTUuMDE4IDYuODAyMSAxNC45MTYyIDYuODI5MzFWOS42MDEzNkMxNC45MTYyIDkuODUzNDIgMTQuNzExOSAxMC4wNTc3IDE0LjQ1OTggMTAuMDU3N0MxNC4yMDc4IDEwLjA1NzcgMTQuMDAzNCA5Ljg1MzQxIDE0LjAwMzQgOS42MDEzNlY3Ljk4OTg1QzE0LjAwMzIgNy45OTk4MiAxNC4wMDMxIDguMDA5ODEgMTQuMDAzMSA4LjAxOTgzTDE0LjAwMzQgOS42MDEzNkwxNC4wMDI1IDYuNzExNUMxNC4wMDI1IDYuNDQ5NzQgMTMuOTIwOSA2LjIwNzA1IDEzLjc4MTggNi4wMDc0OEMxMy41NjIgNS42OTI0MiAxMy4xOTg5IDUuNDg0ODMgMTIuNzg3IDUuNDc5MzdMMTIuNzcwMyA1LjQ3OTI2QzEyLjY1ODggNS40NzkyNiAxMi41NTA3IDUuNDk0MDggMTIuNDQ3OSA1LjUyMTg2WiIgZmlsbD0iIzFEMUMyMyIvPgo8L3N2Zz4="), auto', // }, fromNodeJSON(node, json, isFirstCreate) { initFormDataFromJSON(node, json, isFirstCreate); return; }, toNodeJSON(node: WorkflowNodeEntity): WorkflowNodeJSON { const nodeError = node.getData(FlowNodeErrorData)?.getError(); // 如果节点有错误,这里抛出错误,避免后面的代码执行异常 if (nodeError) { throw nodeError; } const transform = node.getData(TransformData)!; let formJSON = toFormJSON(node); const metaData: Record = {}; // 持久化子画布位置 const nodeMeta = node.getNodeMeta(); const subCanvas = nodeMeta.subCanvas?.(node); if (subCanvas?.isCanvas === false) { const canvasNodeTransform = subCanvas.canvasNode.getData(FlowNodeTransformData); const { x, y } = canvasNodeTransform.transform.position; metaData.canvasPosition = { x, y }; } const json: WorkflowNodeJSON = { id: node.id, type: node.flowNodeType, meta: { position: { x: transform.position.x, y: transform.position.y }, ...metaData, }, data: formJSON, }; return json; }, }; ================================================ FILE: packages/canvas-engine/free-layout-core/src/workflow-document.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { customAlphabet } from 'nanoid'; import { inject, injectable, optional, postConstruct } from 'inversify'; import { Emitter, type IPoint } from '@flowgram.ai/utils'; import { NodeEngineContext } from '@flowgram.ai/form-core'; import { AddNodeData, FlowDocument, FlowNodeBaseType, FlowNodeTransformData, } from '@flowgram.ai/document'; import { injectPlaygroundContext, PlaygroundConfigEntity, PlaygroundContext, PositionData, TransformData, } from '@flowgram.ai/core'; import { WorkflowLinesManager } from './workflow-lines-manager'; import { WorkflowDocumentOptions, WorkflowDocumentOptionsDefault, } from './workflow-document-option'; import { getFlowNodeFormData } from './utils/flow-node-form-data'; import { buildGroupJSON, delay, fitView, getAntiOverlapPosition } from './utils'; import { type WorkflowContentChangeEvent, WorkflowContentChangeType, WorkflowEdgeJSON, type WorkflowJSON, type WorkflowNodeJSON, type WorkflowNodeMeta, type WorkflowNodeRegistry, WorkflowSubCanvas, } from './typings'; import { WorkflowSelectService } from './service/workflow-select-service'; import { FREE_LAYOUT_KEY, type FreeLayout } from './layout'; import { WorkflowNodeLinesData, WorkflowNodePortsData } from './entity-datas'; import { WorkflowLineEntity, WorkflowLinePortInfo, WorkflowNodeEntity, WorkflowPortEntity, } from './entities'; const nanoid = customAlphabet('1234567890', 5); export const WorkflowDocumentProvider = Symbol('WorkflowDocumentProvider'); export type WorkflowDocumentProvider = () => WorkflowDocument; @injectable() export class WorkflowDocument extends FlowDocument { private _onContentChangeEmitter = new Emitter(); protected readonly onLoadedEmitter = new Emitter(); readonly onContentChange = this._onContentChangeEmitter.event; private _onReloadEmitter = new Emitter(); readonly onReload = this._onReloadEmitter.event; /** * 数据加载完成 */ readonly onLoaded = this.onLoadedEmitter.event; protected _loading = false; @inject(WorkflowLinesManager) linesManager: WorkflowLinesManager; @inject(PlaygroundConfigEntity) playgroundConfig: PlaygroundConfigEntity; @injectPlaygroundContext() playgroundContext: PlaygroundContext; @inject(WorkflowDocumentOptions) options: WorkflowDocumentOptions = {}; @inject(NodeEngineContext) @optional() nodeEngineContext: NodeEngineContext; @inject(WorkflowSelectService) selectServices: WorkflowSelectService; get loading(): boolean { return this._loading; } /** * use `ctx.tools.fitView()` instead * @deprecated * @param easing */ async fitView(easing?: boolean): Promise { return fitView(this, this.playgroundConfig, easing).then(() => { this.linesManager.forceUpdate(); }); } @postConstruct() init(): void { super.init(); this.currentLayoutKey = this.options.defaultLayout || FREE_LAYOUT_KEY; this.linesManager.init(this); this.playgroundConfig.getCursors = () => this.options.cursors; this.linesManager.onAvailableLinesChange((e) => this.fireContentChange(e)); this.playgroundConfig.onReadonlyOrDisabledChange(({ readonly }) => { if (this.nodeEngineContext) { this.nodeEngineContext.readonly = readonly; } }); } async load(): Promise { if (this.disposed) return; this._loading = true; await super.load(); this._loading = false; this.onLoadedEmitter.fire(); } /** * @deprecated use `ctx.operation.fromJSON` instead */ async reload(json: WorkflowJSON, delayTime = 0): Promise { if (this.disposed) return; this._loading = true; this.clear(); this.fromJSON(json); // loading添加delay,避免reload时触发fireContentChange的副作用 await delay(delayTime); this._loading = false; this._onReloadEmitter.fire(this); } /** * 从数据加载 * @param json */ fromJSON(json: Partial, fireRender = true): void { if (this.disposed) return; const workflowJSON: WorkflowJSON = { nodes: json.nodes ?? [], edges: json.edges ?? [], }; // 触发画布更新 this.entityManager.changeEntityLocked = true; // 逐层渲染 this.batchAddFromJSON(workflowJSON); this.entityManager.changeEntityLocked = false; this.transformer.loading = false; // 批量触发画布更新 if (fireRender) { this.fireRender(); } } /** * 清空画布 */ clear(): void { this.getAllNodes().map((node) => node.dispose()); // 清空节点 this.linesManager.getAllLines().map((line) => line.dispose()); // 清空线条 this.getAllPorts().map((port) => port.dispose()); // 清空端口 this.selectServices.clear(); // 清空选择 } /** * 创建流程节点 * @param json */ createWorkflowNode( json: WorkflowNodeJSON, /** @deprecated */ isClone: boolean = false, parentID?: string ): WorkflowNodeEntity { return this._createWorkflowNode(json, { parentID }); } /** * 创建流程节点 * @param json */ private _createWorkflowNode( json: WorkflowNodeJSON, options?: { parentID?: string; onNodeCreated?: (node: WorkflowNodeEntity) => void; onEdgeCreated?: (edge: WorkflowLineEntity) => void; } ): WorkflowNodeEntity { const { parentID, onNodeCreated, onEdgeCreated } = options ?? {}; // 是否是一个已经存在的节点 const existedNode = this.getNode(json.id); const isExistedNode = existedNode && existedNode.flowNodeType === json.type; const parent = this.getNode(parentID ?? this.root.id) ?? this.root; const node = this.addNode( { ...json, parent, }, undefined, true ) as WorkflowNodeEntity; const registry = node.getNodeRegistry() as WorkflowNodeRegistry; const { formMeta } = registry; const meta = node.getNodeMeta(); const formData = getFlowNodeFormData(node); const transform = node.getData(FlowNodeTransformData)!; const freeLayout = this.layout as FreeLayout; if (!isExistedNode) { transform.onDataChange(() => { // TODO 这个有点难以理解,其实是为了同步size 数据 freeLayout.syncTransform(node); }); } let { position } = meta; if (!position) { // 获取默认的位置 position = this.getNodeDefaultPosition(json.type); } // 更新节点位置信息 node.getData(TransformData)!.update({ position, }); // 初始化表单数据 if (formMeta && formData) { if (!formData.formModel.initialized) { // 如果表单数据在前置步骤(fromJSON)内已定义,则跳过表单初始化逻辑 formData.createForm(formMeta, json.data); formData.onDataChange(() => { this.fireContentChange({ type: WorkflowContentChangeType.NODE_DATA_CHANGE, toJSON: () => formData.toJSON(), entity: node, }); }); } else { formData.updateFormValues(json.data); } } // 位置变更 const positionData = node.getData(PositionData)!; if (!isExistedNode) { positionData.onDataChange(() => { this.fireContentChange({ type: WorkflowContentChangeType.MOVE_NODE, toJSON: () => positionData.toJSON(), entity: node, }); }); } const subCanvas = this.getNodeSubCanvas(node); if (!isExistedNode && !subCanvas?.isCanvas) { this.fireContentChange({ type: WorkflowContentChangeType.ADD_NODE, entity: node, toJSON: () => this.toNodeJSON(node), }); node.onDispose(() => { if (!node.parent || node.parent.flowNodeType === FlowNodeBaseType.ROOT) { return; } const parentTransform = node.parent.getData(FlowNodeTransformData); parentTransform.fireChange(); }); let lastDeleteNodeData: WorkflowNodeJSON | undefined; node.preDispose.onDispose(() => { lastDeleteNodeData = this.toNodeJSON(node); }); node.onDispose(() => { this.fireContentChange({ type: WorkflowContentChangeType.DELETE_NODE, entity: node, toJSON: () => lastDeleteNodeData, }); }); } // 若存在子节点,则创建子节点 if (json.blocks) { this.batchAddFromJSON( { nodes: json.blocks, edges: json.edges ?? [] }, { parent: node, onNodeCreated, onEdgeCreated, } ); } // 子画布联动 if (subCanvas) { const canvasTransform = subCanvas.canvasNode.getData(TransformData); canvasTransform.update({ position: subCanvas.parentNode.getNodeMeta()?.canvasPosition, }); if (!isExistedNode) { subCanvas.parentNode.onDispose(() => { subCanvas.canvasNode.dispose(); }); subCanvas.canvasNode.onDispose(() => { subCanvas.parentNode.dispose(); }); } } if (!isExistedNode) { this.onNodeCreateEmitter.fire({ node, data: json, json, }); } else { this.onNodeUpdateEmitter.fire({ node, data: json, json, }); } return node; } /** * 添加节点,如果节点已经存在则不会重复创建 * @param data * @param addedNodes */ addNode( data: AddNodeData, addedNodes?: WorkflowNodeEntity[], ignoreCreateAndUpdateEvent?: boolean ): WorkflowNodeEntity { const { id, type = 'block', originParent, parent, meta, hidden, index } = data; let node = this.getNode(id); let isNew = false; const register = this.getNodeRegistry(type, data.originParent); // node 类型变化则全部删除重新来 if (node && node.flowNodeType !== data.type) { node.dispose(); node = undefined; } if (!node) { const { dataRegistries } = register; node = this.entityManager.createEntity(WorkflowNodeEntity, { id, document: this, flowNodeType: type, originParent, meta, }); this.options.preNodeCreate?.(node); const datas = dataRegistries ? this.nodeDataRegistries.concat(...dataRegistries) : this.nodeDataRegistries; node.addInitializeData(datas); node.ports = node.getData(WorkflowNodePortsData); node.lines = node.getData(WorkflowNodeLinesData); node.onDispose(() => this.onNodeDisposeEmitter.fire({ node: node! })); this.options.fromNodeJSON?.(node, data, true); isNew = true; } else { this.options.fromNodeJSON?.(node, data, false); } // 初始化数据重制 node.initData({ originParent, parent, meta, hidden, index, }); addedNodes?.push(node); // 自定义创建逻辑 if (register.onCreate) { const extendNodes = register.onCreate(node, data); if (extendNodes && addedNodes) { addedNodes.push(...extendNodes); } } if (!ignoreCreateAndUpdateEvent) { if (isNew) { this.onNodeCreateEmitter.fire({ node, data, json: data, }); } else { this.onNodeUpdateEmitter.fire({ node, data, json: data }); } } return node; } get layout(): FreeLayout { const layout = this.layouts.find((layout) => layout.name == this.currentLayoutKey); if (!layout) { throw new Error(`Unknown flow layout: ${this.currentLayoutKey}`); } return layout as FreeLayout; } /** * 获取默认的 x y 坐标, 默认为当前画布可视区域中心 * @param type * @protected */ getNodeDefaultPosition(type: string | number): IPoint { const { size } = this.getNodeRegistry(type).meta || {}; // 当前可视区域的中心位置 let position = this.playgroundConfig.getViewport(true).center; if (size) { position = { x: position.x, y: position.y - size.height / 2, }; } // 去掉叠加的 return getAntiOverlapPosition(this, position); } /** * 通过类型创建节点, 如果没有提供position 则直接放在画布中间 * @param type */ createWorkflowNodeByType( type: string | number, position?: IPoint, json: Partial = {}, parentID?: string ): WorkflowNodeEntity { let id: string = json.id as string; if (id === undefined) { // 保证 id 不要重复 do { id = `1${nanoid()}`; } while (this.entityManager.getEntityById(id)); } else { if (this.entityManager.getEntityById(id)) { throw new Error(`[WorkflowDocument.createWorkflowNodeByType] Node Id "${id}" duplicated.`); } } return this._createWorkflowNode( { ...json, id, type, meta: { position, ...json?.meta }, // TODO title 和 meta 要从注册数据去拿 data: json?.data, blocks: json?.blocks, edges: json?.edges, }, { parentID } ); } getAllNodes(): WorkflowNodeEntity[] { return this.entityManager .getEntities(WorkflowNodeEntity) .filter((n) => n.id !== FlowNodeBaseType.ROOT); } getAllEdges(): WorkflowLineEntity[] { return this.entityManager.getEntities(WorkflowLineEntity); } getAllPorts(): WorkflowPortEntity[] { return this.entityManager .getEntities(WorkflowPortEntity) .filter((p) => p.node.id !== FlowNodeBaseType.ROOT); } /** * 获取画布中的非游离节点 * 1. 开始节点 * 2. 从开始节点出发能走到的节点 * 3. 结束节点 * 4. 默认所有子画布内节点为游离节点 */ getAssociatedNodes(): WorkflowNodeEntity[] { const allNode = this.getAllNodes(); const allLines = this.linesManager .getAllLines() .filter((line) => line.from && line.to) .map((line) => ({ from: line.from!.id, to: line.to!.id, })); const startNodeId = allNode.find((node) => node.isStart)?.id; const endNodeId = allNode.find((node) => node.isNodeEnd)?.id; // 子画布内节点无需开始/结束 const nodeInContainer = allNode .filter((node) => node.parent?.getNodeMeta().isContainer) .map((node) => node.id); const associatedCache = new Set(nodeInContainer); if (endNodeId) { associatedCache.add(endNodeId); } const bfs = (nodeId: string) => { if (associatedCache.has(nodeId)) { return; } associatedCache.add(nodeId); const nextNodes = allLines.reduce((ids, { from, to }) => { if (from === nodeId && !associatedCache.has(to)) { ids.push(to); } return ids; }, [] as string[]); nextNodes.forEach(bfs); }; if (startNodeId) { bfs(startNodeId); } const associatedNodes = allNode.filter((node) => associatedCache.has(node.id)); return associatedNodes; } /** * 触发渲染 */ fireRender() { this.entityManager.fireEntityChanged(WorkflowNodeEntity.type); this.entityManager.fireEntityChanged(WorkflowLineEntity.type); this.entityManager.fireEntityChanged(WorkflowPortEntity.type); } fireContentChange(event: WorkflowContentChangeEvent): void { if (this._loading || this.disposed || this.entityManager.changeEntityLocked) { return; } this._onContentChangeEmitter.fire(event); } toNodeJSON(node: WorkflowNodeEntity): WorkflowNodeJSON { // 如果是子画布,返回其父节点的JSON const subCanvas = this.getNodeSubCanvas(node); if (subCanvas?.isCanvas === true) { return this.toNodeJSON(subCanvas.parentNode); } const json = this.toNodeJSONFromOptions(node); const children = this.getNodeChildren(node); // 计算子节点 JSON const blocks = children.map((child) => this.toNodeJSON(child)); // 计算子线条 JSON const linesMap = new Map(); children.forEach((child) => { const childLinesData = child.getData(WorkflowNodeLinesData); [...childLinesData.inputLines, ...childLinesData.outputLines] .filter(Boolean) .forEach((line) => { const lineJSON = this.toLineJSON(line); if (!lineJSON || linesMap.has(line.id)) { return; } linesMap.set(line.id, lineJSON); }); }); const edges = Array.from(linesMap.values()); // 使用 Map 防止线条重复 // 拼接 JSON if (blocks.length > 0) json.blocks = blocks; if (edges.length > 0) json.edges = edges; return json; } /** * 节点转换为JSON, 没有format的过程 * @param node * @returns */ private toNodeJSONFromOptions(node: WorkflowNodeEntity): WorkflowNodeJSON { if (this.options.toNodeJSON) { return this.options.toNodeJSON(node) as WorkflowNodeJSON; } return WorkflowDocumentOptionsDefault.toNodeJSON!(node) as WorkflowNodeJSON; } copyNode( node: WorkflowNodeEntity, newNodeId?: string | undefined, format?: (json: WorkflowNodeJSON) => WorkflowNodeJSON, position?: IPoint ): WorkflowNodeEntity { let json = this.toNodeJSON(node); if (format) { json = format(json); } position = position || { x: json.meta!.position!.x + 30, y: json.meta!.position!.y + 30, }; return this._createWorkflowNode( { id: newNodeId || `1${nanoid()}`, type: node.flowNodeType, meta: { ...json.meta, position, }, data: json.data, blocks: json.blocks, edges: json.edges, }, { parentID: node.parent?.id, } ); } copyNodeFromJSON( flowNodeType: string, nodeJSON: WorkflowNodeJSON, newNodeId?: string | undefined, position?: IPoint, parentID?: string ): WorkflowNodeEntity { position = position || { x: nodeJSON.meta!.position!.x + 30, y: nodeJSON.meta!.position!.y + 30, }; return this._createWorkflowNode( { id: newNodeId || `1${nanoid()}`, type: flowNodeType, meta: { ...nodeJSON.meta, position, }, data: nodeJSON.data, blocks: nodeJSON.blocks, edges: nodeJSON.edges, }, { parentID, } ); } canRemove(node: WorkflowNodeEntity, silent?: boolean): boolean { const meta = node.getNodeMeta(); if (meta.deleteDisable) { return false; } if (this.options.canDeleteNode && !this.options.canDeleteNode(node, silent)) { return false; } return true; } /** * 判断端口是否为错误态 */ isErrorPort(port: WorkflowPortEntity, defaultValue = false) { if (typeof this.options.isErrorPort === 'function') { return this.options.isErrorPort(port); } return defaultValue; } /** * 导出数据 */ toJSON(): WorkflowJSON { if (this.disposed) { throw new Error( 'The WorkflowDocument has been disposed and it is no longer possible to call toJSON.' ); } const rootJSON = this.toNodeJSON(this.root); const json = { nodes: rootJSON.blocks ?? [], edges: rootJSON.edges ?? [], }; return json; } dispose() { super.dispose(); this._onReloadEmitter.dispose(); } /** * 批量添加节点 * @deprecated use 'batchAddFromJSON' instead * @param json * @param options */ public renderJSON( json: WorkflowJSON, options?: { parent?: WorkflowNodeEntity; /** @deprecated useless api */ isClone?: boolean; } ): { nodes: WorkflowNodeEntity[]; edges: WorkflowLineEntity[]; } { return this.batchAddFromJSON(json, options); } /** * 批量添加节点 */ public batchAddFromJSON( json: WorkflowJSON, options?: { parent?: WorkflowNodeEntity; onNodeCreated?: (node: WorkflowNodeEntity) => void; onEdgeCreated?: (edge: WorkflowLineEntity) => void; } ): { nodes: WorkflowNodeEntity[]; edges: WorkflowLineEntity[]; } { const { parent = this.root, onNodeCreated, onEdgeCreated } = options ?? {}; // 创建节点 const parentID = this.getNodeSubCanvas(parent)?.canvasNode.id ?? parent.id; const processedJSON = buildGroupJSON(json); const nodes = processedJSON.nodes.map((nodeJSON: WorkflowNodeJSON) => this._createWorkflowNode(nodeJSON, { parentID, onNodeCreated, onEdgeCreated, }) ); // 创建线条 const edges = processedJSON.edges .map((edge) => this.createWorkflowLine(edge, parentID)) .filter(Boolean) as WorkflowLineEntity[]; // 触发回调 nodes.forEach((node) => options?.onNodeCreated?.(node)); edges.forEach((edge) => options?.onEdgeCreated?.(edge)); return { nodes, edges }; } private getNodeSubCanvas(node: WorkflowNodeEntity): WorkflowSubCanvas | undefined { if (!node) return; const nodeMeta = node.getNodeMeta(); const subCanvas = nodeMeta.subCanvas?.(node); return subCanvas; } private getNodeChildren(node: WorkflowNodeEntity): WorkflowNodeEntity[] { if (!node || node.flowNodeType === FlowNodeBaseType.GROUP) return []; const subCanvas = this.getNodeSubCanvas(node); // get real children const realChildren = subCanvas ? subCanvas.canvasNode.blocks : node.blocks; // filter sub canvas node const childrenWithoutSubCanvas = realChildren .filter((child) => { const childMeta = child.getNodeMeta(); return !childMeta.subCanvas?.(node)?.isCanvas; }) .filter(Boolean); // flat group nodes const children = childrenWithoutSubCanvas .map((child) => { if (child.flowNodeType === FlowNodeBaseType.GROUP) { return [child, ...child.blocks]; } return child; }) .flat(); return children; } private toLineJSON(line: WorkflowLineEntity): WorkflowEdgeJSON | undefined { const lineJSON = line.toJSON(); if ( !line.from || !line.info.from || !line.fromPort || !line.to || !line.info.to || !line.toPort ) { return; } // 父子节点之间连线,需替换子画布为父节点 const fromSubCanvas = this.getNodeSubCanvas(line.from); const toSubCanvas = this.getNodeSubCanvas(line.to); if (fromSubCanvas && !fromSubCanvas.isCanvas && toSubCanvas && toSubCanvas.isCanvas) { // 忽略子画布与父节点的连线 return; } if (line.from === line.to.parent && fromSubCanvas) { return { ...lineJSON, sourceNodeID: fromSubCanvas.parentNode.id, }; } if (line.to === line.from.parent && toSubCanvas) { return { ...lineJSON, targetNodeID: toSubCanvas.parentNode.id, }; } return lineJSON; } private createWorkflowLine( json: WorkflowEdgeJSON, parentID?: string ): WorkflowLineEntity | undefined { const fromNode = this.getNode(json.sourceNodeID); const toNode = this.getNode(json.targetNodeID); // 脏数据清除 if (!fromNode || !toNode) { return; } const lineInfo: WorkflowLinePortInfo = { from: json.sourceNodeID, fromPort: json.sourcePortID, to: json.targetNodeID, toPort: json.targetPortID, data: json.data, }; if (!parentID) { return this.linesManager.createLine(lineInfo); } // 父子节点之间连线,需替换父节点为子画布 const canvasNode = this.getNode(parentID); if (!canvasNode) { return this.linesManager.createLine(lineInfo); } const parentSubCanvas = this.getNodeSubCanvas(canvasNode); if (!parentSubCanvas) { return this.linesManager.createLine(lineInfo); } if (lineInfo.from === parentSubCanvas.parentNode.id) { return this.linesManager.createLine({ ...lineInfo, from: parentSubCanvas.canvasNode.id, }); } if (lineInfo.to === parentSubCanvas.parentNode.id) { return this.linesManager.createLine({ ...lineInfo, to: parentSubCanvas.canvasNode.id, }); } return this.linesManager.createLine(lineInfo); } } ================================================ FILE: packages/canvas-engine/free-layout-core/src/workflow-lines-manager.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { last } from 'lodash-es'; import { inject, injectable } from 'inversify'; import { DisposableCollection, Emitter, type IPoint } from '@flowgram.ai/utils'; import { FlowNodeRenderData, FlowNodeTransformData } from '@flowgram.ai/document'; import { EntityManager, PlaygroundConfigEntity } from '@flowgram.ai/core'; import { WorkflowDocumentOptions } from './workflow-document-option'; import { type WorkflowDocument } from './workflow-document'; import { WorkflowPortType } from './utils'; import { LineColor, LineColors, LinePoint, LineRenderType, LineType, type WorkflowLineRenderContributionFactory, } from './typings/workflow-line'; import { type WorkflowContentChangeEvent, WorkflowContentChangeType, type WorkflowEdgeJSON, WorkflowNodeRegistry, } from './typings'; import { WorkflowHoverService, WorkflowSelectService } from './service'; import { WorkflowNodeLinesData } from './entity-datas/workflow-node-lines-data'; import { WorkflowLineRenderData } from './entity-datas'; import { LINE_HOVER_DISTANCE, WorkflowLineEntity, type WorkflowLinePortInfo, type WorkflowNodeEntity, WorkflowPortEntity, } from './entities'; /** * 线条管理 */ @injectable() export class WorkflowLinesManager { protected document: WorkflowDocument; protected toDispose = new DisposableCollection(); // 线条类型 protected _lineType: LineRenderType = LineType.BEZIER; protected onAvailableLinesChangeEmitter = new Emitter(); protected onForceUpdateEmitter = new Emitter(); @inject(WorkflowHoverService) hoverService: WorkflowHoverService; @inject(WorkflowSelectService) selectService: WorkflowSelectService; @inject(EntityManager) protected readonly entityManager: EntityManager; @inject(WorkflowDocumentOptions) readonly options: WorkflowDocumentOptions; /** * 有效的线条被添加或者删除时候触发,未连上的线条不算 */ readonly onAvailableLinesChange = this.onAvailableLinesChangeEmitter.event; /** * 强制渲染 lines */ readonly onForceUpdate = this.onForceUpdateEmitter.event; readonly contributionFactories: WorkflowLineRenderContributionFactory[] = []; init(doc: WorkflowDocument): void { this.document = doc; } forceUpdate() { this.onForceUpdateEmitter.fire(); } get lineType() { return this._lineType; } get lineColor(): LineColor { const color: LineColor = { default: LineColors.DEFUALT, error: LineColors.ERROR, hidden: LineColors.HIDDEN, drawing: LineColors.DRAWING, hovered: LineColors.HOVER, selected: LineColors.SELECTED, flowing: LineColors.FLOWING, }; if (this.options.lineColor) { Object.assign(color, this.options.lineColor); } return color; } switchLineType(newType?: LineRenderType): LineRenderType { if (newType === undefined) { if (this._lineType === LineType.BEZIER) { newType = LineType.LINE_CHART; } else { newType = LineType.BEZIER; } } if (newType !== this._lineType) { this._lineType = newType; // 更新线条数据 this.getAllLines().forEach((line) => { line.getData(WorkflowLineRenderData).update(); }); window.requestAnimationFrame(() => { // 触发线条重渲染 this.entityManager.fireEntityChanged(WorkflowLineEntity.type); }); } return this._lineType; } getAllLines(): WorkflowLineEntity[] { return this.entityManager.getEntities(WorkflowLineEntity); } getAllAvailableLines(): WorkflowLineEntity[] { return this.getAllLines().filter((l) => !l.isDrawing && !l.isHidden); } hasLine(portInfo: Omit): boolean { return !!this.entityManager.getEntityById( WorkflowLineEntity.portInfoToLineId(portInfo) ); } getLine(portInfo: Omit): WorkflowLineEntity | undefined { return this.entityManager.getEntityById( WorkflowLineEntity.portInfoToLineId(portInfo) ); } getLineById(id: string): WorkflowLineEntity | undefined { return this.entityManager.getEntityById(id); } replaceLine( oldPortInfo: Omit, newPortInfo: Omit ): WorkflowLineEntity { const oldLine = this.getLine(oldPortInfo); if (oldLine) { oldLine.dispose(); } return this.createLine(newPortInfo)!; } createLine( options: { drawingTo?: LinePoint; // 无连接的线条 drawingFrom?: LinePoint; key?: string; // 自定义 key } & WorkflowLinePortInfo ): WorkflowLineEntity | undefined { const { from, to, drawingTo, fromPort, drawingFrom, toPort, data } = options; const available = Boolean(from && to); const key = options.key || WorkflowLineEntity.portInfoToLineId(options); let line = this.entityManager.getEntityById(key)!; if (line) { // 如果之前有线条,则先把颜色去掉 line.highlightColor = ''; line.validate(); return line; } const fromNode = from ? this.entityManager .getEntityById(from)! .getData(WorkflowNodeLinesData) : undefined; const toNode = to ? this.entityManager .getEntityById(to)! .getData(WorkflowNodeLinesData)! : undefined; if (!fromNode && !toNode) { // 非法情况 return; } this.isDrawing = Boolean(drawingTo || drawingFrom); line = this.entityManager.createEntity(WorkflowLineEntity, { id: key, document: this.document, linesManager: this, from, fromPort, toPort, to, drawingTo, drawingFrom, data, }); this.registerData(line); fromNode?.addLine(line); toNode?.addLine(line); line.onDispose(() => { this.isDrawing = false; fromNode?.removeLine(line); toNode?.removeLine(line); }); line.onDispose(() => { if (available) { this.onAvailableLinesChangeEmitter.fire({ type: WorkflowContentChangeType.DELETE_LINE, toJSON: () => line.toJSON(), entity: line, }); } }); line.onLineDataChange(({ oldValue }) => { this.onAvailableLinesChangeEmitter.fire({ type: WorkflowContentChangeType.LINE_DATA_CHANGE, toJSON: () => line.toJSON(), oldValue, entity: line, }); }); // 是否为有效的线条 if (available) { this.onAvailableLinesChangeEmitter.fire({ type: WorkflowContentChangeType.ADD_LINE, toJSON: () => line.toJSON(), entity: line, }); } // 创建时检验 连线错误态 & 端口错误态 line.validate(); return line; } /** * 获取线条中距离鼠标位置最近的线条和距离 * @param mousePos 鼠标位置 * @param minDistance 最小检测距离 * @returns 距离鼠标位置最近的线条 以及距离 */ getCloseInLineFromMousePos( mousePos: IPoint, minDistance: number = LINE_HOVER_DISTANCE ): WorkflowLineEntity | undefined { let targetLine: WorkflowLineEntity | undefined, targetLineDist: number | undefined; this.getAllLines().forEach((line) => { const dist = line.getHoverDist(mousePos); if (dist <= minDistance && (!targetLineDist || targetLineDist >= dist)) { targetLineDist = dist; targetLine = line; } }); return targetLine; } /** * 是否在调整线条 */ isDrawing = false; dispose(): void { this.toDispose.dispose(); } get disposed(): boolean { return this.toDispose.disposed; } isErrorLine(fromPort?: WorkflowPortEntity, toPort?: WorkflowPortEntity, defaultValue?: boolean) { if (this.options.isErrorLine) { return this.options.isErrorLine(fromPort, toPort, this); } return !!defaultValue; } isReverseLine(line: WorkflowLineEntity, defaultValue = false): boolean { if (this.options.isReverseLine) { return this.options.isReverseLine(line); } return defaultValue; } isHideArrowLine(line: WorkflowLineEntity, defaultValue = false): boolean { if (this.options.isHideArrowLine) { return this.options.isHideArrowLine(line); } return defaultValue; } isFlowingLine(line: WorkflowLineEntity, defaultValue = false): boolean { if (this.options.isFlowingLine) { return this.options.isFlowingLine(line); } return defaultValue; } isDisabledLine(line: WorkflowLineEntity, defaultValue = false): boolean { if (this.options.isDisabledLine) { return this.options.isDisabledLine(line); } return defaultValue; } setLineRenderType(line: WorkflowLineEntity): LineRenderType | undefined { if (this.options.setLineRenderType) { return this.options.setLineRenderType(line); } return undefined; } setLineClassName(line: WorkflowLineEntity): string | undefined { if (this.options.setLineClassName) { return this.options.setLineClassName(line); } return undefined; } getLineColor(line: WorkflowLineEntity): string | undefined { // 隐藏的优先级比 hasError 高 if (line.isHidden) { return this.lineColor.hidden; } // 颜色锁定 if (line.lockedColor) { return line.lockedColor; } if (line.hasError) { return this.lineColor.error; } if (line.highlightColor) { return line.highlightColor; } if (line.drawingTo) { return this.lineColor.drawing; } if (this.hoverService.isHovered(line.id)) { return this.lineColor.hovered; } if (this.selectService.isSelected(line.id)) { return this.lineColor.selected; } // 检查是否为流动线条 if (this.isFlowingLine(line)) { return this.lineColor.flowing; } return this.lineColor.default; } canAddLine(fromPort: WorkflowPortEntity, toPort: WorkflowPortEntity, silent?: boolean): boolean { if ( fromPort === toPort || fromPort.node === toPort.node || fromPort.portType !== 'output' || toPort.portType !== 'input' || fromPort.disabled || toPort.disabled ) { return false; } const fromCanAdd = fromPort.node.getNodeRegistry().canAddLine; const toCanAdd = toPort.node.getNodeRegistry().canAddLine; if (fromCanAdd && !fromCanAdd(fromPort, toPort, this, silent)) { return false; } if (toCanAdd && !toCanAdd(fromPort, toPort, this, silent)) { return false; } if (this.options.canAddLine) { return this.options.canAddLine(fromPort, toPort, this, silent); } // 默认不能连接自己 return fromPort.node !== toPort.node; } toJSON(): WorkflowEdgeJSON[] { return this.getAllLines() .filter((l) => !l.isDrawing) .map((l) => l.toJSON()); } getPortById(portId: string): WorkflowPortEntity | undefined { return this.entityManager.getEntityById(portId); } canRemove( line: WorkflowLineEntity, newLineInfo?: Required>, silent?: boolean ): boolean { if ( this.options && this.options.canDeleteLine && !this.options.canDeleteLine(line, newLineInfo, silent) ) { return false; } return true; } canReset(oldLine: WorkflowLineEntity, newLineInfo: Required): boolean { if ( this.options && this.options.canResetLine && !this.options.canResetLine(oldLine, newLineInfo, this) ) { return false; } return true; } /** * 根据鼠标位置找到 port * @param pos */ getPortFromMousePos(pos: IPoint, portType?: WorkflowPortType): WorkflowPortEntity | undefined { const allNodes = this.getSortedNodes().reverse(); const allPorts = allNodes .map((node) => { if (!portType) { return node.ports.allPorts; } return portType === 'input' ? node.ports.inputPorts : node.ports.outputPorts; }) .flat(); const targetPort = allPorts.find((port) => port.isHovered(pos.x, pos.y)); if (targetPort) { const containNodes = this.getContainNodesFromMousePos(pos); const targetNode = last(containNodes); // 点位可能会被节点覆盖 if (targetNode && targetNode !== targetPort.node) { return; } } return targetPort; } /** * 根据鼠标位置找到 node * @param pos - 鼠标位置 */ getNodeFromMousePos(pos: IPoint): WorkflowNodeEntity | undefined { // 先挑选出 bounds 区域符合的 node const { selection } = this.selectService; const containNodes = this.getContainNodesFromMousePos(pos); // 当有元素被选中的时候选中元素在顶层 if (selection?.length) { const filteredNodes = containNodes.filter((node) => selection.some((_node) => node.id === _node.id) ); if (filteredNodes?.length) { return last(filteredNodes); } } // 默认取最顶层的 return last(containNodes); } registerContribution(factory: WorkflowLineRenderContributionFactory): this { this.contributionFactories.push(factory); return this; } private registerData(line: WorkflowLineEntity) { line.addData(WorkflowLineRenderData); } private getSortedNodes() { return this.document.getAllNodes().sort((a, b) => this.getNodeIndex(a) - this.getNodeIndex(b)); } /** 获取鼠标坐标位置的所有节点(stackIndex 从小到大排序) */ private getContainNodesFromMousePos(pos: IPoint): WorkflowNodeEntity[] { const allNodes = this.getSortedNodes(); const zoom = this.entityManager.getEntity(PlaygroundConfigEntity)?.config?.zoom || 1; const containNodes = allNodes .map((node) => { const { bounds } = node.getData(FlowNodeTransformData); // 交互要求,节点边缘 4px 的时候就认为选中节点 if ( bounds .clone() .pad(4 / zoom) .contains(pos.x, pos.y) ) { return node; } }) .filter(Boolean) as WorkflowNodeEntity[]; return containNodes; } private getNodeIndex(node: WorkflowNodeEntity): number { const nodeRenderData = node.getData(FlowNodeRenderData); return nodeRenderData.stackIndex; } } ================================================ FILE: packages/canvas-engine/free-layout-core/tsconfig.json ================================================ { "extends": "@flowgram.ai/ts-config/tsconfig.flow.path.json", "compilerOptions": { "types": ["vitest/globals"], }, "include": [ "./src", "./__tests__" ], "exclude": ["node_modules"] } ================================================ FILE: packages/canvas-engine/free-layout-core/vitest.config.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ const path = require('path'); import { defineConfig } from 'vitest/config'; export default defineConfig({ build: { commonjsOptions: { transformMixedEsModules: true, }, }, test: { testTimeout: 30000, globals: true, mockReset: false, environment: 'jsdom', setupFiles: [path.resolve(__dirname, './vitest.setup.ts')], include: ['**/?(*.){test,spec}.?(c|m)[jt]s?(x)'], exclude: [ '**/__mocks__**', '**/node_modules/**', '**/dist/**', '**/lib/**', // lib 编译结果忽略掉 '**/cypress/**', '**/.{idea,git,cache,output,temp}/**', '**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*', ], }, }); ================================================ FILE: packages/canvas-engine/free-layout-core/vitest.setup.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import 'reflect-metadata'; ================================================ FILE: packages/canvas-engine/renderer/__mocks__/flow-document-container.mock.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { EntityManager } from '@flowgram.ai/core' import { FlowDocument, FlowDocumentContainerModule, FlowDocumentContribution, FlowNodeTransformData, FlowNodeTransitionData, } from '@flowgram.ai/document' import { Container, decorate, injectable, type interfaces } from 'inversify' export class FlowDocumentMockRegister implements FlowDocumentContribution { registerDocument(document: FlowDocument) { document.registerNodeDatas(FlowNodeTransformData, FlowNodeTransitionData) } } decorate(injectable(), FlowDocumentMockRegister) export function createDocumentContainer(): interfaces.Container { const container = new Container() container.load(FlowDocumentContainerModule) container.bind(EntityManager).toSelf() container.bind(FlowDocumentContribution).to(FlowDocumentMockRegister) return container } export function createDocument(): FlowDocument { return createDocumentContainer().get(FlowDocument) } ================================================ FILE: packages/canvas-engine/renderer/__mocks__/flow-drag-entity.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export const NOT_SCROLL_EVENT = { clientX: 300, clientY: 300, } as MouseEvent export const SCROLL_BOTTOM_EVENT = { clientX: 0, clientY: 3020, } as MouseEvent export const SCROLL_TOP_EVENT = { clientX: 0, clientY: 10, } as MouseEvent export const SCROLL_RIGHT_EVENT = { clientX: 3020, clientY: 300, } as MouseEvent export const SCROLL_LEFT_EVENT = { clientX: 10, clientY: 300, } as MouseEvent ================================================ FILE: packages/canvas-engine/renderer/__mocks__/flow-json.mock.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { FlowDocumentJSON } from '@flowgram.ai/document' export const flowJson: FlowDocumentJSON = { nodes: [ { type: 'start', id: 'start', }, { type: 'tryCatch', id: 'tryCatch_f9fa62fa783', blocks: [ { id: 'branch_9fa62fa783d', meta: { size: { width: 280, height: 28, }, }, }, { id: 'branch_fa62fa783d7', meta: { size: { width: 280, height: 28, }, }, blocks: [ { type: 'createRecord', id: 'createRecord_463df50d176', }, { type: 'createRecord', id: 'createRecord_fb7a69ab5b8', }, ] }, { id: 'branch_c57c09b038e', meta: { size: { width: 280, height: 28, }, }, }, { id: 'branch_7c09b038e0b', meta: { size: { width: 280, height: 28, }, }, }, ], }, { type: 'loop', id: 'while_4bd4950692a', blocks: [ { id: '$loopBranch$while_4bd4950692a', blocks: [ { type: 'createRecord', id: 'createRecord_fb7a69ab5b8', }, ] }, ], }, { type: 'createRecord', id: 'createRecord_6f8cad399fb', }, { type: 'loop', id: 'forEach_4eeb9f9cde8', blocks: [ { id: '$loopBranch$forEach_4eeb9f9cde8', }, ], }, { type: 'dynamicSplit', id: 'exclusiveSplit_d2bdee4eb90', blocks: [ { id: 'branch_008864cf1f9', meta: { size: { width: 280, height: 28, }, }, }, { id: 'branch_08864cf1f9d', meta: { size: { width: 280, height: 28, }, }, }, ], }, { type: 'createRecord', id: 'createRecord_c192e8f6d8d', }, { type: 'approval', id: 'approval_fc79f9fa62f', blocks: [ { id: 'branch_c9c9f0a61f0', meta: { size: { width: 280, height: 28, }, }, }, { id: 'branch_9c9f0a61f00', meta: { size: { width: 280, height: 28, }, }, }, ], }, { type: 'dynamicSplit', id: 'parallelSplit_2e05c1fc79f', blocks: [ { id: 'branch_8864cf1f9d3', meta: { size: { width: 280, height: 28, }, }, }, { id: 'branch_864cf1f9d39', meta: { size: { width: 280, height: 28, }, }, }, { id: 'branch_64cf1f9d393', meta: { size: { width: 280, height: 28, }, }, }, { id: 'branch_4cf1f9d3938', meta: { size: { width: 280, height: 28, }, }, }, { id: 'branch_cf1f9d39381', meta: { size: { width: 280, height: 28, }, }, }, ], }, { type: 'dynamicSplit', id: 'exclusiveSplit_d1c021e4362', blocks: [ { id: 'branch_a61f008864c', meta: { size: { width: 280, height: 28, }, }, }, { id: 'branch_61f008864cf', meta: { size: { width: 280, height: 28, }, }, }, ], }, { type: 'dynamicSplit', id: 'exclusiveSplit_de37a4886e7', blocks: [ { id: 'branch_1f008864cf1', meta: { size: { width: 280, height: 28, }, }, }, { id: 'branch_f008864cf1f', meta: { size: { width: 280, height: 28, }, }, }, ], }, { type: 'createRecord', id: 'createRecord_25ab93c8764', }, { type: 'dynamicSplit', id: 'exclusiveSplit_15ef1db02e0', blocks: [ { id: 'branch_c9f0a61f008', meta: { size: { width: 280, height: 28, }, }, }, { id: 'branch_9f0a61f0088', meta: { size: { width: 280, height: 28, }, }, }, { id: 'branch_f0a61f00886', meta: { size: { width: 280, height: 28, }, }, }, { id: 'branch_0a61f008864', meta: { size: { width: 280, height: 28, }, }, }, ], }, { type: 'createRecord', id: 'createRecord_fc2f1d9ed41', }, { type: 'kunlun_all_all_lark_openapi_im_chats', id: 'kunlun_all_all_lark_openapi_im_chats_5dd05c93e4e', }, { type: 'kunlun_all_all_byted_bmq_action', id: 'kunlun_all_all_byted_bmq_action_f41ff46de8a', }, { type: 'kunlun_all_all_lark_openapi_doc_manage', id: 'kunlun_all_all_lark_openapi_doc_manage_b789635bc6b', }, { type: 'kunlun_all_all_lark_open_spreadsheet', id: 'kunlun_all_all_lark_open_spreadsheet_e8a7c39384d', }, { type: 'end', id: 'end', }, ], } ================================================ FILE: packages/canvas-engine/renderer/__mocks__/flow-labels-mock-register.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { type FlowNodeRegistry, FlowTransitionLabelEnum, } from '@flowgram.ai/document'; import { FlowTextKey } from '../src/flow-renderer-registry'; /** * 动态接入 mock register,测试 labels */ export const FlowLabelsMockRegister: FlowNodeRegistry = { type: 'mock', getLabels(transition) { return [ { type: FlowTransitionLabelEnum.BRANCH_DRAGGING_LABEL, offset: transition.transform.outputPoint, props: { side: 'left', } }, { type: FlowTransitionLabelEnum.BRANCH_DRAGGING_LABEL, offset: transition.transform.outputPoint, }, { type: FlowTransitionLabelEnum.COLLAPSE_LABEL, offset: transition.transform.outputPoint, }, { type: FlowTransitionLabelEnum.COLLAPSE_ADDER_LABEL, offset: transition.transform.outputPoint, }, { type: FlowTransitionLabelEnum.COLLAPSE_ADDER_LABEL, offset: transition.transform.outputPoint, props: { activateNode: { getData: () => ({ hovered: true }), isVertical: true, } } }, { type: FlowTransitionLabelEnum.COLLAPSE_ADDER_LABEL, offset: transition.transform.outputPoint, props: { activateNode: { getData: () => ({ hovered: true }), isVertical: true, }, } }, { type: FlowTransitionLabelEnum.TEXT_LABEL, offset: transition.transform.outputPoint, }, { type: FlowTransitionLabelEnum.TEXT_LABEL, renderKey: FlowTextKey.LOOP_WHILE_TEXT, offset: transition.transform.outputPoint, }, { type: FlowTransitionLabelEnum.TEXT_LABEL, renderKey: FlowTextKey.CATCH_TEXT, props: { style: { width: 100 } }, rotate: '90deg', offset: transition.transform.outputPoint, }, { type: FlowTransitionLabelEnum.CUSTOM_LABEL, offset: transition.transform.outputPoint, }, { type: FlowTransitionLabelEnum.CUSTOM_LABEL, renderKey: FlowTextKey.LOOP_WHILE_TEXT, offset: transition.transform.outputPoint, }, { type: 'unknown' as any, offset: transition.transform.outputPoint, }, ]; }, }; ================================================ FILE: packages/canvas-engine/renderer/__mocks__/flow-mock-node-json.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export const flowJson = { nodes: [ { type: 'start', id: 'start', }, { type: 'mock', id: 'mock', }, ], } ================================================ FILE: packages/canvas-engine/renderer/__mocks__/flow-selected-nodes.mock.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export const FLOW_SELECTED_NODES = { nodes: [ { id: 'start', type: 'start', blocks: [], }, { id: 'createRecord_613973143a5', type: 'createRecord', blocks: [], }, { id: 'exclusiveSplit_13973143a53', type: 'dynamicSplit', blocks: [ { id: 'branch_0b5ee7b1189', meta: { size: { width: 280, height: 28, }, }, }, { id: 'branch_b5ee7b11890', meta: { size: { width: 280, height: 28, }, }, }, ], }, { id: 'exclusiveSplit_30baf8b1da0', type: 'dynamicSplit', blocks: [ { id: 'branch_33d40b5ee7b', meta: { size: { width: 280, height: 28, }, }, blocks: [ { id: 'createRecord_897b61c55f3', type: 'createRecord', blocks: [], }, ], }, { id: 'branch_3d40b5ee7b1', meta: { size: { width: 280, height: 28, }, }, blocks: [ { id: 'exclusiveSplit_d0070ce5d04', type: 'dynamicSplit', blocks: [ { id: 'branch_d40b5ee7b11', meta: { size: { width: 280, height: 28, }, }, blocks: [ { id: 'createRecord_47e8fe1dfc3', type: 'createRecord', blocks: [], }, { id: 'createRecord_32dcdd10274', type: 'createRecord', blocks: [], }, { id: 'exclusiveSplit_a5579b3997d', type: 'dynamicSplit', blocks: [ { id: 'branch_5ee7b11890c', meta: { size: { width: 280, height: 28, }, }, blocks: [ { id: 'createRecord_b57b00eee94', type: 'createRecord', blocks: [], }, ], }, { id: 'branch_ee7b11890c1', meta: { size: { width: 280, height: 28, }, }, }, ], }, ] }, { id: 'branch_40b5ee7b118', meta: { size: { width: 280, height: 28, }, }, blocks: [ { id: 'tryCatch_cb31cd3f34f', type: 'tryCatch', blocks: [ { id: 'branch_b31cd3f34fe', blocks: [ { id: 'createRecord_a32ff708e68', type: 'createRecord', blocks: [], }, ] }, { id: 'branch_31cd3f34fec', blocks: [ { id: 'createRecord_94cf09ad24b', type: 'createRecord', blocks: [], }, ] }, ], }, ] }, ], }, ] }, ], }, { id: 'end', type: 'end', blocks: [], }, ], } ================================================ FILE: packages/canvas-engine/renderer/__mocks__/mock-lines.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { FlowTransitionLineEnum } from "@flowgram.ai/document" export const mockDivergeLine1 = { type: FlowTransitionLineEnum.DIVERGE_LINE, from: {x: 140, y: 212}, to: {x: 0, y: 235}, } export const mockDivergeLine2 = { type: FlowTransitionLineEnum.DIVERGE_LINE, from: {x: 140, y: 212}, to: {x: 156, y: 232}, } export const mockDivergeLine3 = { type: FlowTransitionLineEnum.DIVERGE_LINE, from: {x: 140, y: 212}, to: {x: 141, y: 332}, } export const mockMergeLine3 = { type: FlowTransitionLineEnum.MERGE_LINE, from: {x: 140, y: 212}, to: {x: 0, y: 235}, } export const mockMergeLine4 = { type: FlowTransitionLineEnum.MERGE_LINE, from: {x: 140, y: 212}, to: {x: 141, y: 335}, } export const mockMergeLine5 = { type: FlowTransitionLineEnum.MERGE_LINE, from: {x: 140, y: 212}, to: {x: 150, y: 335}, } export const noRadiusLine = { type: FlowTransitionLineEnum.DIVERGE_LINE, from: {x: 140, y: 212}, to: {x: 141, y: 213}, } export const noTypeLine = { type: undefined, from: {x: 140, y: 212}, to: {x: 0, y: 312}, } ================================================ FILE: packages/canvas-engine/renderer/__mocks__/renderer.mock.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { EntityManager, PlaygroundConfigEntity, PlaygroundContainerModule } from '@flowgram.ai/core' import { Container } from 'inversify' export function createPlaygroundContainer(): Container { const container = new Container() container.load(PlaygroundContainerModule) return container } export function createPlaygroundConfigEntity(): PlaygroundConfigEntity { return createPlaygroundContainer() .get(EntityManager) .getEntity(PlaygroundConfigEntity, true)! } ================================================ FILE: packages/canvas-engine/renderer/__mocks__/setup-file.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import 'reflect-metadata' ================================================ FILE: packages/canvas-engine/renderer/__tests__/components/Adder.test.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import * as React from 'react'; import { vi, describe, test, expect } from 'vitest'; import { render } from '@testing-library/react'; import Adder from '../../src/components/Adder'; vi.mock('../../src/components/utils', () => ({ DEFAULT_LABEL_ACTIVATE_HEIGHT: 10, getTransitionLabelHoverWidth() { return 10; }, })); describe.skip('Adder', () => { test('should render Adder correctly', () => { const data = { entity: { document: { renderState: { getNodeDroppingId() {}, getDragStartEntity() {}, }, renderTree: { getOriginInfo() { return { next: null, }; }, }, }, }, } as any; const rendererRegistry = { getRendererComponent() { return { renderer() { return 'hello'; }, }; }, } as any; const { getByText } = render(); expect(getByText('hello')).toBeDefined(); }); }); // describe('getFlowRenderKey', () => { // test('should getFlowRenderKey work correctly', () => { // // branch // expect(getFlowRenderKey(DRAGGING_TYPE.BRANCH, false, false)).toBe(FlowRendererKey.ADDER); // expect(getFlowRenderKey(DRAGGING_TYPE.BRANCH, true, false)).toBe(FlowRendererKey.ADDER); // expect(getFlowRenderKey(DRAGGING_TYPE.BRANCH, true, true)).toBe(FlowRendererKey.ADDER); // expect(getFlowRenderKey(DRAGGING_TYPE.BRANCH, false, true)).toBe(FlowRendererKey.ADDER); // // node // expect(getFlowRenderKey(DRAGGING_TYPE.NODE, false, false)).toBe( // FlowRendererKey.DRAGGABLE_ADDER, // ); // expect(getFlowRenderKey(DRAGGING_TYPE.NODE, true, false)).toBe( // FlowRendererKey.DRAG_HIGHLIGHT_ADDER, // ); // expect(getFlowRenderKey(DRAGGING_TYPE.NODE, true, true)).toBe(FlowRendererKey.ADDER); // expect(getFlowRenderKey(DRAGGING_TYPE.NODE, false, true)).toBe(FlowRendererKey.ADDER); // }); // }); ================================================ FILE: packages/canvas-engine/renderer/__tests__/components/rounded-turning-line.test.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import * as React from 'react'; import { describe, test, expect, vi } from 'vitest'; import { Container, interfaces } from 'inversify'; import { render } from '@testing-library/react'; import { FlowDocumentContainerModule, FlowTransitionLine, FlowTransitionLineEnum, } from '@flowgram.ai/document'; import RoundedTurningLine from '../../src/components/RoundedTurningLine'; function createDocumentContainer(): interfaces.Container { const container = new Container(); container.load(FlowDocumentContainerModule); // container.bind(FlowDocumentContribution).to(FlowDocumentMockRegister); return container; } vi.mock('../../src/hooks/use-base-color.ts', () => ({ useBaseColor: () => ({ baseActivatedColor: '#fff', baseColor: '#fff', }), BASE_DEFAULT_COLOR: '#BBBFC4', BASE_DEFAULT_ACTIVATED_COLOR: '#82A7FC', })); describe('RoundedTurningLine', () => { test('should render RoundedTurningLine correctly', () => { const line: FlowTransitionLine = { type: FlowTransitionLineEnum.ROUNDED_LINE, from: { x: 0, y: 0, }, to: { x: 100, y: 100, }, vertices: [ { x: 100, y: 0, }, ], }; const { container } = render(); expect(container.querySelector('path')).toBeDefined(); }); test('should render RoundedTurningLine horizontal & arrow & active', () => { const line: FlowTransitionLine = { type: FlowTransitionLineEnum.ROUNDED_LINE, from: { x: 0, y: 0, }, to: { x: 100, y: 100, }, activated: true, }; const { container } = render(); expect(container.querySelector('path')).toBeDefined(); }); test('should render with vertices', () => { const line: FlowTransitionLine = { type: FlowTransitionLineEnum.ROUNDED_LINE, from: { x: 0, y: 0, }, to: { x: 100, y: 100, }, vertices: [ { x: 0, y: 30, }, { x: 0, y: 30, radiusX: 30, radiusY: 30, }, { x: 0, y: 30, moveX: 10, moveY: 10, }, { x: 50, y: 50, }, ], activated: true, }; const { container } = render(); expect(container.querySelector('path')).toBeDefined(); }); test('should hide RoundedTurningLine', () => { const line: FlowTransitionLine = { type: FlowTransitionLineEnum.ROUNDED_LINE, from: { x: 0, y: 0, }, to: { x: 100, y: 100, }, }; const { container } = render(); expect(container.querySelector('path')).toBeNull(); }); }); ================================================ FILE: packages/canvas-engine/renderer/__tests__/entities/__snapshots__/flow-drag-entities.test.ts.snap ================================================ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`flow-drag-entity > flow drag scroll 1`] = `undefined`; exports[`flow-drag-entity > flow drag scroll 2`] = `0`; exports[`flow-drag-entity > flow drag scroll 3`] = `2`; exports[`flow-drag-entity > flow drag scroll 4`] = `3`; exports[`flow-drag-entity > flow drag scroll 5`] = `1`; ================================================ FILE: packages/canvas-engine/renderer/__tests__/entities/flow-drag-entities.test.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { beforeEach, describe, expect, it } from 'vitest'; import { Rectangle } from '@flowgram.ai/utils'; import { FlowDocument, FlowNodeTransitionData, FlowTransitionLabelEnum, LABEL_SIDE_TYPE, } from '@flowgram.ai/document'; import { EntityManager, PlaygroundConfigEntity, PlaygroundContext } from '@flowgram.ai/core'; import { FlowDragEntity } from '../../src/entities/flow-drag-entity'; import { flowJson } from '../../__mocks__/flow-json.mock'; import { NOT_SCROLL_EVENT, SCROLL_BOTTOM_EVENT, SCROLL_LEFT_EVENT, SCROLL_RIGHT_EVENT, SCROLL_TOP_EVENT, } from '../../__mocks__/flow-drag-entity'; import { createDocumentContainer } from '../../__mocks__/flow-document-container.mock'; // layer 层 drag entity 单测 describe('flow-drag-entity', () => { let container = createDocumentContainer(); let document: FlowDocument; let flowDragEntity: FlowDragEntity; let nodeTransition: FlowNodeTransitionData; let firstBranchTransition: FlowNodeTransitionData; const collisionRect = new Rectangle(-50, -50, 100, 100); const notCollisionRect = new Rectangle(50, 50, 100, 100); beforeEach(() => { container = createDocumentContainer(); document = container.get(FlowDocument); const entityManager = container.get(EntityManager); container.bind(PlaygroundContext).toConstantValue({}); document.fromJSON(flowJson); flowDragEntity = new FlowDragEntity({ entityManager }); const playgroundConfigEntity = entityManager.getEntity(PlaygroundConfigEntity); playgroundConfigEntity?.updateConfig({ clientX: 0, clientY: 0, width: 3000, height: 3000, }); const nodeEntity = document.getNode('$blockIcon$approval_fc79f9fa62f'); nodeTransition = nodeEntity?.getData( FlowNodeTransitionData ) as FlowNodeTransitionData; const firstBranchEntity = document.getNode('branch_8864cf1f9d3'); firstBranchTransition = firstBranchEntity?.getData( FlowNodeTransitionData ) as FlowNodeTransitionData; }); it('flow drag scroll', () => { const el = global.document.createElement('div'); // 页面不滚动 expect(flowDragEntity.scrollDirection(NOT_SCROLL_EVENT, 0, 0)).toMatchSnapshot(); // 页面滚动 expect(flowDragEntity.scrollDirection(SCROLL_TOP_EVENT, 0, 0)).toMatchSnapshot(); expect(flowDragEntity.scrollDirection(SCROLL_LEFT_EVENT, 0, 0)).toMatchSnapshot(); expect(flowDragEntity.scrollDirection(SCROLL_RIGHT_EVENT, 0, 0)).toMatchSnapshot(); expect(flowDragEntity.scrollDirection(SCROLL_BOTTOM_EVENT, 0, 0)).toMatchSnapshot(); // 停止滚动 flowDragEntity.stopAllScroll(); expect(flowDragEntity.hasScroll).toEqual(false); }); it('flow drag node collision true', () => { // 测试默认 offset x y 为 0 expect(flowDragEntity.isCollision(nodeTransition, collisionRect, false)).toEqual({ hasCollision: true, labelOffsetType: undefined, }); }); it('flow drag node label empty', () => { expect(flowDragEntity.isCollision(nodeTransition, notCollisionRect, false)).toEqual({ hasCollision: false, labelOffsetType: undefined, }); }); it('flow drag node collision false', () => { const emptyLabelNodeTransition = { ...nodeTransition, labels: [{ type: FlowTransitionLabelEnum.BRANCH_DRAGGING_LABEL, offset: { x: 0, y: 0 } }], } as FlowNodeTransitionData; expect(flowDragEntity.isCollision(emptyLabelNodeTransition, collisionRect, false)).toEqual({ hasCollision: false, labelOffsetType: undefined, }); }); it('flow drag branch collision true', () => { const preBranchNodeTransition = { ...firstBranchTransition, labels: [ { type: FlowTransitionLabelEnum.BRANCH_DRAGGING_LABEL, props: { side: LABEL_SIDE_TYPE.PRE_BRANCH, } as any, offset: { x: 0, y: 0 }, }, ], } as FlowNodeTransitionData; // 第一个分支场景,校验 labelOffsetType 场景 expect(flowDragEntity.isCollision(preBranchNodeTransition, collisionRect, true)).toEqual({ hasCollision: true, labelOffsetType: LABEL_SIDE_TYPE.PRE_BRANCH, }); }); it('flow drag branch collision false', () => { const preBranchNodeTransition = { ...firstBranchTransition, labels: [ { type: FlowTransitionLabelEnum.BRANCH_DRAGGING_LABEL, props: { side: LABEL_SIDE_TYPE.PRE_BRANCH, } as any, offset: { x: 0, y: 0 }, }, ], } as FlowNodeTransitionData; expect(flowDragEntity.isCollision(preBranchNodeTransition, notCollisionRect, true)).toEqual({ hasCollision: false, labelOffsetType: LABEL_SIDE_TYPE.NORMAL_BRANCH, }); }); it('flow drag dispose', () => { flowDragEntity.dispose(); expect(flowDragEntity.toDispose.disposed).toEqual(true); }); }); ================================================ FILE: packages/canvas-engine/renderer/__tests__/entities/flow-select-config-entity.test.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { Rectangle } from '@flowgram.ai/utils'; import { FlowDocument, FlowNodeTransformData } from '@flowgram.ai/document'; import { EntityManager } from '@flowgram.ai/core'; import { FlowSelectConfigEntity } from '../../src/entities/flow-select-config-entity'; import { FLOW_SELECTED_NODES } from '../../__mocks__/flow-selected-nodes.mock'; import { createDocumentContainer } from '../../__mocks__/flow-document-container.mock'; describe('flow-select-config-entity', () => { let document: FlowDocument; let configEntity: FlowSelectConfigEntity; let transformVisibles: FlowNodeTransformData[] = []; beforeEach(() => { const container = createDocumentContainer(); document = container.get(FlowDocument); configEntity = container.get(EntityManager).getEntity(FlowSelectConfigEntity, true)!; document.fromJSON(FLOW_SELECTED_NODES); document.transformer.refresh(); transformVisibles = document .getRenderDatas(FlowNodeTransformData, false) .filter((transform) => { const { entity } = transform; if (entity.originParent) { return entity.getNodeMeta().selectable && entity.originParent.getNodeMeta().selectable; } return entity.getNodeMeta().selectable; }); }); it('base', () => { expect(configEntity.selectedNodes.length).toEqual(0); expect(configEntity.getSelectedBounds().width).toEqual(0); const node = document.getNode('createRecord_47e8fe1dfc3')!; configEntity.selectedNodes = [node]; expect(configEntity.selectedNodes.map((n) => n.id)).toEqual(['createRecord_47e8fe1dfc3']); expect(configEntity.getSelectedBounds().width).toEqual(300); configEntity.clearSelectedNodes(); expect(configEntity.getSelectedBounds().width).toEqual(0); }); it('select from bounds', () => { const bounds = new Rectangle(-150, 630, 300, 80); configEntity.selectFromBounds(bounds, transformVisibles); expect(configEntity.selectedNodes.map((n) => n.id)).toEqual(['exclusiveSplit_30baf8b1da0']); }); }); ================================================ FILE: packages/canvas-engine/renderer/__tests__/flow-renderer.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ ================================================ FILE: packages/canvas-engine/renderer/__tests__/layers/__snapshots__/flow-drag-layer.test.ts.snap ================================================ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`flow-drag-layer > test ready 1`] = `undefined`; ================================================ FILE: packages/canvas-engine/renderer/__tests__/layers/__snapshots__/flow-label-layer.test.tsx.snap ================================================ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`flow-label-layer > test render 1`] = `
123
catch-text
`; ================================================ FILE: packages/canvas-engine/renderer/__tests__/layers/__snapshots__/flow-selector-box-layer.test.tsx.snap ================================================ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`flow-selector-box-layer > test ready 1`] = `undefined`; ================================================ FILE: packages/canvas-engine/renderer/__tests__/layers/flow-drag-layer.test.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { beforeEach, describe, expect, it } from 'vitest'; import { Container, decorate, injectable, type interfaces } from 'inversify'; import { FlowDocument, FlowDocumentContainerModule, FlowDocumentContribution, } from '@flowgram.ai/document'; import { createDefaultPlaygroundConfig, PlaygroundConfig, PlaygroundContainerModule, } from '@flowgram.ai/core'; import { FlowRendererRegistry } from '../../src/flow-renderer-registry'; import { FlowRendererContribution } from '../../src/flow-renderer-contribution'; import { FlowDragLayer, FlowRendererContainerModule } from '../../src'; import { flowJson } from '../../__mocks__/flow-json.mock'; import { FlowDocumentMockRegister } from '../../__mocks__/flow-document-container.mock'; class FlowRenderMockRegister implements FlowRendererContribution { registerRenderer(registry: FlowRendererRegistry): void { registry.registerLayers(FlowDragLayer); } } decorate(injectable(), FlowRenderMockRegister); function createDocumentContainer(): interfaces.Container { const container = new Container(); container.load(FlowDocumentContainerModule); container.bind(FlowDocumentContribution).to(FlowDocumentMockRegister); return container; } // layer 层 drag entity 单测 describe('flow-drag-layer', () => { let container = createDocumentContainer(); let document: FlowDocument; let registry: FlowRendererRegistry; beforeEach(() => { container = createDocumentContainer(); container.load(FlowRendererContainerModule); container.load(PlaygroundContainerModule); container.bind(FlowRendererContribution).to(FlowRenderMockRegister); container.bind(PlaygroundConfig).toConstantValue(createDefaultPlaygroundConfig()); document = container.get(FlowDocument); document.fromJSON(flowJson); registry = container.get(FlowRendererRegistry); registry.init(); }); // 测试初始化 // it('test ready', () => { // expect(registry.pipeline.renderer.layers.map(layer => layer?.onReady?.())).toMatchSnapshot(); // }); // 渲染 it('test ready', () => { registry.pipeline.renderer.layers.forEach(layer => { expect(layer.render?.()).toMatchSnapshot(); }); }); }); ================================================ FILE: packages/canvas-engine/renderer/__tests__/layers/flow-label-layer.test.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { Container, decorate, injectable, type interfaces } from 'inversify'; import { render } from '@testing-library/react'; import { FlowDocument, FlowDocumentContainerModule, FlowDocumentContribution, } from '@flowgram.ai/document'; import { createDefaultPlaygroundConfig, PlaygroundConfig, PlaygroundContainerModule, } from '@flowgram.ai/core'; import { FlowLabelsLayer } from '../../src/layers/flow-labels-layer'; import { FlowRendererRegistry, FlowTextKey } from '../../src/flow-renderer-registry'; import { FlowRendererContribution } from '../../src/flow-renderer-contribution'; import { FlowRendererContainerModule } from '../../src/flow-renderer-container-module'; import { flowJson } from '../../__mocks__/flow-mock-node-json'; import { FlowLabelsMockRegister } from '../../__mocks__/flow-labels-mock-register'; import { FlowDocumentMockRegister } from '../../__mocks__/flow-document-container.mock'; class FlowRenderMockRegister implements FlowRendererContribution { registerRenderer(registry: FlowRendererRegistry): void { registry.registerLayers(FlowLabelsLayer); } } decorate(injectable(), FlowRenderMockRegister); function createDocumentContainer(): interfaces.Container { const container = new Container(); container.load(FlowDocumentContainerModule); container.bind(FlowDocumentContribution).to(FlowDocumentMockRegister); return container; } // layer 层 drag entity 单测 describe('flow-label-layer', () => { let container = createDocumentContainer(); let document: FlowDocument; let registry: FlowRendererRegistry; beforeEach(() => { container = createDocumentContainer(); container.load(FlowRendererContainerModule); container.load(PlaygroundContainerModule); container.bind(FlowRendererContribution).to(FlowRenderMockRegister); container.bind(PlaygroundConfig).toConstantValue(createDefaultPlaygroundConfig()); document = container.get(FlowDocument); document.init(); document.registerFlowNodes( FlowLabelsMockRegister, // 通过 getLabel 方法 mock label ); document.fromJSON(flowJson); registry = container.get(FlowRendererRegistry); registry.init(); }); // 测试初始化 it('test ready', () => { registry.pipeline.renderer.layers.forEach(layer => { layer?.onReady?.(); expect(layer.node.style.zIndex).toEqual('9'); }); }); // 缩放 it('test zoom', () => { registry.pipeline.renderer.layers.forEach(layer => { layer?.onZoom?.(2); expect(layer.node!.style.transform).toEqual('scale(2)'); }); }); // FIXME: render 单测目前不全 // 渲染 it('test render', () => { vi.mock('@flowgram.ai/core', async importOriginal => { const contextMaker = { makeFormItemMaterialContext: vi.fn().mockReturnValue('mock-context'), isDragBranch: true, labelSide: 'left', isDroppableBranch: () => true, dropNodeId: 'mock', dragging: true, isDroppableNode: () => true, }; return { // @ts-ignore ...(await importOriginal()), // mock Adder 组件里的 useService useService: vi.fn().mockReturnValue(contextMaker), }; }); const res: (JSX.Element | undefined)[] = []; registry.pipeline.renderer.layers.forEach(layer => { const render = (layer as any)?._render?.bind(layer); // mock rendererRegistry // @ts-ignore layer.rendererRegistry = { getRendererComponent: () => ({ renderer: () => null, }), getText: key => { if (key === FlowTextKey.LOOP_WHILE_TEXT) { return '123'; } return; }, }; res.push(render()); }); const app = render(<>{res}); expect(app.asFragment()).toMatchSnapshot(); }); }); ================================================ FILE: packages/canvas-engine/renderer/__tests__/layers/flow-lines-layer.test.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { Container, decorate, injectable, type interfaces } from 'inversify'; import { FlowDocument, FlowDocumentContainerModule, FlowDocumentContribution, } from '@flowgram.ai/document'; import { createDefaultPlaygroundConfig, PlaygroundConfig, PlaygroundConfigEntity, PlaygroundContainerModule, } from '@flowgram.ai/core'; import { FlowRendererRegistry } from '../../src/flow-renderer-registry'; import { FlowRendererContribution } from '../../src/flow-renderer-contribution'; import { FlowRendererContainerModule } from '../../src/flow-renderer-container-module'; import { FlowLinesLayer, FlowNodesTransformLayer } from '../../src'; import { flowJson } from '../../__mocks__/flow-json.mock'; import { FlowDocumentMockRegister } from '../../__mocks__/flow-document-container.mock'; class FlowRenderMockRegister implements FlowRendererContribution { registerRenderer(registry: FlowRendererRegistry): void { registry.registerLayers(FlowLinesLayer); } } decorate(injectable(), FlowRenderMockRegister); function createDocumentContainer(): interfaces.Container { const container = new Container(); container.load(FlowDocumentContainerModule); container.bind(FlowDocumentContribution).to(FlowDocumentMockRegister); return container; } describe('flow-lines-layer', () => { let container = createDocumentContainer(); let document: FlowDocument; let registry: FlowRendererRegistry; beforeEach(() => { container = createDocumentContainer(); container.load(FlowRendererContainerModule); container.load(PlaygroundContainerModule); container.bind(FlowRendererContribution).to(FlowRenderMockRegister); container.bind(PlaygroundConfig).toConstantValue(createDefaultPlaygroundConfig()); document = container.get(FlowDocument); document.init(); document.fromJSON(flowJson); registry = container.get(FlowRendererRegistry); registry.init(); // Mock the ResizeObserver const ResizeObserverMock = vi.fn(() => ({ observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn(), })); // Stub the global ResizeObserver vi.stubGlobal('ResizeObserver', ResizeObserverMock); }); // 测试初始化 it('test ready', () => { registry.pipeline.renderer.layers.forEach((layer) => { (layer as FlowLinesLayer).onReady(); expect(layer.node.style.zIndex).toEqual('1'); }); }); // 缩放 it('test zoom', () => { const config = container.get(PlaygroundConfigEntity); config.updateConfig({ zoom: 2 }); registry.pipeline.renderer.layers.forEach((layer) => { const linesLayer = layer as FlowLinesLayer; linesLayer.onZoom(); expect(linesLayer.viewBox).toEqual('0 0 500 500'); }); }); // FIXME: render 单测目前不全 }); ================================================ FILE: packages/canvas-engine/renderer/__tests__/layers/flow-selector-box-layer.test.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { beforeEach, describe, expect, it } from 'vitest'; import { Container, decorate, injectable, type interfaces } from 'inversify'; import { FlowDocument, FlowDocumentContainerModule, FlowDocumentContribution, } from '@flowgram.ai/document'; import { createDefaultPlaygroundConfig, PlaygroundConfig, PlaygroundContainerModule, } from '@flowgram.ai/core'; import { FlowRendererRegistry } from '../../src/flow-renderer-registry'; import { FlowRendererContribution } from '../../src/flow-renderer-contribution'; import { FlowSelectorBoxLayer, FlowRendererContainerModule, FlowSelectConfigEntity, } from '../../src'; import { flowJson } from '../../__mocks__/flow-json.mock'; import { FlowDocumentMockRegister } from '../../__mocks__/flow-document-container.mock'; class FlowRenderMockRegister implements FlowRendererContribution { registerRenderer(registry: FlowRendererRegistry): void { registry.registerLayers(FlowSelectorBoxLayer); } } decorate(injectable(), FlowRenderMockRegister); function createDocumentContainer(): interfaces.Container { const container = new Container(); container.load(FlowDocumentContainerModule); container.bind(FlowDocumentContribution).to(FlowDocumentMockRegister); return container; } // box layer 单测 describe('flow-selector-box-layer', () => { let container = createDocumentContainer(); let document: any; let registry: FlowRendererRegistry; beforeEach(() => { container = createDocumentContainer(); container.load(FlowRendererContainerModule); container.load(PlaygroundContainerModule); container.bind(FlowRendererContribution).to(FlowRenderMockRegister); container.bind(PlaygroundConfig).toConstantValue(createDefaultPlaygroundConfig()); container.bind(FlowSelectConfigEntity).toSelf().inSingletonScope(); document = container.get(FlowDocument as any); document.fromJSON(flowJson); registry = container.get(FlowRendererRegistry); registry.init(); }); // 渲染, FIXME 补充单测 it('test ready', () => { registry.pipeline.renderer.layers.forEach(layer => { // layer.onReady(); expect(layer.render?.()).toMatchSnapshot(); }); }); }); ================================================ FILE: packages/canvas-engine/renderer/__tests__/layers/flow-transform-layer.test.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { Container, decorate, injectable, type interfaces } from 'inversify'; import { FlowDocument, FlowDocumentContainerModule, FlowDocumentContribution, } from '@flowgram.ai/document'; import { createDefaultPlaygroundConfig, PlaygroundConfig, PlaygroundContainerModule, } from '@flowgram.ai/core'; import { FlowRendererRegistry } from '../../src/flow-renderer-registry'; import { FlowRendererContribution } from '../../src/flow-renderer-contribution'; import { FlowRendererContainerModule } from '../../src/flow-renderer-container-module'; import { FlowNodesTransformLayer } from '../../src'; import { flowJson } from '../../__mocks__/flow-json.mock'; import { FlowDocumentMockRegister } from '../../__mocks__/flow-document-container.mock'; class FlowRenderMockRegister implements FlowRendererContribution { registerRenderer(registry: FlowRendererRegistry): void { registry.registerLayers(FlowNodesTransformLayer); } } decorate(injectable(), FlowRenderMockRegister); function createDocumentContainer(): interfaces.Container { const container = new Container(); container.load(FlowDocumentContainerModule); container.bind(FlowDocumentContribution).to(FlowDocumentMockRegister); return container; } // layer 层 drag entity 单测 describe('flow-transform-layer', () => { let container = createDocumentContainer(); let document: FlowDocument; let registry: FlowRendererRegistry; beforeEach(() => { container = createDocumentContainer(); container.load(FlowRendererContainerModule); container.load(PlaygroundContainerModule); container.bind(FlowRendererContribution).to(FlowRenderMockRegister); container.bind(PlaygroundConfig).toConstantValue(createDefaultPlaygroundConfig()); document = container.get(FlowDocument); document.init(); document.fromJSON(flowJson); registry = container.get(FlowRendererRegistry); registry.init(); // Mock the ResizeObserver const ResizeObserverMock = vi.fn(() => ({ observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn(), })); // Stub the global ResizeObserver vi.stubGlobal('ResizeObserver', ResizeObserverMock); }); // 测试初始化 it('test ready', () => { registry.pipeline.renderer.layers.forEach(layer => { (layer as FlowNodesTransformLayer).onReady(); expect(layer.node.style.zIndex).toEqual('10'); }); }); // 缩放 it('test zoom', () => { registry.pipeline.renderer.layers.forEach(layer => { (layer as FlowNodesTransformLayer).onZoom(2); expect(layer.node!.style.transform).toEqual('scale(2)'); }); }); // FIXME: render 单测目前不全 // 渲染 it('test render', () => { registry.pipeline.renderer.layers.forEach(layer => { // const autorun = registry.pipeline.renderer.layerAutorunMap.get(layer); // autorun?.(); (layer as FlowNodesTransformLayer).updateNodesBounds(); }); }); }); ================================================ FILE: packages/canvas-engine/renderer/__tests__/utils/element.test.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { vi, describe, expect, it } from 'vitest'; import { isHidden, isRectInit } from '../../src/utils/element'; describe('test isHidden', () => { it('isHidden true', () => { vi.stubGlobal('getComputedStyle', () => ({ display: 'none', })); const mockElement = { offsetParent: null, }; const res = isHidden(mockElement as unknown as HTMLElement); expect(res).toEqual(true); }); it('isHidden false', () => { vi.stubGlobal('getComputedStyle', () => ({ display: 'block', })); const mockElement1 = { offsetParent: true, }; const res = isHidden(mockElement1 as unknown as HTMLElement); expect(res).toEqual(false); }); }); describe('isRectInit', () => { it('should return false when input is undefined', () => { expect(isRectInit(undefined)).toBe(false); }); it('should return false when all properties are 0', () => { const emptyRect: DOMRect = { bottom: 0, height: 0, left: 0, right: 0, top: 0, width: 0, x: 0, y: 0, toJSON: () => ({}), }; expect(isRectInit(emptyRect)).toBe(false); }); it('should return true when any property is not 0', () => { const validRect: DOMRect = { bottom: 100, height: 100, left: 0, right: 100, top: 0, width: 100, x: 0, y: 0, toJSON: () => ({}), }; expect(isRectInit(validRect)).toBe(true); }); }); ================================================ FILE: packages/canvas-engine/renderer/__tests__/utils/find-selected-nodes.test.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { type FlowDocument } from '@flowgram.ai/document'; import { findSelectedNodes } from '../../src/utils/find-selected-nodes'; import { FLOW_SELECTED_NODES } from '../../__mocks__/flow-selected-nodes.mock'; import { createDocument } from '../../__mocks__/flow-document-container.mock'; function selectNodes(document: FlowDocument, nodeIds: string[]): string[] { const nodes = nodeIds.map(n => document.getNode(n)); return findSelectedNodes(nodes).map(n => n.id); } describe('find selected nodes', () => { let document: FlowDocument; beforeEach(() => { document = createDocument(); document.fromJSON(FLOW_SELECTED_NODES); }); /** * 同分支选择 */ it('some branch', () => { const res = selectNodes(document, [ 'createRecord_47e8fe1dfc3', 'createRecord_32dcdd10274', 'exclusiveSplit_a5579b3997d', ]); expect(res).toEqual([ 'createRecord_47e8fe1dfc3', 'createRecord_32dcdd10274', 'exclusiveSplit_a5579b3997d', ]); }); /** * 同分支下再选择子节点 */ it('some branch with sub branch', () => { const res = selectNodes(document, [ 'createRecord_47e8fe1dfc3', 'createRecord_32dcdd10274', 'createRecord_b57b00eee94', // 这个属于 "exclusiveSplit_a5579b3997d" 的子节点 ]); expect(res).toEqual([ 'createRecord_47e8fe1dfc3', 'createRecord_32dcdd10274', 'exclusiveSplit_a5579b3997d', ]); }); /** * 跨分支选择 */ it('different branch', () => { const res = selectNodes(document, ['createRecord_897b61c55f3', 'createRecord_b57b00eee94']); expect(res).toEqual(['exclusiveSplit_30baf8b1da0']); const res2 = selectNodes(document, ['createRecord_897b61c55f3', 'createRecord_47e8fe1dfc3']); expect(res2).toEqual(['exclusiveSplit_30baf8b1da0']); }); }); ================================================ FILE: packages/canvas-engine/renderer/__tests__/utils/get-vertices.test.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { describe, expect, it } from 'vitest'; import { calcEllipseY, getHorizontalVertices, getVertices } from '../../src/components/utils'; import { mockDivergeLine1, mockDivergeLine2, mockDivergeLine3, mockMergeLine3, mockMergeLine4, mockMergeLine5, noRadiusLine, noTypeLine, } from '../../__mocks__/mock-lines'; describe('test Vertices', () => { it('calcEllipseY', () => { expect(calcEllipseY(1, 1, 3)).toEqual(0); }); // 垂直布局 it('getVertices diverge_line', () => { expect(() => getVertices(undefined as any)).toThrowError(); // 正常线条 const res1 = getVertices(mockDivergeLine1); expect(res1).toEqual([ { x: 140, y: 215, radiusY: 3 }, { x: 0, y: 215 }, ]); // radiusYCount = 1 const res2 = getVertices(mockDivergeLine2); expect(res2).toEqual([{ x: 156, y: 212, radiusY: 20 }]); // radiusYCount > 1 & radiusXCount < 1 const res3 = getVertices(mockDivergeLine3); expect(res3).toEqual([ { x: 140, y: 232, moveX: 0.5 }, { x: 141, y: 232, moveX: 0.5 }, ]); }); it('getVertices merge_line', () => { // merge_line radiusYCount < 2 const res3 = getVertices(mockMergeLine3); expect(res3).toEqual([{ x: 140, y: 235 }]); // merge_line radiusYCount > 2 & radiusXCount < 2 const res4 = getVertices(mockMergeLine4); expect(res4).toEqual([ { x: 140, y: 315, moveX: 0.5 }, { x: 141, y: 315, moveX: 0.5 }, ]); // merge_line radiusYCount > 2 & radiusXCount > 2 const res5 = getVertices(mockMergeLine5); expect(res5).toEqual([ { x: 140, y: 315, moveX: 5 }, { x: 150, y: 315, moveX: 5 }, ]); }); it('getVertices no radius line', () => { const noRadiusRes = getVertices(noRadiusLine); expect(noRadiusRes).toEqual([]); const noTypeRes = getVertices(noTypeLine as any); expect(noTypeRes).toEqual([]); }); // 水平布局 it('getHorizontalVertices diverge_line', () => { expect(() => getHorizontalVertices(undefined as any)).toThrowError(); // 正常线条 const res1 = getHorizontalVertices(mockDivergeLine1); expect(res1).toEqual([ { x: 160, y: 212, moveY: 11.5 }, { x: 160, y: 235, moveY: 11.5 }, ]); // radiusYCount = 1 const res2 = getHorizontalVertices(mockDivergeLine2); expect(res2).toEqual([{ x: 156, y: 212, radiusX: 16 }]); // radiusYCount > 1 & radiusXCount < 2 const res3 = getHorizontalVertices(mockDivergeLine3, 0.9); expect(res3).toEqual([ { x: 121, y: 212, radiusX: -19 }, { x: 121, y: 332 }, ]); }); it('getHorizontalVertices merge_line', () => { // merge_line radiusYCount < 2 const res3 = getHorizontalVertices(mockMergeLine3); expect(res3).toEqual([ { x: -20, y: 212, moveY: 11.5 }, { x: -20, y: 235, moveY: 11.5 }, ]); // merge_line radiusYCount > 2 & radiusXCount < 2 const res4 = getHorizontalVertices(mockMergeLine4, 0.9); expect(res4).toEqual([{ x: 141, y: 212 }]); // merge_line radiusYCount > 2 & radiusXCount > 2 const res5 = getHorizontalVertices(mockMergeLine5); expect(res5).toEqual([]); }); it('getHorizontalVertices no radius line', () => { const noRadiusRes = getHorizontalVertices(noRadiusLine); expect(noRadiusRes).toEqual([]); const noTypeRes = getHorizontalVertices(noTypeLine as any); expect(noTypeRes).toEqual([]); }); }); ================================================ FILE: packages/canvas-engine/renderer/__tests__/utils/scroll-limit.test.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { Rectangle } from '@flowgram.ai/utils'; import { scrollLimit } from '../../src/utils/scroll-limit'; import { createPlaygroundConfigEntity } from '../../__mocks__/renderer.mock'; test('scroll limit', () => { const config = createPlaygroundConfigEntity(); config.updateConfig({ width: 1668, height: 527, clientX: 60, clientY: 89, scrollX: 18, scrollY: -14, }); const initScrollData = { scrollX: 100, scrollY: -100 }; const res = scrollLimit(initScrollData, [new Rectangle(0, 0, 10, 10)], config, () => ({ scrollX: config.config.scrollX, scrollY: config.config.scrollY, })); expect(res).toEqual({ scrollX: 18, scrollY: -14 }); }); ================================================ FILE: packages/canvas-engine/renderer/eslint.config.js ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ const { defineFlatConfig } = require('@flowgram.ai/eslint-config'); module.exports = defineFlatConfig({ preset: 'web', packageRoot: __dirname, }); ================================================ FILE: packages/canvas-engine/renderer/index.module.less ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ :root { --g-selection-background: #336df4; --g-editor-background: #f2f3f5; --g-playground-select: var(--g-selection-background); --g-playground-hover: var(--g-selection-background); --g-playground-line: var(--g-selection-background); --g-playground-blur: #999; --g-playground-ruler-select: #3794ff; --g-playground-selectBox-outline: var(--g-selection-background); --g-playground-selectBox-background: rgba(51, 109, 244, 0.1); --g-playground-select-hover-background: rgba(51, 109, 244, 0.1); --g-playground-select-control-size: 12px; } :global { .gedit-playground { position: absolute; width: 100%; height: 100%; left: 0; top: 0; z-index: 10; overflow: hidden; user-select: none; outline: none; box-sizing: border-box; background-color: var(--g-editor-background); } .gedit-playground-scroll-right { position: absolute; right: 2px; height: 100vh; width: 7px; z-index: 10; } .gedit-playground-scroll-bottom { position: absolute; bottom: 2px; width: 100vw; height: 7px; z-index: 10; } .gedit-playground-scroll-right-block { position: absolute; opacity: 0.3; border-radius: 3.5px; } .gedit-playground-scroll-right-block:hover { opacity: 0.6; } .gedit-playground-scroll-bottom-block { position: absolute; opacity: 0.3; border-radius: 3.5px; } .gedit-playground-scroll-bottom-block:hover { opacity: 0.6; } .gedit-playground-scroll-hidden { opacity: 0; } .gedit-playground * { box-sizing: border-box; } .gedit-playground-loading { position: absolute; color: white; left: 50%; top: 50%; z-index: 100; display: flex; justify-content: center; align-items: center; transition: opacity 0.8s; flex-direction: column; text-align: center; opacity: 0.8; } .gedit-hidden { display: none; } .gedit-playground-pipeline { position: absolute; overflow: visible; width: 100%; height: 100%; left: 0; top: 0; } .gedit-playground-pipeline::before { content: ''; position: absolute; width: 1px; height: 100%; left: 0; top: 0; } .gedit-playground-layer { position: absolute; overflow: visible; } .gedit-selector-box { position: absolute; left: 0; top: 0; width: 0; height: 0; z-index: 33; outline: 1px solid var(--g-playground-selectBox-outline); background-color: var(--g-playground-selectBox-background); } .gedit-selector-box-block { position: absolute; left: 0; top: 0; width: 0; height: 0; z-index: 9999; display: none; background-color: rgba(0, 0, 0, 0); } .gedit-selector-bounds-background { position: absolute; left: 0; top: 0; width: 0; height: 0; outline: 1px solid var(--g-playground-selectBox-outline); background-color: #f0f4ff; } .gedit-selector-bounds-foreground { position: absolute; left: 0; top: 0; width: 0; height: 0; z-index: 33; background: rgba(255, 255, 255, 0); } .gedit-flow-activity-node { position: absolute; } } ================================================ FILE: packages/canvas-engine/renderer/package.json ================================================ { "name": "@flowgram.ai/renderer", "version": "0.1.8", "homepage": "https://flowgram.ai/", "repository": "https://github.com/bytedance/flowgram.ai", "license": "MIT", "exports": { "types": "./dist/index.d.ts", "import": "./dist/esm/index.js", "require": "./dist/index.js" }, "main": "./dist/index.js", "module": "./dist/esm/index.js", "types": "./dist/index.d.ts", "files": [ "dist", "index.module.less" ], "scripts": { "build": "npm run build:fast -- --dts-resolve", "build:fast": "tsup src/index.ts --format cjs,esm --sourcemap --legacy-output", "build:watch": "npm run build:fast -- --dts-resolve", "clean": "rimraf dist", "test": "vitest run", "test:cov": "vitest run --coverage", "ts-check": "tsc --noEmit", "watch": "npm run build:fast -- --dts-resolve --watch --ignore-watch dist" }, "dependencies": { "@flowgram.ai/core": "workspace:*", "@flowgram.ai/document": "workspace:*", "@flowgram.ai/i18n": "workspace:*", "@flowgram.ai/utils": "workspace:*", "inversify": "^6.0.1", "lodash-es": "^4.17.21", "reflect-metadata": "~0.2.2" }, "devDependencies": { "@flowgram.ai/eslint-config": "workspace:*", "@flowgram.ai/ts-config": "workspace:*", "@testing-library/react": "^12", "@types/lodash-es": "^4.17.12", "@types/react": "^18", "@types/react-dom": "^18", "@vitest/coverage-v8": "^3.2.4", "eslint": "^9.0.0", "tsup": "^8.0.1", "typescript": "^5.8.3", "vitest": "^3.2.4" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" }, "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" } } ================================================ FILE: packages/canvas-engine/renderer/src/components/Adder.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React, { useCallback, useState } from 'react'; import { type AdderProps, type FlowNodeTransitionData, type FlowNodeEntity, FlowDragService, } from '@flowgram.ai/document'; import { useService } from '@flowgram.ai/core'; import { FlowRendererKey, type FlowRendererRegistry } from '../flow-renderer-registry'; import { getTransitionLabelHoverHeight, getTransitionLabelHoverWidth } from './utils'; interface PropsType { data: FlowNodeTransitionData; rendererRegistry: FlowRendererRegistry; hoverWidth?: number; hoverHeight?: number; // 业务自定义 props [key: string]: unknown; } // export only for tests export const getFlowRenderKey = ( node: FlowNodeEntity, { dragService }: { dragService?: FlowDragService }, ) => { if (dragService && dragService.dragging && dragService.isDroppableNode(node)) { if (dragService.dropNodeId === node.id) { return FlowRendererKey.DRAG_HIGHLIGHT_ADDER; } return FlowRendererKey.DRAGGABLE_ADDER; } return FlowRendererKey.ADDER; }; /** * Adder 高亮热区扩散目的: * ux 调研的时候不少用户反馈点看的不是很清楚(初始点较小) * 因此给的解决办法是加深加大 icon 再加扩大 hover 热区 * * Adder 模块高亮规则: * 取前后节点宽度的最大值为高亮区域宽度 * 高度固定为 32px */ export default function Adder(props: PropsType) { const { data, rendererRegistry, hoverHeight = getTransitionLabelHoverHeight(data), hoverWidth = getTransitionLabelHoverWidth(data), ...restProps } = props; const [hoverActivated, setHoverActivated] = useState(false); const handleMouseEnter = useCallback(() => setHoverActivated(true), []); const handleMouseLeave = useCallback(() => setHoverActivated(false), []); const node = data.entity; const dragService = useService(FlowDragService); // 根据拖拽条件转换状态 const flowRenderKey = getFlowRenderKey(node, { dragService }); const adder = rendererRegistry.getRendererComponent(flowRenderKey); const from = node; // 获取 originTree 的 to 节点 const to = data.entity.document.renderTree.getOriginInfo(node).next; // 实际渲染的 to 节点 const renderTo = node.next; const child = React.createElement( adder.renderer as (props: AdderProps) => JSX.Element, { node, from, to, renderTo, hoverActivated, setHoverActivated, hoverWidth, hoverHeight, ...restProps, } as AdderProps, ); return ( // eslint-disable-next-line react/jsx-filename-extension
{child}
); } ================================================ FILE: packages/canvas-engine/renderer/src/components/BranchDraggableRenderer.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React from 'react'; import { type AdderProps, type FlowNodeTransitionData, type LABEL_SIDE_TYPE, FlowDragService, } from '@flowgram.ai/document'; import { FlowNodeEntity } from '@flowgram.ai/document'; import { useService } from '@flowgram.ai/core'; import { FlowRendererKey, type FlowRendererRegistry } from '../flow-renderer-registry'; interface PropsType { data: FlowNodeTransitionData; rendererRegistry: FlowRendererRegistry; hoverHeight?: number; side?: LABEL_SIDE_TYPE; // 业务自定义 props [key: string]: unknown; } const getFlowRenderKey = ( node: FlowNodeEntity, { dragService, side }: { dragService: FlowDragService; side?: LABEL_SIDE_TYPE }, ) => { if ( dragService.isDragBranch && side && dragService.labelSide === side && dragService.isDroppableBranch(node, side) ) { if (dragService.dropNodeId === node.id) { // 元素拖拽区域激活 return FlowRendererKey.DRAG_BRANCH_HIGHLIGHT_ADDER; } // 节点元素拖拽,展示可被拖入区域为添加节点位置 return FlowRendererKey.DRAGGABLE_ADDER; } // 默认不展示 return ''; }; /** * 分支可被拖拽进入区域样式渲染 */ export default function BranchDraggableRenderer(props: PropsType) { const { data, rendererRegistry, side, ...restProps } = props; const node = data.entity; const dragService = useService(FlowDragService); const flowRenderKey = getFlowRenderKey(node, { side, dragService }); if (!flowRenderKey) { return null; } const adder = rendererRegistry.getRendererComponent(flowRenderKey); const from = node; // 获取 originTree 的 to 节点 const to = data.entity.document.renderTree.getOriginInfo(node).next; // 实际渲染的 to 节点 const renderTo = node.next; const child = React.createElement( adder.renderer as (props: AdderProps) => JSX.Element, { node, from, to, renderTo, ...restProps, } as AdderProps, ); return
{child}
; } ================================================ FILE: packages/canvas-engine/renderer/src/components/Collapse.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React, { useState, useCallback } from 'react'; import { type CollapseProps, FlowNodeRenderData, type FlowNodeTransitionData, } from '@flowgram.ai/document'; import { FlowRendererKey, type FlowRendererRegistry } from '../flow-renderer-registry'; import { getTransitionLabelHoverHeight, getTransitionLabelHoverWidth } from './utils'; interface PropsType extends Partial { data: FlowNodeTransitionData; rendererRegistry: FlowRendererRegistry; hoverHeight?: number; hoverWidth?: number; wrapperStyle?: React.CSSProperties; // 业务自定义 props [key: string]: unknown; } export default function Collapse(props: PropsType) { const { data, rendererRegistry, forceVisible, hoverHeight = getTransitionLabelHoverHeight(data), hoverWidth = getTransitionLabelHoverWidth(data), wrapperStyle, ...restProps } = props; const { activateNode } = restProps; const [hoverActivated, setHoverActivated] = useState(false); const activateData = activateNode?.getData(FlowNodeRenderData); const handleMouseEnter = useCallback(() => { setHoverActivated(true); activateData?.toggleMouseEnter(); }, []); const handleMouseLeave = useCallback(() => { setHoverActivated(false); activateData?.toggleMouseLeave(); }, []); const collapseOpener = rendererRegistry.getRendererComponent(FlowRendererKey.COLLAPSE); const node = data.entity; const child = React.createElement( collapseOpener.renderer as (props: CollapseProps) => JSX.Element, { node, collapseNode: node, ...restProps, hoverActivated, } as CollapseProps, ); const isChildVisible = data.collapsed || activateData?.hovered || hoverActivated || forceVisible; return (
{isChildVisible ? child : null}
); } ================================================ FILE: packages/canvas-engine/renderer/src/components/CollapseAdder.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React, { useState, useCallback } from 'react'; import { type CollapseAdderProps, FlowNodeRenderData, type FlowNodeTransitionData, } from '@flowgram.ai/document'; import { type FlowRendererRegistry } from '../flow-renderer-registry'; import Collapse from './Collapse'; import Adder from './Adder'; interface PropsType extends Partial { data: FlowNodeTransitionData; rendererRegistry: FlowRendererRegistry; // 业务自定义 props [key: string]: unknown; } /** * 加号和收起复合 Label * @param props * @returns */ export default function CollapseAdder(props: PropsType) { const { data, rendererRegistry, ...restProps } = props; const { activateNode } = restProps; // 收起展开按钮是否可见 const [hoverActivated, setHoverActivated] = useState(false); const activateData = activateNode?.getData(FlowNodeRenderData); const handleMouseEnter = useCallback(() => { setHoverActivated(true); }, []); const handleMouseLeave = useCallback(() => { setHoverActivated(false); }, []); const isVertical = activateNode?.isVertical; const activated = activateData?.hovered || hoverActivated; if (isVertical) { return (
{(activated || data.collapsed) && ( )} {!data.collapsed && ( )}
); } return (
{(activated || data.collapsed) && ( )} {!data.collapsed && ( )}
); } ================================================ FILE: packages/canvas-engine/renderer/src/components/CustomLine.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React from 'react'; import type { FlowTransitionLine } from '@flowgram.ai/document'; import { type FlowRendererRegistry } from '../flow-renderer-registry'; interface PropsType extends FlowTransitionLine { rendererRegistry: FlowRendererRegistry; } function CustomLine(props: PropsType): JSX.Element { const { renderKey, rendererRegistry, ...line } = props; if (!renderKey) { return <>; } const renderer = rendererRegistry.getRendererComponent(renderKey); if (!renderer) { return <>; } const Component = renderer.renderer as (props: FlowTransitionLine) => JSX.Element; return ; } export default CustomLine; ================================================ FILE: packages/canvas-engine/renderer/src/components/LabelsRenderer.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React from 'react'; import { type IPoint, Rectangle } from '@flowgram.ai/utils'; import { type CustomLabelProps, type FlowNodeTransitionData, type FlowTransitionLabel, FlowTransitionLabelEnum, } from '@flowgram.ai/document'; import { type FlowRendererRegistry } from '../flow-renderer-registry'; import CollapseAdder from './CollapseAdder'; import Collapse from './Collapse'; import BranchDraggableRenderer from './BranchDraggableRenderer'; import Adder from './Adder'; export interface LabelOpts { // eslint-disable-next-line react/no-unused-prop-types data: FlowNodeTransitionData; rendererRegistry: FlowRendererRegistry; isViewportVisible: (bounds: Rectangle) => boolean; labelsSave: JSX.Element[]; getLabelColor: (activated?: boolean) => string; } const TEXT_LABEL_STYLE: React.CSSProperties = { fontSize: 12, color: '#8F959E', textAlign: 'center', whiteSpace: 'nowrap', backgroundColor: 'var(--g-editor-background)', lineHeight: '20px', }; const LABEL_MAX_WIDTH = 150; const LABEL_MAX_HEIGHT = 60; function getLabelBounds(offset: IPoint) { return new Rectangle( offset.x - LABEL_MAX_WIDTH / 2, offset.y - LABEL_MAX_HEIGHT / 2, LABEL_MAX_WIDTH, LABEL_MAX_HEIGHT ); } export function createLabels(labelProps: LabelOpts): void { const { data, rendererRegistry, labelsSave, getLabelColor } = labelProps; const { labels, renderData } = data || {}; const { activated } = renderData || {}; // 标签绘制逻辑 const renderLabel = (label: FlowTransitionLabel, index: number) => { const { offset, renderKey, props, rotate, origin, type } = label || {}; const offsetX = offset.x; const offsetY = offset.y; let child = null; switch (type) { case FlowTransitionLabelEnum.BRANCH_DRAGGING_LABEL: child = ( ); break; case FlowTransitionLabelEnum.ADDER_LABEL: child = ( ); break; case FlowTransitionLabelEnum.COLLAPSE_LABEL: child = ( ); break; case FlowTransitionLabelEnum.COLLAPSE_ADDER_LABEL: child = ( ); break; case FlowTransitionLabelEnum.TEXT_LABEL: if (!renderKey) { return null; } const text = rendererRegistry.getText(renderKey) || renderKey; child = (
{text}
); break; case FlowTransitionLabelEnum.CUSTOM_LABEL: if (!renderKey) { return null; } try { const renderer = rendererRegistry.getRendererComponent(renderKey); child = React.createElement( renderer.renderer as (props: any) => JSX.Element, { node: data.entity, labelId: label.labelId || labelProps.data.entity.id, ...props, } as CustomLabelProps ); } catch (err) { console.error(err); child = renderKey; } break; default: break; } const originX = typeof origin?.[0] === 'number' ? origin?.[0] : 0.5; const originY = typeof origin?.[1] === 'number' ? origin?.[1] : 0.5; return (
{child}
); }; labels.forEach((label, index) => { if (labelProps.isViewportVisible(getLabelBounds(label.offset))) { labelsSave.push(renderLabel(label, index) as JSX.Element); } }); } ================================================ FILE: packages/canvas-engine/renderer/src/components/LinesRenderer.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React from 'react'; import { Rectangle } from '@flowgram.ai/utils'; import { FlowDragService, type FlowNodeTransitionData, type FlowTransitionLine, FlowTransitionLineEnum, DefaultSpacingKey, } from '@flowgram.ai/document'; import { getDefaultSpacing } from '@flowgram.ai/document'; import { type FlowRendererRegistry } from '../flow-renderer-registry'; import StraightLine from './StraightLine'; import RoundedTurningLine from './RoundedTurningLine'; import CustomLine from './CustomLine'; export interface PropsType { data: FlowNodeTransitionData; rendererRegistry: FlowRendererRegistry; isViewportVisible: (bounds: Rectangle) => boolean; linesSave: JSX.Element[]; dragService: FlowDragService; } export function createLines(props: PropsType): void { const { data, rendererRegistry, linesSave, dragService } = props; const { lines, entity } = data || {}; const radius = getDefaultSpacing(entity, DefaultSpacingKey.ROUNDED_LINE_RADIUS); const xRadius = getDefaultSpacing(entity, DefaultSpacingKey.ROUNDED_LINE_X_RADIUS); const yRadius = getDefaultSpacing(entity, DefaultSpacingKey.ROUNDED_LINE_Y_RADIUS); // 线条绘制逻辑 const renderLine = (line: FlowTransitionLine, index: number) => { const { renderData } = data; const { isVertical } = data.entity; const { lineActivated } = renderData || {}; const draggingLineHide = (line.type === FlowTransitionLineEnum.DRAGGING_LINE || line.isDraggingLine) && !dragService.isDroppableBranch(data.entity, line.side); const draggingLineActivated = (line.type === FlowTransitionLineEnum.DRAGGING_LINE || line.isDraggingLine) && data.entity?.id === dragService.dropNodeId && line.side === dragService.labelSide; switch (line.type) { case FlowTransitionLineEnum.STRAIGHT_LINE: return ( ); case FlowTransitionLineEnum.DIVERGE_LINE: case FlowTransitionLineEnum.DRAGGING_LINE: case FlowTransitionLineEnum.MERGE_LINE: case FlowTransitionLineEnum.ROUNDED_LINE: return ( ); case FlowTransitionLineEnum.CUSTOM_LINE: return ( ); default: break; } return undefined; }; lines.forEach((line, index) => { const bounds = Rectangle.createRectangleWithTwoPoints(line.from, line.to).pad(10); if (props.isViewportVisible(bounds)) { const jsxEl = renderLine(line, index) as JSX.Element; if (jsxEl) linesSave.push(jsxEl); } }); } ================================================ FILE: packages/canvas-engine/renderer/src/components/MarkerActivatedArrow.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React from 'react'; import { useBaseColor } from '../hooks/use-base-color'; export const MARK_ACTIVATED_ARROW_ID = '$marker_arrow_activated$'; // export const MARK_ACTIVATED_ARROW_URL = `url(#${MARK_ACTIVATED_ARROW_ID})`; function MarkerActivatedArrow(props: { id?: string }): JSX.Element { const { baseActivatedColor } = useBaseColor(); return ( ); } // version 变化才触发组件更新 export default MarkerActivatedArrow; ================================================ FILE: packages/canvas-engine/renderer/src/components/MarkerArrow.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React from 'react'; import { useBaseColor } from '../hooks/use-base-color'; export const MARK_ARROW_ID = '$marker_arrow$'; // export const MARK_ARROW_URL = `url(#${MARK_ARROW_ID})`; function MarkerArrow(props: { id: string }): JSX.Element { const { baseColor } = useBaseColor(); return ( ); } // version变化才触发组件更新 export default MarkerArrow; ================================================ FILE: packages/canvas-engine/renderer/src/components/RoundedTurningLine.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React, { useMemo } from 'react'; import { isNil } from 'lodash-es'; import { Point } from '@flowgram.ai/utils'; import { type FlowTransitionLine } from '@flowgram.ai/document'; import { useService } from '@flowgram.ai/core'; import { useBaseColor } from '../hooks/use-base-color'; import { DEFAULT_LINE_ATTRS, DEFAULT_RADIUS, getHorizontalVertices, getVertices } from './utils'; import MarkerArrow, { MARK_ARROW_ID } from './MarkerArrow'; import MarkerActivatedArrow, { MARK_ACTIVATED_ARROW_ID } from './MarkerActivatedArrow'; import { FlowRendererKey, FlowRendererRegistry } from '../flow-renderer-registry'; interface PropsType extends FlowTransitionLine { radius?: number; hide?: boolean; xRadius?: number; yRadius?: number; } function MarkerDefs(props: { id: string; activated?: boolean }): JSX.Element { const renderRegistry = useService(FlowRendererRegistry); const ArrowRenderer = renderRegistry?.tryToGetRendererComponent( props.activated ? FlowRendererKey.MARKER_ACTIVATE_ARROW : FlowRendererKey.MARKER_ARROW ); if (ArrowRenderer) { return ; } if (props.activated) { return ( ); } return ( ); } /** * 圆角转弯线 */ function RoundedTurningLine(props: PropsType): JSX.Element | null { const { vertices, radius = DEFAULT_RADIUS, hide, xRadius, yRadius, ...line } = props; const { from, to, arrow, activated, style } = line || {}; const { baseActivatedColor, baseColor } = useBaseColor(); // 如果没有 vertices,根据线条类型计算转折点 const realVertices = vertices || (props.isHorizontal ? getHorizontalVertices(line, xRadius, yRadius) : getVertices(line, xRadius, yRadius)); const middleStr: string = useMemo( () => realVertices .map((point, idx) => { const prev = realVertices[idx - 1] || from; const next = realVertices[idx + 1] || to; // 前后 delta 变化 const prevDelta = { x: Math.abs(prev.x - point.x), y: Math.abs(prev.y - point.y) }; const nextDelta = { x: Math.abs(next.x - point.x), y: Math.abs(next.y - point.y) }; // 不是垂直直角的拐弯线报错 const isRightAngleX = prevDelta.x === 0 && nextDelta.y === 0; const isRightAngleY = prevDelta.y === 0 && nextDelta.x === 0; const isRightAngle = isRightAngleX || isRightAngleY; if (!isRightAngle) { console.error(`vertex ${point.x},${point.y} is not right angle`); } // 圆角入点和出点为 control 往两个方向移动一段距离,距离不够 radius 为短距离 const inPoint = new Point().copyFrom(point); const outPoint = new Point().copyFrom(point); const radiusX = isNil(point.radiusX) ? radius : point.radiusX; const radiusY = isNil(point.radiusY) ? radius : point.radiusY; let rx = radiusX; let ry = radiusY; if (isRightAngleX) { ry = Math.min(prevDelta.y, radiusY); const moveY = isNil(point.moveY) ? ry : point.moveY; inPoint.y += from.y < point.y ? -moveY : +moveY; rx = Math.min(nextDelta.x, radiusX); const moveX = isNil(point.moveX) ? rx : point.moveX; outPoint.x += to.x < point.x ? -moveX : +moveX; } if (isRightAngleY) { rx = Math.min(prevDelta.x, radiusX); const moveX = isNil(point.moveX) ? rx : point.moveX; inPoint.x += from.x < point.x ? -moveX : +moveX; ry = Math.min(nextDelta.y, radiusY); const moveY = isNil(point.moveY) ? ry : point.moveY; outPoint.y += to.y < point.y ? -moveY : +moveY; } // radius overflow 策略为截断,则回复 rx, ry 为原始 radius if (point.radiusOverflow === 'truncate') { rx = radiusX; ry = radiusY; } // 是否是顺时针? // - 基于 AB 和 AC 的向量叉积 // - A 点:inPoint, B 点:point, C 点:outPoint const crossProduct = (point.x - inPoint.x) * (outPoint.y - inPoint.y) - (point.y - inPoint.y) * (outPoint.x - inPoint.x); const isClockWise = crossProduct > 0; // 控制点为当前节点 return `L ${inPoint.x} ${inPoint.y} A ${rx} ${ry} 0 0 ${isClockWise ? 1 : 0} ${ outPoint.x } ${outPoint.y}`; }) .join(' '), [realVertices] ); if (hide) { return null; } const pathStr = `M ${from.x} ${from.y} ${middleStr} L ${to.x} ${to.y}`; const markerId = activated ? `${MARK_ACTIVATED_ARROW_ID}${props.lineId}` : `${MARK_ARROW_ID}${props.lineId}`; return ( <> {arrow ? : null} ); } // version 变化才触发组件更新 export default RoundedTurningLine; ================================================ FILE: packages/canvas-engine/renderer/src/components/StraightLine.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React from 'react'; import type { FlowTransitionLine } from '@flowgram.ai/document'; import { useBaseColor } from '../hooks/use-base-color'; import { DEFAULT_LINE_ATTRS } from './utils'; function StraightLine(props: FlowTransitionLine): JSX.Element { const { from, to, activated, style } = props; const { baseColor, baseActivatedColor } = useBaseColor(); return ( ); } // version 变化才触发组件更新 export default StraightLine; ================================================ FILE: packages/canvas-engine/renderer/src/components/utils.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import type React from 'react'; import { FlowNodeTransformData, type FlowNodeTransitionData, type FlowTransitionLine, FlowTransitionLineEnum, type Vertex, DefaultSpacingKey, DEFAULT_SPACING, } from '@flowgram.ai/document'; import { BASE_DEFAULT_COLOR } from '../hooks/use-base-color'; export const DEFAULT_LINE_ATTRS: React.SVGProps = { stroke: BASE_DEFAULT_COLOR, fill: 'transparent', strokeLinecap: 'round', strokeLinejoin: 'round', }; // 默认的圆角半径 export const DEFAULT_RADIUS = DEFAULT_SPACING[DefaultSpacingKey.ROUNDED_LINE_RADIUS]; // 小圆角 export const MINI_RADIUS = 10; // 默认 label 激活高度 export const DEFAULT_LABEL_ACTIVATE_HEIGHT = 32; /** * 根据椭圆方程计算 y 坐标 * * x^2 / rx^2 + y^2 / ry^2 = 1 */ export const calcEllipseY = (x: number, rx: number, ry: number) => Math.sqrt(ry ** 2 * (1 - x ** 2 / rx ** 2)); /** * 获取转弯线的转折点 (水平布局) */ export function getHorizontalVertices( line: FlowTransitionLine, xRadius = 16, yRadius = 20 ): Vertex[] { const { from, to, type } = line || {}; // 空间可以容纳的圆角数 const deltaY = Math.abs(to.y - from.y); const deltaX = Math.abs(to.x - from.x); const radiusXCount = deltaX / xRadius; const radiusYCount = deltaY / yRadius; let res: Vertex[] = []; // 容纳不下一个圆角,直接连线 if (radiusXCount < 1) { return []; } switch (type) { case FlowTransitionLineEnum.DIVERGE_LINE: case FlowTransitionLineEnum.DRAGGING_LINE: if (radiusXCount <= 1) { return [ { x: to.x, y: from.y, radiusX: deltaX, }, ]; } res = [ { x: from.x + yRadius, y: from.y, }, { x: from.x + yRadius, y: to.y, }, ]; if (radiusXCount < 2) { const firstRadius = deltaX - yRadius; res = [ { x: from.x + firstRadius, y: from.y, // 第一个圆角收缩 y 半径 radiusX: firstRadius, }, { x: from.x + firstRadius, y: to.y, }, ]; } // y 轴空间不足处理 if (radiusYCount < 2) { res[0].moveY = deltaY / 2; res[1].moveY = deltaY / 2; } return res; case FlowTransitionLineEnum.MERGE_LINE: // 聚合线 y 轴空间不足时直接连上 if (radiusXCount < 2) { return [ { x: to.x, y: from.y, }, ]; } res = [ { x: to.x - yRadius, y: from.y, }, { x: to.x - yRadius, y: to.y, }, ]; // y 轴空间不足处理 if (radiusYCount < 2) { res[0].moveY = deltaY / 2; res[1].moveY = deltaY / 2; } return res; default: break; } return []; } /** * 获取转弯线的转折点 (垂直布局) */ export function getVertices(line: FlowTransitionLine, xRadius = 16, yRadius = 20): Vertex[] { const { from, to, type } = line || {}; // 空间可以容纳的圆角数 const deltaY = Math.abs(to.y - from.y); const deltaX = Math.abs(to.x - from.x); const radiusYCount = deltaY / yRadius; const radiusXCount = deltaX / xRadius; let res: Vertex[] = []; // 容纳不下一个圆角,直接连线 if (radiusYCount < 1) { return []; } switch (type) { case FlowTransitionLineEnum.DIVERGE_LINE: case FlowTransitionLineEnum.DRAGGING_LINE: if (radiusYCount <= 1) { return [ { x: to.x, y: from.y, radiusY: deltaY, }, ]; } res = [ { x: from.x, y: from.y + yRadius, }, { x: to.x, y: from.y + yRadius, }, ]; if (radiusYCount < 2) { const firstRadius = deltaY - yRadius; res = [ { x: from.x, y: from.y + firstRadius, // 第一个圆角收缩 y 半径 radiusY: firstRadius, }, { x: to.x, y: from.y + firstRadius, }, ]; } // x 轴空间不足处理 if (radiusXCount < 2) { res[0].moveX = deltaX / 2; res[1].moveX = deltaX / 2; } return res; case FlowTransitionLineEnum.MERGE_LINE: // 聚合线 y 轴空间不足时直接连上 if (radiusYCount < 2) { return [ { x: from.x, y: to.y, }, ]; } res = [ { x: from.x, y: to.y - yRadius, }, { x: to.x, y: to.y - yRadius, }, ]; // x 轴空间不足处理 if (radiusXCount < 2) { res[0].moveX = deltaX / 2; res[1].moveX = deltaX / 2; } return res; default: break; } return []; } // 获取上一个节点和下一个节点中较宽的宽度作为 hover 热区 export function getTransitionLabelHoverWidth(data: FlowNodeTransitionData) { const { isVertical } = data.entity; if (isVertical) { const nextWidth = data.entity.next?.firstChild && !data.entity.next.isInlineBlocks ? data.entity.next.firstChild!.getData(FlowNodeTransformData)!.size.width : data.entity.next?.getData(FlowNodeTransformData)!.size.width; // 获取上一个节点和下一个节点中较宽的宽度作为 hover 热区 const maxWidth = Math.max( data.entity.getData(FlowNodeTransformData)?.size.width ?? DEFAULT_SPACING[DefaultSpacingKey.HOVER_AREA_WIDTH], nextWidth || 0 ); return maxWidth; } if (data.transform.next) { return data.transform.next.inputPoint.x - data.transform.outputPoint.x; } return DEFAULT_LABEL_ACTIVATE_HEIGHT; } export function getTransitionLabelHoverHeight(data: FlowNodeTransitionData) { const { isVertical } = data.entity; if (isVertical) { if (data.transform.next) { return data.transform.next.inputPoint.y - data.transform.outputPoint.y; } return DEFAULT_LABEL_ACTIVATE_HEIGHT; } const nextHeight = data.entity.next?.firstChild && !data.entity.next.isInlineBlocks ? data.entity.next.firstChild!.getData(FlowNodeTransformData)!.size.height : data.entity.next?.getData(FlowNodeTransformData)!.size.height; // 获取上一个节点和下一个节点中较宽的宽度作为 hover 热区 const maxHeight = Math.max( data.entity.getData(FlowNodeTransformData)?.size.height || 280, nextHeight || 0 ); return maxHeight; } ================================================ FILE: packages/canvas-engine/renderer/src/entities/README.md ================================================ ## 画布渲染相关 entity 数据 这里存放的是和画布渲染强耦合的数据 ================================================ FILE: packages/canvas-engine/renderer/src/entities/flow-drag-entity.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { Rectangle } from '@flowgram.ai/utils'; import { type FlowNodeTransitionData, FlowTransitionLabelEnum, LABEL_SIDE_TYPE, } from '@flowgram.ai/document'; import { ConfigEntity, type EntityOpts, PlaygroundConfigEntity } from '@flowgram.ai/core'; import { DEFAULT_LABEL_ACTIVATE_HEIGHT } from '../components/utils'; const BRANCH_HOVER_HEIGHT = 64; interface FlowDragEntityConfig extends EntityOpts {} enum ScrollDirection { TOP, BOTTOM, LEFT, RIGHT, } const SCROLL_DELTA = 4; const SCROLL_INTERVAL = 20; const SCROLL_BOUNDING = 20; const EDITOR_LEFT_BAR_WIDTH = 60; export interface CollisionRetType { hasCollision: boolean; labelOffsetType?: LABEL_SIDE_TYPE; } export class FlowDragEntity extends ConfigEntity { private playgroundConfigEntity: PlaygroundConfigEntity; static type = 'FlowDragEntity'; private containerX = 0; private containerY = 0; private _scrollXInterval: { interval: number; origin: number } | undefined; private _scrollYInterval: { interval: number; origin: number } | undefined; get hasScroll(): boolean { return Boolean(this._scrollXInterval || this._scrollYInterval); } constructor(conf: any) { super(conf); this.playgroundConfigEntity = this.entityManager.getEntity( PlaygroundConfigEntity, true )!; } isCollision( transition: FlowNodeTransitionData, rect: Rectangle, isBranch: boolean ): CollisionRetType { const scale = this.playgroundConfigEntity.finalScale || 0; if (isBranch) { return this.isBranchCollision(transition, rect, scale); } return this.isNodeCollision(transition, rect, scale); } // 检测节点维度碰撞方法 isNodeCollision( transition: FlowNodeTransitionData, rect: Rectangle, scale: number ): CollisionRetType { const { labels } = transition; const { isVertical } = transition.entity; const hasCollision = labels.some((label) => { if ( !label || ![ FlowTransitionLabelEnum.ADDER_LABEL, FlowTransitionLabelEnum.COLLAPSE_ADDER_LABEL, ].includes(label.type) ) { return false; } const hoverWidth = isVertical ? transition.transform.bounds.width : DEFAULT_LABEL_ACTIVATE_HEIGHT; const hoverHeight = isVertical ? DEFAULT_LABEL_ACTIVATE_HEIGHT : transition.transform.bounds.height; const labelRect = new Rectangle( (label.offset.x - hoverWidth / 2) * scale, (label.offset.y - hoverHeight / 2) * scale, hoverWidth * scale, hoverHeight * scale ); // 检测两个正方形是否相互碰撞 return Rectangle.intersects(labelRect, rect); }); return { hasCollision, // 节点不关心 offsetType labelOffsetType: undefined, }; } // 检测分支维度碰撞 isBranchCollision( transition: FlowNodeTransitionData, rect: Rectangle, scale: number ): CollisionRetType { const { labels } = transition; const { isVertical } = transition.entity; let labelOffsetType: LABEL_SIDE_TYPE = LABEL_SIDE_TYPE.NORMAL_BRANCH; const hasCollision = labels.some((label) => { if (!label || label.type !== FlowTransitionLabelEnum.BRANCH_DRAGGING_LABEL) { return false; } const hoverHeight = isVertical ? BRANCH_HOVER_HEIGHT : label.width || 0; // BRANCH_DRAGGING_LABEL 类型的 label 一定存在 width 属性 const hoverWidth = isVertical ? label.width || 0 : BRANCH_HOVER_HEIGHT; const labelRect = new Rectangle( (label.offset.x - hoverWidth / 2) * scale, (label.offset.y - hoverHeight / 2) * scale, hoverWidth * scale, hoverHeight * scale ); // 检测两个正方形是否相互碰撞 const collision = Rectangle.intersects(labelRect, rect); if (collision) { labelOffsetType = label.props!.side; } return collision; }); return { hasCollision, labelOffsetType, }; } private _startScrollX(origin: number, added: boolean): void { if (this._scrollXInterval) { return; } const interval = window.setInterval(() => { const current = this._scrollXInterval; if (!current) return; // eslint-disable-next-line no-multi-assign const scrollX = (current.origin = added ? current.origin + SCROLL_DELTA : current.origin - SCROLL_DELTA); this.playgroundConfigEntity.updateConfig({ scrollX, }); const playgroundConfig = this.playgroundConfigEntity.config; if (playgroundConfig?.scrollX === scrollX) { if (added) { this.containerX += SCROLL_DELTA; } else { this.containerX -= SCROLL_DELTA; } } }, SCROLL_INTERVAL); this._scrollXInterval = { interval, origin }; } private _stopScrollX(): void { if (this._scrollXInterval) { clearInterval(this._scrollXInterval.interval); this._scrollXInterval = undefined; } } private _startScrollY(origin: number, added: boolean): void { if (this._scrollYInterval) { return; } const interval = window.setInterval(() => { const current = this._scrollYInterval; if (!current) return; // eslint-disable-next-line no-multi-assign const scrollY = (current.origin = added ? current.origin + SCROLL_DELTA : current.origin - SCROLL_DELTA); this.playgroundConfigEntity.updateConfig({ scrollY, }); const playgroundConfig = this.playgroundConfigEntity.config; if (playgroundConfig?.scrollY === scrollY) { if (added) { this.containerY += SCROLL_DELTA; } else { this.containerY -= SCROLL_DELTA; } } }, SCROLL_INTERVAL); this._scrollYInterval = { interval, origin }; } private _stopScrollY(): void { if (this._scrollYInterval) { clearInterval(this._scrollYInterval.interval); this._scrollYInterval = undefined; } } stopAllScroll(): void { this._stopScrollX(); this._stopScrollY(); } scrollDirection(e: MouseEvent, x: number, y: number): ScrollDirection | undefined { const playgroundConfig = this.playgroundConfigEntity.config; const currentScrollX = playgroundConfig.scrollX; const currentScrollY = playgroundConfig.scrollY; this.containerX = x; this.containerY = y; const clientRect = this.playgroundConfigEntity.playgroundDomNode.getBoundingClientRect(); const mouseToBottom = playgroundConfig.height + clientRect.y - e.clientY; if (mouseToBottom < SCROLL_BOUNDING) { this._startScrollY(currentScrollY, true); return ScrollDirection.BOTTOM; } const mouseToTop = e.clientY - clientRect.y; if (mouseToTop < SCROLL_BOUNDING) { this._startScrollY(currentScrollY, false); return ScrollDirection.TOP; } this._stopScrollY(); const mouseToRight = playgroundConfig.width + clientRect.x - e.clientX; if (mouseToRight < SCROLL_BOUNDING) { this._startScrollX(currentScrollX, true); return ScrollDirection.RIGHT; } const mouseToLeft = e.clientX - clientRect.x; if (mouseToLeft < SCROLL_BOUNDING + EDITOR_LEFT_BAR_WIDTH) { this._startScrollX(currentScrollX, false); return ScrollDirection.LEFT; } this._stopScrollX(); return undefined; } dispose(): void { this.toDispose.dispose(); } } ================================================ FILE: packages/canvas-engine/renderer/src/entities/flow-select-config-entity.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { type FlowNodeEntity, FlowNodeRenderData, FlowNodeTransformData, } from '@flowgram.ai/document'; import { ConfigEntity } from '@flowgram.ai/core'; import { Compare, Rectangle } from '@flowgram.ai/utils'; import { findSelectedNodes } from '../utils/find-selected-nodes'; interface FlowSelectConfigEntityData { selectedNodes: FlowNodeEntity[]; } const BOUNDS_PADDING_DEFAULT = 10; /** * 圈选节点相关数据存储 */ export class FlowSelectConfigEntity extends ConfigEntity { static type = 'FlowSelectConfigEntity'; boundsPadding = BOUNDS_PADDING_DEFAULT; getDefaultConfig(): FlowSelectConfigEntityData { return { selectedNodes: [], }; } get selectedNodes(): FlowNodeEntity[] { return this.config.selectedNodes; } /** * 选中节点 * @param nodes */ set selectedNodes(nodes: FlowNodeEntity[]) { nodes = findSelectedNodes(nodes); // if (nodes.length === 1 && nodes[0].flowNodeType === FlowNodeBaseType.END) { // nodes = []; // } if ( nodes.length !== this.config.selectedNodes.length || nodes.some(n => !this.config.selectedNodes.includes(n)) ) { this.config.selectedNodes.forEach(oldNode => { if (!nodes.includes(oldNode)) { oldNode.getData(FlowNodeRenderData)!.activated = false; } }); // 高亮选中的节点 nodes.forEach(node => { node.getData(FlowNodeRenderData)!.activated = true; }); if (Compare.isArrayShallowChanged(this.config.selectedNodes, nodes)) { this.updateConfig({ selectedNodes: nodes, }); } } } /** * 清除选中节点 */ clearSelectedNodes() { if (this.config.selectedNodes.length === 0) return; this.config.selectedNodes.forEach(node => { node.getData(FlowNodeRenderData)!.activated = false; }); this.updateConfig({ selectedNodes: [], }); } /** * 通过选择框选中节点 * @param rect * @param transforms */ selectFromBounds(rect: Rectangle, transforms: FlowNodeTransformData[]): void { const selectedNodes: FlowNodeEntity[] = []; transforms.forEach(transform => { if (Rectangle.intersects(rect, transform.bounds)) { if (transform.entity.originParent) { selectedNodes.push(transform.entity.originParent); } else { selectedNodes.push(transform.entity); } } }); this.selectedNodes = selectedNodes; } /** * 获取选中节点外围的最大边框 */ getSelectedBounds(): Rectangle { const nodes = this.selectedNodes; if (nodes.length === 0) { return Rectangle.EMPTY; } return Rectangle.enlarge(nodes.map(n => n.getData(FlowNodeTransformData)!.bounds)).pad( this.boundsPadding, ); } } ================================================ FILE: packages/canvas-engine/renderer/src/entities/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export * from './flow-drag-entity'; export * from './flow-select-config-entity'; export * from './selector-box-config-entity'; ================================================ FILE: packages/canvas-engine/renderer/src/entities/selector-box-config-entity.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { PositionSchema, SizeSchema, ConfigEntity, PlaygroundDragEvent, } from '@flowgram.ai/core'; import { Rectangle } from '@flowgram.ai/utils'; export interface SelectorBoxConfigData extends PlaygroundDragEvent { disabled?: boolean; // 是否禁用选择框 } /** * 选择框配置 */ export class SelectorBoxConfigEntity extends ConfigEntity { static type = 'SelectorBoxConfigEntity'; get dragInfo(): PlaygroundDragEvent { return this.config; } setDragInfo(info: PlaygroundDragEvent): void { this.updateConfig(info); } get disabled(): boolean { return this.config && !!this.config.disabled; } set disabled(disabled: boolean) { this.updateConfig({ disabled, }); } get isStart(): boolean { return this.dragInfo.isStart; } get isMoving(): boolean { return this.dragInfo.isMoving; } get position(): PositionSchema { const { dragInfo } = this; return { x: dragInfo.startPos.x < dragInfo.endPos.x ? dragInfo.startPos.x : dragInfo.endPos.x, y: dragInfo.startPos.y < dragInfo.endPos.y ? dragInfo.startPos.y : dragInfo.endPos.y, }; } get size(): SizeSchema { const { dragInfo } = this; return { width: Math.abs(dragInfo.startPos.x - dragInfo.endPos.x), height: Math.abs(dragInfo.startPos.y - dragInfo.endPos.y), }; } get collapsed(): boolean { const { size } = this; return size.width === 0 && size.height === 0; } collapse(): void { this.setDragInfo({ ...this.dragInfo, isMoving: false, isStart: false, }); } toRectangle(scale: number): Rectangle { const { position, size } = this; return new Rectangle( position.x / scale, position.y / scale, size.width / scale, size.height / scale, ); } } ================================================ FILE: packages/canvas-engine/renderer/src/flow-renderer-container-module.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { ContainerModule } from 'inversify'; import { FlowRendererResizeObserver } from './flow-renderer-resize-observer'; import { FlowRendererRegistry } from './flow-renderer-registry'; export const FlowRendererContainerModule = new ContainerModule(bind => { bind(FlowRendererRegistry).toSelf().inSingletonScope(); bind(FlowRendererResizeObserver).toSelf().inSingletonScope(); }); ================================================ FILE: packages/canvas-engine/renderer/src/flow-renderer-contribution.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import type { FlowRendererRegistry } from './flow-renderer-registry'; export const FlowRendererContribution = Symbol('FlowRendererContribution'); export interface FlowRendererContribution { registerRenderer?(registry: FlowRendererRegistry): void; } ================================================ FILE: packages/canvas-engine/renderer/src/flow-renderer-registry.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { inject, injectable, multiInject, optional } from 'inversify'; import { I18n } from '@flowgram.ai/i18n'; import { type Layer, type LayerRegistry, PipelineRegistry } from '@flowgram.ai/core'; import { FlowRendererContribution } from './flow-renderer-contribution'; export enum FlowRendererComponentType { REACT, // react 组件 DOM, // dom 组件 TEXT, // 文案 } export enum FlowRendererKey { NODE_RENDER = 'node-render', // 节点渲染 ADDER = 'adder', // 添加按钮渲染 COLLAPSE = 'collapse', // 节点展开收起标签(包含展开态和收起态) BRANCH_ADDER = 'branch-adder', // 分支添加按钮 TRY_CATCH_COLLAPSE = 'try-catch-collapse', // 错误处理分支整体收起 DRAG_NODE = 'drag-node', // 拖拽节点 DRAGGABLE_ADDER = 'draggable-adder', // 拖拽可被拖入 DRAG_HIGHLIGHT_ADDER = 'drag-highlight-adder', // 拖拽高亮 DRAG_BRANCH_HIGHLIGHT_ADDER = 'drag-branch-highlight-adder', // 分支拖拽添加高亮 SELECTOR_BOX_POPOVER = 'selector-box-popover', // 选择框右上角菜单 /** * @deprecated */ CONTEXT_MENU_POPOVER = 'context-menu-popover', // 右键菜单 SUB_CANVAS = 'sub-canvas', // 子画布渲染 SLOT_ADDER = 'slot-adder', // 插槽添加按钮 SLOT_LABEL = 'slot-label', // 插槽标签 SLOT_COLLAPSE = 'slot-collapse', // 插槽收起按钮渲染 // 工作流线条箭头自定义渲染 ARROW_RENDERER = 'arrow-renderer', // 工作流线条箭头渲染器 // 下边两个不一定存在 MARKER_ARROW = 'marker-arrow', // loop 的默认箭头 MARKER_ACTIVATE_ARROW = 'marker-active-arrow', // loop 的激活态箭头 } export enum FlowTextKey { // 循环节点相关 LOOP_END_TEXT = 'loop-end-text', // 文案:循环结束 LOOP_TRAVERSE_TEXT = 'loop-traverse-text', // 文案:循环遍历 LOOP_WHILE_TEXT = 'loop-while-text', // 文案:满足条件时 // TryCatch 相关 TRY_START_TEXT = 'try-start-text', // 文案:监控开始 TRY_END_TEXT = 'try-end-text', // 文案:监控结束 CATCH_TEXT = 'catch-text', // 发生错误 } export interface FlowRendererComponent { type: FlowRendererComponentType; renderer: (props?: any) => any; } /** * 命令分类 */ export enum FlowRendererCommandCategory { SELECTOR_BOX = 'SELECTOR_BOX', // 选择框 } @injectable() export class FlowRendererRegistry { private componentsMap = new Map(); private textMap = new Map(); @multiInject(FlowRendererContribution) @optional() private contribs: FlowRendererContribution[] = []; @inject(PipelineRegistry) readonly pipeline: PipelineRegistry; init() { this.contribs.forEach((contrib) => contrib.registerRenderer?.(this)); } /** * 注册 组件数据 */ registerRendererComponents( renderKey: FlowRendererKey | string, comp: FlowRendererComponent ): void { this.componentsMap.set(renderKey, comp); } registerReactComponent(renderKey: FlowRendererKey | string, renderer: (props: any) => any): void { this.componentsMap.set(renderKey, { type: FlowRendererComponentType.REACT, renderer, }); } /** * 注册文案 */ registerText(configs: Record): void { Object.entries(configs).forEach(([key, value]) => { this.textMap.set(key, value); }); } getText(textKey: string) { return I18n.t(textKey, { defaultValue: '' }) || this.textMap.get(textKey); } /** * TODO: support memo */ public getRendererComponent(renderKey: FlowRendererKey | string): FlowRendererComponent { const comp = this.componentsMap.get(renderKey); if (!comp) { throw new Error(`Unknown render key ${renderKey}`); } return comp; } tryToGetRendererComponent( renderKey: FlowRendererKey | string ): FlowRendererComponent | undefined { return this.componentsMap.get(renderKey); } /** * 注册画布层 */ registerLayers(...layerRegistries: LayerRegistry[]): void { layerRegistries.forEach((layer) => this.pipeline.registerLayer(layer)); } /** * 根据配置注册画布 * @param layerRegistry * @param options */ registerLayer

( layerRegistry: LayerRegistry, options?: P['options'] ): void { this.pipeline.registerLayer(layerRegistry, options); } } ================================================ FILE: packages/canvas-engine/renderer/src/flow-renderer-resize-observer.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { injectable } from 'inversify'; import { type FlowNodeTransformData } from '@flowgram.ai/document'; import { Disposable } from '@flowgram.ai/utils'; import { isHidden, isRectInit } from './utils/element'; /** * 监听 dom 元素的 size 变化,用于画布节点的大小变化重新计算 */ @injectable() export class FlowRendererResizeObserver { /** * 监听元素 size,并同步到 transform * @param el * @param transform */ observe(el: HTMLElement, transform: FlowNodeTransformData): Disposable { const observer = new ResizeObserver(entries => { /** * NOTICE: 不加 window.requestAnimationFrame * 会导致 "ResizeObserver loop completed with undelivered notifications." 报错 * 这个报错在 chrome 和 firefox 是默认被忽略的,但本地调试会被编译工具弹窗打断 */ window.requestAnimationFrame(() => { if (!Array.isArray(entries) || !entries.length) { return; } const entry = entries[0]; const { contentRect, target } = entry; // 元素宽高未计算时,不更新节点 size const isContentRectInit = isRectInit(contentRect); // 目标节点脱离 DOM 树,忽略本次变更 const isLeaveDOMTree = !target.parentNode; // IDE 环境下画布元素可能 display none,这时候会监听到元素宽高 0 导致闪屏 // 此情况下不作 resize 重渲染 const isHiddenElement = isHidden(target.parentNode as HTMLElement); if (isContentRectInit && !isLeaveDOMTree && !isHiddenElement) { // 更新节点 size 数据 transform.size = { width: Math.round(contentRect.width * 10) / 10, height: Math.round(contentRect.height * 10) / 10, }; } }); }); observer.observe(el); return Disposable.create(() => { observer.unobserve(el); }); } } ================================================ FILE: packages/canvas-engine/renderer/src/hooks/use-base-color.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { ConstantKeys, FlowDocumentOptions } from '@flowgram.ai/document'; import { useService } from '@flowgram.ai/core'; export const BASE_DEFAULT_COLOR = '#BBBFC4'; export const BASE_DEFAULT_ACTIVATED_COLOR = '#82A7FC'; export function useBaseColor(): { baseColor: string; baseActivatedColor: string } { const options = useService(FlowDocumentOptions); return { baseColor: options.constants?.[ConstantKeys.BASE_COLOR] || BASE_DEFAULT_COLOR, baseActivatedColor: options.constants?.[ConstantKeys.BASE_ACTIVATED_COLOR] || BASE_DEFAULT_ACTIVATED_COLOR, }; } ================================================ FILE: packages/canvas-engine/renderer/src/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export * from './entities'; export * from './layers'; export * from './flow-renderer-contribution'; export * from './flow-renderer-registry'; export * from './flow-renderer-container-module'; export { ScrollBarEvents } from './utils'; export { MARK_ARROW_ID } from './components/MarkerArrow'; export { MARK_ACTIVATED_ARROW_ID } from './components/MarkerActivatedArrow'; export { useBaseColor } from './hooks/use-base-color'; export { createLines } from './components/LinesRenderer'; ================================================ FILE: packages/canvas-engine/renderer/src/layers/flow-context-menu-layer.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React from 'react'; import { inject, injectable } from 'inversify'; import { domUtils } from '@flowgram.ai/utils'; import { CommandRegistry, ContextMenuService, EditorState, EditorStateConfigEntity, Layer, observeEntity, PipelineLayerPriority, PlaygroundConfigEntity, SelectionService, } from '@flowgram.ai/core'; import { FlowRendererCommandCategory, FlowRendererKey, FlowRendererRegistry, } from '../flow-renderer-registry'; import { SelectorBoxConfigEntity } from '../entities/selector-box-config-entity'; import { FlowSelectConfigEntity } from '../entities/flow-select-config-entity'; /** * 流程右键菜单 */ @injectable() export class FlowContextMenuLayer extends Layer { @inject(CommandRegistry) readonly commandRegistry: CommandRegistry; @inject(FlowRendererRegistry) readonly rendererRegistry: FlowRendererRegistry; @inject(ContextMenuService) readonly contextMenuService: ContextMenuService; @observeEntity(FlowSelectConfigEntity) protected flowSelectConfigEntity: FlowSelectConfigEntity; @inject(SelectionService) readonly selectionService: SelectionService; @observeEntity(PlaygroundConfigEntity) protected playgroundConfigEntity: PlaygroundConfigEntity; @observeEntity(EditorStateConfigEntity) protected editorStateConfig: EditorStateConfigEntity; @observeEntity(SelectorBoxConfigEntity) protected selectorBoxConfigEntity: SelectorBoxConfigEntity; readonly node = domUtils.createDivWithClass('gedit-context-menu-layer'); readonly nodeRef = React.createRef<{ setVisible: (v: boolean) => void; }>(); isEnabled(): boolean { const currentState = this.editorStateConfig.getCurrentState(); return ( !this.config.disabled && !this.config.readonly && currentState === EditorState.STATE_SELECT && !this.selectorBoxConfigEntity.disabled ); } onReady(): void { // 这个是覆盖到节点上边的,所以要比 flow-nodes-content-layer 大 this.node!.style.zIndex = '30'; this.node!.style.display = 'block'; // 监听鼠标右键 this.toDispose.pushAll([ this.listenPlaygroundEvent( 'contextmenu', (e: MouseEvent): boolean | undefined => { if (!this.isEnabled()) return; this.contextMenuService.rightPanelVisible = true; const bounds = this.flowSelectConfigEntity.getSelectedBounds(); if (bounds.width === 0 || bounds.height === 0) { return; } e.stopPropagation(); e.preventDefault(); this.nodeRef.current?.setVisible(true); const clientBounds = this.playgroundConfigEntity.getClientBounds(); const dragBlockX = e.clientX - (this.pipelineNode.offsetLeft || 0) - clientBounds.x; const dragBlockY = e.clientY - (this.pipelineNode.offsetTop || 0) - clientBounds.y; this.node.style.left = `${dragBlockX}px`; this.node.style.top = `${dragBlockY}px`; }, PipelineLayerPriority.BASE_LAYER ), this.listenPlaygroundEvent('mousedown', () => { this.nodeRef.current?.setVisible(false); this.contextMenuService.rightPanelVisible = false; }), ]); } onScroll() { this.nodeRef.current?.setVisible(false); } onZoom() { this.nodeRef.current?.setVisible(false); } /** * Destroy */ dispose(): void { super.dispose(); } /** * 渲染工具栏 */ renderCommandMenus(): JSX.Element[] { return this.commandRegistry.commands .filter((cmd) => cmd.category === FlowRendererCommandCategory.SELECTOR_BOX) .map((cmd) => { const CommandRenderer = this.rendererRegistry.getRendererComponent( (cmd.icon as string) || cmd.id )?.renderer; return ( this.commandRegistry.executeCommand(cmd.id, e)} /> ); }) .filter((c) => c); } render(): JSX.Element { const SelectorBoxPopover = this.rendererRegistry.getRendererComponent( FlowRendererKey.CONTEXT_MENU_POPOVER ).renderer; return ; } } ================================================ FILE: packages/canvas-engine/renderer/src/layers/flow-debug-layer.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { inject, injectable } from 'inversify'; import { domUtils } from '@flowgram.ai/utils'; import { FlowDocument, FlowDocumentTransformerEntity, FlowNodeEntity, FlowNodeTransformData, } from '@flowgram.ai/document'; import { Layer, observeEntity, observeEntityDatas } from '@flowgram.ai/core'; import { getScrollViewport } from '../utils'; let rgbTimes = 0; function randomColor(percent: number): string { const max = Math.min((percent / 10) * 255, 255); rgbTimes += 1; // rgb 轮询就可以错开颜色 const rgb = rgbTimes % 3; const random = () => Math.floor(Math.random() * max); return `rgb(${rgb === 0 ? random() : 0}, ${rgb === 1 ? random() : 0}, ${ rgb === 2 ? random() : 0 })`; } /** * 调试用,会绘出所有节点的边界 */ @injectable() export class FlowDebugLayer extends Layer { @inject(FlowDocument) readonly document: FlowDocument; @observeEntity(FlowDocumentTransformerEntity) readonly documentTransformer: FlowDocumentTransformerEntity; @observeEntityDatas(FlowNodeEntity, FlowNodeTransformData) _transforms: FlowNodeTransformData[]; get transforms(): FlowNodeTransformData[] { return this.document.getRenderDatas(FlowNodeTransformData); } node = document.createElement('div') as HTMLElement; viewport = domUtils.createDivWithClass('gedit-flow-debug-bounds'); boundsNodes = domUtils.createDivWithClass('gedit-flow-debug-bounds'); pointsNodes = domUtils.createDivWithClass('gedit-flow-debug-points'); versionNodes = domUtils.createDivWithClass('gedit-flow-debug-versions gedit-hidden'); /** * ?debug=xxxx, 则返回 xxxx */ filterKey = window.location.search.match(/debug=([^&]+)/)?.[1] || ''; protected originLine = document.createElement('div') as HTMLDivElement; domCache = new WeakMap< FlowNodeTransformData, { color: string; bbox: HTMLDivElement; version: HTMLDivElement; input: HTMLDivElement; output: HTMLDivElement; } >(); onReady() { this.node!.style.zIndex = '20'; domUtils.setStyle(this.originLine, { position: 'absolute', width: 1, height: '100%', left: this.pipelineNode.style.left, top: 0, borderLeft: '1px dashed rgba(255, 0, 0, 0.5)', }); this.pipelineNode.parentElement!.appendChild(this.originLine); this.node.appendChild(this.viewport); this.node.appendChild(this.versionNodes); this.node.appendChild(this.boundsNodes); this.node.appendChild(this.pointsNodes); this.renderScrollViewportBounds(); } onScroll() { this.originLine.style.left = this.pipelineNode.style.left; this.renderScrollViewportBounds(); } onResize() { this.renderScrollViewportBounds(); } onZoom(scale: number) { this.node!.style.transform = `scale(${scale})`; this.renderScrollViewportBounds(); } createBounds(transform: FlowNodeTransformData, color: string, depth: number): void { // 根据 debug=xxxx 进行匹配过滤 if (this.filterKey && transform.key.indexOf(this.filterKey) === -1) return; let cache = this.domCache.get(transform)!; const { bounds, inputPoint, outputPoint } = transform; if (!cache) { const bbox = domUtils.createDivWithClass('') as HTMLDivElement; const input = domUtils.createDivWithClass('') as HTMLDivElement; const output = domUtils.createDivWithClass('') as HTMLDivElement; const version = domUtils.createDivWithClass('') as HTMLDivElement; bbox.title = transform.key; input.title = transform.key + '(input)'; output.title = transform.key + '(output)'; version.title = transform.key; this.boundsNodes.appendChild(bbox); this.pointsNodes.appendChild(input); this.pointsNodes.appendChild(output); this.versionNodes.appendChild(version); transform.onDispose(() => { bbox.remove(); input.remove(); output.remove(); }); cache = { bbox, input, output, version, color }; this.domCache.set(transform, cache); } domUtils.setStyle(cache.version, { position: 'absolute', marginLeft: '-9px', marginTop: '-10px', borderRadius: 12, background: '#f54a45', padding: 4, color: 'navajowhite', display: transform.renderState.hidden ? 'none' : 'block', zIndex: depth + 1000, left: bounds.center.x, top: bounds.center.y, }); cache.version.innerHTML = transform.version.toString(); domUtils.setStyle(cache.input, { position: 'absolute', width: 10, height: 10, marginLeft: -5, marginTop: -5, borderRadius: 5, left: inputPoint.x, top: inputPoint.y, opacity: 0.4, zIndex: depth, backgroundColor: cache.color, whiteSpace: 'nowrap', overflow: 'visible', }); cache.input.innerHTML = `${inputPoint.x},${inputPoint.y}`; domUtils.setStyle(cache.output, { position: 'absolute', width: 10, height: 10, marginLeft: -5, marginTop: -5, borderRadius: 5, left: outputPoint.x, top: outputPoint.y, opacity: 0.4, zIndex: depth, backgroundColor: cache.color, whiteSpace: 'nowrap', overflow: 'visible', }); cache.output.innerHTML = `${outputPoint.x},${outputPoint.y}`; domUtils.setStyle(cache.bbox, { position: 'absolute', width: bounds.width, height: bounds.height, left: bounds.left, top: bounds.top, opacity: `${depth / 30}`, backgroundColor: cache.color, }); } /** * 显示 viewport 可滚动区域 */ renderScrollViewportBounds() { const viewportBounds = getScrollViewport( { scrollX: this.config.config.scrollX, scrollY: this.config.config.scrollY, }, this.config ); domUtils.setStyle(this.viewport, { position: 'absolute', width: viewportBounds.width - 2, height: viewportBounds.height - 2, left: viewportBounds.left + 1, top: viewportBounds.top + 1, border: '1px solid rgba(200, 200, 255, 0.5)', }); } autorun() { if (this.documentTransformer.loading) return; this.documentTransformer.refresh(); // let lastDepth = 0 let color = randomColor(0); this.document.traverse((entity, depth) => { const transform = entity.getData(FlowNodeTransformData)!; // if (lastDepth !== depth) { // // 层级变化则更新颜色 // } color = randomColor(depth); this.createBounds(transform, color, depth); // lastDepth = depth }); this.renderScrollViewportBounds(); } } ================================================ FILE: packages/canvas-engine/renderer/src/layers/flow-drag-layer.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import ReactDOM from 'react-dom'; import React from 'react'; import { inject, injectable } from 'inversify'; import { Rectangle, Xor } from '@flowgram.ai/utils'; import { FlowDocument, FlowNodeBaseType, FlowNodeEntity, FlowNodeRenderData, FlowNodeTransformData, FlowNodeTransitionData, FlowRendererStateEntity, type LABEL_SIDE_TYPE, FlowDragService, FlowNodeJSON, } from '@flowgram.ai/document'; import { EditorState, EditorStateConfigEntity, Layer, observeEntity, observeEntityDatas, PlaygroundConfigEntity, } from '@flowgram.ai/core'; import { PlaygroundDrag } from '@flowgram.ai/core'; import { type FlowRendererComponent, FlowRendererKey, FlowRendererRegistry, } from '../flow-renderer-registry'; import { type CollisionRetType, FlowDragEntity } from '../entities/flow-drag-entity'; import { FlowSelectConfigEntity } from '../entities'; // 移动超过一定距离后触发拖拽生效 const DRAG_OFFSET = 10; const DEFAULT_DRAG_OFFSET_X = 8; const DEFAULT_DRAG_OFFSET_Y = 8; interface Position { x: number; y: number; } type StartDragProps = { dragEntities?: FlowNodeEntity[]; } & Xor< { dragStartEntity: FlowNodeEntity; }, { dragJSON: FlowNodeJSON; isBranch?: boolean; onCreateNode: (json: FlowNodeJSON, dropEntity: FlowNodeEntity) => Promise; } >; export interface FlowDragOptions { onDrop?: (opts: { dragNodes: FlowNodeEntity[]; dropNode: FlowNodeEntity }) => void; canDrop?: ( opts: { dropNode: FlowNodeEntity; isBranch?: boolean; } & Xor< { dragNodes: FlowNodeEntity[]; }, { dragJSON: FlowNodeJSON; } > ) => boolean; } /** * 监听节点的激活状态 */ @injectable() export class FlowDragLayer extends Layer { @inject(FlowDocument) readonly document: FlowDocument; @inject(FlowDragService) readonly flowDragService: FlowDragService; @observeEntityDatas(FlowNodeEntity, FlowNodeTransformData) transforms: FlowNodeTransformData[]; @observeEntity(EditorStateConfigEntity) protected editorStateConfig: EditorStateConfigEntity; @observeEntity(PlaygroundConfigEntity) protected playgroundConfigEntity: PlaygroundConfigEntity; @observeEntity(FlowDragEntity) protected flowDragConfigEntity: FlowDragEntity; @observeEntity(FlowRendererStateEntity) protected flowRenderStateEntity: FlowRendererStateEntity; @observeEntity(FlowSelectConfigEntity) protected selectConfigEntity: FlowSelectConfigEntity; private initialPosition: Position; private disableDragScroll: Boolean = false; private dragJSON?: FlowNodeJSON; private onCreateNode?: ( json: FlowNodeJSON, dropEntity: FlowNodeEntity ) => Promise; dragOffset = { x: DEFAULT_DRAG_OFFSET_X, y: DEFAULT_DRAG_OFFSET_Y, }; get transitions(): FlowNodeTransitionData[] { const result: FlowNodeTransitionData[] = []; this.document.traverse((entity) => { result.push(entity.getData(FlowNodeTransitionData)!); }); return result; } @inject(FlowRendererRegistry) readonly rendererRegistry: FlowRendererRegistry; get dragStartEntity() { return this.flowRenderStateEntity.getDragStartEntity()!; } set dragStartEntity(entity: FlowNodeEntity | undefined) { this.flowRenderStateEntity.setDragStartEntity(entity); } get dragEntities() { return this.flowRenderStateEntity.getDragEntities()!; } set dragEntities(entities: FlowNodeEntity[]) { this.flowRenderStateEntity.setDragEntities(entities); } private dragNodeComp: FlowRendererComponent; containerRef = React.createRef(); draggingNodeMask = document.createElement('div'); protected isGrab(): boolean { const currentState = this.editorStateConfig.getCurrentState(); return currentState === EditorState.STATE_GRAB; } setDraggingStatus(status: boolean): void { if (this.flowDragService.nodeDragIdsWithChildren.length) { this.flowDragService.nodeDragIdsWithChildren.forEach((_id) => { const node = this.entityManager.getEntityById(_id); const data = node?.getData(FlowNodeRenderData)!; data.dragging = status; }); } this.flowRenderStateEntity.setDragging(status); } dragEnable(e: MouseEvent) { return ( Math.abs(e.clientX - this.initialPosition.x) > DRAG_OFFSET || Math.abs(e.clientY - this.initialPosition.y) > DRAG_OFFSET ); } handleMouseMove(event: MouseEvent) { if ((this.dragJSON || this.dragStartEntity) && this.dragEnable(event)) { // 变更拖拽节点的位置 this.setDraggingStatus(true); const scale = this.playgroundConfigEntity.finalScale; if (this.containerRef.current) { const dragNode = this.containerRef.current.children?.[0]; const clientBounds = this.playgroundConfigEntity.getClientBounds(); const dragBlockX = event.clientX - (this.pipelineNode.offsetLeft || 0) - clientBounds.x - (dragNode.clientWidth - this.dragOffset.x) * scale; const dragBlockY = event.clientY - (this.pipelineNode.offsetTop || 0) - clientBounds.y - (dragNode.clientHeight - this.dragOffset.y) * scale; // 获取节点状态是节点类型还是分支类型 const isBranch = this.flowDragService.isDragBranch; // 节点类型拖拽碰撞检测 const draggingRect = new Rectangle( dragBlockX, dragBlockY, dragNode.clientWidth * scale, dragNode.clientHeight * scale ); let side: LABEL_SIDE_TYPE | undefined; const collisionTransition = this.transitions.find((transition) => { // 过滤已被折叠 label if (transition?.entity?.parent?.collapsed) { return false; } const { hasCollision, labelOffsetType } = this.flowDragConfigEntity.isCollision( transition, draggingRect, isBranch ) as CollisionRetType; side = labelOffsetType; return hasCollision; }); if ( collisionTransition && (isBranch ? this.flowDragService.isDroppableBranch(collisionTransition.entity, side) : this.flowDragService.isDroppableNode(collisionTransition.entity)) && (!this.options.canDrop || this.options.canDrop({ dragNodes: this.dragEntities, dropNode: collisionTransition.entity, isBranch, })) ) { // 设置碰撞的 label id this.flowRenderStateEntity.setNodeDroppingId(collisionTransition.entity.id); } else { // 没有碰撞清空 highlight this.flowRenderStateEntity.setNodeDroppingId(''); } // 判断拖拽种类是节点类型还是分支类型 this.flowRenderStateEntity.setDragLabelSide(side); this.containerRef.current.style.visibility = 'visible'; this.pipelineNode.parentElement!.appendChild(this.draggingNodeMask); this.containerRef.current.style.left = `${ dragBlockX + this.pipelineNode.offsetLeft + clientBounds.x + window.scrollX }px`; this.containerRef.current.style.top = `${ dragBlockY + this.pipelineNode.offsetTop + clientBounds.y + window.scrollY }px`; this.containerRef.current.style.transformOrigin = 'top left'; this.containerRef.current.style.transform = `scale(${scale})`; if (!this.disableDragScroll) { this.flowDragConfigEntity.scrollDirection(event, dragBlockX, dragBlockY); } } } } async handleMouseUp() { this.setDraggingStatus(false); if (this.dragStartEntity || this.dragJSON) { const activatedNodeId = this.flowDragService.dropNodeId; if (activatedNodeId) { if (this.flowDragService.isDragBranch) { if (this.dragJSON) { await this.flowDragService.dropCreateNode(this.dragJSON, this.onCreateNode); } else { this.flowDragService.dropBranch(); } } else { if (this.dragJSON) { await this.flowDragService.dropCreateNode(this.dragJSON, this.onCreateNode); } else { this.flowDragService.dropNode(); } this.selectConfigEntity.clearSelectedNodes(); } } // 清空碰撞 id this.flowRenderStateEntity.setNodeDroppingId(''); this.flowRenderStateEntity.setDragLabelSide(); this.flowRenderStateEntity.setIsBranch(false); this.dragStartEntity = undefined; this.dragEntities = []; // 滚动停止 this.flowDragConfigEntity.stopAllScroll(); } this.disableDragScroll = false; this.dragJSON = undefined; if (this.containerRef.current) { this.containerRef.current.style.visibility = 'hidden'; if (this.pipelineNode.parentElement!.contains(this.draggingNodeMask)) { this.pipelineNode.parentElement!.removeChild(this.draggingNodeMask); } } } protected _dragger = new PlaygroundDrag({ onDrag: (e) => { this.handleMouseMove(e); }, onDragEnd: () => { this.handleMouseUp(); }, stopGlobalEventNames: ['contextmenu'], }); /** * 开始拖拽事件 * @param e */ async startDrag( e: { clientX: number; clientY: number }, { dragStartEntity: startEntityFromProps, dragEntities, dragJSON, isBranch, onCreateNode, }: StartDragProps, options?: { dragOffsetX?: number; dragOffsetY?: number; disableDragScroll?: boolean; } ) { // 1. 避免按住空格拖动滚动场景覆盖,context disabled 会出现在画布编辑被抢锁时候触发 if (this.isGrab() || this.config.disabled || this.config.readonly) { return; } this.disableDragScroll = Boolean(options?.disableDragScroll); this.dragJSON = dragJSON; this.onCreateNode = onCreateNode; this.flowRenderStateEntity.setIsBranch(Boolean(isBranch)); this.dragOffset.x = options?.dragOffsetX || DEFAULT_DRAG_OFFSET_X; this.dragOffset.y = options?.dragOffsetY || DEFAULT_DRAG_OFFSET_Y; const type = startEntityFromProps?.flowNodeType || dragJSON?.type; const isIcon = type === FlowNodeBaseType.BLOCK_ICON; const isOrderIcon = type === FlowNodeBaseType.BLOCK_ORDER_ICON; const dragStartEntity = isIcon || isOrderIcon ? startEntityFromProps!.parent! : startEntityFromProps; // 部分节点不支持拖拽 if (dragStartEntity && !dragStartEntity!.getData(FlowNodeRenderData).draggable) { return; } this.initialPosition = { x: e.clientX, y: e.clientY, }; this.dragStartEntity = dragStartEntity; this.dragEntities = dragEntities || (this.dragStartEntity ? [this.dragStartEntity!] : []); return this._dragger.start(e.clientX, e.clientY); } onReady() { this.draggingNodeMask.style.width = '100%'; this.draggingNodeMask.style.height = '100%'; this.draggingNodeMask.style.position = 'absolute'; this.draggingNodeMask.classList.add('dragging-node'); this.draggingNodeMask.style.zIndex = '99'; this.draggingNodeMask.style.cursor = 'pointer'; this.dragNodeComp = this.rendererRegistry.getRendererComponent(FlowRendererKey.DRAG_NODE); // 监听拖入事件 if (this.options.onDrop) { this.toDispose.push(this.flowDragService.onDrop(this.options.onDrop)); } } dispose(): void { this._dragger.dispose(); super.dispose(); } render() { // styled-component component type 为 any const DragComp: any = this.dragNodeComp.renderer; return ReactDOM.createPortal(

e.stopPropagation()} >
, document.body ); } } ================================================ FILE: packages/canvas-engine/renderer/src/layers/flow-labels-layer.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React from 'react'; import { throttle } from 'lodash-es'; import { inject, injectable } from 'inversify'; import { domUtils } from '@flowgram.ai/utils'; import { FlowDocument, FlowDocumentTransformerEntity, FlowNodeEntity, FlowNodeTransitionData, FlowRendererStateEntity, } from '@flowgram.ai/document'; import { Layer, observeEntity, observeEntityDatas } from '@flowgram.ai/core'; import { useBaseColor } from '../hooks/use-base-color'; import { FlowRendererRegistry } from '../flow-renderer-registry'; import { createLabels } from '../components/LabelsRenderer'; @injectable() export class FlowLabelsLayer extends Layer { @inject(FlowDocument) readonly document: FlowDocument; @inject(FlowRendererRegistry) readonly rendererRegistry: FlowRendererRegistry; node = domUtils.createDivWithClass('gedit-flow-labels-layer'); @observeEntity(FlowDocumentTransformerEntity) readonly documentTransformer: FlowDocumentTransformerEntity; @observeEntity(FlowRendererStateEntity) readonly flowRenderState: FlowRendererStateEntity; /** * 监听 transition 变化 */ @observeEntityDatas(FlowNodeEntity, FlowNodeTransitionData) _transitions: FlowNodeTransitionData[]; get transitions(): FlowNodeTransitionData[] { return this.document.getRenderDatas(FlowNodeTransitionData); } /** * 监听缩放,目前采用整体缩放 * @param scale */ onZoom(scale: number) { this.node!.style.transform = `scale(${scale})`; } /** * 可视区域变化 */ onViewportChange: ReturnType = throttle(() => { this.render(); }, 100); onReady() { // 图层顺序调整:节点 > label > 线条 // 节点 z-index: 10 this.node.style.zIndex = '9'; } /** * 监听readonly和 disabled 状态 并刷新layer, 并刷新 */ onReadonlyOrDisabledChange() { this.render(); } render() { const labels: JSX.Element[] = []; if (this.documentTransformer?.loading) return <>; this.documentTransformer?.refresh?.(); const { baseActivatedColor, baseColor } = useBaseColor(); const isViewportVisible = this.config.isViewportVisible.bind(this.config); this.transitions.forEach((transition) => { createLabels({ data: transition, rendererRegistry: this.rendererRegistry, isViewportVisible, labelsSave: labels, getLabelColor: (activated) => (activated ? baseActivatedColor : baseColor), }); }); // 这里采用扁平化的 react 结构性能更高 return <>{labels}; } } ================================================ FILE: packages/canvas-engine/renderer/src/layers/flow-lines-layer.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React from 'react'; import { groupBy, throttle } from 'lodash-es'; import { inject, injectable } from 'inversify'; import { domUtils } from '@flowgram.ai/utils'; import { FlowDocument, FlowDocumentTransformerEntity, FlowNodeEntity, FlowNodeTransitionData, FlowRendererStateEntity, FlowDragService, } from '@flowgram.ai/document'; import { Layer, observeEntity, observeEntityDatas } from '@flowgram.ai/core'; import { FlowRendererRegistry } from '../flow-renderer-registry'; import { createLines } from '../components/LinesRenderer'; @injectable() export class FlowLinesLayer extends Layer { @inject(FlowDocument) readonly document: FlowDocument; @inject(FlowDragService) protected readonly dragService: FlowDragService; @inject(FlowRendererRegistry) readonly rendererRegistry: FlowRendererRegistry; node = domUtils.createDivWithClass('gedit-flow-lines-layer'); @observeEntity(FlowDocumentTransformerEntity) readonly documentTransformer: FlowDocumentTransformerEntity; @observeEntity(FlowRendererStateEntity) readonly flowRenderState: FlowRendererStateEntity; /** * 监听 transition 变化 */ @observeEntityDatas(FlowNodeEntity, FlowNodeTransitionData) _transitions: FlowNodeTransitionData[]; get transitions(): FlowNodeTransitionData[] { return this.document.getRenderDatas(FlowNodeTransitionData); } /** * 可视区域变化 */ onViewportChange: ReturnType = throttle(() => { this.render(); }, 100); onZoom() { const svgContainer = this.node!.querySelector('svg.flow-lines-container')!; svgContainer?.setAttribute?.('viewBox', this.viewBox); } onReady() { this.node.style.zIndex = '1'; } get viewBox(): string { const ratio = 1000 / this.config.finalScale; return `0 0 ${ratio} ${ratio}`; } render(): JSX.Element { const allLines: JSX.Element[] = []; const isViewportVisible = this.config.isViewportVisible.bind(this.config); // 还没初始化 if (this.documentTransformer.loading) return <>; this.documentTransformer.refresh(); this.transitions.forEach((transition) => { createLines({ data: transition, rendererRegistry: this.rendererRegistry, isViewportVisible, linesSave: allLines, dragService: this.dragService, }); }); // svg 没有 z-index,只能通过顺序来设置前后层级 // 通过将 activated 的项排到最后,防止 hover 层级覆盖 const { activateLines = [], normalLines = [] } = groupBy(allLines, (line) => line.props.activated ? 'activateLines' : 'normalLines' ); const resultLines = [...normalLines, ...activateLines]; return ( {resultLines} ); } } ================================================ FILE: packages/canvas-engine/renderer/src/layers/flow-nodes-content-layer.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import ReactDOM from 'react-dom'; import React from 'react'; import { inject, injectable } from 'inversify'; import { Cache, type CacheOriginItem, domUtils } from '@flowgram.ai/utils'; import { FlowDocument, FlowDocumentTransformerEntity, FlowNodeEntity, FlowNodeRenderData, FlowNodeTransformData, } from '@flowgram.ai/document'; import { Layer, observeEntity, observeEntityDatas, PlaygroundEntityContext, } from '@flowgram.ai/core'; import { FlowRendererKey, FlowRendererRegistry } from '../flow-renderer-registry'; interface NodePortal extends CacheOriginItem { id: string; Portal: () => JSX.Element; } /** * 渲染节点内容 */ @injectable() export class FlowNodesContentLayer extends Layer { @inject(FlowDocument) readonly document: FlowDocument; @inject(FlowRendererRegistry) readonly rendererRegistry: FlowRendererRegistry; @observeEntity(FlowDocumentTransformerEntity) readonly documentTransformer: FlowDocumentTransformerEntity; @observeEntityDatas(FlowNodeEntity, FlowNodeRenderData) _renderStates: FlowNodeRenderData[]; get renderStatesVisible(): FlowNodeRenderData[] { return this.document.getRenderDatas(FlowNodeRenderData, false); } private renderMemoCache = new WeakMap(); node = domUtils.createDivWithClass('gedit-flow-nodes-layer'); getPortalRenderer(data: FlowNodeRenderData): (props: any) => JSX.Element { const meta = data.entity.getNodeMeta(); const renderer = this.rendererRegistry.getRendererComponent( (meta.renderKey as FlowRendererKey) || FlowRendererKey.NODE_RENDER ); const reactRenderer = renderer.renderer as any; let memoCache = this.renderMemoCache.get(reactRenderer); if (!memoCache) { memoCache = React.memo(reactRenderer); this.renderMemoCache.set(reactRenderer, memoCache); } return memoCache; } /** * 监听缩放,目前采用整体缩放 * @param scale */ onZoom(scale: number) { this.node!.style.transform = `scale(${scale})`; } dispose(): void { this.reactPortals.dispose(); super.dispose(); } protected reactPortals = Cache.create( (data?: FlowNodeRenderData) => { const { node, entity } = data!; const { config } = this; const PortalRenderer = this.getPortalRenderer(data!); function Portal(): JSX.Element { React.useEffect(() => { // 第一次加载需要把宽高通知 if (!entity.getNodeMeta().autoResizeDisable && node.clientWidth && node.clientHeight) { const transform = entity.getData(FlowNodeTransformData); if (transform) transform.size = { width: node.clientWidth, height: node.clientHeight, }; } }, [entity, node]); // 这里使用 portal,改 dom 样式不会引起 react 重新渲染 return ReactDOM.createPortal( , node ); } return { id: node.id || entity.id, dispose: () => { // TODO, 删除逻辑由 node 去控制了 }, Portal, } as NodePortal; } ); onReady() { this.node!.style.zIndex = '10'; } /** * 监听readonly和 disabled 状态 并刷新layer, 并刷新节点 */ onReadonlyOrDisabledChange() { this.render(); } getPortals(): NodePortal[] { return this.reactPortals.getMoreByItems(this.renderStatesVisible); } render() { if (this.documentTransformer.loading) return <>; this.documentTransformer.refresh(); // 从缓存获取节点 return ( <> {this.getPortals().map((portal) => ( ))} ); } } ================================================ FILE: packages/canvas-engine/renderer/src/layers/flow-nodes-transform-layer.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { inject, injectable } from 'inversify'; import { Cache, type Disposable, domUtils } from '@flowgram.ai/utils'; import { FlowDocument, FlowDocumentTransformerEntity, FlowNodeEntity, FlowNodeTransformData, } from '@flowgram.ai/document'; import { Layer, observeEntity, observeEntityDatas } from '@flowgram.ai/core'; // import { throttle } from 'lodash-es' import { FlowRendererResizeObserver } from '../flow-renderer-resize-observer'; interface TransformRenderCache { updateBounds(): void; } export interface FlowNodesTransformLayerOptions { renderElement?: HTMLElement | (() => HTMLElement | undefined); } /** * 渲染节点位置 */ @injectable() export class FlowNodesTransformLayer extends Layer { @inject(FlowDocument) readonly document: FlowDocument; @inject(FlowRendererResizeObserver) readonly resizeObserver: FlowRendererResizeObserver; @observeEntity(FlowDocumentTransformerEntity) readonly documentTransformer: FlowDocumentTransformerEntity; @observeEntityDatas(FlowNodeEntity, FlowNodeTransformData) _transforms: FlowNodeTransformData[]; node = domUtils.createDivWithClass('gedit-flow-nodes-layer'); get transformVisibles(): FlowNodeTransformData[] { return this.document.getRenderDatas(FlowNodeTransformData, false); } /** * 监听缩放,目前采用整体缩放 * @param scale */ onZoom(scale: number) { this.node!.style.transform = `scale(${scale})`; } dispose(): void { this.renderCache.dispose(); super.dispose(); } // onViewportChange() { // this.throttleUpdate() // } // throttleUpdate = throttle(() => { // this.renderCache.getFromCache().forEach((cache) => cache.updateBounds()) // }, 100) protected renderCache = Cache.create( (transform?: FlowNodeTransformData) => { const { renderState } = transform!; const { node } = renderState; const { entity } = transform!; node.id = entity.id; let resizeDispose: Disposable | undefined; const append = () => { if (resizeDispose) return; // 监听 dom 节点的大小变化 this.renderElement.appendChild(node); if (!entity.getNodeMeta().autoResizeDisable) { resizeDispose = this.resizeObserver.observe(node, transform!); } }; const dispose = () => { if (!resizeDispose) return; // 脱离文档流,但是 react 组件会保留 if (node.parentElement) { this.renderElement.removeChild(node); } resizeDispose.dispose(); resizeDispose = undefined; }; append(); return { dispose, updateBounds: () => { const { bounds } = transform!; // 保留2位小数 const rawX: number = parseFloat(node.style.left); const rawY: number = parseFloat(node.style.top); if (!this.isCoordEqual(rawX, bounds.x) || !this.isCoordEqual(rawY, bounds.y)) { node.style.left = `${bounds.x}px`; node.style.top = `${bounds.y}px`; } }, }; } ); private isCoordEqual(a: number, b: number) { const browserCoordEpsilon = 0.05; // 浏览器处理坐标的精度误差: 两位小数四舍五入 return Math.abs(a - b) < browserCoordEpsilon; } onReady() { this.node!.style.zIndex = '10'; } get visibeBounds() { return this.transformVisibles.map((transform) => transform.bounds); } /** * 更新节点的 bounds 数据 */ updateNodesBounds() { this.renderCache .getMoreByItems(this.transformVisibles) .forEach((render) => render.updateBounds()); } autorun() { // 更新节点偏移数据 O(n) TODO 这个更新会从 render 里移除改成自动触发 if (this.documentTransformer.loading) return; this.documentTransformer.refresh(); this.updateNodesBounds(); } private get renderElement(): HTMLElement { if (typeof this.options.renderElement === 'function') { const element = this.options.renderElement(); if (element) { return element; } } else if (typeof this.options.renderElement !== 'undefined') { return this.options.renderElement as HTMLElement; } return this.node; } } ================================================ FILE: packages/canvas-engine/renderer/src/layers/flow-scroll-bar-layer.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { inject, injectable, optional } from 'inversify'; import { FlowDocument, FlowNodeTransformData } from '@flowgram.ai/document'; import { Layer, observeEntity, PlaygroundConfigEntity, PlaygroundDrag, } from '@flowgram.ai/core'; import { domUtils, Rectangle } from '@flowgram.ai/utils'; // import { // FlowDocument, // FlowDocumentTransformerEntity, // FlowNodeTransformData, // } from '@flowgram.ai/document' import { ScrollBarEvents } from '../utils/scroll-bar-events'; import { getScrollViewport } from '../utils'; // 中间区域边框宽度 const BORDER_WIDTH = 2; // 右下角预留的 offset const BLOCK_OFFSET = 11; // 滚动条样式宽 const SCROLL_BAR_WIDTH = '7px'; // 滚动条显示状态 enum ScrollBarVisibility { Show = 'show', Hidden = 'hidden', } export interface ScrollBarOptions { /** * 显示滚动条的时机,可选常驻或滚动时显示 */ showScrollBars: 'whenScrolling' | 'always'; getBounds(): Rectangle; } /** * 渲染滚动条 layer */ @injectable() export class FlowScrollBarLayer extends Layer { @optional() @inject(ScrollBarEvents) readonly events?: ScrollBarEvents; @inject(FlowDocument) @optional() flowDocument?: FlowDocument; @observeEntity(PlaygroundConfigEntity) protected playgroundConfigEntity: PlaygroundConfigEntity; // @observeEntity(FlowDocumentTransformerEntity) readonly documentTransformer: FlowDocumentTransformerEntity // 右滚动区域 readonly rightScrollBar = domUtils.createDivWithClass('gedit-playground-scroll-right'); // 右滚动条 readonly rightScrollBarBlock = domUtils.createDivWithClass('gedit-playground-scroll-right-block'); // 底滚动区域 readonly bottomScrollBar = domUtils.createDivWithClass('gedit-playground-scroll-bottom'); // 底滚动条 readonly bottomScrollBarBlock = domUtils.createDivWithClass( 'gedit-playground-scroll-bottom-block', ); // 最左边的位置 private mostLeft: number; // 最右边的位置 private mostRight: number; // 最上面的位置 private mostTop: number; // 最下面的位置 private mostBottom: number; // 视区宽度 private viewportWidth: number; // 视区高度 private viewportHeight: number; // 元素宽高 private width: number; private height: number; // 底部滚动条宽度 private scrollBottomWidth: number; // 右侧滚动条高度 private scrollRightHeight: number; // 缩放比 private scale: number; // 总滚动距离 private sum = 0; // 初始 x 轴滚动距离 private initialScrollX = 0; // 初始 y 轴滚动距离 private initialScrollY = 0; // 隐藏滚动条的时延 private hideTimeout: number | undefined; // 浏览器视图宽度 get clientViewportWidth(): number { return this.viewportWidth * this.scale - BLOCK_OFFSET; } // 浏览器视图高度 get clientViewportHeight(): number { return this.viewportHeight * this.scale - BLOCK_OFFSET; } // 视图的完整宽度 get viewportFullWidth(): number { return this.mostLeft - this.mostRight; } // 视图的完整高度 get viewportFullHeight(): number { return this.mostTop - this.mostBottom; } // 视图的可移动宽度 get viewportMoveWidth(): number { return this.mostLeft - this.mostRight + this.width; } // 视图的可移动高度 get viewportMoveHeight(): number { return this.mostTop - this.mostBottom + this.height; } getToLeft(scrollX: number): number { return ((scrollX - this.mostRight) / this.viewportMoveWidth) * this.clientViewportWidth; } getToTop(scrollY: number): number { return ((scrollY - this.mostBottom) / this.viewportMoveHeight) * this.clientViewportHeight; } clickRightScrollBar(e: MouseEvent) { e.preventDefault(); e.stopPropagation(); const ratio = 1 - (e?.y || 0) / this.clientViewportHeight; const scrollY = (this.mostTop - this.viewportFullHeight * ratio) * this.scale; // 滚动到指定位置 this.playgroundConfigEntity.scroll( { scrollY, }, false, ); } clickBottomScrollBar(e: MouseEvent) { e.preventDefault(); e.stopPropagation(); const ratio = 1 - (e?.x || 0) / this.clientViewportWidth; const scrollX = (this.mostLeft - this.viewportFullWidth * ratio) * this.scale; // 滚动到指定位置 this.playgroundConfigEntity.scroll( { scrollX, }, false, ); } onBoardingToast() { // onBoarding 逻辑,滚动条指示优化,弹出 toast this.events?.dragStart(); } protected bottomGrabDragger = new PlaygroundDrag({ onDragStart: e => { this.config.updateCursor('grabbing'); this.sum = 0; this.initialScrollX = this.config.getViewport().x; this.onBoardingToast(); }, onDrag: e => { this.sum += e.movingDelta.x; this.playgroundConfigEntity.scroll( { scrollX: (this.initialScrollX + (this.sum * this.viewportFullWidth) / (this.clientViewportWidth - this.scrollBottomWidth)) * this.scale, }, false, ); }, onDragEnd: e => { this.config.updateCursor('default'); }, }); protected rightGrabDragger = new PlaygroundDrag({ onDragStart: e => { this.config.updateCursor('grabbing'); this.sum = 0; this.initialScrollY = this.config.getViewport().y; this.onBoardingToast(); }, onDrag: e => { this.sum += e.movingDelta.y; this.playgroundConfigEntity.scroll( { scrollY: (this.initialScrollY + (this.sum * this.viewportFullHeight) / (this.clientViewportHeight - this.scrollRightHeight)) * this.scale, }, false, ); }, onDragEnd: e => { this.config.updateCursor('default'); }, }); protected changeScrollBarVisibility(scrollBar: HTMLDivElement, status: ScrollBarVisibility) { const addClassName = status === ScrollBarVisibility.Show ? 'gedit-playground-scroll-show' : 'gedit-playground-scroll-hidden'; const delClassName = status === ScrollBarVisibility.Show ? 'gedit-playground-scroll-hidden' : 'gedit-playground-scroll-show'; domUtils.addClass(scrollBar, addClassName); domUtils.delClass(scrollBar, delClassName); } onReady() { if (!this.options.getBounds) { this.options = { getBounds: () => { const document = this.flowDocument; if (!document) return Rectangle.EMPTY; document.transformer.refresh(); return document.root.getData(FlowNodeTransformData)!.bounds; }, showScrollBars: 'whenScrolling', }; } this.pipelineNode.parentNode!.appendChild(this.rightScrollBar); this.pipelineNode.parentNode!.appendChild(this.rightScrollBarBlock); this.pipelineNode.parentNode!.appendChild(this.bottomScrollBar); this.pipelineNode.parentNode!.appendChild(this.bottomScrollBarBlock); // 模拟滚动条点击时的滚动 this.rightScrollBar.onclick = this.clickRightScrollBar.bind(this); this.bottomScrollBar.onclick = this.clickBottomScrollBar.bind(this); // 滚动时才显示滚动条 则要监听鼠标事件 hover 的时候也要显示 if (this.options.showScrollBars === 'whenScrolling') { this.rightScrollBar.addEventListener('mouseenter', (e: MouseEvent) => { this.changeScrollBarVisibility(this.rightScrollBarBlock, ScrollBarVisibility.Show); }); this.rightScrollBar.addEventListener('mouseleave', (e: MouseEvent) => { this.changeScrollBarVisibility(this.rightScrollBarBlock, ScrollBarVisibility.Hidden); }); this.bottomScrollBar.addEventListener('mouseenter', (e: MouseEvent) => { this.changeScrollBarVisibility(this.bottomScrollBarBlock, ScrollBarVisibility.Show); }); this.bottomScrollBar.addEventListener('mouseleave', (e: MouseEvent) => { this.changeScrollBarVisibility(this.bottomScrollBarBlock, ScrollBarVisibility.Hidden); }); } // 监听拖拽滚动 this.bottomScrollBarBlock.addEventListener('mousedown', (e: MouseEvent) => { this.bottomGrabDragger.start(e.clientX, e.clientY); e.stopPropagation(); }); this.rightScrollBarBlock.addEventListener('mousedown', (e: MouseEvent) => { this.rightGrabDragger.start(e.clientX, e.clientY); e.stopPropagation(); }); } autorun() { // if (this.documentTransformer.loading) return null // this.documentTransformer.refresh() if (this.hideTimeout) { clearTimeout(this.hideTimeout); } // 中间活动区域宽高 const viewportBounds = getScrollViewport( { scrollX: this.config.config.scrollX, scrollY: this.config.config.scrollY, }, this.config, ); // 画布视区宽高 const viewport = this.config.getViewport(); // 计算视区的时候预留 11px,防止右下角滚动条重叠 this.viewportWidth = viewport.width; this.viewportHeight = viewport.height; // 中间部分元素的宽高 const rootBounds = this.options.getBounds(); // this.document.root.getData(FlowNodeTransformData)!.bounds this.width = rootBounds?.width || 0; this.height = rootBounds?.height || 0; // 中间部分元素的左右间距 const paddingLeftRight = (this.viewportWidth - viewportBounds.width) / 2 - BORDER_WIDTH; // 中间部分元素的上下间距 const paddingTopBottom = (this.viewportHeight - viewportBounds.height) / 2 - BORDER_WIDTH; // 画布可滚动总长度 const canvasTotalWidth = this.width + viewportBounds.width; const canvasTotalHeight = this.height + viewportBounds.height; // 根据当前滚动距离计算 滚动条距离边界间距 // 中间元素初始的偏移位置: const initialOffsetX = rootBounds.x; const initialOffsetY = rootBounds.y; // 最左边的位置 this.mostLeft = this.width + initialOffsetX - paddingLeftRight; // 最右边的位置 this.mostRight = this.mostLeft - canvasTotalWidth; // 最上面的位置 this.mostTop = this.height + initialOffsetY - paddingTopBottom; // 最下面的位置 this.mostBottom = this.mostTop - canvasTotalHeight; this.scale = this.config.finalScale; const calcViewportWidth = this.clientViewportWidth; const calcViewportHeight = this.clientViewportHeight; // 计算公式: // 可视区域 - 滚动条的长度 / 可视区域 = 可视区域 / 画布可滚动距离 // 底部滚动条宽度 this.scrollBottomWidth = calcViewportWidth - (calcViewportWidth * (this.mostLeft - this.mostRight)) / this.viewportMoveWidth; // 右侧滚动条高度 this.scrollRightHeight = calcViewportHeight - (calcViewportHeight * (this.mostTop - this.mostBottom)) / this.viewportMoveHeight; // 计算滚动条滚动位移的距离 // 可滚动区域:canvasTotalWidth - scrollBottomWidth const bottomBarToLeft = this.getToLeft(viewport.x); const rightBarToTop = this.getToTop(viewport.y); // 设置右侧的滚动条内的 block 样式 domUtils.setStyle(this.rightScrollBarBlock, { right: 2, top: rightBarToTop, background: '#1F2329', zIndex: 10, height: this.scrollRightHeight, width: SCROLL_BAR_WIDTH, }); // 设置底部的滚动条内的 block 样式 domUtils.setStyle(this.bottomScrollBarBlock, { left: bottomBarToLeft, bottom: 2, background: '#1F2329', zIndex: 10, height: SCROLL_BAR_WIDTH, width: this.scrollBottomWidth, }); this.changeScrollBarVisibility(this.rightScrollBarBlock, ScrollBarVisibility.Show); this.changeScrollBarVisibility(this.bottomScrollBarBlock, ScrollBarVisibility.Show); // 滚动时才显示滚动条 // 定时器在 1s 后隐藏滚动条 if (this.options.showScrollBars === 'whenScrolling') { this.hideTimeout = window.setTimeout(() => { this.changeScrollBarVisibility(this.rightScrollBarBlock, ScrollBarVisibility.Hidden); this.changeScrollBarVisibility(this.bottomScrollBarBlock, ScrollBarVisibility.Hidden); this.hideTimeout = undefined; }, 1000); } } } ================================================ FILE: packages/canvas-engine/renderer/src/layers/flow-scroll-limit-layer.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { inject, injectable } from 'inversify'; import { FlowDocument, FlowNodeTransformData } from '@flowgram.ai/document'; import { Layer } from '@flowgram.ai/core'; import { ScrollSchema } from '@flowgram.ai/utils'; import { scrollLimit } from '../utils'; /** * 控制滚动边界 */ @injectable() export class FlowScrollLimitLayer extends Layer { @inject(FlowDocument) readonly document: FlowDocument; getInitScroll(): ScrollSchema { return this.document.layout.getInitScroll(this.pipelineNode.getBoundingClientRect()); } onReady(): void { const initScroll = () => this.getInitScroll(); this.config.updateConfig(initScroll()); this.config.addScrollLimit(scroll => scrollLimit( scroll, [this.document.root.getData(FlowNodeTransformData)!.bounds], this.config, initScroll, ), ); } } ================================================ FILE: packages/canvas-engine/renderer/src/layers/flow-selector-bounds-layer.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React from 'react'; import { inject, injectable } from 'inversify'; import { domUtils } from '@flowgram.ai/utils'; import { Rectangle } from '@flowgram.ai/utils'; import { FlowNodeEntity, FlowNodeRenderData, FlowNodeTransformData } from '@flowgram.ai/document'; import { CommandRegistry, EditorState, EditorStateConfigEntity, Layer, LayerOptions, PlaygroundConfig, observeEntity, observeEntityDatas, } from '@flowgram.ai/core'; import { FlowRendererKey, FlowRendererRegistry } from '../flow-renderer-registry'; import { FlowSelectConfigEntity, SelectorBoxConfigEntity } from '../entities'; export interface SelectorBoxPopoverProps { bounds: Rectangle; config: PlaygroundConfig; flowSelectConfig: FlowSelectConfigEntity; commandRegistry: CommandRegistry; children?: React.ReactNode; } export interface FlowSelectorBoundsLayerOptions extends LayerOptions { ignoreOneSelect?: boolean; ignoreChildrenLength?: boolean; boundsPadding?: number; // 边框留白,默认 10 disableBackground?: boolean; // 禁用背景框 backgroundClassName?: string; // 节点下边 foregroundClassName?: string; // 节点上边 SelectorBoxPopover?: React.FC; // 选择框工具层 CustomBoundsRenderer?: React.FC; // 自定义渲染 } /** * 流程节点被框选后的边界区域渲染 */ @injectable() export class FlowSelectorBoundsLayer extends Layer { @inject(FlowRendererRegistry) readonly rendererRegistry: FlowRendererRegistry; @inject(CommandRegistry) readonly commandRegistry: CommandRegistry; @observeEntity(FlowSelectConfigEntity) protected flowSelectConfigEntity: FlowSelectConfigEntity; @observeEntity(EditorStateConfigEntity) protected editorStateConfig: EditorStateConfigEntity; @observeEntity(SelectorBoxConfigEntity) protected selectorBoxConfigEntity: SelectorBoxConfigEntity; /** * 需要监听节点的展开和收起状态,重新绘制边框 */ @observeEntityDatas(FlowNodeEntity, FlowNodeRenderData) renderStates: FlowNodeRenderData[]; @observeEntityDatas(FlowNodeEntity, FlowNodeTransformData) _transforms: FlowNodeTransformData[]; readonly node = domUtils.createDivWithClass('gedit-selector-bounds-layer'); readonly selectBoundsBackground = domUtils.createDivWithClass('gedit-selector-bounds-background'); onReady(): void { // 这个是覆盖到节点上边的,所以要比 flow-nodes-content-layer 大 this.node!.style.zIndex = '20'; const { firstChild } = this.pipelineNode; if (this.options.boundsPadding !== undefined) { this.flowSelectConfigEntity.boundsPadding = this.options.boundsPadding; } if (this.options.backgroundClassName) { this.selectBoundsBackground.classList.add(this.options.backgroundClassName); } // 这里创建一个空 layer 用于放背景 const selectorBoundsLayer = domUtils.createDivWithClass( 'gedit-selector-bounds-background-layer gedit-playground-layer' ); selectorBoundsLayer.appendChild(this.selectBoundsBackground); // 背景框需要在节点的下边 this.pipelineNode.insertBefore(selectorBoundsLayer, firstChild); } onZoom(scale: number) { this.node!.style.transform = `scale(${scale})`; this.selectBoundsBackground.parentElement!.style.transform = `scale(${scale})`; } onViewportChange() { // 需要调整 bounds 菜单的位置 this.render(); } isEnabled(): boolean { const currentState = this.editorStateConfig.getCurrentState(); return currentState === EditorState.STATE_SELECT; } // /** // * 渲染工具栏 // */ // renderCommandMenus(): JSX.Element[] { // return this.commandRegistry.commands // .filter(cmd => cmd.category === FlowRendererCommandCategory.SELECTOR_BOX) // .map(cmd => { // const CommandRenderer = this.rendererRegistry.getRendererComponent( // (cmd.icon as string) || cmd.id, // )?.renderer; // // return ( // // eslint-disable-next-line react/jsx-filename-extension // this.commandRegistry.executeCommand(cmd.id, e)} // /> // ); // }) // .filter(c => c); // } render(): JSX.Element { const { ignoreOneSelect, ignoreChildrenLength, SelectorBoxPopover: SelectorBoxPopoverFromOpts, disableBackground, CustomBoundsRenderer, } = this.options; const bounds = this.flowSelectConfigEntity.getSelectedBounds(); const selectedNodes = this.flowSelectConfigEntity.selectedNodes; const bg = this.selectBoundsBackground; const isDragging = !this.selectorBoxConfigEntity.isStart; if ( bounds.width === 0 || bounds.height === 0 || // 选中单个的时候不显示 (ignoreOneSelect && selectedNodes.length === 1 && // 选中的节点不包含多个子节点 (ignoreChildrenLength || (selectedNodes[0] as FlowNodeEntity).childrenLength <= 1)) ) { domUtils.setStyle(bg, { display: 'none', }); return <>; } if (CustomBoundsRenderer) { return ( ); } const style = { display: 'block', left: bounds.left, top: bounds.top, width: bounds.width, height: bounds.height, }; if (!disableBackground) { domUtils.setStyle(bg, style); } let foregroundClassName = 'gedit-selector-bounds-foreground'; if (this.options.foregroundClassName) { foregroundClassName += ' ' + this.options.foregroundClassName; } const SelectorBoxPopover = SelectorBoxPopoverFromOpts || this.rendererRegistry.tryToGetRendererComponent(FlowRendererKey.SELECTOR_BOX_POPOVER) ?.renderer; if (!isDragging || !SelectorBoxPopover) return
; return (
); } } ================================================ FILE: packages/canvas-engine/renderer/src/layers/flow-selector-box-layer.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { inject, injectable } from 'inversify'; import { domUtils, PositionSchema } from '@flowgram.ai/utils'; import { FlowDocument, FlowNodeEntity, FlowNodeTransformData } from '@flowgram.ai/document'; import { ContextMenuService, EditorState, EditorStateConfigEntity, Layer, LayerOptions, observeEntity, PipelineLayerPriority, PlaygroundConfigEntity, PlaygroundDrag, SelectionService, } from '@flowgram.ai/core'; import { FlowSelectConfigEntity, SelectorBoxConfigEntity } from '../entities'; export interface FlowSelectorBoxOptions extends LayerOptions { /** * 默认不提供则为点击空白地方可以框选 * @param e * @param entity */ canSelect?: (e: MouseEvent, entity: SelectorBoxConfigEntity) => boolean; } /** * 流程选择框 */ @injectable() export class FlowSelectorBoxLayer extends Layer { @inject(FlowDocument) protected flowDocument: FlowDocument; @inject(ContextMenuService) readonly contextMenuService: ContextMenuService; @observeEntity(PlaygroundConfigEntity) protected playgroundConfigEntity: PlaygroundConfigEntity; @inject(SelectionService) readonly selectionService: SelectionService; @observeEntity(SelectorBoxConfigEntity) protected selectorBoxConfigEntity: SelectorBoxConfigEntity; @observeEntity(FlowSelectConfigEntity) protected selectConfigEntity: FlowSelectConfigEntity; @observeEntity(EditorStateConfigEntity) protected editorStateConfig: EditorStateConfigEntity; readonly node = domUtils.createDivWithClass('gedit-selector-box-layer'); /** * 选择框 */ protected selectorBox = this.createDOMCache('gedit-selector-box'); /** * 用于遮挡鼠标,避免触发 hover */ protected selectorBoxBlock = this.createDOMCache('gedit-selector-box-block'); protected transformVisibles: FlowNodeTransformData[]; /** * 拖动选择框 */ protected selectboxDragger = new PlaygroundDrag({ onDragStart: (e) => { this.selectConfigEntity.clearSelectedNodes(); const mousePos = this.playgroundConfigEntity.getPosFromMouseEvent(e); this.transformVisibles = this.flowDocument .getRenderDatas(FlowNodeTransformData, false) .filter((transform) => { const { entity } = transform; if (entity.originParent) { return ( this.nodeSelectable(entity, mousePos) && this.nodeSelectable(entity.originParent, mousePos) ); } return this.nodeSelectable(entity, mousePos); }); this.selectorBoxConfigEntity.setDragInfo(e); this.updateSelectorBox(this.selectorBoxConfigEntity); }, onDrag: (e) => { this.selectorBoxConfigEntity.setDragInfo(e); // 更新选择框 this.selectConfigEntity.selectFromBounds( this.selectorBoxConfigEntity.toRectangle(this.playgroundConfigEntity.finalScale), this.transformVisibles ); this.updateSelectorBox(this.selectorBoxConfigEntity); }, onDragEnd: (e) => { this.selectorBoxConfigEntity.setDragInfo(e); this.transformVisibles.length = 0; this.updateSelectorBox(this.selectorBoxConfigEntity); }, }); onReady(): void { if (!this.options.canSelect) { this.options.canSelect = (e: MouseEvent) => { const target = e.target as HTMLElement | undefined; // 默认点击空白地方可以框选 return target === this.pipelineNode || target === this.playgroundNode; }; } // 将选中的节点同步到全局 // TODO 后续要统一到 selection service this.toDispose.pushAll([ this.selectConfigEntity.onConfigChanged(() => { this.selectionService.selection = this.selectConfigEntity.selectedNodes; }), this.selectionService.onSelectionChanged(() => { const selectedNodes = this.selectionService.selection.filter( (entity) => entity instanceof FlowNodeEntity ); this.selectConfigEntity.selectedNodes = selectedNodes as FlowNodeEntity[]; }), ]); this.listenPlaygroundEvent( 'mousedown', (e: MouseEvent): boolean | undefined => { if (!this.isEnabled()) return; // 自定义拦截选择框事件 if (this.options.canSelect && !this.options.canSelect(e, this.selectorBoxConfigEntity)) { return; } const currentState = this.editorStateConfig.getCurrentState(); // 鼠标友好模式,框选后,再次点击其他地方或者框选其他地方,需要清空已有选择的节点 if (currentState === EditorState.STATE_MOUSE_FRIENDLY_SELECT) { this.selectConfigEntity.clearSelectedNodes(); } // const target = e.target as HTMLElement | undefined; // TODO 下边这些特化逻辑迁移到固定布局逻辑 // const linesLayer = document.querySelector('.gedit-flow-lines-layer'); // const toolsTarget = document.querySelector('.flow-canvas-selector-box-tools'); // const isInTools = toolsTarget && (toolsTarget === target || toolsTarget.contains(target!)); // 保证 service 更新后进行是否清除的计算 // setTimeout(() => { // // 如果点击到选中区域的菜单栏 // if (!isInTools && !this.contextMenuService.rightPanelVisible) { // // 取消之前的选择状态 // this.selectConfigEntity.clearSelectedNodes(); // } // }, 0); // if ( // target === this.pipelineNode || // target === this.playgroundNode // 点击空白区域 // linesLayer?.contains(target!) // 点击 svg 线条 // target?.classList.contains('flow-canvas-adder') || // 点击添加按钮的留白区域 // target?.classList.contains('flow-canvas-block-icon') // 点击添加按钮的留白区域 // ) { // return true; // } this.selectboxDragger.start(e.clientX, e.clientY, this.config); return true; }, PipelineLayerPriority.BASE_LAYER ); } isEnabled(): boolean { const currentState = this.editorStateConfig.getCurrentState(); const isMouseFriendly = currentState === EditorState.STATE_MOUSE_FRIENDLY_SELECT; return ( !this.config.disabled && !this.config.readonly && // 鼠标友好模式下,需要按下 shift 启动框选 ((isMouseFriendly && this.editorStateConfig.isPressingShift) || currentState === EditorState.STATE_SELECT) && !this.selectorBoxConfigEntity.disabled ); } /** * Destroy */ dispose(): void { this.selectorBox.dispose(); this.selectorBoxBlock.dispose(); super.dispose(); } protected updateSelectorBox(selector: SelectorBoxConfigEntity): void { const node = this.selectorBox.get(); const block = this.selectorBoxBlock.get(); // 非可用状态且在 moving 则关闭选择框 if (!this.isEnabled() && selector.isMoving) { this.selectorBoxConfigEntity.collapse(); } if (!this.isEnabled() || !selector.isMoving) { node.setStyle({ display: 'none', }); block.setStyle({ display: 'none', }); } else { node.setStyle({ display: 'block', left: selector.position.x, top: selector.position.y, width: selector.size.width, height: selector.size.height, }); // 这是遮挡滑块,防止触发节点 hover block.setStyle({ display: 'block', left: selector.position.x - 10, top: selector.position.y - 10, width: selector.size.width + 20, height: selector.size.height + 20, }); } } private nodeSelectable(node: FlowNodeEntity, mousePos: PositionSchema) { const selectable = node.getNodeMeta().selectable; if (typeof selectable === 'function') { return selectable(node, mousePos); } else { return selectable; } } // autorun(): void { // this.updateSelectorBox(this.selectorBoxConfigEntity); // } } ================================================ FILE: packages/canvas-engine/renderer/src/layers/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export * from './flow-nodes-transform-layer'; export * from './flow-nodes-content-layer'; export * from './flow-lines-layer'; export * from './flow-labels-layer'; export * from './flow-debug-layer'; export * from './flow-scroll-bar-layer'; export * from './flow-drag-layer'; export * from './flow-selector-box-layer'; export * from './flow-selector-bounds-layer'; export * from './flow-context-menu-layer'; export * from './flow-scroll-limit-layer'; ================================================ FILE: packages/canvas-engine/renderer/src/utils/element.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { isNil } from 'lodash-es'; export const isHidden = (dom?: HTMLElement) => { if (!dom || isNil(dom?.offsetParent)) { return true; } const style = window.getComputedStyle(dom); if (style?.display === 'none') { return true; } return false; }; export const isRectInit = (rect?: DOMRect): boolean => { if (!rect) { return false; } // 检查所有属性是否都为0,表示DOMRect未初始化 if ( rect.bottom === 0 && rect.height === 0 && rect.left === 0 && rect.right === 0 && rect.top === 0 && rect.width === 0 && rect.x === 0 && rect.y === 0 ) { return false; } return true; }; ================================================ FILE: packages/canvas-engine/renderer/src/utils/find-selected-nodes.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { uniq } from 'lodash-es'; import { type FlowNodeEntity } from '@flowgram.ai/document'; function getNodePath(node: FlowNodeEntity): FlowNodeEntity[] { const path: FlowNodeEntity[] = [node]; node = node.parent as FlowNodeEntity; while (node) { path.push(node); node = node.parent as FlowNodeEntity; } return path.reverse(); } /** * 过滤掉画布节点, 有 originParent,都是非独立节点 * @param entity */ function findRealEntity(entity: FlowNodeEntity): FlowNodeEntity { while (entity.originParent) { entity = entity.originParent; } return entity; } /** * 生成选中节点的路径 * 如 * [ * 'root', * 'exclusiveSplit_30baf8b1da0', * 'exclusiveSplit_d0070ce5d04', * 'createRecord_47e8fe1dfc3' * ], * [ * 'root', * 'exclusiveSplit_30baf8b1da0', * 'exclusiveSplit_d0070ce5d04', * 'createRecord_32dcdd10274' * ], * [ * 'root', * 'exclusiveSplit_30baf8b1da0', * 'exclusiveSplit_d0070ce5d04', * 'exclusiveSplit_a5579b3997d', // 这里产生分叉 * 'createRecord_b57b00eee94' // 父亲节点分叉了,这里就忽略了 * ] * ] * 1. 相同分支的节点,选择每个节点 * 2. 跨分支的节点选择共同的父节点 */ export function findSelectedNodes(nodes: FlowNodeEntity[]): FlowNodeEntity[] { if (nodes.length === 0) return []; /** * 生成节点的路径 */ const nodePathList: FlowNodeEntity[][] = nodes.map((n) => getNodePath(n)); /** * 只需要比较最小的路径 */ const minLength = Math.min(...nodePathList.map((n) => n.length)); let index = 0; let selectedItems: FlowNodeEntity[] = []; /** * 从二维数组的每一层打平去看,看看有没有分叉,如果有分叉就在当前层停止并作为选中的节点 */ while (index < minLength) { // eslint-disable-next-line no-loop-func selectedItems = uniq(nodePathList.map((p) => p[index])); // 存在分叉 if (selectedItems.length > 1) { break; } index += 1; } return uniq(selectedItems.map((item) => findRealEntity(item))); } ================================================ FILE: packages/canvas-engine/renderer/src/utils/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export * from './scroll-limit'; export * from './scroll-bar-events'; ================================================ FILE: packages/canvas-engine/renderer/src/utils/scroll-bar-events.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ /** * 滚动条点击事件监听 */ export const ScrollBarEvents = Symbol('ScrollBarEvents'); export interface ScrollBarEvents { dragStart: () => void; } ================================================ FILE: packages/canvas-engine/renderer/src/utils/scroll-limit.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { Rectangle } from '@flowgram.ai/utils'; import { type PlaygroundConfigEntity } from '@flowgram.ai/core'; export interface ScrollData { scrollX: number; scrollY: number; } // viewport 缩小 30 像素 const SCROLL_LIMIT_PADDING = -120; export function getScrollViewport( scrollData: ScrollData, config: PlaygroundConfigEntity ): Rectangle { const scale = config.finalScale; return new Rectangle( scrollData.scrollX / scale, scrollData.scrollY / scale, config.config.width / scale, config.config.height / scale ).pad(SCROLL_LIMIT_PADDING / scale, SCROLL_LIMIT_PADDING / scale); } /** * 限制滚动 */ export function scrollLimit( scroll: ScrollData, boundsList: Rectangle[], config: PlaygroundConfigEntity, initScroll: () => ScrollData ): ScrollData { scroll = { ...scroll }; const configData = config.config; const oldScroll = { scrollX: configData.scrollX, scrollY: configData.scrollY }; // 画布 size 还没初始化滚动不限制 if (boundsList.length === 0 || configData.width === 0 || configData.height === 0) return scroll; const viewport = getScrollViewport(scroll, config); const isVisible = boundsList.find((bounds) => Rectangle.isViewportVisible(bounds, viewport)); if (!isVisible) { const oldViewport = getScrollViewport(oldScroll, config); const isOldVisible = boundsList.find((bounds) => Rectangle.isViewportVisible(bounds, oldViewport) ); // 如果之前也是不可见就不阻止 if (!isOldVisible) { return initScroll(); } return oldScroll; } return scroll; } ================================================ FILE: packages/canvas-engine/renderer/tsconfig.json ================================================ { "extends": "@flowgram.ai/ts-config/tsconfig.flow.path.json", "compilerOptions": { "types": [ "vitest/globals" ], }, "include": [ "./src" ], "exclude": [ "**/__tests__/**", "**/__mocks__/**" ] } ================================================ FILE: packages/canvas-engine/renderer/vitest.config.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ const path = require('path'); import { defineConfig } from 'vitest/config'; export default defineConfig({ build: { commonjsOptions: { transformMixedEsModules: true, }, }, test: { globals: true, mockReset: false, environment: 'jsdom', setupFiles: [path.resolve(__dirname, './vitest.setup.ts')], include: ['**/?(*.){test,spec}.?(c|m)[jt]s?(x)'], exclude: [ '**/__mocks__**', '**/node_modules/**', '**/dist/**', '**/lib/**', // lib 编译结果忽略掉 '**/cypress/**', '**/.{idea,git,cache,output,temp}/**', '**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*', ], }, }); ================================================ FILE: packages/canvas-engine/renderer/vitest.setup.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import 'reflect-metadata'; ================================================ FILE: packages/client/editor/eslint.config.js ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ const { defineFlatConfig } = require('@flowgram.ai/eslint-config'); module.exports = defineFlatConfig({ preset: 'web', packageRoot: __dirname, }); ================================================ FILE: packages/client/editor/package.json ================================================ { "name": "@flowgram.ai/editor", "version": "0.1.8", "homepage": "https://flowgram.ai/", "repository": "https://github.com/bytedance/flowgram.ai", "license": "MIT", "exports": { "types": "./dist/index.d.ts", "import": "./dist/esm/index.js", "require": "./dist/index.js" }, "main": "./dist/index.js", "module": "./dist/esm/index.js", "types": "./dist/index.d.ts", "files": [ "dist" ], "scripts": { "build": "npm run build:fast -- --dts-resolve", "build:fast": "tsup src/index.ts --format cjs,esm --sourcemap --legacy-output", "build:watch": "npm run build:fast -- --dts-resolve", "clean": "rimraf dist", "test": "exit 0", "test:cov": "exit 0", "ts-check": "tsc --noEmit", "watch": "npm run build:fast -- --dts-resolve --watch --ignore-watch dist" }, "dependencies": { "@flowgram.ai/core": "workspace:*", "@flowgram.ai/document": "workspace:*", "@flowgram.ai/form": "workspace:*", "@flowgram.ai/form-core": "workspace:*", "@flowgram.ai/history": "workspace:*", "@flowgram.ai/history-node-plugin": "workspace:*", "@flowgram.ai/i18n-plugin": "workspace:*", "@flowgram.ai/materials-plugin": "workspace:*", "@flowgram.ai/node": "workspace:*", "@flowgram.ai/node-core-plugin": "workspace:*", "@flowgram.ai/node-variable-plugin": "workspace:*", "@flowgram.ai/playground-react": "workspace:*", "@flowgram.ai/redux-devtool-plugin": "workspace:*", "@flowgram.ai/renderer": "workspace:*", "@flowgram.ai/shortcuts-plugin": "workspace:*", "@flowgram.ai/utils": "workspace:*", "@flowgram.ai/variable-plugin": "workspace:*", "@flowgram.ai/reactive": "workspace:*", "inversify": "^6.0.1", "reflect-metadata": "~0.2.2" }, "devDependencies": { "@flowgram.ai/eslint-config": "workspace:*", "@flowgram.ai/ts-config": "workspace:*", "@types/bezier-js": "4.1.3", "@types/lodash-es": "^4.17.12", "@types/react": "^18", "@types/react-dom": "^18", "@vitest/coverage-v8": "^3.2.4", "eslint": "^9.0.0", "react": "^18", "react-dom": "^18", "tsup": "^8.0.1", "typescript": "^5.8.3", "vitest": "^3.2.4" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" }, "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" } } ================================================ FILE: packages/client/editor/src/clients/flow-editor-client-plugins.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { definePluginCreator } from '@flowgram.ai/core'; import { createNodeClientPlugins } from './node-client/create-node-client-plugins'; import { FlowEditorClient } from './flow-editor-client'; export const createFlowEditorClientPlugin = definePluginCreator<{}>({ onBind({ bind }) { bind(FlowEditorClient).toSelf().inSingletonScope(); }, }); export const createFlowEditorClientPlugins = () => [ ...createNodeClientPlugins(), createFlowEditorClientPlugin({}), ]; ================================================ FILE: packages/client/editor/src/clients/flow-editor-client.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { injectable, inject } from 'inversify'; import { type FormItem } from '@flowgram.ai/form-core'; import { FlowNodeEntity } from '@flowgram.ai/document'; import { Playground, PlaygroundConfigRevealOpts } from '@flowgram.ai/core'; import { FocusNodeFormItemOptions, NodeClient } from './node-client'; interface FocusNodeOptions { zoom?: PlaygroundConfigRevealOpts['zoom']; easing?: PlaygroundConfigRevealOpts['easing']; // 是否开启缓动,默认开启 easingDuration?: PlaygroundConfigRevealOpts['easingDuration']; // 默认 500 ms scrollToCenter?: PlaygroundConfigRevealOpts['scrollToCenter']; // 是否滚动到中心 } @injectable() export class FlowEditorClient { @inject(NodeClient) readonly nodeClient: NodeClient; @inject(Playground) readonly playground: Playground; focusNodeFormItem(formItem: FormItem, options?: FocusNodeFormItemOptions) { this.nodeClient.nodeFocusService.focusNodeFormItem(formItem, options); } focusNode(node: FlowNodeEntity, options?: FocusNodeOptions) { this.playground.scrollToView({ entities: [node], ...options }); } } ================================================ FILE: packages/client/editor/src/clients/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export * from './node-client'; export * from './flow-editor-client'; export * from './flow-editor-client-plugins'; ================================================ FILE: packages/client/editor/src/clients/node-client/create-node-client-plugins.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { definePluginCreator } from '@flowgram.ai/core'; import { NodeFocusService } from './node-focus-service'; import { NodeClient } from './node-client'; import { createNodeHighlightPlugin } from './highlight/create-node-highlight-plugin'; export const createNodeClientPlugin = definePluginCreator<{}>({ onBind({ bind }) { bind(NodeFocusService).toSelf().inSingletonScope(); bind(NodeClient).toSelf().inSingletonScope(); }, }); export const createNodeClientPlugins = () => [ createNodeHighlightPlugin({}), createNodeClientPlugin({}), ]; ================================================ FILE: packages/client/editor/src/clients/node-client/highlight/constants.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export const DEFAULT_HIGHLIGHT_COLOR = 'rgba(238, 245, 40, 0.5)'; export const DEFAULT_HIGHLIGHT_PADDING = 0; ================================================ FILE: packages/client/editor/src/clients/node-client/highlight/create-node-highlight-plugin.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { definePluginCreator } from '@flowgram.ai/core'; import { createHighlightStyle, removeHighlightStyle } from './highlight-style'; export const createNodeHighlightPlugin = definePluginCreator<{}>({ onInit() { createHighlightStyle(); }, onDispose() { removeHighlightStyle(); }, }); ================================================ FILE: packages/client/editor/src/clients/node-client/highlight/highlight-form-item.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { FormItem } from '@flowgram.ai/form-core'; import { FlowNodeRenderData } from '@flowgram.ai/document'; import { HIGHLIGHT_CLASSNAME } from './highlight-style'; import { DEFAULT_HIGHLIGHT_PADDING } from './constants'; export interface HighLightOptions { padding?: number; overlayClassName?: string; } export function highlightFormItem( formItem: FormItem, options?: HighLightOptions, ): HTMLDivElement | undefined { const parent = formItem.formModel.flowNodeEntity.getData(FlowNodeRenderData).node; const target = formItem.domRef.current; if (!target) { return undefined; } const overlay = document.createElement('div'); const { padding = DEFAULT_HIGHLIGHT_PADDING, overlayClassName } = options || {}; overlay.style.position = 'absolute'; overlay.style.top = '0'; overlay.style.left = '0'; overlay.style.width = '100%'; overlay.style.height = '100%'; overlay.style.zIndex = '9999'; parent.appendChild(overlay); const parentRect = parent.getBoundingClientRect(); const targetRect = target.getBoundingClientRect(); overlay.style.top = targetRect.top - parentRect.top - padding + 'px'; overlay.style.left = targetRect.left - parentRect.left - padding + 'px'; overlay.style.width = targetRect.width + padding * 2 + 'px'; overlay.style.height = targetRect.height + padding * 2 + 'px'; overlay.className = overlayClassName || HIGHLIGHT_CLASSNAME; setTimeout(() => { overlay.remove(); }, 2000); return overlay; } ================================================ FILE: packages/client/editor/src/clients/node-client/highlight/highlight-style.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export const HIGHLIGHT_CLASSNAME = 'flowide-highlight'; const styleText = ` @keyframes flowide-fade { from { opacity: 1.0; } to { opacity: 0; } } @-webkit-keyframes flowide-fade { from { opacity: 1.0; } to { opacity: 0; } } .${HIGHLIGHT_CLASSNAME} { background-color: rgba(238, 245, 40, 0.5); animation: flowide-fade 2s 1 forwards; -webkit-animation: flowide-fade 2s 1 forwards; } `; let styleDom: HTMLStyleElement | undefined; export function createHighlightStyle(): void { if (styleDom) return; styleDom = document.createElement('style'); styleDom.innerHTML = styleText; document.head.appendChild(styleDom); } export function removeHighlightStyle(): void { styleDom?.remove(); styleDom = undefined; } ================================================ FILE: packages/client/editor/src/clients/node-client/highlight/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export { highlightFormItem, HighLightOptions } from './highlight-form-item'; export { useHighlight } from './use-highlight'; ================================================ FILE: packages/client/editor/src/clients/node-client/highlight/use-highlight.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { useRef } from 'react'; import { FormModel } from '@flowgram.ai/form-core'; interface HighlightProps { form: FormModel; path: string; } export function useHighlight(props: HighlightProps) { const ref = useRef(null); const { form, path } = props; const formItem = form.getFormItemByPath(path); if (!formItem) { return null; } formItem.domRef = ref; return ref; } ================================================ FILE: packages/client/editor/src/clients/node-client/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export * from './highlight'; export * from './node-client'; export * from './node-focus-service'; ================================================ FILE: packages/client/editor/src/clients/node-client/node-client.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { injectable, inject } from 'inversify'; import { NodeFocusService } from './node-focus-service'; @injectable() export class NodeClient { @inject(NodeFocusService) nodeFocusService: NodeFocusService; } ================================================ FILE: packages/client/editor/src/clients/node-client/node-focus-service.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { injectable, inject } from 'inversify'; import { type FormItem } from '@flowgram.ai/form-core'; import { Playground, PlaygroundConfigRevealOpts } from '@flowgram.ai/core'; import { highlightFormItem, HighLightOptions } from './highlight'; export type FocusNodeCanvasOptions = PlaygroundConfigRevealOpts; export interface FocusNodeFormItemOptions { canvas?: FocusNodeCanvasOptions; highlight?: boolean | HighLightOptions; } @injectable() export class NodeFocusService { @inject(Playground) readonly playground: Playground; protected previousOverlay: HTMLDivElement | undefined; protected currentPromise: Promise | undefined; highlightNodeFormItem(formItem: FormItem, options?: HighLightOptions) { this.previousOverlay = highlightFormItem(formItem, options); } focusNodeFormItem(formItem: FormItem, options?: FocusNodeFormItemOptions): Promise { const node = formItem.formModel.flowNodeEntity; const { canvas = {}, highlight } = options || {}; if (this.previousOverlay) { this.previousOverlay.remove(); this.previousOverlay = undefined; } const currentPromise = this.playground .scrollToView({ entities: [node], scrollToCenter: true, ...canvas }) .then(() => { if (!formItem || !highlight || this.currentPromise !== currentPromise) { return; } this.highlightNodeFormItem(formItem, typeof highlight === 'boolean' ? {} : highlight); }); this.currentPromise = currentPromise; return this.currentPromise; } } ================================================ FILE: packages/client/editor/src/components/editor-provider.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React, { useMemo, useCallback } from 'react'; import { interfaces } from 'inversify'; import { FlowDocument } from '@flowgram.ai/document'; import { PlaygroundReactProvider, createPluginContextDefault, SelectionService, } from '@flowgram.ai/core'; import { EditorPluginContext, EditorProps, createDefaultPreset } from '../preset'; export const EditorProvider: React.FC = (props: EditorProps) => { const { children, ...others } = props; const preset = useMemo(() => createDefaultPreset(others), []); const customPluginContext = useCallback( (container: interfaces.Container) => ({ ...createPluginContextDefault(container), get document(): FlowDocument { return container.get(FlowDocument); }, get selection(): SelectionService { return container.get(SelectionService); }, } as EditorPluginContext), [] ); return ( {children} ); }; ================================================ FILE: packages/client/editor/src/components/editor-renderer.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export { PlaygroundReactRenderer as EditorRenderer } from '@flowgram.ai/core'; ================================================ FILE: packages/client/editor/src/components/editor.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React from 'react'; import { EditorProps } from '../preset'; import { EditorRenderer } from './editor-renderer'; import { EditorProvider } from './editor-provider'; /** * 画布编辑器 * @param props * @constructor */ export const Editor: React.FC = (props: EditorProps) => { const { children, ...otherProps } = props; return ( {children} ); }; ================================================ FILE: packages/client/editor/src/components/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export * from './editor-provider'; export * from './editor-renderer'; export * from './editor'; ================================================ FILE: packages/client/editor/src/hooks/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export * from './use-flow-editor'; ================================================ FILE: packages/client/editor/src/hooks/use-flow-editor.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { useService } from '@flowgram.ai/core'; import { FlowEditorClient } from '../clients'; export function useFlowEditor(): FlowEditorClient { return useService(FlowEditorClient); } ================================================ FILE: packages/client/editor/src/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import 'reflect-metadata'; import { FormModelV2 } from '@flowgram.ai/node'; /* 核心 模块导出 */ export * from '@flowgram.ai/utils'; export * from '@flowgram.ai/core'; export * from '@flowgram.ai/document'; export * from '@flowgram.ai/renderer'; export * from '@flowgram.ai/variable-plugin'; export * from '@flowgram.ai/shortcuts-plugin'; export * from '@flowgram.ai/node-core-plugin'; export * from '@flowgram.ai/i18n-plugin'; export { ReactiveState, ReactiveBaseState, Tracker, useReactiveState, useReadonlyReactiveState, useObserve, observe, } from '@flowgram.ai/reactive'; export { type interfaces, injectable, postConstruct, named, Container, ContainerModule, AsyncContainerModule, inject, multiInject, } from 'inversify'; export { FlowNodeFormData, NodeRender, type NodeRenderProps } from '@flowgram.ai/form-core'; export type { FormState, FieldState, FieldArrayRenderProps, FieldRenderProps, FormRenderProps, Validate, FormControl, FieldName, FieldError, FieldWarning, IField, IFieldArray, IForm, Errors, Warnings, } from '@flowgram.ai/form'; export { Form, Field, FieldArray, useForm, useField, useCurrentField, useCurrentFieldState, useFieldValidate, useWatch, ValidateTrigger, FeedbackLevel, } from '@flowgram.ai/form'; export * from '@flowgram.ai/node'; export { FormModelV2 as FormModel }; /** * 固定布局模块导出 */ export * from './preset'; export * from './components'; export * from './hooks'; export * from './clients'; /** * Plugin 导出 */ export * from '@flowgram.ai/node-variable-plugin'; export { createPlaygroundReactPreset } from '@flowgram.ai/playground-react'; ================================================ FILE: packages/client/editor/src/preset/editor-default-preset.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { interfaces } from 'inversify'; import { FlowNodeScope, getNodePrivateScope, getNodeScope } from '@flowgram.ai/variable-plugin'; import { FlowRendererContainerModule, FlowRendererRegistry } from '@flowgram.ai/renderer'; import { createReduxDevToolPlugin } from '@flowgram.ai/redux-devtool-plugin'; import { createNodeVariablePlugin } from '@flowgram.ai/node-variable-plugin'; import { createNodeCorePlugin } from '@flowgram.ai/node-core-plugin'; import { getNodeForm, NodeFormProps } from '@flowgram.ai/node'; import { createMaterialsPlugin } from '@flowgram.ai/materials-plugin'; import { createI18nPlugin } from '@flowgram.ai/i18n-plugin'; import { createHistoryNodePlugin } from '@flowgram.ai/history-node-plugin'; import { FlowDocumentContainerModule } from '@flowgram.ai/document'; import { createPlaygroundPlugin, Plugin, PluginsProvider } from '@flowgram.ai/core'; import { createFlowEditorClientPlugins } from '../clients/flow-editor-client-plugins'; import { EditorPluginContext, EditorProps } from './editor-props'; export function createDefaultPreset( opts: EditorProps, plugins: Plugin[] = [] ): PluginsProvider { return (ctx: CTX) => { opts = { ...EditorProps.DEFAULT, ...opts }; /** * i18n support */ if (opts.i18n) { plugins.push(createI18nPlugin(opts.i18n)); } /** * 默认注册顶层 flow editor client plugin */ plugins.push(...createFlowEditorClientPlugins()); /** * 注册 Redux 开发者工具 */ if (opts.reduxDevTool?.enable) { plugins.push(createReduxDevToolPlugin(opts.reduxDevTool)); } /** * 注册画布模块 */ const defaultContainerModules: interfaces.ContainerModule[] = [ FlowDocumentContainerModule, // 默认文档 FlowRendererContainerModule, // 默认渲染 ]; /** * 注册物料 */ plugins.push(createMaterialsPlugin(opts.materials || {})); /** * 注册节点引擎 */ if (opts.nodeEngine && opts.nodeEngine.enable !== false) { plugins.push(createNodeCorePlugin({ materials: opts.nodeEngine.materials })); if (opts.variableEngine?.enable) { plugins.push(createNodeVariablePlugin({})); } if (opts.history?.enable && opts.history?.enableChangeNode !== false) { plugins.push(createHistoryNodePlugin({})); } } /** * 画布生命周期注册 */ plugins.push( createPlaygroundPlugin({ onInit: (ctx) => { if (opts.nodeRegistries) { ctx.document.registerFlowNodes(...opts.nodeRegistries); } // 自定义画布内部常量 if (opts.constants) { ctx.document.options.constants = opts.constants; } if (opts.getNodeDefaultRegistry) { ctx.document.options.getNodeDefaultRegistry = opts.getNodeDefaultRegistry; } ctx.document.options.preNodeCreate = (node) => { /** * Define node.form */ if (opts.nodeEngine && opts.nodeEngine.enable !== false) { let cache: NodeFormProps | undefined; Object.defineProperty(node, 'form', { get: () => { if (cache) return cache; cache = getNodeForm(node); return cache; }, }); } /** * Define node.scope & node.privateScope */ if (opts.variableEngine && opts.variableEngine.enable !== false) { let cache: FlowNodeScope | undefined; let privateCache: FlowNodeScope | undefined; Object.defineProperty(node, 'scope', { get: () => { if (cache) return cache; cache = getNodeScope(node); return cache; }, }); Object.defineProperty(node, 'privateScope', { get: () => { if (privateCache) return privateCache; privateCache = getNodePrivateScope(node); return privateCache; }, }); } }; ctx.get(FlowRendererRegistry).init(); }, onReady(ctx) { if (opts.initialData) { ctx.document.fromJSON(opts.initialData); } if (opts.readonly) { ctx.playground.config.readonly = opts.readonly; } ctx.document.load().then(() => { if (opts.onLoad) opts.onLoad(ctx); }); }, onDispose(ctx) { ctx.document.dispose(); }, containerModules: defaultContainerModules, }) ); return plugins; }; } ================================================ FILE: packages/client/editor/src/preset/editor-props.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { FlowNodeScope, VariablePluginOptions } from '@flowgram.ai/variable-plugin'; import { ReduxDevToolPluginOptions } from '@flowgram.ai/redux-devtool-plugin'; import { PlaygroundReactProps, SelectionService } from '@flowgram.ai/playground-react'; import { NodeCorePluginOptions } from '@flowgram.ai/node-core-plugin'; import { type NodeFormProps } from '@flowgram.ai/node'; import { MaterialsPluginOptions } from '@flowgram.ai/materials-plugin'; import { I18nPluginOptions } from '@flowgram.ai/i18n-plugin'; import { HistoryPluginOptions } from '@flowgram.ai/history'; import { FormMetaOrFormMetaGenerator } from '@flowgram.ai/form-core'; import { FlowDocument, FlowDocumentJSON, FlowNodeEntity, type FlowNodeJSON, FlowNodeRegistry, FlowNodeType, } from '@flowgram.ai/document'; import { PluginContext } from '@flowgram.ai/core'; declare module '@flowgram.ai/document' { interface FlowNodeEntity { form: NodeFormProps | undefined; scope: FlowNodeScope | undefined; privateScope: FlowNodeScope | undefined; } } export interface EditorPluginContext extends PluginContext { document: FlowDocument; selection: SelectionService; } export interface EditorProps< CTX extends EditorPluginContext = EditorPluginContext, JSON = FlowDocumentJSON > extends PlaygroundReactProps { /** * Initialize data * 初始化数据 */ initialData?: JSON; /** * whether it is readonly * 是否为 readonly */ readonly?: boolean; /** * node registries * 节点定义 */ nodeRegistries?: FlowNodeRegistry[]; /** * Get the default node registry, which will be merged with the 'nodeRegistries' * 提供默认的节点注册,这个会和 nodeRegistries 做合并 */ getNodeDefaultRegistry?: (type: FlowNodeType) => FlowNodeRegistry; /** * Node engine configuration */ nodeEngine?: NodeCorePluginOptions & { /** * Default formMeta */ createDefaultFormMeta?: (node: FlowNodeEntity) => FormMetaOrFormMetaGenerator; /** * Enable node engine */ enable?: boolean; }; /** * By default, all nodes are expanded * 默认是否展开所有节点 */ allNodesDefaultExpanded?: boolean; /** * Canvas material, Used to customize react components * 画布物料, 用于自定义 react 组件 */ materials?: MaterialsPluginOptions; /** * 画布数据加载完成, 请使用 onAllLayersRendered 替代 * @deprecated * */ onLoad?: (ctx: CTX) => void; /** * 是否开启变量引擎 * Variable engine enable */ variableEngine?: VariablePluginOptions; /** * Redo/Undo enable */ history?: HistoryPluginOptions & { disableShortcuts?: boolean }; /** * redux devtool configuration */ reduxDevTool?: ReduxDevToolPluginOptions; /** * Scroll configuration * 滚动配置 */ scroll?: { enableScrollLimit?: boolean; // 开启滚动限制 disableScrollBar?: boolean; // 关闭滚动条 disableScroll?: boolean; // 禁止滚动 }; /** * Node data transformation, called by ctx.document.fromJSON * 节点数据转换, 由 ctx.document.fromJSON 调用 * @param node - current node * @param json - Current node json data */ toNodeJSON?(node: FlowNodeEntity, json: FlowNodeJSON): FlowNodeJSON; /** * Node data transformation, called by ctx.document.toJSON * 节点数据转换, 由 ctx.document.toJSON 调用 * @param node - current node * @param json - Current node json data * @param isFirstCreate - Whether it is created for the first time, If document.fromJSON is recalled, but the node already exists, isFirstCreate is false */ fromNodeJSON?(node: FlowNodeEntity, json: FlowNodeJSON, isFirstCreate: boolean): FlowNodeJSON; /** * Canvas internal constant customization * 画布内部常量自定义 */ constants?: Record; /** * i18n * 国际化 */ i18n?: I18nPluginOptions; } export namespace EditorProps { /** * 默认配置 */ export const DEFAULT: EditorProps = { background: {}, }; } ================================================ FILE: packages/client/editor/src/preset/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export * from './editor-props'; export * from './editor-default-preset'; ================================================ FILE: packages/client/editor/tsconfig.json ================================================ { "extends": "@flowgram.ai/ts-config/tsconfig.flow.path.json", "compilerOptions": { "types": [], }, "include": ["./src"], "exclude": ["node_modules"] } ================================================ FILE: packages/client/editor/vitest.config.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ const path = require('path'); import { defineConfig } from 'vitest/config'; export default defineConfig({ build: { commonjsOptions: { transformMixedEsModules: true, }, }, test: { globals: true, mockReset: false, environment: 'jsdom', setupFiles: [path.resolve(__dirname, './vitest.setup.ts')], include: ['**/?(*.){test,spec}.?(c|m)[jt]s?(x)'], exclude: [ '**/__mocks__**', '**/node_modules/**', '**/dist/**', '**/lib/**', // lib 编译结果忽略掉 '**/cypress/**', '**/.{idea,git,cache,output,temp}/**', '**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*', ], }, }); ================================================ FILE: packages/client/editor/vitest.setup.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import 'reflect-metadata'; ================================================ FILE: packages/client/fixed-layout-editor/__mocks__/flow.mock.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { FlowDocumentJSON } from '../src'; export const emptyMock: FlowDocumentJSON = { nodes: [ { id: 'start_0', type: 'start', blocks: [], }, { id: 'end_0', type: 'end', blocks: [], }, ], }; export const formMock: FlowDocumentJSON = { nodes: [{ id: 'noop_0', type: 'noop', data: { title: 'noop title', }, blocks: [], }] } export const formMock2: FlowDocumentJSON = { nodes: [{ id: 'noop_0', type: 'noop', data: { title: 'noop title changed', }, blocks: [], }] } export const baseWithDataMock: FlowDocumentJSON = { nodes: [ { id: 'start_0', type: 'start', data: { title: 'start title', }, blocks: [], }, { id: 'dynamicSplit_0', type: 'dynamicSplit', data: { title: 'dynamic title', }, blocks: [ { id: 'block_0', data: { title: '' }, blocks: [], type: 'block' }, { id: 'block_1',data: { title: '' }, blocks: [], type: 'block'}, { id: 'block_2',data: { title: '' },blocks: [], type: 'block' } ], }, { id: 'end_0', type: 'end', data: { title: 'end title', }, blocks: [], }, ] } export const baseWithDataMock2: FlowDocumentJSON = { nodes: [ { id: 'start_0', type: 'start', data: { title: 'start title changed', }, blocks: [], }, { id: 'dynamicSplit_0', type: 'dynamicSplit', data: { title: 'dynamic title changed', }, blocks: [ { id: 'block_3', data: { title: '' }, blocks: [], type: 'block' }, { id: 'block_4',data: { title: '' }, blocks: [], type: 'block'}, { id: 'block_2',data: { title: 'title changed' },blocks: [], type: 'block' } ], }, { id: 'end_0', type: 'end', data: { title: 'end title changed', }, blocks: [], }, ] } export const baseMock: FlowDocumentJSON = { nodes: [ { id: 'start_0', type: 'start', blocks: [], }, { id: 'dynamicSplit_0', type: 'dynamicSplit', blocks: [ { id: 'block_0', data: {}, blocks: [], type: 'block' }, { id: 'block_1',data: {}, blocks: [], type: 'block'}, { id: 'block_2',data: {},blocks: [], type: 'block' } ], }, { id: 'end_0', type: 'end', blocks: [], }, ], }; ================================================ FILE: packages/client/fixed-layout-editor/__mocks__/form.mock.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import {Field, FieldRenderProps, FormMeta, FormRenderProps} from "@flowgram.ai/editor"; export const render = ({ form }: FormRenderProps) => { return ( <> {({ field: { value, onChange } }: FieldRenderProps) => ( <> )} ); } export const formMock: FormMeta = { render }; ================================================ FILE: packages/client/fixed-layout-editor/__tests__/__snapshots__/fixed-layout-preset.test.ts.snap ================================================ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`fixed-layout-preset > custom fromNodeJSON and toNodeJSON 1`] = ` { "nodes": [ { "blocks": [], "data": { "isFirstCreate": true, "runningTimes": 1, "title": "start title", }, "id": "start_0", "type": "start", }, { "blocks": [ { "blocks": [], "data": { "isFirstCreate": true, "runningTimes": 1, "title": "", }, "id": "block_0", "type": "block", }, { "blocks": [], "data": { "isFirstCreate": true, "runningTimes": 1, "title": "", }, "id": "block_1", "type": "block", }, { "blocks": [], "data": { "isFirstCreate": true, "runningTimes": 1, "title": "", }, "id": "block_2", "type": "block", }, ], "data": { "isFirstCreate": true, "runningTimes": 1, "title": "dynamic title", }, "id": "dynamicSplit_0", "type": "dynamicSplit", }, { "blocks": [], "data": { "isFirstCreate": true, "runningTimes": 1, "title": "end title", }, "id": "end_0", "type": "end", }, ], } `; exports[`fixed-layout-preset > custom fromNodeJSON and toNodeJSON 2`] = ` { "nodes": [ { "blocks": [], "data": { "isFirstCreate": false, "runningTimes": 1, "title": "start title changed", }, "id": "start_0", "type": "start", }, { "blocks": [ { "blocks": [], "data": { "isFirstCreate": true, "runningTimes": 1, "title": "", }, "id": "block_3", "type": "block", }, { "blocks": [], "data": { "isFirstCreate": true, "runningTimes": 1, "title": "", }, "id": "block_4", "type": "block", }, { "blocks": [], "data": { "isFirstCreate": false, "runningTimes": 1, "title": "title changed", }, "id": "block_2", "type": "block", }, ], "data": { "isFirstCreate": false, "runningTimes": 1, "title": "dynamic title changed", }, "id": "dynamicSplit_0", "type": "dynamicSplit", }, { "blocks": [], "data": { "isFirstCreate": false, "runningTimes": 1, "title": "end title changed", }, "id": "end_0", "type": "end", }, ], } `; exports[`fixed-layout-preset > nodeEngine(v2) toJSON 1`] = ` { "nodes": [ { "blocks": [], "data": { "title": "noop title2", }, "id": "noop_0", "type": "noop", }, ], } `; ================================================ FILE: packages/client/fixed-layout-editor/__tests__/create-container.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { interfaces } from 'inversify'; import { HistoryService } from '@flowgram.ai/fixed-history-plugin'; import { createPlaygroundContainer, Playground, loadPlugins, PluginContext, createPluginContextDefault, FlowDocument, EditorProps, } from '@flowgram.ai/editor'; import { FixedLayoutPluginContext, FixedLayoutProps, FlowOperationService, createFixedLayoutPreset, } from '../src'; export function createContainer(opts: FixedLayoutProps): interfaces.Container { const container = createPlaygroundContainer(); const playground = container.get(Playground); const preset = createFixedLayoutPreset(opts); const customPluginContext = (container: interfaces.Container) => ({ ...createPluginContextDefault(container), get document(): FlowDocument { return container.get(FlowDocument); }, } as FixedLayoutPluginContext); const ctx = customPluginContext(container); container.rebind(PluginContext).toConstantValue(ctx); loadPlugins(preset(ctx), container); playground.init(); return container; } export function createHistoryContainer(props: EditorProps = {}) { const container = createContainer({ history: { enable: true, }, ...props, }); const flowDocument = container.get(FlowDocument); const flowOperationService = container.get(FlowOperationService); const historyService = container.get(HistoryService); return { flowDocument, flowOperationService, historyService, }; } ================================================ FILE: packages/client/fixed-layout-editor/__tests__/fixed-layout-preset.test.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { describe, it, expect, beforeEach } from 'vitest'; import { FlowDocument, FlowNodeFormData } from '@flowgram.ai/editor'; import { baseWithDataMock, baseWithDataMock2, formMock, formMock2 } from '../__mocks__/flow.mock'; import { createContainer } from './create-container'; describe('fixed-layout-preset', () => { let flowDocument: FlowDocument; beforeEach(() => { const container = createContainer({}); flowDocument = container.get(FlowDocument); }); it('fromJSON and toJSON', () => { flowDocument.fromJSON(baseWithDataMock); expect(flowDocument.toJSON()).toEqual(baseWithDataMock); // reload data flowDocument.fromJSON(baseWithDataMock2); expect(flowDocument.toJSON()).toEqual(baseWithDataMock2); }); it('custom fromNodeJSON and toNodeJSON', () => { const container = createContainer({ fromNodeJSON: (node, json, isFirstCreate) => { if (!json.data) { json.data = {}; } json.data = { ...json.data, isFirstCreate }; return json; }, toNodeJSON(node, json) { json.data.runningTimes = (json.data.runningTimes || 0) + 1; return json; }, }); container.get(FlowDocument).fromJSON(baseWithDataMock); expect(container.get(FlowDocument).toJSON()).toMatchSnapshot(); container.get(FlowDocument).fromJSON(baseWithDataMock2); expect(container.get(FlowDocument).toJSON()).toMatchSnapshot(); }); it('nodeEngine(v2) toJSON', async () => { const container = createContainer({ nodeEngine: {}, nodeRegistries: [ { type: 'noop', formMeta: { render: () => undefined, }, }, ], }); flowDocument = container.get(FlowDocument); flowDocument.fromJSON(formMock); expect(flowDocument.toJSON()).toEqual(formMock); const { formModel } = flowDocument.getNode('noop_0').getData(FlowNodeFormData); expect(formModel.getFormItemByPath('title').value).toEqual('noop title'); formModel.getFormItemByPath('title').value = 'noop title2'; expect(flowDocument.toJSON()).toMatchSnapshot(); flowDocument.fromJSON(formMock2); expect(flowDocument.toJSON()).toEqual(formMock2); }); }); ================================================ FILE: packages/client/fixed-layout-editor/__tests__/services/flow-operation-service.test.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { FlowDocument, FlowNodeEntity } from '@flowgram.ai/editor'; import { getNodeChildrenIds } from '../utils'; import { createContainer } from '../create-container'; import { FlowOperationService } from '../../src/types'; import { baseMock } from '../../__mocks__/flow.mock'; describe('flow-operation-service', () => { let flowOperationService: FlowOperationService; let flowDocument: FlowDocument; beforeEach(() => { const container = createContainer({}); flowDocument = container.get(FlowDocument); flowOperationService = container.get(FlowOperationService); flowDocument.fromJSON(baseMock); }); it('addFromNode', () => { const type = 'test'; const nodeJSON = { id: 'test', type, }; const added = flowOperationService.addFromNode('start_0', nodeJSON); const node = flowDocument.getNode(added.id) as FlowNodeEntity; expect(node).toBe(added); expect(added.id).toEqual(nodeJSON.id); }); it('deleteNode', () => { const id = 'dynamicSplit_0'; flowOperationService.deleteNode(id); const node = flowDocument.getNode(id); expect(node).toBeUndefined(); }); it('delete block by deleteNode', () => { const id = 'block_0'; flowOperationService.deleteNode(id); const node = flowDocument.getNode(id); expect(node).toBeUndefined(); }); it('addNode', () => { const nodeJSON = { id: 'test-node', type: 'test', }; const parent = flowDocument.getNode('start_0'); const added = flowOperationService.addNode(nodeJSON, { parent, }); const entity = flowDocument.getNode(added.id); expect(entity).toBe(added); expect(entity?.parent).toBe(parent); expect(entity?.originParent).toBeUndefined(); }); it('addBlock', () => { const target = flowDocument.getNode('dynamicSplit_0') as FlowNodeEntity; const added = flowOperationService.addBlock(target, { id: 'test-block', type: 'test-block', }); const entity = flowDocument.getNode(added.id); expect(entity).toBe(added); expect(entity?.parent?.id).toEqual('$inlineBlocks$dynamicSplit_0'); expect(entity?.originParent).toBe(target); }); it('deleteNodes', () => { const parent = flowDocument.getNode('start_0'); const added = flowOperationService.addNode( { id: 'delete-node', type: 'test', }, { parent, }, ); flowOperationService.deleteNodes([added]); expect(flowDocument.getNode(added.id)).toBeUndefined(); }); it('createGroup ungroup', () => { const node1 = flowOperationService.addFromNode('start_0', { id: 'add', type: 'add', }); const node2 = flowDocument.getNode('dynamicSplit_0') as FlowNodeEntity; // TODO 这里需要优化,理论上createGroup不应该依赖渲染,现在createGroup内部有一个index的校验,但index在transformer中被设置对了 flowDocument.transformer.refresh(); const group = flowOperationService.createGroup([node1, node2]) as FlowNodeEntity; const root = flowDocument.getNode('root'); expect(root?.collapsedChildren.map(c => c.id)).toEqual(['start_0', group.id, 'end_0']); expect(group.collapsedChildren.map(c => c.id)).toEqual([node1.id, node2.id]); flowOperationService.ungroup(group); expect(root?.collapsedChildren.map(c => c.id)).toEqual([ 'start_0', 'add', 'dynamicSplit_0', 'end_0', ]); }); it('moveNode', () => { flowOperationService.moveNode('block_1', { index: 2, }); const split = flowDocument.getNode('dynamicSplit_0'); expect(getNodeChildrenIds(split, true)).toEqual(['block_0', 'block_2', 'block_1']); }); }); ================================================ FILE: packages/client/fixed-layout-editor/__tests__/services/history-operation-service/add-block.test.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { describe, it, expect, beforeEach } from 'vitest'; import { FlowNodeEntity } from '@flowgram.ai/editor'; import { createHistoryContainer } from '../../create-container'; import { baseMock } from '../../../__mocks__/flow.mock'; describe('history-operation-service addNode', () => { const { flowDocument, flowOperationService, historyService } = createHistoryContainer(); beforeEach(() => { flowDocument.fromJSON(baseMock); }); it('addBlock', async () => { const block0 = flowDocument.getNode('block_0') as FlowNodeEntity; const block1 = flowDocument.getNode('block_1') as FlowNodeEntity; const block2 = flowDocument.getNode('block_2') as FlowNodeEntity; const target = flowDocument.getNode('dynamicSplit_0') as FlowNodeEntity; // 测试添加分支 const added = flowOperationService.addBlock(target, { id: 'test-block', type: 'test-block', }); const entity = flowDocument.getNode(added.id); const children = target.collapsedChildren[1].children; expect(entity).toBe(added); expect(entity?.parent?.id).toEqual('$inlineBlocks$dynamicSplit_0'); expect(entity?.originParent).toBe(target); expect(children).toEqual([block0, block1, block2, added]); // 测试添加分支,index为0 const added0 = flowOperationService.addBlock( target, { id: 'test-block0', type: 'test-block0', }, { index: 0, }, ); expect(children).toEqual([added0, block0, block1, block2, added]); // 测试undo await historyService.undo(); expect(children).toEqual([block0, block1, block2, added]); await historyService.undo(); expect(children).toEqual([block0, block1, block2]); }); }); ================================================ FILE: packages/client/fixed-layout-editor/__tests__/services/history-operation-service/add-from-node.test.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { describe, it, expect, beforeEach } from 'vitest'; import { FlowNodeEntity } from '@flowgram.ai/editor'; import { createHistoryContainer } from '../../create-container'; import { baseMock } from '../../../__mocks__/flow.mock'; describe('history-operation-service', () => { const { flowDocument, flowOperationService, historyService } = createHistoryContainer(); beforeEach(() => { flowDocument.fromJSON(baseMock); }); it('addFromNode', () => { const id = 'test-id'; const type = 'test'; const nodeJSON = { id, type, }; const added = flowOperationService.addFromNode('start_0', nodeJSON); expect(added.id).toEqual(id); const node = flowDocument.getNode(id) as FlowNodeEntity; expect(node).toBe(added); historyService.undo(); const node2 = flowDocument.getNode(id) as FlowNodeEntity; expect(node2).toBeUndefined(); }); it('add first node in a block by addFromNode', () => { const id = 'test-id'; const blockIconId = '$blockOrderIcon$block_1'; const added = flowOperationService.addFromNode(blockIconId, { id, type: 'test', }); expect(added.id).toEqual(id); const node = flowDocument.getNode(id) as FlowNodeEntity; expect(node).toBe(added); expect(node.pre?.id).toEqual(blockIconId); }); }); ================================================ FILE: packages/client/fixed-layout-editor/__tests__/services/history-operation-service/add-node.test.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { describe, it, expect, beforeEach } from 'vitest'; import { FlowNodeEntity } from '@flowgram.ai/editor'; import { getRootChildrenIds } from '../../utils'; import { createHistoryContainer } from '../../create-container'; import { baseMock, emptyMock } from '../../../__mocks__/flow.mock'; describe('history-operation-service addNode', () => { const { flowDocument, flowOperationService, historyService } = createHistoryContainer(); it('addNode simple', async () => { flowDocument.fromJSON(emptyMock); flowOperationService.addNode( { type: 'test', id: 'test', }, { parent: flowDocument.getNode('root'), index: 1, }, ); expect(getRootChildrenIds(flowDocument)).toEqual(['start_0', 'test', 'end_0']); // 测试undo await historyService.undo(); expect(getRootChildrenIds(flowDocument)).toEqual(['start_0', 'end_0']); }); it('addNode composed', async () => { flowDocument.fromJSON(baseMock); const nodeJSON = { id: 'test-node', type: 'test', }; const parent = flowDocument.getNode('start_0') as FlowNodeEntity; const added = flowOperationService.addNode(nodeJSON, { parent, }); const entity = flowDocument.getNode(added.id); expect(entity).toBe(added); expect(entity?.parent).toBe(parent); expect(entity?.originParent).toBeUndefined(); expect(parent.collapsedChildren).toEqual([added]); // 测试hidden const added1 = flowOperationService.addNode( { id: 'test-node1', type: 'test1', }, { parent, hidden: true, }, ); const entity1 = flowDocument.getNode(added1.id) as FlowNodeEntity; expect(entity1).toBe(added1); expect(entity1.hidden).toBe(true); expect(parent.collapsedChildren).toEqual([added, added1]); // 测试index添加 const added2 = flowOperationService.addNode( { id: 'test-node2', type: 'test2', }, { parent, index: 1, }, ); expect(parent.collapsedChildren).toEqual([added, added2, added1]); // 测试undo await historyService.undo(); expect(flowDocument.getNode(added2.id)).toBeUndefined(); expect(parent.collapsedChildren).toEqual([added, added1]); await historyService.undo(); expect(flowDocument.getNode(added1.id)).toBeUndefined(); expect(parent.collapsedChildren).toEqual([added]); await historyService.undo(); expect(flowDocument.getNode(added.id)).toBeUndefined(); expect(parent.collapsedChildren).toEqual([]); }); it('add loop children by addNode', async () => { flowDocument.fromJSON(emptyMock); const loop = flowOperationService.addFromNode('start_0', { id: 'test-loop', type: 'loop', }); expect(loop.id).toEqual('test-loop'); const loopJson = flowDocument.toJSON(); const child = flowOperationService.addNode( { id: 'loop-child1', type: 'test', }, { parent: loop, index: 0, }, ) as FlowNodeEntity; expect(child.id).toEqual('loop-child1'); expect(child.pre?.id).toEqual('$loopRightEmpty$test-loop'); expect(child.parent?.id).toEqual('$block$test-loop'); const str = flowDocument.toString(); await historyService.undo(); expect(flowDocument.toJSON()).toEqual(loopJson); await historyService.redo(); expect(flowDocument.toString()).toEqual(str); }); it('add dynamic split children by addNode', async () => { flowDocument.fromJSON(baseMock); const child = flowOperationService.addNode( { id: 'block_test', type: 'block', }, { parent: 'dynamicSplit_0', index: 0, }, ) as FlowNodeEntity; expect(child.id).toEqual('block_test'); expect(child.pre?.id).toBeUndefined(); expect(child.next?.id).toBe('block_0'); expect(child.originParent?.id).toBe('dynamicSplit_0'); expect(child.parent?.id).toBe('$inlineBlocks$dynamicSplit_0'); const str = flowDocument.toString(); await historyService.undo(); expect(flowDocument.toJSON()).toEqual(baseMock); await historyService.redo(); expect(flowDocument.toString()).toEqual(str); }); it('add block children by addNode', async () => { flowDocument.fromJSON(baseMock); const child = flowOperationService.addNode( { id: 'test', type: 'test', }, { parent: 'block_0', index: 0, }, ) as FlowNodeEntity; expect(child.id).toEqual('test'); expect(child.pre?.id).toBe('$blockOrderIcon$block_0'); expect(child.next?.id).toBeUndefined(); expect(child.originParent?.id).toBeUndefined(); expect(child.parent?.id).toBe('block_0'); const str = flowDocument.toString(); await historyService.undo(); expect(flowDocument.toJSON()).toEqual(baseMock); await historyService.redo(); expect(flowDocument.toString()).toEqual(str); }); }); ================================================ FILE: packages/client/fixed-layout-editor/__tests__/services/history-operation-service/apply.test.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { describe, it, expect } from 'vitest'; import { FlowNodeEntity, OperationType } from '@flowgram.ai/editor'; import { createHistoryContainer } from '../../create-container'; import { baseMock } from '../../../__mocks__/flow.mock'; describe('history-operation-service apply', () => { const { flowDocument, flowOperationService, historyService } = createHistoryContainer(); it('apply deleteNodes', async () => { flowDocument.fromJSON(baseMock); const id = 'dynamicSplit_0'; const node = flowDocument.getNode(id) as FlowNodeEntity; flowOperationService.apply({ type: OperationType.deleteNodes, value: { fromId: 'start_0', nodes: [node.toJSON()], }, }); expect(flowDocument.getNode(id)).toBeUndefined(); historyService.undo(); expect(flowDocument.toJSON()).toEqual(baseMock); }); }); ================================================ FILE: packages/client/fixed-layout-editor/__tests__/services/history-operation-service/create-group.test.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { describe, it, expect, beforeEach } from 'vitest'; import { FlowNodeEntity } from '@flowgram.ai/editor'; import { createHistoryContainer } from '../../create-container'; import { baseMock } from '../../../__mocks__/flow.mock'; describe('history-operation-service', () => { const { flowDocument, flowOperationService, historyService } = createHistoryContainer(); beforeEach(() => { flowDocument.fromJSON(baseMock); }); it('createGroup', async () => { const node1 = flowOperationService.addFromNode('start_0', { id: 'add', type: 'add', }); const node2 = flowDocument.getNode('dynamicSplit_0') as FlowNodeEntity; flowDocument.transformer.refresh(); const json = flowDocument.toJSON(); const group = flowOperationService.createGroup([node1, node2]) as FlowNodeEntity; // 分组创建后 json 变化(group 不再作为系统节点) expect(flowDocument.toJSON()).not.toEqual(json); const root = flowDocument.getNode('root'); expect(root?.collapsedChildren.map((c) => c.id)).toEqual(['start_0', group.id, 'end_0']); expect(group.collapsedChildren.map((c) => c.id)).toEqual([node1.id, node2.id]); await historyService.undo(); expect(root?.collapsedChildren.map((c) => c.id)).toEqual([ 'start_0', 'add', 'dynamicSplit_0', 'end_0', ]); }); }); ================================================ FILE: packages/client/fixed-layout-editor/__tests__/services/history-operation-service/delete-node.test.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { describe, it, expect, beforeEach } from 'vitest'; import { FlowNodeEntity } from '@flowgram.ai/editor'; import { createHistoryContainer } from '../../create-container'; import { baseMock } from '../../../__mocks__/flow.mock'; describe('history-operation-service', () => { const { flowDocument, flowOperationService, historyService } = createHistoryContainer(); beforeEach(() => { flowDocument.fromJSON(baseMock); }); it('deleteNode', () => { const id = 'dynamicSplit_0'; flowOperationService.deleteNode(id); const node = flowDocument.getNode(id); expect(node).toBeUndefined(); historyService.undo(); const node1 = flowDocument.getNode(id); expect(node1?.id).toEqual(id); }); it('delete first block by deleteNode', () => { const id = 'block_0'; flowOperationService.deleteNode(id); const node = flowDocument.getNode(id); expect(node).toBeUndefined(); historyService.undo(); const node1 = flowDocument.getNode(id) as FlowNodeEntity; expect(node1.id).toEqual(id); const pre = node1.pre; expect(pre).toBeUndefined(); expect(node1.next?.id).toEqual('block_1'); expect(node1.parent?.id).toEqual('$inlineBlocks$dynamicSplit_0'); expect(node1.originParent?.id).toEqual('dynamicSplit_0'); }); it('delete intermediate block by deleteNode', () => { const id = 'block_1'; flowOperationService.deleteNode(id); const node = flowDocument.getNode(id); expect(node).toBeUndefined(); historyService.undo(); const node1 = flowDocument.getNode(id) as FlowNodeEntity; expect(node1.id).toEqual(id); expect(node1.pre?.id).toEqual('block_0'); expect(node1.next?.id).toEqual('block_2'); expect(node1.parent?.id).toEqual('$inlineBlocks$dynamicSplit_0'); expect(node1.originParent?.id).toEqual('dynamicSplit_0'); }); it('delete last block by deleteNode', () => { const id = 'block_2'; flowOperationService.deleteNode(id); const node = flowDocument.getNode(id); expect(node).toBeUndefined(); historyService.undo(); const node1 = flowDocument.getNode(id) as FlowNodeEntity; expect(node1.id).toEqual(id); expect(node1.pre?.id).toEqual('block_1'); expect(node1.next).toBeUndefined(); expect(node1.parent?.id).toEqual('$inlineBlocks$dynamicSplit_0'); expect(node1.originParent?.id).toEqual('dynamicSplit_0'); }); it('delete empty group by deleteNode', async () => { const node = flowDocument.getNode('dynamicSplit_0') as FlowNodeEntity; const group = flowOperationService.createGroup([node]) as FlowNodeEntity; const renderStruct = flowDocument.toString(); historyService.transact(() => { flowOperationService.deleteNode(node); flowOperationService.deleteNode(group); }); await historyService.undo(); expect(flowDocument.toString()).toEqual(renderStruct); }); it('redo test delete block by deleteNode', async () => { flowOperationService.deleteNode('block_1'); const str = flowDocument.toString(); await historyService.undo(); await historyService.redo(); expect(flowDocument.toString()).toEqual(str); }); }); ================================================ FILE: packages/client/fixed-layout-editor/__tests__/services/history-operation-service/delete-nodes.test.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { describe, it, expect, beforeEach } from 'vitest'; import { getNodeChildrenIds } from '../../utils'; import { createHistoryContainer } from '../../create-container'; import { emptyMock } from '../../../__mocks__/flow.mock'; describe('history-operation-service deleteNodes', () => { const { flowDocument, flowOperationService, historyService } = createHistoryContainer(); let toDelete1, toDelete2, toDelete3; function getRootChildrenIds() { return getNodeChildrenIds(flowDocument.getNode('root')); } beforeEach(() => { flowDocument.fromJSON(emptyMock); // start_0 -> to-delete1 -> to-delete2 -> to-delete3 -> end_0 toDelete3 = flowOperationService.addFromNode('start_0', { id: 'to-delete3', type: 'test', }); toDelete2 = flowOperationService.addFromNode('start_0', { id: 'to-delete2', type: 'test', }); toDelete1 = flowOperationService.addFromNode('start_0', { id: 'to-delete1', type: 'test', }); }); it('delete order nodes', async () => { const toDelete1JSON = toDelete1.toJSON(); const toDelete2JSON = toDelete2.toJSON(); flowOperationService.deleteNodes(['to-delete1', toDelete2]); expect(flowDocument.getNode('to-delete1')).toBeUndefined(); expect(flowDocument.getNode('to-delete2')).toBeUndefined(); expect(getRootChildrenIds()).toEqual(['start_0', 'to-delete3', 'end_0']); await historyService.undo(); expect(flowDocument.getNode('to-delete1')?.toJSON()).toEqual(toDelete1JSON); expect(flowDocument.getNode('to-delete2')?.toJSON()).toEqual(toDelete2JSON); expect(getRootChildrenIds()).toEqual([ 'start_0', 'to-delete1', 'to-delete2', 'to-delete3', 'end_0', ]); }); it('delete reverse nodes', async () => { const toDelete1JSON = toDelete1.toJSON(); const toDelete2JSON = toDelete2.toJSON(); flowOperationService.deleteNodes([toDelete2, 'to-delete1']); expect(flowDocument.getNode('to-delete1')).toBeUndefined(); expect(flowDocument.getNode('to-delete2')).toBeUndefined(); expect(getRootChildrenIds()).toEqual(['start_0', 'to-delete3', 'end_0']); await historyService.undo(); expect(flowDocument.getNode('to-delete1')?.toJSON()).toEqual(toDelete1JSON); expect(flowDocument.getNode('to-delete2')?.toJSON()).toEqual(toDelete2JSON); expect(getRootChildrenIds()).toEqual([ 'start_0', 'to-delete1', 'to-delete2', 'to-delete3', 'end_0', ]); }); it('delete random nodes', async () => { const toDelete1JSON = toDelete1.toJSON(); const toDelete3JSON = toDelete3.toJSON(); flowOperationService.deleteNodes([toDelete3, 'to-delete1']); expect(flowDocument.getNode('to-delete1')).toBeUndefined(); expect(flowDocument.getNode('to-delete3')).toBeUndefined(); expect(getRootChildrenIds()).toEqual(['start_0', 'to-delete2', 'end_0']); await historyService.undo(); expect(flowDocument.getNode('to-delete1')?.toJSON()).toEqual(toDelete1JSON); expect(flowDocument.getNode('to-delete3')?.toJSON()).toEqual(toDelete3JSON); expect(getRootChildrenIds()).toEqual([ 'start_0', 'to-delete1', 'to-delete2', 'to-delete3', 'end_0', ]); }); }); ================================================ FILE: packages/client/fixed-layout-editor/__tests__/services/history-operation-service/move-node.test.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { describe, it, expect, beforeEach } from 'vitest'; import { FlowNodeEntity } from '@flowgram.ai/editor'; import { getNodeChildrenIds } from '../../utils'; import { createHistoryContainer } from '../../create-container'; import { baseMock } from '../../../__mocks__/flow.mock'; describe('history-operation-service moveNode', () => { const { flowDocument, flowOperationService, historyService } = createHistoryContainer(); beforeEach(() => { flowDocument.fromJSON(baseMock); }); it('move block with parent', async () => { flowOperationService.moveNode('block_1', { parent: '$inlineBlocks$dynamicSplit_0', index: 2, }); const split = flowDocument.getNode('dynamicSplit_0'); expect(getNodeChildrenIds(split, true)).toEqual(['block_0', 'block_2', 'block_1']); await historyService.undo(); expect(getNodeChildrenIds(split, true)).toEqual(['block_0', 'block_1', 'block_2']); }); it('move block without parent', async () => { flowOperationService.moveNode('block_1', { index: 2, }); const split = flowDocument.getNode('dynamicSplit_0'); expect(getNodeChildrenIds(split, true)).toEqual(['block_0', 'block_2', 'block_1']); await historyService.undo(); expect(getNodeChildrenIds(split, true)).toEqual(['block_0', 'block_1', 'block_2']); }); it('move block with index', async () => { flowOperationService.moveNode('block_0', { index: 1, }); const split = flowDocument.getNode('dynamicSplit_0'); expect(getNodeChildrenIds(split, true)).toEqual(['block_1', 'block_0', 'block_2']); await historyService.undo(); expect(getNodeChildrenIds(split, true)).toEqual(['block_0', 'block_1', 'block_2']); }); it('move block without index', async () => { flowOperationService.moveNode('block_1'); const split = flowDocument.getNode('dynamicSplit_0'); expect(getNodeChildrenIds(split, true)).toEqual(['block_0', 'block_2', 'block_1']); await historyService.undo(); expect(getNodeChildrenIds(split, true)).toEqual(['block_0', 'block_1', 'block_2']); }); it('move block to other parent', async () => { flowDocument.addFromNode('dynamicSplit_0', { id: 'dynamicSplit_1', type: 'dynamicSplit', blocks: [{ id: 'block_3' }, { id: 'block_4' }, { id: 'block_5' }], }); flowOperationService.moveNode('block_1', { parent: '$inlineBlocks$dynamicSplit_1', index: 1, }); const split = flowDocument.getNode('dynamicSplit_0'); const split1 = flowDocument.getNode('dynamicSplit_1'); expect(getNodeChildrenIds(split, true)).toEqual(['block_0', 'block_2']); expect(getNodeChildrenIds(split1, true)).toEqual(['block_3', 'block_1', 'block_4', 'block_5']); await historyService.undo(); expect(getNodeChildrenIds(split, true)).toEqual(['block_0', 'block_1', 'block_2']); expect(getNodeChildrenIds(split1, true)).toEqual(['block_3', 'block_4', 'block_5']); }); it('move block to other parent which has no children', async () => { flowDocument.addFromNode('dynamicSplit_0', { id: 'dynamicSplit_1', type: 'dynamicSplit', blocks: [], }); flowOperationService.moveNode('block_1', { parent: '$inlineBlocks$dynamicSplit_1', }); const split = flowDocument.getNode('dynamicSplit_0'); const split1 = flowDocument.getNode('dynamicSplit_1'); expect(getNodeChildrenIds(split, true)).toEqual(['block_0', 'block_2']); expect(getNodeChildrenIds(split1, true)).toEqual(['block_1']); expect(historyService.canUndo()).toBe(true); }); it('move node without parent and index', async () => { const root = flowDocument.getNode('root'); flowOperationService.moveNode('start_0'); expect(getNodeChildrenIds(root)).toEqual(['dynamicSplit_0', 'end_0', 'start_0']); await historyService.undo(); expect(getNodeChildrenIds(root)).toEqual(['start_0', 'dynamicSplit_0', 'end_0']); }); it('move node with parent and without index', async () => { const root = flowDocument.getNode('root'); const block0 = flowDocument.getNode('block_0'); flowOperationService.addNode( { id: 'test0', type: 'test' }, { parent: block0, } ); flowDocument.addFromNode('start_0', { type: 'test', id: 'test1', }); flowOperationService.moveNode('test1', { parent: 'block_0' }); expect(getNodeChildrenIds(root)).toEqual(['start_0', 'dynamicSplit_0', 'end_0']); expect(getNodeChildrenIds(block0)).toEqual(['$blockOrderIcon$block_0', 'test0', 'test1']); await historyService.undo(); expect(getNodeChildrenIds(root)).toEqual(['start_0', 'test1', 'dynamicSplit_0', 'end_0']); expect(getNodeChildrenIds(block0)).toEqual(['$blockOrderIcon$block_0', 'test0']); }); it('move node with parent and index', async () => { const root = flowDocument.getNode('root'); flowDocument.addFromNode('dynamicSplit_0', { type: 'test', id: 'test', }); // 向后移动 flowOperationService.moveNode('dynamicSplit_0', { index: 2, }); expect(getNodeChildrenIds(root)).toEqual(['start_0', 'test', 'dynamicSplit_0', 'end_0']); // 向前移动 flowOperationService.moveNode('dynamicSplit_0', { index: 1, }); expect(getNodeChildrenIds(root)).toEqual(['start_0', 'dynamicSplit_0', 'test', 'end_0']); await historyService.undo(); expect(getNodeChildrenIds(root)).toEqual(['start_0', 'test', 'dynamicSplit_0', 'end_0']); await historyService.undo(); expect(getNodeChildrenIds(root)).toEqual(['start_0', 'dynamicSplit_0', 'test', 'end_0']); }); it('move node to group', async () => { const group = flowOperationService.createGroup([ flowDocument.getNode('dynamicSplit_0') as FlowNodeEntity, ]) as FlowNodeEntity; const root = flowDocument.getNode('root'); flowOperationService.moveNode('start_0', { parent: group, }); expect(getNodeChildrenIds(root)).toEqual([group.id, 'end_0']); expect(getNodeChildrenIds(group)).toEqual(['dynamicSplit_0', 'start_0']); await historyService.undo(); expect(getNodeChildrenIds(root)).toEqual(['start_0', group.id, 'end_0']); expect(getNodeChildrenIds(group)).toEqual(['dynamicSplit_0']); }); }); ================================================ FILE: packages/client/fixed-layout-editor/__tests__/services/history-operation-service/set-form-value.test.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { beforeEach, describe, it, expect } from 'vitest'; import { FlowNodeFormData, FormModelV2 } from '@flowgram.ai/editor'; import { createHistoryContainer } from '../../create-container'; import { formMock } from '../../../__mocks__/form.mock'; import { emptyMock } from '../../../__mocks__/flow.mock'; describe('history-operation-service changeFormData', () => { const { flowDocument, flowOperationService, historyService } = createHistoryContainer({ nodeEngine: {}, nodeRegistries: [ { type: 'formV2', formMeta: formMock, }, ], }); beforeEach(() => { flowDocument.fromJSON(emptyMock); }); it('setFormValue', async () => { const formNode = flowOperationService.addFromNode('start_0', { type: 'formV2', id: 'form', }); // TODO 新引擎需要渲染后才会createField, 这里先手动模拟下 const formModel = formNode?.getData(FlowNodeFormData)?.getFormModel() as FormModelV2; formModel.nativeFormModel?.createField('name'); flowOperationService.setFormValue(formNode, 'name', 'test'); expect(formNode.toJSON()?.data?.name).toEqual('test'); await historyService.undo(); expect(formNode.toJSON()?.data?.name).toEqual(undefined); }); }); ================================================ FILE: packages/client/fixed-layout-editor/__tests__/services/history-operation-service/transact.test.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { beforeEach, describe, it, expect } from 'vitest'; import { createHistoryContainer } from '../../create-container'; import { baseMock } from '../../../__mocks__/flow.mock'; describe('history-operation-service transact', () => { const { flowDocument, flowOperationService, historyService } = createHistoryContainer(); beforeEach(() => { flowDocument.fromJSON(baseMock); }); it('startTransaction endTransaction', async () => { flowOperationService.startTransaction(); ['block_0', 'block_1'].forEach(id => { flowOperationService.deleteNode(id); }); flowOperationService.addBlock('dynamicSplit_0', { id: 'test-block1', }); flowOperationService.addBlock('dynamicSplit_0', { id: 'test-block2', }); flowOperationService.endTransaction(); await historyService.undo(); expect(historyService.undoRedoService.canUndo()).toEqual(false); expect(flowDocument.toJSON()).toEqual(baseMock); }); }); ================================================ FILE: packages/client/fixed-layout-editor/__tests__/services/history-operation-service/ungroup.test.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { describe, it, expect, beforeEach } from 'vitest'; import { FlowNodeEntity } from '@flowgram.ai/editor'; import { createHistoryContainer } from '../../create-container'; import { baseMock } from '../../../__mocks__/flow.mock'; describe('history-operation-service', () => { const { flowDocument, flowOperationService, historyService } = createHistoryContainer(); beforeEach(() => { flowDocument.fromJSON(baseMock); }); it('ungroup', async () => { const node1 = flowOperationService.addFromNode('start_0', { id: 'add', type: 'add', }); const node2 = flowDocument.getNode('dynamicSplit_0') as FlowNodeEntity; flowDocument.transformer.refresh(); const group = flowOperationService.createGroup([node1, node2]) as FlowNodeEntity; const root = flowDocument.getNode('root'); flowOperationService.ungroup(group); expect(root?.collapsedChildren.map(c => c.id)).toEqual([ 'start_0', 'add', 'dynamicSplit_0', 'end_0', ]); await historyService.undo(); expect(root?.collapsedChildren.map(c => c.id)).toEqual(['start_0', group.id, 'end_0']); }); }); ================================================ FILE: packages/client/fixed-layout-editor/__tests__/utils.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { FlowDocument, FlowNodeEntity } from '@flowgram.ai/editor'; export function getNodeChildrenIds(node: FlowNodeEntity | undefined, isBranch: boolean = false) { if (!node) { return []; } if (isBranch) { return getNodeChildrenIds( node.collapsedChildren.find(c => c.id === `$inlineBlocks$${node.id}`), ); } return node?.collapsedChildren.map(c => c.id); } export function getRootChildrenIds(flowDocument: FlowDocument) { return getNodeChildrenIds(flowDocument.getNode('root')); } ================================================ FILE: packages/client/fixed-layout-editor/eslint.config.js ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ const { defineFlatConfig } = require('@flowgram.ai/eslint-config'); module.exports = defineFlatConfig({ preset: 'web', packageRoot: __dirname, }); ================================================ FILE: packages/client/fixed-layout-editor/index.css ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ :root { --g-selection-background: #4d53e8; --g-editor-background: #f2f3f5; --g-playground-select: var(--g-selection-background); --g-playground-hover: var(--g-selection-background); --g-playground-line: var(--g-selection-background); --g-playground-blur: #999; --g-playground-selectBox-outline: var(--g-selection-background); --g-playground-selectBox-background: rgba(141, 144, 231, 0.1); --g-playground-select-hover-background: rgba(77, 83, 232, 0.1); --g-playground-select-control-size: 12px; } .gedit-playground { position: absolute; width: 100%; height: 100%; left: 0; top: 0; z-index: 10; overflow: hidden; user-select: none; outline: none; box-sizing: border-box; background-color: var(--g-editor-background); } .gedit-playground .flow-lines-container { overflow: visible; } .gedit-transition-ease { transition: left, top 0.3s ease; } .gedit-playground-scroll-right { position: absolute; right: 2px; height: 100vh; width: 7px; z-index: 10; } .gedit-playground-scroll-bottom { position: absolute; bottom: 2px; width: 100vw; height: 7px; z-index: 10; } .gedit-playground-scroll-right-block { position: absolute; opacity: 0.3; border-radius: 3.5px; } .gedit-playground-scroll-right-block:hover { opacity: 0.6; } .gedit-playground-scroll-bottom-block { position: absolute; opacity: 0.3; border-radius: 3.5px; } .gedit-playground-scroll-bottom-block:hover { opacity: 0.6; } .gedit-playground-scroll-hidden { opacity: 0; } .gedit-playground-loading { position: absolute; color: white; left: 50%; top: 50%; z-index: 100; display: flex; justify-content: center; align-items: center; transition: opacity 0.8s; flex-direction: column; text-align: center; opacity: 0.8; } .gedit-hidden { display: none; } .gedit-playground-pipeline { position: absolute; overflow: visible; width: 100%; height: 100%; left: 0; top: 0; } .gedit-playground-pipeline::before { content: ''; position: absolute; width: 1px; height: 100%; left: 0; top: 0; } .gedit-playground-layer { position: absolute; overflow: visible; } .gedit-selector-box { position: absolute; left: 0; top: 0; width: 0; height: 0; z-index: 33; outline: 1px solid var(--g-playground-selectBox-outline); background-color: var(--g-playground-selectBox-background); } .gedit-selector-box-block { position: absolute; left: 0; top: 0; width: 0; height: 0; z-index: 9999; display: none; background-color: rgba(0, 0, 0, 0); } .gedit-selector-bounds-background { position: absolute; left: 0; top: 0; width: 0; height: 0; outline: 1px solid var(--g-playground-selectBox-outline); background-color: #f0f4ff; } .gedit-selector-bounds-foreground { position: absolute; left: 0; top: 0; width: 0; height: 0; z-index: 33; background: rgba(255, 255, 255, 0); } .gedit-flow-activity-node { position: absolute; } .gedit-grid-svg { display: block; position: absolute; left: 20px; top: 20px; width: 0; height: 0; } ================================================ FILE: packages/client/fixed-layout-editor/package.json ================================================ { "name": "@flowgram.ai/fixed-layout-editor", "version": "0.1.8", "homepage": "https://flowgram.ai/", "repository": "https://github.com/bytedance/flowgram.ai", "license": "MIT", "exports": { ".": { "types": "./dist/index.d.ts", "require": "./dist/index.js", "import": "./dist/esm/index.js" }, "./index.css": { "import": "./index.css", "require": "./index.css" } }, "main": "./dist/index.js", "module": "./dist/esm/index.js", "types": "./dist/index.d.ts", "files": [ "dist", "index.css" ], "scripts": { "build": "npm run build:fast -- --dts-resolve", "build:fast": "tsup src/index.ts --format cjs,esm --sourcemap --legacy-output", "build:watch": "npm run build:fast -- --dts-resolve", "clean": "rimraf dist", "test": "vitest run", "test:cov": "vitest run --coverage", "ts-check": "tsc --noEmit", "watch": "npm run build:fast -- --dts-resolve --watch --ignore-watch dist" }, "dependencies": { "@flowgram.ai/core": "workspace:*", "@flowgram.ai/editor": "workspace:*", "@flowgram.ai/fixed-drag-plugin": "workspace:*", "@flowgram.ai/fixed-history-plugin": "workspace:*", "@flowgram.ai/fixed-layout-core": "workspace:*", "@flowgram.ai/history": "workspace:*", "@flowgram.ai/reactive": "workspace:*", "@flowgram.ai/select-box-plugin": "workspace:*", "@flowgram.ai/utils": "workspace:*", "inversify": "^6.0.1", "reflect-metadata": "~0.2.2" }, "devDependencies": { "@flowgram.ai/eslint-config": "workspace:*", "@flowgram.ai/ts-config": "workspace:*", "@types/bezier-js": "4.1.3", "@types/lodash-es": "^4.17.12", "@types/react": "^18", "@types/react-dom": "^18", "@vitest/coverage-v8": "^3.2.4", "eslint": "^9.0.0", "react": "^18", "react-dom": "^18", "tsup": "^8.0.1", "typescript": "^5.8.3", "vitest": "^3.2.4" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" }, "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" } } ================================================ FILE: packages/client/fixed-layout-editor/src/components/fixed-layout-editor-provider.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React, { useMemo, useCallback, forwardRef } from 'react'; import { interfaces } from 'inversify'; import { HistoryService } from '@flowgram.ai/history'; import { FlowDocument, createPluginContextDefault, PlaygroundReactProvider, ClipboardService, SelectionService, Playground, } from '@flowgram.ai/editor'; import { FlowOperationService } from '../types'; import { createFixedLayoutPreset, FixedLayoutPluginContext, FixedLayoutPluginTools, FixedLayoutProps, } from '../preset'; export const FixedLayoutEditorProvider = forwardRef( function FixedLayoutEditorProvider(props: FixedLayoutProps, ref) { const { parentContainer, children, ...others } = props; const preset = useMemo(() => createFixedLayoutPreset(others), []); const customPluginContext = useCallback( (container: interfaces.Container) => ({ ...createPluginContextDefault(container), get document(): FlowDocument { return container.get(FlowDocument); }, get operation(): FlowOperationService { return container.get(FlowOperationService); }, get clipboard(): ClipboardService { return container.get(ClipboardService); }, get selection(): SelectionService { return container.get(SelectionService); }, get history(): HistoryService { return container.get(HistoryService); }, get tools(): FixedLayoutPluginTools { return { fitView: (easing?: boolean) => { const playgroundConfig = container.get(Playground).config; const doc = container.get(FlowDocument); return playgroundConfig.fitView(doc.root.bounds, easing, 30); }, }; }, } as FixedLayoutPluginContext), [] ); return ( {children} ); } ); ================================================ FILE: packages/client/fixed-layout-editor/src/components/fixed-layout-editor.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React, { forwardRef } from 'react'; import { EditorRenderer } from '@flowgram.ai/editor'; import { FixedLayoutPluginContext, FixedLayoutProps } from '../preset'; import { FixedLayoutEditorProvider } from './fixed-layout-editor-provider'; /** * 固定布局编辑器 * @param props * @constructor */ export const FixedLayoutEditor = forwardRef( function FixedLayoutEditor(props: FixedLayoutProps, ref) { const { children, ...otherProps } = props; return ( {children} ); }, ); ================================================ FILE: packages/client/fixed-layout-editor/src/components/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export * from './fixed-layout-editor-provider'; export * from './fixed-layout-editor'; ================================================ FILE: packages/client/fixed-layout-editor/src/hooks/use-client-context.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { useService, PluginContext } from '@flowgram.ai/editor'; import { FixedLayoutPluginContext } from '../preset'; export function useClientContext(): FixedLayoutPluginContext { return useService(PluginContext); } ================================================ FILE: packages/client/fixed-layout-editor/src/hooks/use-node-render.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React, { useCallback, useEffect, useContext, useMemo, useRef, useState } from 'react'; import { useObserve } from '@flowgram.ai/reactive'; import { useStartDragNode } from '@flowgram.ai/fixed-drag-plugin'; import { usePlayground, FlowNodeBaseType, FlowNodeEntity, FlowNodeRenderData, useService, Disposable, PlaygroundEntityContext, NodeFormProps, getNodeForm, } from '@flowgram.ai/editor'; import { FlowOperationService } from '../types'; export interface NodeRenderReturnType { id: string; type: string | number; /** * 节点 data 数据 */ data: any; /** * 更新节点 data 数据 */ updateData: (newData: any) => void; /** * BlockOrderIcon节点,一般用于分支的第一个占位节点 */ isBlockOrderIcon: boolean; /** * BlockIcon 节点,一般用于带有分支的占位节点 */ isBlockIcon: boolean; /** * 当前节点 (如果是 icon 则会返回它的父节点) */ node: FlowNodeEntity; /** * 是否在拖拽中 */ dragging: boolean; /** * 节点是否激活 */ activated: boolean; /** * 节点是否展开 */ expanded: boolean; /** * 触发拖拽 * @param e */ startDrag: (e: React.MouseEvent) => void; /** * 鼠标进入, 主要用于控制 activated 状态 */ onMouseEnter: (e: React.MouseEvent) => void; /** * 鼠标离开, 主要用于控制 activated 状态 */ onMouseLeave: (e: React.MouseEvent) => void; /** * 渲染表单,只有节点引擎开启才能使用 */ form: NodeFormProps | undefined; /** * 获取节点的扩展数据 */ getExtInfo(): T; /** * 更新节点的扩展数据 * @param extInfo */ updateExtInfo(extInfo: T, fullUpdate?: boolean): void; /** * 展开/收起节点 * @param expanded */ toggleExpand(): void; /** * 删除节点 */ deleteNode: () => void; /** * 全局 readonly 状态 */ readonly: boolean; } /** * Provides methods related to node rendering * @param nodeFromProps */ export function useNodeRender(nodeFromProps?: FlowNodeEntity): NodeRenderReturnType { const renderNode = nodeFromProps || useContext(PlaygroundEntityContext); const nodeCache = useRef(); const renderData = renderNode.getData(FlowNodeRenderData)!; const { expanded, dragging, activated } = renderData; const { startDrag: startDragOrigin, dragOffset } = useStartDragNode(); const playground = usePlayground(); const isBlockOrderIcon = renderNode.flowNodeType === FlowNodeBaseType.BLOCK_ORDER_ICON; const isBlockIcon = renderNode.flowNodeType === FlowNodeBaseType.BLOCK_ICON; const [formValueVersion, updateFormValueVersion] = useState(0); const formValueDependRef = useRef(false); formValueDependRef.current = false; // 在 BlockIcon 情况,如果在触发 fromJSON 时候更新表单数据导致刷新节点会存在 renderNode.parent 为 undefined,所以这里 nodeCache 进行缓存 const node = (isBlockOrderIcon || isBlockIcon ? renderNode.parent! : renderNode) || nodeCache.current; nodeCache.current = node; const operationService = useService(FlowOperationService); const deleteNode = useCallback(() => { operationService.deleteNode(node); }, [node, operationService]); const startDrag = useCallback( (e: React.MouseEvent) => { startDragOrigin( e, { dragStartEntity: renderNode }, { dragOffsetX: dragOffset.x, dragOffsetY: dragOffset.y } ); }, [renderNode, startDragOrigin] ); const onMouseEnter = useCallback( (e: React.MouseEvent) => { renderData.toggleMouseEnter(); }, [renderData] ); const onMouseLeave = useCallback( (e: React.MouseEvent) => { renderData.toggleMouseLeave(); }, [renderData] ); const toggleExpand = useCallback(() => { renderData.toggleExpand(); }, [renderData]); const getExtInfo = useCallback(() => node.getExtInfo() as any, [node]); const updateExtInfo = useCallback( (data: any, fullUpdate?: boolean) => { node.updateExtInfo(data, fullUpdate); }, [node] ); const form = useMemo(() => getNodeForm(node), [node]); // Listen FormState change const formState = useObserve(form?.state); useEffect(() => { let dispose: Disposable | undefined; if (isBlockIcon || isBlockOrderIcon) { // icon 的扩展数据是存在父节点上 dispose = renderNode.parent!.onExtInfoChange(() => renderNode.renderData.fireChange()); } return () => dispose?.dispose(); }, [renderNode, isBlockIcon, isBlockOrderIcon]); useEffect(() => { const toDispose = form?.onFormValuesChange(() => { if (formValueDependRef.current) { updateFormValueVersion((v) => v + 1); } }); return () => toDispose?.dispose(); }, [form]); const readonly = playground.config.readonly; return useMemo( () => ({ id: node.id, type: node.flowNodeType, get data() { if (form) { formValueDependRef.current = true; return form.values; } return getExtInfo(); }, updateData(values: any) { if (form) { form.updateFormValues(values); } else { updateExtInfo(values, true); } }, node, isBlockOrderIcon, isBlockIcon, activated, readonly, expanded, dragging, startDrag, deleteNode, onMouseEnter, onMouseLeave, getExtInfo, updateExtInfo, toggleExpand, get form() { if (!form) return undefined; return { ...form, get values() { formValueDependRef.current = true; return form.values!; }, get state() { return formState; }, }; }, }), [ node, isBlockOrderIcon, isBlockIcon, activated, readonly, expanded, dragging, startDrag, deleteNode, onMouseEnter, onMouseLeave, getExtInfo, updateExtInfo, toggleExpand, form, formState, formValueVersion, ] ); } ================================================ FILE: packages/client/fixed-layout-editor/src/hooks/use-playground-tools.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { useCallback, useEffect, useState } from 'react'; import { DisposableCollection } from '@flowgram.ai/utils'; import { HistoryService } from '@flowgram.ai/history'; import { FlowDocument, FlowLayoutDefault, FlowNodeRenderData, PlaygroundInteractiveType, EditorState, } from '@flowgram.ai/editor'; import { usePlayground, usePlaygroundContainer, useService } from '@flowgram.ai/editor'; export interface PlaygroundToolsPropsType { /** * 最大缩放比,默认 2 */ maxZoom?: number; /** * 最小缩放比,默认 0.25 */ minZoom?: number; } export interface PlaygroundTools { /** * 缩放 zoom 大小比例 */ zoom: number; /** * 放大 */ zoomin: (easing?: boolean) => void; /** * 缩小 */ zoomout: (easing?: boolean) => void; /** * 设置缩放比例 * @param zoom */ updateZoom: (newZoom: number, easing?: boolean, easingDuration?: number) => void; /** * 自适应视区 */ fitView: (easing?: boolean, easingDuration?: number, padding?: number) => Promise; /** * 是否垂直布局 */ isVertical: boolean; /** * 切换布局, 如果不传入则直接切换 */ changeLayout: (layout?: FlowLayoutDefault) => void; /** 交互模式:鼠标 or 触控板 */ interactiveType: PlaygroundInteractiveType; setInteractiveType: (type: PlaygroundInteractiveType) => void; /** * 是否可 redo */ canRedo: boolean; /** * 是否可 undo */ canUndo: boolean; /** * redo */ redo: () => void; /** * undo */ undo: () => void; } export function usePlaygroundTools(props?: PlaygroundToolsPropsType): PlaygroundTools { const { maxZoom, minZoom } = props || {}; const playground = usePlayground(); const container = usePlaygroundContainer(); const historyService = container.isBound(HistoryService) ? container.get(HistoryService) : undefined; const doc = useService(FlowDocument); const [interactiveType, setInteractiveType] = useState('PAD'); const [zoom, setZoom] = useState(1); const [currentLayout, updateLayout] = useState(doc.layout); const [canUndo, setCanUndo] = useState(false); const [canRedo, setCanRedo] = useState(false); // 获取合适视角 const handleFitView = useCallback( (easing?: boolean, easingDuration?: number, padding?: number) => { padding = padding || 30; return playground.config.fitView(doc.root.bounds, easing, padding, easingDuration); }, [doc, playground] ); const changeLayout = useCallback( (newLayout?: FlowLayoutDefault) => { const allNodes = doc.getAllNodes(); newLayout = newLayout || (doc.layout.name === FlowLayoutDefault.HORIZONTAL_FIXED_LAYOUT ? FlowLayoutDefault.VERTICAL_FIXED_LAYOUT : FlowLayoutDefault.HORIZONTAL_FIXED_LAYOUT); allNodes.map((node) => { const renderData = node.getData(FlowNodeRenderData); renderData.node.classList.add('gedit-transition-ease'); }); setTimeout(() => { handleFitView(); }, 10); setTimeout(() => { allNodes.map((node) => { const renderData = node.getData(FlowNodeRenderData); renderData.node.classList.remove('gedit-transition-ease'); }); }, 500); doc.setLayout(newLayout); updateLayout(doc.layout); }, [doc, playground] ); const handleZoomOut = useCallback( (easing?: boolean) => { playground?.config.zoomout(easing); }, [zoom, playground] ); const handleZoomIn = useCallback( (easing?: boolean) => { playground?.config.zoomin(easing); }, [zoom, playground] ); const handleUpdateZoom = useCallback( (value: number, easing?: boolean, easingDuration?: number) => { playground.config.updateZoom(value, easing, easingDuration); }, [playground] ); const handleUndo = useCallback(() => historyService?.undo(), [historyService]); const handleRedo = useCallback(() => historyService?.redo(), [historyService]); function handleUpdateInteractiveType(interactiveType: PlaygroundInteractiveType) { if (interactiveType === 'MOUSE') { playground.editorState.changeState(EditorState.STATE_MOUSE_FRIENDLY_SELECT.id); } else if (interactiveType === 'PAD') { playground.editorState.changeState(EditorState.STATE_SELECT.id); } setInteractiveType(interactiveType); } useEffect(() => { const dispose = new DisposableCollection(); if (playground) { dispose.push(playground.onZoom((z) => setZoom(z))); } if (historyService) { dispose.push( historyService.undoRedoService.onChange(() => { setCanUndo(historyService.canUndo()); setCanRedo(historyService.canRedo()); }) ); } return () => dispose.dispose(); }, [playground, historyService]); useEffect(() => { const config = playground.config.config; playground.config.updateConfig({ maxZoom: maxZoom !== undefined ? maxZoom : config.maxZoom, minZoom: minZoom !== undefined ? minZoom : config.minZoom, }); }, [playground, maxZoom, minZoom]); return { zoomin: handleZoomIn, zoomout: handleZoomOut, fitView: handleFitView, updateZoom: handleUpdateZoom, zoom, isVertical: currentLayout.name === FlowLayoutDefault.VERTICAL_FIXED_LAYOUT, changeLayout, canRedo, canUndo, undo: handleUndo, redo: handleRedo, interactiveType, setInteractiveType: handleUpdateInteractiveType, }; } ================================================ FILE: packages/client/fixed-layout-editor/src/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import 'reflect-metadata'; /* 核心模块导出 */ export * from '@flowgram.ai/editor'; /** * 固定布局模块导出 */ export * from '@flowgram.ai/fixed-layout-core'; export { useStartDragNode } from '@flowgram.ai/fixed-drag-plugin'; export * from './preset'; export * from './components'; export * from '@flowgram.ai/fixed-history-plugin'; export * from './hooks/use-node-render'; export * from './hooks/use-playground-tools'; export { useClientContext } from './hooks/use-client-context'; export * from './types'; export { createOperationPlugin } from './plugins/create-operation-plugin'; ================================================ FILE: packages/client/fixed-layout-editor/src/plugins/create-operation-plugin.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { definePluginCreator } from '@flowgram.ai/core'; import { FlowOperationService } from '../types'; import { HistoryOperationServiceImpl } from '../services/history-operation-service'; import { FlowOperationServiceImpl } from '../services/flow-operation-service'; import { FixedLayoutProps } from '../preset'; export const createOperationPlugin = definePluginCreator({ onBind: ({ bind }, opts) => { bind(FlowOperationService) .to(opts?.history?.enable ? HistoryOperationServiceImpl : FlowOperationServiceImpl) .inSingletonScope(); }, onDispose: ctx => { const flowOperationService = ctx.container.get(FlowOperationService); flowOperationService.dispose(); }, }); ================================================ FILE: packages/client/fixed-layout-editor/src/preset/fixed-layout-preset.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { createSelectBoxPlugin } from '@flowgram.ai/select-box-plugin'; import { FixedLayoutContainerModule } from '@flowgram.ai/fixed-layout-core'; import { FixedHistoryService, createFixedHistoryPlugin } from '@flowgram.ai/fixed-history-plugin'; import { createFixedDragPlugin } from '@flowgram.ai/fixed-drag-plugin'; import { PluginsProvider, createDefaultPreset, createVariablePlugin, createPlaygroundPlugin, createShortcutsPlugin, SelectionService, Command, Plugin, FlowDocument, FlowNodeEntity, FlowDocumentOptionsDefault, FlowDocumentOptions, FlowNodesContentLayer, FlowNodesTransformLayer, FlowScrollBarLayer, FlowScrollLimitLayer, createPlaygroundReactPreset, } from '@flowgram.ai/editor'; import { compose } from '../utils/compose'; import { FlowOperationService } from '../types'; import { createOperationPlugin } from '../plugins/create-operation-plugin'; import { fromNodeJSON, toNodeJSON } from './node-serialize'; import { FixedLayoutPluginContext, FixedLayoutProps } from './fixed-layout-props'; export function createFixedLayoutPreset( opts: FixedLayoutProps ): PluginsProvider { return (ctx: FixedLayoutPluginContext) => { opts = { ...FixedLayoutProps.DEFAULT, ...opts }; let plugins: Plugin[] = [createOperationPlugin(opts)]; /** * 注册默认的快捷键 */ plugins.push( createShortcutsPlugin({ registerShortcuts(registry) { const selection = ctx.get(SelectionService); registry.addHandlers({ commandId: Command.Default.DELETE, shortcuts: ['backspace', 'delete'], isEnabled: () => selection.selection.length > 0 && !ctx.playground.config.readonlyOrDisabled, execute: () => { // TODO 这里要判断 CurrentEditor const nodes = selection.selection.filter( (entity) => entity instanceof FlowNodeEntity ) as FlowNodeEntity[]; const flowOperationService = ctx.get(FlowOperationService); flowOperationService.deleteNodes(nodes); selection.selection = selection.selection.filter((s) => !s.disposed); }, }); if (opts?.history?.enable) { const fixedHistoryService = ctx.get(FixedHistoryService); if (!opts.history.disableShortcuts) { registry.addHandlers({ commandId: Command.Default.UNDO, shortcuts: ['meta z', 'ctrl z'], isEnabled: () => true, execute: () => { fixedHistoryService.undo(); }, }); registry.addHandlers({ commandId: Command.Default.REDO, shortcuts: ['meta shift z', 'ctrl shift z'], isEnabled: () => true, execute: () => { fixedHistoryService.redo(); }, }); } } }, }), /** * 圈选逻辑实现 */ createSelectBoxPlugin({ canSelect: (e) => // 需满足以下条件: // - 鼠标左键 e.button === 0 && !(ctx.get(FlowDocument) as FlowDocument).renderState.config.nodeHoveredId, ...(opts.selectBox || {}), }), /** * 固定布局拖拽逻辑实现 */ createFixedDragPlugin(opts.dragdrop || {}) ); /** * 加载默认编辑器配置 */ plugins = createDefaultPreset(opts, plugins)(ctx); /** * 注册 变量系统 */ if (opts.variableEngine?.enable) { plugins.push( createVariablePlugin({ ...opts.variableEngine, layout: 'fixed', }) ); } /** * 注册 历史记录 */ if (opts.history?.enable) { plugins.push(createFixedHistoryPlugin(opts.history)); } /* * 加载固定布局画布模块 * */ plugins.push( createPlaygroundPlugin({ containerModules: [FixedLayoutContainerModule], onBind(bindConfig) { if (!bindConfig.isBound(FlowDocumentOptions)) { bindConfig.bind(FlowDocumentOptions).toConstantValue({ ...FlowDocumentOptionsDefault, defaultLayout: opts.defaultLayout, toNodeJSON: (node) => toNodeJSON(opts, node), fromNodeJSON: (node, json, isFirstCreate) => fromNodeJSON(opts, node, json, isFirstCreate), allNodesDefaultExpanded: opts.allNodesDefaultExpanded, } as FlowDocumentOptions); } }, onInit: (ctx) => { ctx.playground.registerLayers( FlowNodesContentLayer, // 节点内容渲染 FlowNodesTransformLayer // 节点位置偏移计算 ); // 劫持节点线条 if (opts.formatNodeLines) { ctx.document.options.formatNodeLines = compose([ ctx.document.options.formatNodeLines, opts.formatNodeLines, ]); } // 劫持节点 label if (opts.formatNodeLabels) { ctx.document.options.formatNodeLabels = compose([ ctx.document.options.formatNodeLabels, opts.formatNodeLabels, ]); } if (opts.scroll?.enableScrollLimit) { // 控制滚动范围 ctx.playground.registerLayer(FlowScrollLimitLayer); } if (!opts.scroll?.disableScrollBar) { // 控制条 ctx.playground.registerLayer(FlowScrollBarLayer); } if (opts.scroll?.disableScroll) { ctx.playground.config.scrollDisable = true; } if (opts.nodeRegistries) { ctx.document.registerFlowNodes(...opts.nodeRegistries); } }, }) ); return createPlaygroundReactPreset(opts, plugins)(ctx); }; } ================================================ FILE: packages/client/fixed-layout-editor/src/preset/fixed-layout-props.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { SelectBoxPluginOptions } from '@flowgram.ai/select-box-plugin'; import { FixedHistoryPluginOptions, HistoryService } from '@flowgram.ai/fixed-history-plugin'; import { type FixDragPluginOptions } from '@flowgram.ai/fixed-drag-plugin'; import { ClipboardService, EditorPluginContext, EditorProps, FlowDocument, FlowDocumentJSON, FlowLayoutDefault, SelectionService, PluginContext, FlowNodeEntity, FlowTransitionLine, FlowTransitionLabel, } from '@flowgram.ai/editor'; import { FlowOperationService } from '../types'; export const FixedLayoutPluginContext = PluginContext; export interface FixedLayoutPluginTools { fitView: (easing?: boolean) => Promise; } export interface FixedLayoutPluginContext extends EditorPluginContext { document: FlowDocument; /** * 提供对画布节点相关操作方法, 并 支持 redo/undo */ operation: FlowOperationService; clipboard: ClipboardService; selection: SelectionService; history: HistoryService; tools: FixedLayoutPluginTools; } /** * 固定布局配置 */ export interface FixedLayoutProps extends EditorProps { /** * SelectBox config */ selectBox?: SelectBoxPluginOptions; /** * Drag/Drop config */ dragdrop?: FixDragPluginOptions; /** * Redo/Undo enable */ history?: FixedHistoryPluginOptions & { disableShortcuts?: boolean }; /** * vertical or horizontal layout */ defaultLayout?: FlowLayoutDefault | string; // 默认布局 /** * Customize the node line * 自定义节点线条 */ formatNodeLines?: (node: FlowNodeEntity, lines: FlowTransitionLine[]) => FlowTransitionLine[]; /** * Custom node label * 自定义节点 label */ formatNodeLabels?: (node: FlowNodeEntity, lines: FlowTransitionLabel[]) => FlowTransitionLabel[]; } export namespace FixedLayoutProps { /** * 默认配置 */ export const DEFAULT: FixedLayoutProps = { ...EditorProps.DEFAULT, scroll: { enableScrollLimit: true, }, } as FixedLayoutProps; } ================================================ FILE: packages/client/fixed-layout-editor/src/preset/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export * from './fixed-layout-props'; export * from './fixed-layout-preset'; ================================================ FILE: packages/client/fixed-layout-editor/src/preset/node-serialize.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { FlowNodeEntity, FlowNodeJSON, FlowNodeFormData } from '@flowgram.ai/editor'; import { FixedLayoutProps } from './fixed-layout-props'; export function fromNodeJSON( opts: FixedLayoutProps, node: FlowNodeEntity, json: FlowNodeJSON, isFirstCreate: boolean ) { json = opts.fromNodeJSON ? opts.fromNodeJSON(node, json, isFirstCreate) : json; const formData = node.getData(FlowNodeFormData)!; // 如果没有使用表单引擎,将 data 数据填入 extInfo if (!formData) { if (json.data) { node.updateExtInfo(json.data, true); } } else { const defaultFormMeta = opts.nodeEngine?.createDefaultFormMeta?.(node); const formMeta = node.getNodeRegistry()?.formMeta || defaultFormMeta; if (formMeta) { if (isFirstCreate) { formData.createForm(formMeta, json.data); } else { formData.updateFormValues(json.data); } } } } export function toNodeJSON(opts: FixedLayoutProps, node: FlowNodeEntity): FlowNodeJSON { const nodesMap: Record = {}; let startNodeJSON: FlowNodeJSON; node.document.traverse((node) => { const isSystemNode = node.id.startsWith('$'); if (isSystemNode) return; const formData = node.getData(FlowNodeFormData); let formJSON = formData && formData.formModel && formData.formModel.initialized ? formData.toJSON() : undefined; let nodeJSON: FlowNodeJSON = { id: node.id, type: node.flowNodeType, data: formData ? formJSON : node.getExtInfo(), blocks: [], }; if (opts.toNodeJSON) { nodeJSON = opts.toNodeJSON(node, nodeJSON); } if (!startNodeJSON) startNodeJSON = nodeJSON; let { parent } = node; if (parent && parent.id.startsWith('$')) { parent = parent.originParent; } const parentJSON = parent ? nodesMap[parent.id] : undefined; if (parentJSON) { parentJSON.blocks?.push(nodeJSON); } nodesMap[node.id] = nodeJSON; }, node); // @ts-ignore return startNodeJSON; } ================================================ FILE: packages/client/fixed-layout-editor/src/services/flow-operation-service.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { inject, injectable } from 'inversify'; import { FlowGroupService, FlowNodeEntity, FlowNodeEntityOrId, FlowNodeFormData, FlowOperationBaseServiceImpl, FormModel, FormModelV2, isFormModelV2, } from '@flowgram.ai/editor'; import { FlowOperationService } from '../types'; @injectable() export class FlowOperationServiceImpl extends FlowOperationBaseServiceImpl implements FlowOperationService { @inject(FlowGroupService) protected groupService: FlowGroupService; createGroup(nodes: FlowNodeEntity[]): FlowNodeEntity | undefined { return this.groupService.createGroup(nodes); } ungroup(groupNode: FlowNodeEntity): void { return this.groupService.ungroup(groupNode); } setFormValue(nodeOrId: FlowNodeEntityOrId, path: string, value: unknown): void { const node = this.toNodeEntity(nodeOrId); const formModel = node?.getData(FlowNodeFormData)?.getFormModel(); if (!formModel) { return; } if (isFormModelV2(formModel)) { (formModel as FormModelV2).setValueIn(path, value); } else { const formItem = (formModel as FormModel).getFormItemByPath(path); if (!formItem) { return; } formItem.value = value; } } startTransaction(): void {} endTransaction(): void {} } ================================================ FILE: packages/client/fixed-layout-editor/src/services/history-operation-service.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { inject, injectable, postConstruct } from 'inversify'; import { HistoryService } from '@flowgram.ai/history'; import { FixedHistoryService } from '@flowgram.ai/fixed-history-plugin'; import { AddBlockConfig, AddOrDeleteNodeValue, FlowDocument, FlowNodeEntity, FlowNodeEntityOrId, FlowNodeJSON, FlowOperation, MoveChildNodesOperationValue, OnNodeAddEvent, OperationType, } from '@flowgram.ai/editor'; import { FlowOperationService } from '../types'; import { FlowOperationServiceImpl } from './flow-operation-service'; @injectable() export class HistoryOperationServiceImpl extends FlowOperationServiceImpl implements FlowOperationService { @inject(FixedHistoryService) protected fixedHistoryService: FixedHistoryService; @inject(HistoryService) protected historyService: HistoryService; @inject(FlowDocument) protected document: FlowDocument; @postConstruct() protected init() { this.toDispose.push(this.onNodeAdd(this.handleNodeAdd.bind(this))); } addFromNode(fromNode: FlowNodeEntityOrId, nodeJSON: FlowNodeJSON): FlowNodeEntity { return this.fixedHistoryService.addFromNode(fromNode, nodeJSON); } addBlock( target: FlowNodeEntityOrId, blockJSON: FlowNodeJSON, config: AddBlockConfig = {} ): FlowNodeEntity { const { parent, index } = config; return this.fixedHistoryService.addBlock(target, blockJSON, parent, index); } deleteNode(nodeOrId: FlowNodeEntityOrId): void { const node = this.toNodeEntity(nodeOrId); if (!node) { return; } this.fixedHistoryService.deleteNode(node); } deleteNodes(nodes: FlowNodeEntityOrId[]): void { const nodesEntities = nodes.map((node) => typeof node === 'string' ? this.document.getNode(node) : node ) as FlowNodeEntity[]; return this.fixedHistoryService.deleteNodes(nodesEntities); } startTransaction(): void { this.historyService.startTransaction(); } endTransaction(): void { this.historyService.endTransaction(); } apply(operation: FlowOperation) { this.historyService.pushOperation(operation); } protected doMoveNode(node: FlowNodeEntity, newParent: FlowNodeEntity, index: number) { const fromParentId = node.parent?.id; if (!fromParentId) { return; } const value: MoveChildNodesOperationValue = { nodeIds: [this.toId(node)], fromParentId: node.parent.id, toParentId: this.toId(newParent), fromIndex: this.getNodeIndex(node), toIndex: index, }; return this.historyService.pushOperation({ type: OperationType.moveChildNodes, value, }); } protected handleNodeAdd({ data: addNodeData }: OnNodeAddEvent): FlowNodeEntity { const { parent, index, hidden, originParent, ...nodeJSON } = addNodeData; const value: AddOrDeleteNodeValue = { data: nodeJSON, parentId: parent?.id, index, hidden, }; return this.historyService.pushOperation( { type: OperationType.addNode, value: value, uri: this.fixedHistoryService.config.getNodeURI(nodeJSON.id), }, { noApply: true, } ); } } ================================================ FILE: packages/client/fixed-layout-editor/src/types.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { FlowNodeEntity, FlowNodeEntityOrId, FlowOperationBaseService, } from '@flowgram.ai/editor'; export interface FlowOperationService extends FlowOperationBaseService { /** * 创建分组 * @param nodes 节点列表 */ createGroup(nodes: FlowNodeEntity[]): FlowNodeEntity | undefined; /** * 取消分组 * @param groupNode */ ungroup(groupNode: FlowNodeEntity): void; /** * 开始事务 */ startTransaction(): void; /** * 结束事务 */ endTransaction(): void; /** * 修改表单数据 * @param node 节点 * @param path 属性路径 * @param value 值 */ setFormValue(node: FlowNodeEntityOrId, path: string, value: unknown): void; } export const FlowOperationService = Symbol('FlowOperationService'); ================================================ FILE: packages/client/fixed-layout-editor/src/utils/compose.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { FlowNodeEntity } from '@flowgram.ai/editor'; export type ComposeListItem = (node: FlowNodeEntity, data: T[]) => T[]; export const compose = (fnList: (ComposeListItem | undefined)[]) => (node: FlowNodeEntity, data: T[]): T[] => { const list = fnList.filter(Boolean) as ComposeListItem[]; if (!list.length) { return data; } return list.reduce((acc: T[], fn) => fn(node, acc), data); }; ================================================ FILE: packages/client/fixed-layout-editor/tsconfig.json ================================================ { "extends": "@flowgram.ai/ts-config/tsconfig.flow.path.json", "compilerOptions": { "types": [], }, "include": ["./src"], "exclude": ["node_modules"] } ================================================ FILE: packages/client/fixed-layout-editor/vitest.config.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ const path = require('path'); import { defineConfig } from 'vitest/config'; export default defineConfig({ build: { commonjsOptions: { transformMixedEsModules: true, }, }, test: { globals: true, mockReset: false, environment: 'jsdom', setupFiles: [path.resolve(__dirname, './vitest.setup.ts')], include: ['**/?(*.){test,spec}.?(c|m)[jt]s?(x)'], exclude: [ '**/__mocks__**', '**/node_modules/**', '**/dist/**', '**/lib/**', // lib 编译结果忽略掉 '**/cypress/**', '**/.{idea,git,cache,output,temp}/**', '**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*', ], }, }); ================================================ FILE: packages/client/fixed-layout-editor/vitest.setup.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import 'reflect-metadata'; ================================================ FILE: packages/client/free-layout-editor/__mocks__/flow.mocks.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { WorkflowJSON } from '@flowgram.ai/free-layout-core'; export const mockJSON: WorkflowJSON = { nodes: [ { id: 'start_0', type: 'start', meta: { position: { x: 180, y: 381.75, }, }, data: { title: 'Start', }, }, { id: 'condition_0', type: 'condition', meta: { position: { x: 640, y: 363.25, }, }, data: { title: 'Condition', }, }, { id: 'end_0', type: 'end', meta: { position: { x: 2220, y: 381.75, }, }, data: { title: 'End', }, }, { id: 'loop_H8M3U', type: 'loop', meta: { position: { x: 1020, y: 547.96875, }, }, data: { title: 'Loop_2', }, blocks: [ { id: 'llm_CBdCg', type: 'llm', meta: { position: { x: 180, y: 0, }, }, data: { title: 'LLM_4', }, }, { id: 'llm_gZafu', type: 'llm', meta: { position: { x: 640, y: 0, }, }, data: { title: 'LLM_5', }, }, ], edges: [ { sourceNodeID: 'llm_CBdCg', targetNodeID: 'llm_gZafu', }, ], }, { id: '159623', type: 'comment', meta: { position: { x: 640, y: 522.46875, }, }, data: { size: { width: 240, height: 150, }, note: 'hi ~\n\nthis is a comment node\n\n- flowgram.ai', }, }, { id: 'group_V-_st', type: 'group', meta: { position: { x: 1020, y: 96.25, }, }, data: { title: 'LLM_Group', color: 'Violet', parentID: 'root', blockIDs: ['llm_0', 'llm_l_TcE'], }, }, { id: 'llm_0', type: 'llm', meta: { position: { x: 640, y: 0, }, }, data: { title: 'LLM_0', }, }, { id: 'llm_l_TcE', type: 'llm', meta: { position: { x: 180, y: 0, }, }, data: { title: 'LLM_1', }, }, ], edges: [ { sourceNodeID: 'start_0', targetNodeID: 'condition_0', }, { sourceNodeID: 'condition_0', targetNodeID: 'llm_l_TcE', sourcePortID: 'if_0', }, { sourceNodeID: 'condition_0', targetNodeID: 'loop_H8M3U', sourcePortID: 'if_f0rOAt', }, { sourceNodeID: 'llm_0', targetNodeID: 'end_0', }, { sourceNodeID: 'loop_H8M3U', targetNodeID: 'end_0', }, { sourceNodeID: 'llm_l_TcE', targetNodeID: 'llm_0', }, ], }; export const mockJSON2: WorkflowJSON = { nodes: [ { id: 'start_0', type: 'start', meta: { position: { x: 0, y: 0, }, }, data: { title: 'Start changed', }, }, { id: 'condition_0', type: 'condition', meta: { position: { x: 235.74542284219706, y: -157.7680906713165, }, }, data: { title: 'Condition changed', }, }, { id: 'end_0', type: 'end', meta: { position: { x: 310.0959023539669, y: 190.25, }, }, data: { title: 'End', }, }, { id: 'loop_H8M3U', type: 'loop', meta: { position: { x: 1020, y: 547.96875, }, }, data: { title: 'Loop_2 changed', }, blocks: [ { id: 'llm_CBdCg', type: 'llm', meta: { position: { x: 180, y: 0, }, }, data: { title: 'LLM_4 chnaged', }, }, { id: 'llm_gZafu', type: 'llm changed', meta: { position: { x: 9.626852659110725, y: 121.49956408020925, }, }, data: { title: 'LLM_5', }, }, ], edges: [ { sourceNodeID: 'llm_CBdCg', targetNodeID: 'llm_gZafu', }, ], }, { id: '159623', type: 'comment', meta: { position: { x: 300, y: 486.2002234088928, }, }, data: { size: { width: 240, height: 150, }, note: 'hi ~\n\nthis is a comment node changed\n\n- flowgram.ai', }, }, { id: 'group_V-_st', type: 'group', meta: { position: { x: 869.4856146469051, y: 56.4254577157803, }, }, data: { title: 'LLM_Group changed', color: 'Violet', parentID: 'root', blockIDs: ['llm_0', 'llm_l_TcE'], }, }, { id: 'llm_0', type: 'llm', meta: { position: { x: 640, y: 0, }, }, data: { title: 'LLM_0 changed', }, }, { id: 'llm_l_TcE', type: 'llm', meta: { position: { x: 180, y: 0, }, }, data: { title: 'LLM_1', }, }, ], edges: [ { sourceNodeID: 'start_0', targetNodeID: 'condition_0', }, { sourceNodeID: 'condition_0', targetNodeID: 'llm_l_TcE', sourcePortID: 'if_0', }, { sourceNodeID: 'condition_0', targetNodeID: 'loop_H8M3U', sourcePortID: 'if_f0rOAt', }, { sourceNodeID: 'llm_0', targetNodeID: 'end_0', }, { sourceNodeID: 'loop_H8M3U', targetNodeID: 'end_0', }, { sourceNodeID: 'llm_l_TcE', targetNodeID: 'llm_0', }, ], }; export const mockSimpleJSON: WorkflowJSON = { nodes: [ { id: 'start_0', type: 'start', meta: { position: { x: 0, y: 0 }, }, data: { title: 'start', }, }, { id: 'end_0', type: 'end', meta: { position: { x: 800, y: 0 }, }, data: { title: 'end', }, }, ], edges: [ { sourceNodeID: 'start_0', targetNodeID: 'end_0', }, ], }; export const mockSimpleJSON2: WorkflowJSON = { nodes: [ { id: 'start_0', type: 'start', meta: { position: { x: 1, y: 1 }, }, data: { title: 'start changed', }, }, { id: 'end_0', type: 'end', meta: { position: { x: 801, y: 1 }, }, data: { title: 'end changed', }, }, ], edges: [ { sourceNodeID: 'start_0', targetNodeID: 'end_0', }, ], }; ================================================ FILE: packages/client/free-layout-editor/__tests__/__snapshots__/free-layout-preset.test.ts.snap ================================================ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`free-layout-preset > custom fromNodeJSON and toNodeJSON 1`] = ` { "edges": [ { "sourceNodeID": "start_0", "targetNodeID": "end_0", }, ], "nodes": [ { "data": { "isFirstCreate": true, "runningTimes": 1, "title": "start", }, "id": "start_0", "meta": { "position": { "x": 0, "y": 0, }, }, "type": "start", }, { "data": { "isFirstCreate": true, "runningTimes": 1, "title": "end", }, "id": "end_0", "meta": { "position": { "x": 800, "y": 0, }, }, "type": "end", }, ], } `; exports[`free-layout-preset > custom fromNodeJSON and toNodeJSON 2`] = ` { "edges": [ { "sourceNodeID": "start_0", "targetNodeID": "end_0", }, ], "nodes": [ { "data": { "isFirstCreate": false, "runningTimes": 1, "title": "start changed", }, "id": "start_0", "meta": { "position": { "x": 1, "y": 1, }, }, "type": "start", }, { "data": { "isFirstCreate": false, "runningTimes": 1, "title": "end changed", }, "id": "end_0", "meta": { "position": { "x": 801, "y": 1, }, }, "type": "end", }, ], } `; ================================================ FILE: packages/client/free-layout-editor/__tests__/create-editor.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { interfaces } from 'inversify'; import { createPlaygroundContainer, Playground, loadPlugins, PluginContext, createPluginContextDefault, FlowDocument, } from '@flowgram.ai/editor'; import { FreeLayoutPluginContext, FreeLayoutProps, createFreeLayoutPreset } from '../src'; export function createEditor(opts: FreeLayoutProps): interfaces.Container { const container = createPlaygroundContainer(); const playground = container.get(Playground); const preset = createFreeLayoutPreset(opts); const customPluginContext = (container: interfaces.Container) => ({ ...createPluginContextDefault(container), get document(): FlowDocument { return container.get(FlowDocument); }, } as FreeLayoutPluginContext); const ctx = customPluginContext(container); container.rebind(PluginContext).toConstantValue(ctx); loadPlugins(preset(ctx), container); playground.init(); return container; } ================================================ FILE: packages/client/free-layout-editor/__tests__/free-layout-preset.test.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React from 'react'; import { describe, it, expect } from 'vitest'; import { WorkflowDocument } from '@flowgram.ai/free-layout-core'; import { FlowDocument, FlowNodeFormData } from '@flowgram.ai/editor'; import { WorkflowOperationService } from '../src/types'; import { mockJSON, mockJSON2, mockSimpleJSON, mockSimpleJSON2 } from '../__mocks__/flow.mocks'; import { createEditor } from './create-editor'; describe('free-layout-preset', () => { it('fromJSON and toJSON', () => { const editor = createEditor({}); const document = editor.get(WorkflowDocument); document.fromJSON(mockJSON); expect(document.toJSON()).toEqual(mockJSON); document.fromJSON(mockJSON2); expect(document.toJSON()).toEqual(mockJSON2); }); it('operation fromJSON', () => { const editor = createEditor({ history: { enable: true, }, }); const operation = editor.get(WorkflowOperationService); const document = editor.get(WorkflowDocument); operation.fromJSON(mockJSON); expect(document.toJSON()).toEqual(mockJSON); document.clear(); operation.fromJSON(mockJSON2); expect(document.toJSON()).toEqual(mockJSON2); }); it('custom fromNodeJSON and toNodeJSON', () => { const container = createEditor({ fromNodeJSON: (node, json, isFirstCreate) => { if (!json.data) { json.data = {}; } json.data = { ...json.data, isFirstCreate }; return json; }, toNodeJSON(node, json) { json.data!.runningTimes = (json.data!.runningTimes || 0) + 1; return json; }, }); container.get(FlowDocument).fromJSON(mockSimpleJSON); expect(container.get(FlowDocument).toJSON()).toMatchSnapshot(); container.get(FlowDocument).fromJSON(mockSimpleJSON2); expect(container.get(FlowDocument).toJSON()).toMatchSnapshot(); }); it('nodeEngine(v2) toJSON', async () => { const container = createEditor({ nodeEngine: {}, nodeRegistries: [ { type: 'start', formMeta: { render: () => React.createElement('div', { className: 'start-node' }), }, }, { type: 'end', formMeta: { render: () => React.createElement('div', { className: 'end-node' }), }, }, ], }); const flowDocument = container.get(FlowDocument); flowDocument.fromJSON(mockSimpleJSON); expect(flowDocument.toJSON()).toEqual(mockSimpleJSON); flowDocument.fromJSON(mockSimpleJSON2); expect(flowDocument.toJSON()).toEqual(mockSimpleJSON2); const { formModel } = flowDocument.getNode('start_0')!.getData(FlowNodeFormData); expect(formModel.getFormItemByPath('title')!.value).toEqual('start changed'); formModel.getFormItemByPath('title')!.value = 'start changed 2'; expect(formModel.toJSON()).toEqual({ title: 'start changed 2', }); }); }); ================================================ FILE: packages/client/free-layout-editor/__tests__/history.test.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { describe, it, expect } from 'vitest'; import { WorkflowDocument } from '@flowgram.ai/free-layout-core'; import { HistoryService } from '@flowgram.ai/free-history-plugin'; import { mockJSON } from '../__mocks__/flow.mocks'; import { createEditor } from './create-editor'; describe('free-layout history', () => { it('line-data-change', async () => { const editor = createEditor({ history: { enable: true, }, }); const document = editor.get(WorkflowDocument); const history = editor.get(HistoryService); let historyEvent: any; history.onApply((e) => { historyEvent = e; }); document.fromJSON(mockJSON); const line = document.linesManager.getLine({ from: 'start_0', to: 'condition_0', }); line.lineData = { a: 33 }; expect(historyEvent.type).toEqual('changeLineData'); expect(historyEvent.value).toEqual({ id: 'start_0_-condition_0_', oldValue: undefined, newValue: { a: 33 }, }); await history.undo(); expect(historyEvent.value).toEqual({ id: 'start_0_-condition_0_', oldValue: { a: 33 }, newValue: undefined, }); expect(line.lineData).toEqual(undefined); await history.redo(); expect(historyEvent.value).toEqual({ id: 'start_0_-condition_0_', oldValue: undefined, newValue: { a: 33 }, }); expect(line.lineData).toEqual({ a: 33 }); // change moreTimes line.lineData = { a: 44 }; line.lineData = { a: 55 }; line.lineData = { a: 66 }; await history.undo(); expect(line.lineData).toEqual(undefined); await history.redo(); expect(line.lineData).toEqual({ a: 66 }); }); it('enableChangeLineData to false', () => { const editor = createEditor({ history: { enable: true, enableChangeLineData: false, }, }); const document = editor.get(WorkflowDocument); document.fromJSON(mockJSON); const history = editor.get(HistoryService); let historyEvent: any; history.onApply((e) => { historyEvent = e; }); const line = document.linesManager.getLine({ from: 'start_0', to: 'condition_0', }); line.lineData = { a: 33 }; expect(historyEvent).toEqual(undefined); }); it('changeNodeForm', async () => { const editor = createEditor({ history: { enable: true, }, nodeEngine: { enable: true, }, getNodeDefaultRegistry: (type) => ({ type, formMeta: { render: () => null, }, }), }); let historyEvent: any; const history = editor.get(HistoryService); history.onApply((e) => { historyEvent = e; }); const flowDocument = editor.get(WorkflowDocument); flowDocument.fromJSON(mockJSON); const node = flowDocument.getNode('start_0'); const form = node.form; form.setValueIn('title', 'title changed'); expect(historyEvent).toEqual({ type: 'changeFormValues', value: { id: 'start_0', path: 'title', value: 'title changed', oldValue: 'Start', }, }); await history.undo(); expect(form.getValueIn('title')).toEqual('Start'); await history.redo(); expect(form.getValueIn('title')).toEqual('title changed'); }); }); ================================================ FILE: packages/client/free-layout-editor/__tests__/use-playground-tools.test.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { beforeEach, describe, expect, it } from 'vitest'; import { interfaces } from 'inversify'; import { renderHook } from '@testing-library/react-hooks'; import { delay } from '@flowgram.ai/utils'; import { WorkflowDocument, WorkflowDocumentOptions, InteractiveType, EditorCursorState, LineType, } from '@flowgram.ai/free-layout-core'; import { Playground, PositionData, FlowNodeBaseType } from '@flowgram.ai/editor'; import { PlaygroundTools, usePlaygroundTools } from '../src'; import { createDocument, createHookWrapper, nestJSON, createSubCanvasNodes } from './utils.mock'; describe( 'use-playground-tools', () => { let toolsData: { current: PlaygroundTools }; let playground: Playground; let container: interfaces.Container; beforeEach(async () => { container = (await createDocument()).container; playground = container.get(Playground); // tools 工具要在 ready 之后才能生效 playground.ready(); const wrapper = createHookWrapper(container); const { result } = renderHook(() => usePlaygroundTools(), { wrapper, }); toolsData = result; }); it('zoomin', async () => { expect(toolsData.current.zoom).toEqual(1); toolsData.current.zoomin(false); expect(toolsData.current.zoom).toEqual(1.1); }); it('zoomout', async () => { expect(toolsData.current.zoom).toEqual(1); toolsData.current.zoomout(false); expect(toolsData.current.zoom).toEqual(0.9); }); it('fitview', async () => { playground.config.updateConfig({ width: 1000, height: 800, }); await toolsData.current.fitView(false); expect(playground.config.scrollData).toEqual({ scrollX: -30, scrollY: -370, }); }); it('autoLayout', async () => { const doc = container.get(WorkflowDocument); let startPos = doc.getNode('start_0')!.getData(PositionData)!; const endPos = doc.getNode('end_0')!.getData(PositionData)!; expect(endPos.x - startPos.x).toEqual(800); const revert = await toolsData.current.autoLayout(); expect(endPos.x - startPos.x).toEqual(620); revert(); // 回滚 expect(endPos.x - startPos.x).toEqual(800); }); it('autoLayout with nested JSON', async () => { const doc = container.get(WorkflowDocument); doc.fromJSON(nestJSON); await delay(10); let startPos = doc.getNode('start_0')!.getData(PositionData)!; const endPos = doc.getNode('end_0')!.getData(PositionData)!; expect(endPos.x - startPos.x).toEqual(800); const revert = await toolsData.current.autoLayout(); expect(endPos.x - startPos.x).toEqual(810); revert(); // 回滚 expect(endPos.x - startPos.x).toEqual(800); }); it.skip('autoLayout with verticalLine', async () => { const document = container.get(WorkflowDocument); // TODO // const documentOptions = container.get(WorkflowDocumentOptions); // documentOptions.isVerticalLine = (line) => { // if ( // line.from?.flowNodeType === 'loop' && // line.to?.flowNodeType === FlowNodeBaseType.SUB_CANVAS // ) { // return true; // } // return false; // }; const { loopNode, subCanvasNode } = await createSubCanvasNodes(document); const loopPos = loopNode.getData(PositionData)!; const subCanvasPos = subCanvasNode.getData(PositionData)!; await delay(10); expect({ x: loopPos.x, y: loopPos.y, }).toEqual({ x: -100, y: 0, }); expect({ x: subCanvasPos.x, y: subCanvasPos.y, }).toEqual({ x: 100, y: 0, }); await toolsData.current.autoLayout(); await delay(10); expect({ x: loopPos.x, y: loopPos.y, }).toEqual({ x: 140, y: 130, }); expect({ x: subCanvasPos.x, y: subCanvasPos.y, }).toEqual({ x: 0, y: 290, }); }); it('switchLineType', async () => { expect(toolsData.current.lineType).toEqual(LineType.BEZIER); toolsData.current.switchLineType(); expect(toolsData.current.lineType).toEqual(LineType.LINE_CHART); toolsData.current.switchLineType(); expect(toolsData.current.lineType).toEqual(LineType.BEZIER); }); it('setCursorState', async () => { await toolsData.current.setCursorState(() => EditorCursorState.GRAB); expect(toolsData.current.cursorState).toEqual('GRAB'); await toolsData.current.setCursorState(EditorCursorState.SELECT); expect(toolsData.current.cursorState).toEqual('SELECT'); }); it('setInteractiveType', async () => { await toolsData.current.setInteractiveType(InteractiveType.MOUSE); expect(toolsData.current.interactiveType).toEqual(InteractiveType.MOUSE); expect(toolsData.current.cursorState).toEqual('GRAB'); await toolsData.current.setInteractiveType(InteractiveType.PAD); expect(toolsData.current.interactiveType).toEqual(InteractiveType.PAD); expect(toolsData.current.cursorState).toEqual('SELECT'); }); }, { timeout: 30000, } ); ================================================ FILE: packages/client/free-layout-editor/__tests__/utils.mock.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React from 'react'; import { ContainerModule, interfaces } from 'inversify'; import { WorkflowBezierLineContribution, WorkflowFoldLineContribution, } from '@flowgram.ai/free-lines-plugin'; import { AutoLayoutService } from '@flowgram.ai/free-auto-layout-plugin'; import { WorkflowAutoLayoutTool } from '../src/tools'; import { FlowDocumentContainerModule, FlowNodeBaseType, FreeLayoutProps, PlaygroundEntityContext, PlaygroundMockTools, PlaygroundReactProvider, WorkflowDocument, WorkflowDocumentContainerModule, WorkflowJSON, WorkflowLinesManager, } from '../src'; const MockContainerModule = new ContainerModule((bind, unbind, isBound, rebind) => { bind(AutoLayoutService).toSelf().inSingletonScope(); }); /** * 创建基本的 Container */ export function createWorkflowContainer(opts: FreeLayoutProps): interfaces.Container { const container = PlaygroundMockTools.createContainer([ FlowDocumentContainerModule, WorkflowDocumentContainerModule, MockContainerModule, ]); container.bind(WorkflowAutoLayoutTool).toSelf().inSingletonScope(); const linesManager = container.get(WorkflowLinesManager); linesManager .registerContribution(WorkflowBezierLineContribution) .registerContribution(WorkflowFoldLineContribution); return container; } export const baseJSON: WorkflowJSON = { nodes: [ { id: 'start_0', type: 'start', meta: { position: { x: 0, y: 0 }, }, data: undefined, }, { id: 'condition_0', type: 'condition', meta: { position: { x: 400, y: 0 }, }, data: undefined, }, { id: 'end_0', type: 'end', meta: { position: { x: 800, y: 0 }, }, data: undefined, }, ], edges: [ { sourceNodeID: 'start_0', targetNodeID: 'condition_0', }, { sourceNodeID: 'condition_0', sourcePortID: 'if', targetNodeID: 'end_0', }, { sourceNodeID: 'condition_0', sourcePortID: 'else', targetNodeID: 'end_0', }, ], }; export const nestJSON: WorkflowJSON = { nodes: [ ...baseJSON.nodes, { id: 'loop_0', type: 'loop', meta: { position: { x: 1200, y: 0 }, }, data: undefined, blocks: [ { id: 'break_0', type: 'break', meta: { position: { x: 0, y: 0 }, }, data: undefined, }, { id: 'variable_0', type: 'variable', meta: { position: { x: 400, y: 0 }, }, data: undefined, }, ], edges: [ { sourceNodeID: 'break_0', targetNodeID: 'variable_0', }, ], }, ], edges: [...baseJSON.edges], }; export async function createDocument(params?: { json: WorkflowJSON; opts: FreeLayoutProps; }): Promise<{ document: WorkflowDocument; container: interfaces.Container; }> { const { json = baseJSON, opts = {} } = params || {}; const container = createWorkflowContainer(opts); const document = container.get(WorkflowDocument); await document.fromJSON(json); return { document, container, }; } export function createHookWrapper( container: interfaces.Container, entityId: string = 'start_0' ): any { // eslint-disable-next-line react/display-name return ({ children }: any) => ( {children} ); } export async function createSubCanvasNodes(document: WorkflowDocument) { await document.fromJSON({ nodes: [], edges: [] }); const loopNode = await document.createWorkflowNode({ id: 'loop_0', type: 'loop', meta: { position: { x: -100, y: 0 }, subCanvas: () => { const parentNode = document.getNode('loop_0'); const canvasNode = document.getNode('subCanvas_0'); if (!parentNode || !canvasNode) { return; } return { isCanvas: false, parentNode, canvasNode, }; }, }, }); const subCanvasNode = await document.createWorkflowNode({ id: 'subCanvas_0', type: FlowNodeBaseType.SUB_CANVAS, meta: { position: { x: 100, y: 0 }, subCanvas: () => ({ isCanvas: true, parentNode: document.getNode('loop_0')!, canvasNode: document.getNode('subCanvas_0')!, }), }, }); document.linesManager.createLine({ from: loopNode.id, to: subCanvasNode.id, }); const variableNode = await document.createWorkflowNode( { id: 'variable_0', type: 'variable', meta: { position: { x: 0, y: 0 }, }, }, false, subCanvasNode.id ); return { loopNode, subCanvasNode, variableNode, }; } ================================================ FILE: packages/client/free-layout-editor/eslint.config.js ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ const { defineFlatConfig } = require('@flowgram.ai/eslint-config'); module.exports = defineFlatConfig({ preset: 'web', packageRoot: __dirname, }); ================================================ FILE: packages/client/free-layout-editor/index.css ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ :root { --g-selection-background: #4d53e8; --g-editor-background: #f2f3f5; --g-playground-select: var(--g-selection-background); --g-playground-hover: var(--g-selection-background); --g-playground-line: var(--g-selection-background); --g-playground-blur: #999; --g-playground-selectBox-outline: var(--g-selection-background); --g-playground-selectBox-background: rgba(141, 144, 231, 0.1); --g-playground-select-hover-background: rgba(77, 83, 232, 0.1); --g-playground-select-control-size: 12px; } .gedit-playground { position: absolute; width: 100%; height: 100%; left: 0; top: 0; z-index: 10; overflow: hidden; user-select: none; outline: none; box-sizing: border-box; background-color: var(--g-editor-background); } .gedit-playground-scroll-right { position: absolute; right: 2px; height: 100vh; width: 7px; z-index: 10; } .gedit-playground-scroll-bottom { position: absolute; bottom: 2px; width: 100vw; height: 7px; z-index: 10; } .gedit-playground-scroll-right-block { position: absolute; opacity: 0.3; border-radius: 3.5px; } .gedit-playground-scroll-right-block:hover { opacity: 0.6; } .gedit-playground-scroll-bottom-block { position: absolute; opacity: 0.3; border-radius: 3.5px; } .gedit-playground-scroll-bottom-block:hover { opacity: 0.6; } .gedit-playground-scroll-hidden { opacity: 0; } .gedit-playground * { box-sizing: border-box; } .gedit-playground-loading { position: absolute; color: white; left: 50%; top: 50%; z-index: 100; display: flex; justify-content: center; align-items: center; transition: opacity 0.8s; flex-direction: column; text-align: center; opacity: 0.8; } .gedit-hidden { display: none; } .gedit-playground-pipeline { position: absolute; overflow: visible; width: 100%; height: 100%; left: 0; top: 0; } .gedit-playground-pipeline::before { content: ''; position: absolute; width: 1px; height: 100%; left: 0; top: 0; } .gedit-playground-layer { position: absolute; overflow: visible; } .gedit-selector-box { position: absolute; left: 0; top: 0; width: 0; height: 0; z-index: 33; outline: 1px solid var(--g-playground-selectBox-outline); background-color: var(--g-playground-selectBox-background); } .gedit-selector-box-block { position: absolute; left: 0; top: 0; width: 0; height: 0; z-index: 9999; display: none; background-color: rgba(0, 0, 0, 0); } .gedit-selector-bounds-background { position: absolute; left: 0; top: 0; width: 0; height: 0; outline: 1px solid var(--g-playground-selectBox-outline); background-color: #f0f4ff; } .gedit-selector-bounds-foreground { position: absolute; left: 0; top: 0; width: 0; height: 0; z-index: 33; background: rgba(255, 255, 255, 0); } .gedit-flow-activity-node { position: absolute; } .gedit-grid-svg { display: block; position: absolute; left: 20px; top: 20px; width: 0; height: 0; } ================================================ FILE: packages/client/free-layout-editor/package.json ================================================ { "name": "@flowgram.ai/free-layout-editor", "version": "0.1.8", "homepage": "https://flowgram.ai/", "repository": "https://github.com/bytedance/flowgram.ai", "license": "MIT", "exports": { ".": { "types": "./dist/index.d.ts", "require": "./dist/index.js", "import": "./dist/esm/index.js" }, "./index.css": { "import": "./index.css", "require": "./index.css" } }, "main": "./dist/index.js", "module": "./dist/esm/index.js", "types": "./dist/index.d.ts", "files": [ "dist", "index.css" ], "scripts": { "build": "npm run build:fast -- --dts-resolve", "build:fast": "tsup src/index.ts --format cjs,esm --sourcemap --legacy-output", "build:watch": "npm run build:fast -- --dts-resolve", "clean": "rimraf dist", "test": "vitest run", "test:cov": "vitest run --coverage", "ts-check": "tsc --noEmit", "watch": "npm run build:fast -- --dts-resolve --watch --ignore-watch dist" }, "dependencies": { "@flowgram.ai/editor": "workspace:*", "@flowgram.ai/free-auto-layout-plugin": "workspace:*", "@flowgram.ai/free-history-plugin": "workspace:*", "@flowgram.ai/free-hover-plugin": "workspace:*", "@flowgram.ai/free-layout-core": "workspace:*", "@flowgram.ai/free-lines-plugin": "workspace:*", "@flowgram.ai/free-stack-plugin": "workspace:*", "@flowgram.ai/history": "workspace:*", "@flowgram.ai/select-box-plugin": "workspace:*", "@flowgram.ai/utils": "workspace:*", "clsx": "^1.1.1", "inversify": "^6.0.1", "reflect-metadata": "~0.2.2" }, "devDependencies": { "@flowgram.ai/eslint-config": "workspace:*", "@flowgram.ai/ts-config": "workspace:*", "@testing-library/react": "^12", "@testing-library/react-hooks": "^8.0.1", "@types/bezier-js": "4.1.3", "@types/lodash-es": "^4.17.12", "@types/react": "^18", "@types/react-dom": "^18", "@vitest/coverage-v8": "^3.2.4", "eslint": "^9.0.0", "react": "^18", "react-dom": "^18", "tsup": "^8.0.1", "typescript": "^5.8.3", "vitest": "^3.2.4" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" }, "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" } } ================================================ FILE: packages/client/free-layout-editor/src/components/free-layout-editor-provider.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React, { useMemo, useCallback, forwardRef } from 'react'; import { interfaces } from 'inversify'; import { WorkflowDocument, fitView } from '@flowgram.ai/free-layout-core'; import { HistoryService } from '@flowgram.ai/free-history-plugin'; import { PlaygroundReactProvider, createPluginContextDefault, ClipboardService, SelectionService, Playground, } from '@flowgram.ai/editor'; import { WorkflowOperationService } from '../types'; import { WorkflowAutoLayoutTool } from '../tools'; import { createFreeLayoutPreset, FreeLayoutPluginContext, FreeLayoutPluginTools, FreeLayoutProps, } from '../preset'; export const FreeLayoutEditorProvider = forwardRef( function FreeLayoutEditorProvider(props: FreeLayoutProps, ref) { const { children, ...others } = props; const preset = useMemo(() => createFreeLayoutPreset(others), []); const customPluginContext = useCallback( (container: interfaces.Container) => ({ ...createPluginContextDefault(container), get document(): WorkflowDocument { return container.get(WorkflowDocument); }, get clipboard(): ClipboardService { return container.get(ClipboardService); }, get selection(): SelectionService { return container.get(SelectionService); }, get history(): HistoryService { return container.get(HistoryService); }, get operation(): WorkflowOperationService { return container.get(WorkflowOperationService); }, get tools(): FreeLayoutPluginTools { const autoLayoutTool = container.get(WorkflowAutoLayoutTool); return { autoLayout: autoLayoutTool.handle.bind(autoLayoutTool), fitView: (easing?: boolean) => fitView( container.get(WorkflowDocument), container.get(Playground).config, easing ), }; }, } as FreeLayoutPluginContext), [] ); return ( {children} ); } ); ================================================ FILE: packages/client/free-layout-editor/src/components/free-layout-editor.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React, { forwardRef } from 'react'; import { EditorRenderer } from '@flowgram.ai/editor'; import { FreeLayoutPluginContext, FreeLayoutProps } from '../preset'; import { FreeLayoutEditorProvider } from './free-layout-editor-provider'; /** * 自由布局编辑器 * @param props * @constructor */ export const FreeLayoutEditor = forwardRef( function FreeLayoutEditor(props: FreeLayoutProps, ref) { const { children, ...otherProps } = props; return ( {children} ); }, ); ================================================ FILE: packages/client/free-layout-editor/src/components/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export * from './free-layout-editor-provider'; export * from './workflow-node-renderer'; export * from './free-layout-editor'; export * from '@flowgram.ai/free-stack-plugin'; // WARNING: 这里用 export * 会有问题! export { WorkflowPortRender, type WorkflowPortRenderProps, } from '@flowgram.ai/free-lines-plugin'; ================================================ FILE: packages/client/free-layout-editor/src/components/workflow-node-renderer.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React from 'react'; import clx from 'clsx'; import { WorkflowPortRender } from '@flowgram.ai/free-lines-plugin'; import { WorkflowNodeEntity, useNodeRender, WorkflowPortEntity, } from '@flowgram.ai/free-layout-core'; export interface WorkflowNodeProps { node: WorkflowNodeEntity; className?: string; style?: React.CSSProperties; children?: React.ReactNode | null; portClassName?: string; portStyle?: React.CSSProperties; onPortClick?: ( port: WorkflowPortEntity, e: React.MouseEvent | React.MouseEventHandler ) => void; /** 端口激活状态颜色 (linked/hovered) */ portPrimaryColor?: string; /** 端口默认状态颜色 */ portSecondaryColor?: string; /** 端口错误状态颜色 */ portErrorColor?: string; /** 端口背景颜色 */ portBackgroundColor?: string; } export const WorkflowNodeRenderer: React.FC = (props) => { const { selected, activated, startDrag, ports, selectNode, nodeRef, onFocus, onBlur } = useNodeRender(); const className = clx(props.className || '', { activated, selected, }); return ( <>
{props.children}
{ports.map((p) => ( props.onPortClick!(p, e) : undefined} className={props.portClassName} style={props.portStyle} primaryColor={props.portPrimaryColor} secondaryColor={props.portSecondaryColor} errorColor={props.portErrorColor} backgroundColor={props.portBackgroundColor} /> ))} ); }; ================================================ FILE: packages/client/free-layout-editor/src/hooks/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export { useAutoLayout } from './use-auto-layout'; export { useClientContext } from './use-client-context'; export { PlaygroundTools, usePlaygroundTools } from './use-playground-tools'; ================================================ FILE: packages/client/free-layout-editor/src/hooks/use-auto-layout.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { useService } from '@flowgram.ai/free-layout-core'; import { WorkflowAutoLayoutTool } from '../tools'; export const useAutoLayout = () => { const autoLayoutTool = useService(WorkflowAutoLayoutTool); return autoLayoutTool.handle.bind(autoLayoutTool); }; ================================================ FILE: packages/client/free-layout-editor/src/hooks/use-client-context.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { useService, PluginContext } from '@flowgram.ai/editor'; import { FreeLayoutPluginContext } from '../preset'; export function useClientContext(): FreeLayoutPluginContext { return useService(PluginContext); } ================================================ FILE: packages/client/free-layout-editor/src/hooks/use-playground-tools.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ /* eslint-disable no-cond-assign */ import { useCallback, useEffect, useState } from 'react'; import { type Disposable } from '@flowgram.ai/utils'; import { EditorCursorState, InteractiveType, LineRenderType, WorkflowDocument, fitView, usePlayground, useService, } from '@flowgram.ai/free-layout-core'; import { EditorState } from '@flowgram.ai/editor'; import { useAutoLayout } from './use-auto-layout'; import { FreeLayoutPluginTools } from '../preset'; interface SetCursorStateCallbackEvent { isPressingSpaceBar: boolean; cursorState: EditorCursorState; } type SetCursorStateCallback = (e: SetCursorStateCallbackEvent) => EditorCursorState | undefined; export interface PlaygroundTools { zoomin: (easing?: boolean) => void; zoomout: (easing?: boolean) => void; fitView: (easing?: boolean) => void; /** * Auto layout tool - 自动布局工具 * https://flowgram.ai/guide/plugin/free-auto-layout-plugin.html */ autoLayout: FreeLayoutPluginTools['autoLayout']; /** * 切换线条 */ switchLineType: (lineType?: LineRenderType) => LineRenderType; lineType: LineRenderType; zoom: number; cursorState: EditorCursorState; setCursorState: (stateId: EditorCursorState | SetCursorStateCallback) => void; /** 交互模式:鼠标 or 触控板 */ interactiveType: InteractiveType; setInteractiveType: (type: InteractiveType) => void; /** 设置鼠标缩放 delta */ setMouseScrollDelta: (mouseScrollDelta: number | ((zoom: number) => number)) => void; } export interface PlaygroundToolsPropsType { /** * 最大缩放比,默认 2 */ maxZoom?: number; /** * 最小缩放比,默认 0.25 */ minZoom?: number; } export function usePlaygroundTools(props?: PlaygroundToolsPropsType): PlaygroundTools { const { maxZoom, minZoom } = props || {}; const playground = usePlayground(); const doc = useService(WorkflowDocument); const [zoom, setZoom] = useState(1); const [lineType, setLineType] = useState(doc.linesManager.lineType); const [cursorState, setCursorState] = useState(EditorCursorState.SELECT); const [interactiveType, setInteractiveType] = useState(InteractiveType.PAD); const handleZoomOut = useCallback( (easing?: boolean) => { playground?.config.zoomout(easing); }, [zoom, playground] ); const handleZoomIn = useCallback( (easing?: boolean) => { playground?.config.zoomin(easing); }, [zoom, playground] ); // 切换线条类型 const handleLineTypeChange = useCallback( (lineType?: LineRenderType) => { const newLineType = doc.linesManager.switchLineType(lineType); setLineType(newLineType); return newLineType; }, [doc] ); // 获取合适视角 const handleFitView = useCallback( (easing?: boolean) => { fitView(doc, playground.config, easing); }, [doc, playground] ); const handleAutoLayout = useAutoLayout(); useEffect(() => { let dispose: Disposable | null = null; if (playground) { dispose = playground.onZoom((z) => setZoom(z)); } return () => { if (dispose) { dispose.dispose(); } }; }, [playground]); useEffect(() => { const disposable = playground.editorState.onStateChange((e) => { setCursorState( e.state === EditorState.STATE_GRAB || e.state === EditorState.STATE_MOUSE_FRIENDLY_SELECT ? EditorCursorState.GRAB : EditorCursorState.SELECT ); // 设置交互模式 setInteractiveType( e.state === EditorState.STATE_MOUSE_FRIENDLY_SELECT ? InteractiveType.MOUSE : InteractiveType.PAD ); }); return () => { disposable.dispose(); }; }, [playground]); function handleUpdateCursorState(stateId: EditorCursorState | SetCursorStateCallback) { let finalStateId: EditorCursorState | undefined; if (typeof stateId === 'function') { finalStateId = stateId({ isPressingSpaceBar: playground.editorState.isPressingSpaceBar, cursorState, }); } else { finalStateId = stateId; } if (typeof finalStateId === 'undefined') { return; } if (finalStateId === EditorCursorState.GRAB) { playground.editorState.changeState(EditorState.STATE_GRAB.id); setCursorState(finalStateId); } else if ((finalStateId = EditorCursorState.SELECT)) { playground.editorState.changeState(EditorState.STATE_SELECT.id); setCursorState(finalStateId); } } function handleUpdateInteractiveType(interactiveType: InteractiveType) { if (interactiveType === InteractiveType.MOUSE) { // 鼠标优先交互模式:更新状态 & 设置小手 playground.editorState.changeState(EditorState.STATE_MOUSE_FRIENDLY_SELECT.id); setCursorState(EditorCursorState.GRAB); } else if (interactiveType === InteractiveType.PAD) { // 触控板优先交互模式:更新状态 & 设置箭头 playground.editorState.changeState(EditorState.STATE_SELECT.id); setCursorState(EditorCursorState.SELECT); } setInteractiveType(interactiveType); return; } function handleUpdateMouseScrollDelta(delta: number | ((zoom: number) => number)) { playground.config.updateConfig({ mouseScrollDelta: delta, }); } useEffect(() => { const config = playground.config.config; playground.config.updateConfig({ maxZoom: maxZoom !== undefined ? maxZoom : config.maxZoom, minZoom: minZoom !== undefined ? minZoom : config.minZoom, }); }, [playground, maxZoom, minZoom]); return { zoomin: handleZoomIn, zoomout: handleZoomOut, fitView: handleFitView, autoLayout: handleAutoLayout, switchLineType: handleLineTypeChange, zoom, lineType, cursorState, setCursorState: handleUpdateCursorState, interactiveType, setInteractiveType: handleUpdateInteractiveType, setMouseScrollDelta: handleUpdateMouseScrollDelta, }; } ================================================ FILE: packages/client/free-layout-editor/src/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import 'reflect-metadata'; /* 核心 模块导出 */ export * from '@flowgram.ai/editor'; /** * 自由布局模块导出 */ export * from '@flowgram.ai/free-layout-core'; export * from './components'; export * from './preset'; export * from './hooks'; export * from './tools'; export * from '@flowgram.ai/free-history-plugin'; export { useClientContext } from './hooks/use-client-context'; ================================================ FILE: packages/client/free-layout-editor/src/plugins/create-operation-plugin.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { definePluginCreator } from '@flowgram.ai/editor'; import { WorkflowOperationService } from '../types'; import { HistoryOperationServiceImpl } from '../services/history-operation-service'; import { WorkflowOperationServiceImpl } from '../services/flow-operation-service'; import { FreeLayoutProps } from '../preset'; export const createOperationPlugin = definePluginCreator({ onBind: ({ bind }, opts) => { bind(WorkflowOperationService) .to(opts?.history?.enable ? HistoryOperationServiceImpl : WorkflowOperationServiceImpl) .inSingletonScope(); }, onDispose: (ctx) => { const flowOperationService = ctx.container.get(WorkflowOperationService); flowOperationService.dispose(); }, }); ================================================ FILE: packages/client/free-layout-editor/src/preset/free-layout-preset.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { createSelectBoxPlugin } from '@flowgram.ai/select-box-plugin'; import { createFreeStackPlugin, StackingContextManager } from '@flowgram.ai/free-stack-plugin'; import { createFreeLinesPlugin } from '@flowgram.ai/free-lines-plugin'; import { WorkflowCommands, WorkflowNodeEntity, WorkflowLineEntity, WorkflowDocumentContainerModule, WorkflowHoverService, WorkflowDocumentOptions, WorkflowDocumentOptionsDefault, WorkflowNodeMeta, } from '@flowgram.ai/free-layout-core'; import { createFreeHoverPlugin } from '@flowgram.ai/free-hover-plugin'; import { HistoryService, createFreeHistoryPlugin } from '@flowgram.ai/free-history-plugin'; import { createFreeAutoLayoutPlugin } from '@flowgram.ai/free-auto-layout-plugin'; import { PluginsProvider, Plugin, createDefaultPreset, SelectionService, createShortcutsPlugin, EditorProps, createVariablePlugin, createPlaygroundPlugin, Command, PluginContext, FlowNodesContentLayer, FlowNodesTransformLayer, FlowScrollBarLayer, FlowScrollLimitLayer, createPlaygroundReactPreset, } from '@flowgram.ai/editor'; import { WorkflowAutoLayoutTool } from '../tools'; import { createOperationPlugin } from '../plugins/create-operation-plugin'; import { fromNodeJSON, toNodeJSON } from './node-serialize'; import { FreeLayoutProps, FreeLayoutPluginContext } from './free-layout-props'; const renderElement = (ctx: PluginContext) => { const stackingContextManager = ctx.get(StackingContextManager); if (stackingContextManager.node) { return stackingContextManager.node; } }; export function createFreeLayoutPreset( opts: FreeLayoutProps ): PluginsProvider { return (ctx: FreeLayoutPluginContext) => { opts = { ...FreeLayoutProps.DEFAULT, ...opts, playground: { ...opts.playground, // 这里要把自由布局的 hoverService 注入进去 get hoverService() { return ctx.get(WorkflowHoverService); }, }, }; let plugins: Plugin[] = []; /** * 注册默认的快捷键 */ plugins.push( createShortcutsPlugin({ registerShortcuts(registry) { const selection = ctx.get(SelectionService); registry.addHandlers({ commandId: WorkflowCommands.DELETE_NODES, shortcuts: ['backspace', 'delete'], isEnabled: () => selection.selection.length > 0 && !ctx.playground.config.readonlyOrDisabled, execute: () => { selection.selection.forEach((entity) => { if (entity instanceof WorkflowNodeEntity) { if (!ctx.document.canRemove(entity)) { return; } const nodeMeta = entity.getNodeMeta(); const subCanvas = nodeMeta.subCanvas?.(entity); if (subCanvas?.isCanvas) { subCanvas.parentNode.dispose(); return; } entity.dispose(); } else if (entity instanceof WorkflowLineEntity) { if (!ctx.document.linesManager.canRemove(entity)) { return; } entity.dispose(); } }); selection.selection = selection.selection.filter((s) => !s.disposed); }, }); if (opts?.history?.enable) { const fixedHistoryService = ctx.get(HistoryService); if (!opts.history.disableShortcuts) { registry.addHandlers({ commandId: Command.Default.UNDO, shortcuts: ['meta z', 'ctrl z'], isEnabled: () => true, execute: () => { fixedHistoryService.undo(); }, }); registry.addHandlers({ commandId: Command.Default.REDO, shortcuts: ['meta shift z', 'ctrl shift z'], isEnabled: () => true, execute: () => { fixedHistoryService.redo(); }, }); } } }, }) ); /** * 加载默认编辑器配置 */ plugins = createDefaultPreset(opts as EditorProps, plugins)(ctx); /** * 注册变量系统 */ if (opts.variableEngine?.enable) { plugins.push( createVariablePlugin({ ...opts.variableEngine, layout: 'free', }) ); } if (opts.history?.enable) { plugins.push(createFreeHistoryPlugin(opts.history as any)); } /** * 注册自由布局模块 */ plugins.push( createPlaygroundPlugin({ onBind: (bindConfig) => { bindConfig.bind(WorkflowAutoLayoutTool).toSelf().inSingletonScope(); bindConfig.rebind(WorkflowDocumentOptions).toConstantValue({ canAddLine: opts.canAddLine?.bind(null, ctx), canDeleteLine: opts.canDeleteLine?.bind(null, ctx), isErrorLine: opts.isErrorLine?.bind(null, ctx), isErrorPort: opts.isErrorPort?.bind(null, ctx), isDisabledPort: opts.isDisabledPort?.bind(null, ctx), isReverseLine: opts.isReverseLine?.bind(null, ctx), isHideArrowLine: opts.isHideArrowLine?.bind(null, ctx), isFlowingLine: opts.isFlowingLine?.bind(null, ctx), isDisabledLine: opts.isDisabledLine?.bind(null, ctx), onDragLineEnd: opts.onDragLineEnd?.bind(null, ctx), setLineRenderType: opts.setLineRenderType?.bind(null, ctx), setLineClassName: opts.setLineClassName?.bind(null, ctx), canDeleteNode: opts.canDeleteNode?.bind(null, ctx), canResetLine: opts.canResetLine?.bind(null, ctx), canDropToNode: opts.canDropToNode?.bind(null, ctx), cursors: opts.cursors ?? WorkflowDocumentOptionsDefault.cursors, lineColor: opts.lineColor ?? WorkflowDocumentOptionsDefault.lineColor, allNodesDefaultExpanded: opts.allNodesDefaultExpanded, twoWayConnection: opts.twoWayConnection ?? true, enableReadonlyNodeDragging: opts.enableReadonlyNodeDragging ?? false, toNodeJSON: (node) => toNodeJSON(opts, node), fromNodeJSON: (node, json, isFirstCreate) => fromNodeJSON(opts, node, json, isFirstCreate), } as WorkflowDocumentOptions); }, onInit: (ctx) => { // 节点内容渲染 ctx.playground.registerLayer(FlowNodesContentLayer); // 节点位置偏移计算 ctx.playground.registerLayer(FlowNodesTransformLayer, { renderElement: () => { if (typeof renderElement === 'function') { return renderElement(ctx); } else { return renderElement; } }, }); if (opts.scroll?.enableScrollLimit) { // 控制滚动范围 ctx.playground.registerLayer(FlowScrollLimitLayer); } if (!opts.scroll?.disableScrollBar) { // 控制条 ctx.playground.registerLayer(FlowScrollBarLayer); } if (opts.scroll?.disableScroll) { ctx.playground.config.scrollDisable = true; } if (opts.onContentChange) { ctx.document.onContentChange((event) => opts.onContentChange!(ctx, event)); } }, containerModules: [WorkflowDocumentContainerModule], }), createOperationPlugin(opts), /** * 渲染层级管理 */ createFreeStackPlugin({}), /** * 线条渲染插件 */ createFreeLinesPlugin({}), /** * 节点 hover 插件 */ createFreeHoverPlugin({}), /** * 自动布局插件 */ createFreeAutoLayoutPlugin({}), /** * 选择框插件 */ createSelectBoxPlugin({ canSelect: (e) => { // 需满足以下条件: // 1. 鼠标左键 if (e.button !== 0) { return false; } // 2. 如存在自定义配置,以配置为准 const element = e.target as Element; if (element) { if (element.classList.contains('gedit-flow-background-layer')) { return true; } if (element.closest('[data-flow-editor-selectable="true"]')) { return true; } if (element.closest('[data-flow-editor-selectable="false"]')) { return false; } } // 3. hover 到节点或者线条不能触发框选 const hoverService = ctx.get(WorkflowHoverService); if (hoverService.isSomeHovered()) { return false; } return true; }, ignoreOneSelect: true, // 自由布局不选择单个节点 ignoreChildrenLength: true, // 自由布局忽略子节点数量 ...(opts.selectBox || {}), }) ); return createPlaygroundReactPreset(opts, plugins)(ctx); }; } ================================================ FILE: packages/client/free-layout-editor/src/preset/free-layout-props.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { SelectBoxPluginOptions } from '@flowgram.ai/select-box-plugin'; import { HistoryService } from '@flowgram.ai/history'; import { LineColor, LineRenderType, onDragLineEndParams, WorkflowContentChangeEvent, WorkflowDocument, WorkflowJSON, WorkflowLineEntity, WorkflowLinePortInfo, type WorkflowLinesManager, WorkflowNodeEntity, WorkflowNodeRegistry, WorkflowPortEntity, } from '@flowgram.ai/free-layout-core'; import { FreeHistoryPluginOptions } from '@flowgram.ai/free-history-plugin'; import { ClipboardService, EditorPluginContext, EditorProps, SelectionService, PluginContext, FlowNodeType, } from '@flowgram.ai/editor'; import { WorkflowOperationService } from '../types'; import { AutoLayoutResetFn, AutoLayoutToolOptions } from '../tools'; export const FreeLayoutPluginContext = PluginContext; export interface FreeLayoutPluginTools { autoLayout: (options?: AutoLayoutToolOptions) => Promise; fitView: (easing?: boolean) => Promise; } export interface FreeLayoutPluginContext extends EditorPluginContext { /** * 文档 */ document: WorkflowDocument; clipboard: ClipboardService; selection: SelectionService; /** * 提供对画布节点相关操作方法, 并 支持 redo/undo */ operation: WorkflowOperationService; history: HistoryService; tools: FreeLayoutPluginTools; } /** * Free layout configuration * 自由布局配置 */ export interface FreeLayoutProps extends EditorProps { /** * SelectBox config * 选择框定义 */ selectBox?: SelectBoxPluginOptions; /** * Node registries * 节点注册 */ nodeRegistries?: WorkflowNodeRegistry[]; /** * By default, all nodes are expanded * 默认是否展开所有节点 */ allNodesDefaultExpanded?: boolean; /* * Cursor configuration, support svg * 光标图片, 支持 svg */ cursors?: { grab?: string; grabbing?: string; }; /** * Line support both-way connection (default true) * 线条支持双向连接 */ twoWayConnection?: boolean; /** * Enable dragging of read-only nodes (default false) * 允许拖拽只读节点 */ enableReadonlyNodeDragging?: boolean; /** * History configuration */ history?: FreeHistoryPluginOptions & { disableShortcuts?: boolean }; /** * Line color configuration * 线条颜色 */ lineColor?: LineColor; /** * Listen for content change * 监听画布内容更新 */ onContentChange?: (ctx: FreeLayoutPluginContext, event: WorkflowContentChangeEvent) => void; /** * Determine whether the line is marked as error * 判断线条是否标红 * @param ctx * @param fromPort * @param toPort * @param lines */ isErrorLine?: ( ctx: FreeLayoutPluginContext, fromPort: WorkflowPortEntity, toPort: WorkflowPortEntity | undefined, lines: WorkflowLinesManager ) => boolean; /** * Determine whether the port is marked as error * 判断端口是否标红 * @param ctx * @param port */ isErrorPort?: (ctx: FreeLayoutPluginContext, port: WorkflowPortEntity) => boolean; /** * Determine if the port is disabled * 判断端口是否禁用 * @param ctx * @param port */ isDisabledPort?: (ctx: FreeLayoutPluginContext, port: WorkflowPortEntity) => boolean; /** * Determine whether the line arrow is reversed * 判断线条箭头是否反转 * @param ctx * @param line */ isReverseLine?: (ctx: FreeLayoutPluginContext, line: WorkflowLineEntity) => boolean; /** * Determine if the line hides the arrow * 判断线条是否隐藏箭头 * @param ctx * @param line */ isHideArrowLine?: (ctx: FreeLayoutPluginContext, line: WorkflowLineEntity) => boolean; /** * Determine whether the line shows a flow effect * 判断线条是否展示流动效果 * @param ctx * @param line */ isFlowingLine?: (ctx: FreeLayoutPluginContext, line: WorkflowLineEntity) => boolean; /** * Determine if a line is disabled * 判断线条是否禁用 * @param ctx * @param line */ isDisabledLine?: (ctx: FreeLayoutPluginContext, line: WorkflowLineEntity) => boolean; /** * Listen for dragging the line to end * 拖拽线条结束 * @param ctx * @param params */ onDragLineEnd?: (ctx: FreeLayoutPluginContext, params: onDragLineEndParams) => Promise; /** * Set the line renderer type * 设置线条渲染器类型 * @param ctx * @param line */ setLineRenderType?: ( ctx: FreeLayoutPluginContext, line: WorkflowLineEntity ) => LineRenderType | undefined; /** * Set the line className * 设置线条样式 * @param ctx * @param line */ setLineClassName?: (ctx: FreeLayoutPluginContext, line: WorkflowLineEntity) => string | undefined; /** * Whether to create lines or not * 是否允许创建线条 * @param ctx * @param fromPort - Source port * @param toPort - Target port */ canAddLine?: ( ctx: FreeLayoutPluginContext, fromPort: WorkflowPortEntity, toPort: WorkflowPortEntity, lines: WorkflowLinesManager, silent?: boolean ) => boolean; /** * Whether to allow the deletion of nodes * 是否允许删除节点 * @param ctx * @param node - 目标节点 * @param silent - 如果为false,可以加 toast 弹窗 */ canDeleteNode?: ( ctx: FreeLayoutPluginContext, node: WorkflowNodeEntity, silent?: boolean ) => boolean; /** * * Whether to delete lines or not * 是否允许删除线条 * @param ctx * @param line - target line * @param newLineInfo - new line info * @param silent - If false, you can add a toast pop-up */ canDeleteLine?: ( ctx: FreeLayoutPluginContext, line: WorkflowLineEntity, newLineInfo?: Required, silent?: boolean ) => boolean; /** * Whether to allow lines to be reset * 是否允许重置线条 * @param ctx * @param oldLine - old line * @param newLineInfo - new line info * @param lines - lines manager */ canResetLine?: ( ctx: FreeLayoutPluginContext, oldLine: WorkflowLineEntity, newLineInfo: Required, lines: WorkflowLinesManager ) => boolean; /** * Whether to allow dragging into the sub-canvas (loop or group) * 是否允许拖入子画布 (loop or group) * @param params */ canDropToNode?: ( ctx: FreeLayoutPluginContext, params: { dragNodeType?: FlowNodeType; dragNode?: WorkflowNodeEntity; dropNode?: WorkflowNodeEntity; dropNodeType?: FlowNodeType; } ) => boolean; } export namespace FreeLayoutProps { /** * 默认配置 */ export const DEFAULT: FreeLayoutProps = { ...EditorProps.DEFAULT, } as FreeLayoutProps; } ================================================ FILE: packages/client/free-layout-editor/src/preset/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export * from './free-layout-preset'; export * from './free-layout-props'; ================================================ FILE: packages/client/free-layout-editor/src/preset/node-serialize.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { WorkflowContentChangeType, WorkflowDocument, WorkflowDocumentOptionsDefault, } from '@flowgram.ai/free-layout-core'; import { FlowNodeBaseType, FlowNodeEntity, FlowNodeFormData, type FlowNodeJSON, } from '@flowgram.ai/editor'; import { FreeLayoutProps } from './free-layout-props'; export function fromNodeJSON( opts: FreeLayoutProps, node: FlowNodeEntity, json: FlowNodeJSON, isFirstCreate: boolean ) { json = opts.fromNodeJSON ? opts.fromNodeJSON(node, json, isFirstCreate) : json; const formData = node.getData(FlowNodeFormData)!; // 如果没有使用表单引擎,将 data 数据填入 extInfo if (!formData) { if (json.data) { node.updateExtInfo(json.data, true); } // extInfo 数据更新则触发内容更新 if (isFirstCreate) { node.onExtInfoChange(() => { (node.document as WorkflowDocument).fireContentChange({ type: WorkflowContentChangeType.NODE_DATA_CHANGE, toJSON: () => node.getExtInfo(), entity: node, }); }); } return; } return WorkflowDocumentOptionsDefault.fromNodeJSON!(node, json, isFirstCreate); } export function toNodeJSON(opts: FreeLayoutProps, node: FlowNodeEntity): FlowNodeJSON { const formData = node.getData(FlowNodeFormData)!; const position = node.transform.position; let json: FlowNodeJSON; // 不使用节点引擎则采用 extInfo if (!formData) { json = { id: node.id, type: node.flowNodeType, meta: { position: { x: position.x, y: position.y }, }, data: node.getExtInfo(), }; } else { json = WorkflowDocumentOptionsDefault.toNodeJSON!(node); } // 处理分组节点 if (node.flowNodeType === FlowNodeBaseType.GROUP) { const parentID = node.parent?.id ?? FlowNodeBaseType.ROOT; const blockIDs = node.blocks.map((block) => block.id) ?? []; json.data = { ...json.data, parentID, blockIDs, }; } return opts.toNodeJSON ? opts.toNodeJSON(node, json) : json; } ================================================ FILE: packages/client/free-layout-editor/src/services/flow-operation-service.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { injectable } from 'inversify'; import { WorkflowOperationBaseServiceImpl } from '@flowgram.ai/free-layout-core'; import { WorkflowOperationService } from '../types'; @injectable() export class WorkflowOperationServiceImpl extends WorkflowOperationBaseServiceImpl implements WorkflowOperationService { startTransaction(): void {} endTransaction(): void {} } ================================================ FILE: packages/client/free-layout-editor/src/services/history-operation-service.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { inject, injectable } from 'inversify'; import { HistoryService } from '@flowgram.ai/history'; import { WorkflowJSON } from '@flowgram.ai/free-layout-core'; import { WorkflowOperationServiceImpl } from './flow-operation-service'; import { WorkflowOperationService } from '../types'; @injectable() export class HistoryOperationServiceImpl extends WorkflowOperationServiceImpl implements WorkflowOperationService { @inject(HistoryService) protected historyService: HistoryService; startTransaction(): void { this.historyService.startTransaction(); } endTransaction(): void { this.historyService.endTransaction(); } fromJSON(json: WorkflowJSON): void { this.startTransaction(); try { super.fromJSON(json); } catch (e) { // eslint-disable-next-line no-console console.log('fromJSON error', e); } this.endTransaction(); } } ================================================ FILE: packages/client/free-layout-editor/src/tools/auto-layout.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { injectable, inject, optional } from 'inversify'; import { PositionSchema } from '@flowgram.ai/utils'; import { HistoryService } from '@flowgram.ai/history'; import { WorkflowDocument, WorkflowNodeEntity } from '@flowgram.ai/free-layout-core'; import { FreeOperationType } from '@flowgram.ai/free-history-plugin'; import { AutoLayoutService, LayoutOptions } from '@flowgram.ai/free-auto-layout-plugin'; import { TransformData } from '@flowgram.ai/editor'; export type AutoLayoutResetFn = () => void; export type AutoLayoutToolOptions = LayoutOptions; /** * Auto layout tool - 自动布局工具 * https://flowgram.ai/guide/plugin/free-auto-layout-plugin.html */ @injectable() export class WorkflowAutoLayoutTool { @inject(WorkflowDocument) private document: WorkflowDocument; @inject(AutoLayoutService) private autoLayoutService: AutoLayoutService; @inject(HistoryService) @optional() private historyService: HistoryService; public async handle(options: AutoLayoutToolOptions = {}): Promise { const resetFn = await this.autoLayout(options); return resetFn; } private async autoLayout(options?: LayoutOptions): Promise { const nodes = this.document.getAllNodes(); const startPositions = nodes.map(this.getNodePosition); await this.autoLayoutService.layout(options); const endPositions = nodes.map(this.getNodePosition); this.updateHistory({ nodes, startPositions, endPositions, }); return this.createResetFn({ nodes, startPositions, }); } private getNodePosition(node: WorkflowNodeEntity): PositionSchema { const transform = node.getData(TransformData); return { x: transform.position.x, y: transform.position.y, }; } private createResetFn(params: { nodes: WorkflowNodeEntity[]; startPositions: PositionSchema[]; }): AutoLayoutResetFn { const { nodes, startPositions } = params; return () => { nodes.forEach((node, index) => { const transform = node.getData(TransformData); const position = startPositions[index]; transform.update({ position, }); }); }; } private updateHistory(params: { nodes: WorkflowNodeEntity[]; startPositions: PositionSchema[]; endPositions: PositionSchema[]; }): void { const { nodes, startPositions: oldValue, endPositions: value } = params; const ids = nodes.map((node) => node.id); this.historyService?.pushOperation( { type: FreeOperationType.dragNodes, value: { ids, value, oldValue, }, }, { noApply: true, } ); } } ================================================ FILE: packages/client/free-layout-editor/src/tools/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export { AutoLayoutToolOptions, AutoLayoutResetFn, WorkflowAutoLayoutTool } from './auto-layout'; ================================================ FILE: packages/client/free-layout-editor/src/types.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { WorkflowJSON, WorkflowOperationBaseService } from '@flowgram.ai/free-layout-core'; export interface WorkflowOperationService extends WorkflowOperationBaseService { /** * 开始事务 */ startTransaction(): void; /** * 结束事务 */ endTransaction(): void; fromJSON(json: WorkflowJSON): void; } export const WorkflowOperationService = Symbol('WorkflowOperationService'); ================================================ FILE: packages/client/free-layout-editor/tsconfig.json ================================================ { "extends": "@flowgram.ai/ts-config/tsconfig.flow.path.json", "compilerOptions": { "types": [], }, "include": ["./src"], "exclude": ["node_modules"] } ================================================ FILE: packages/client/free-layout-editor/vitest.config.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ const path = require('path'); import { defineConfig } from 'vitest/config'; export default defineConfig({ build: { commonjsOptions: { transformMixedEsModules: true, }, }, test: { globals: true, mockReset: false, environment: 'jsdom', setupFiles: [path.resolve(__dirname, './vitest.setup.ts')], include: ['**/?(*.){test,spec}.?(c|m)[jt]s?(x)'], exclude: [ '**/__mocks__**', '**/node_modules/**', '**/dist/**', '**/lib/**', // lib 编译结果忽略掉 '**/cypress/**', '**/.{idea,git,cache,output,temp}/**', '**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*', ], }, }); ================================================ FILE: packages/client/free-layout-editor/vitest.setup.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import 'reflect-metadata'; ================================================ FILE: packages/client/playground-react/eslint.config.js ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ const { defineFlatConfig } = require('@flowgram.ai/eslint-config'); module.exports = defineFlatConfig({ preset: 'web', packageRoot: __dirname, }); ================================================ FILE: packages/client/playground-react/index.css ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ :root { --g-selection-background: #4d53e8; --g-editor-background: #f2f3f5; --g-playground-select: var(--g-selection-background); --g-playground-hover: var(--g-selection-background); --g-playground-line: var(--g-selection-background); --g-playground-blur: #999; --g-playground-selectBox-outline: var(--g-selection-background); --g-playground-selectBox-background: rgba(141, 144, 231, 0.1); --g-playground-select-hover-background: rgba(77, 83, 232, 0.1); --g-playground-select-control-size: 12px; } .gedit-playground { position: absolute; width: 100%; height: 100%; left: 0; top: 0; z-index: 10; overflow: hidden; user-select: none; outline: none; box-sizing: border-box; background-color: var(--g-editor-background); } .gedit-playground-scroll-right { position: absolute; right: 2px; height: 100vh; width: 7px; z-index: 10; } .gedit-playground-scroll-bottom { position: absolute; bottom: 2px; width: 100vw; height: 7px; z-index: 10; } .gedit-playground-scroll-right-block { position: absolute; opacity: 0.3; border-radius: 3.5px; } .gedit-playground-scroll-right-block:hover { opacity: 0.6; } .gedit-playground-scroll-bottom-block { position: absolute; opacity: 0.3; border-radius: 3.5px; } .gedit-playground-scroll-bottom-block:hover { opacity: 0.6; } .gedit-playground-scroll-hidden { opacity: 0; } .gedit-playground * { box-sizing: border-box; } .gedit-playground-loading { position: absolute; color: white; left: 50%; top: 50%; z-index: 100; display: flex; justify-content: center; align-items: center; transition: opacity 0.8s; flex-direction: column; text-align: center; opacity: 0.8; } .gedit-hidden { display: none; } .gedit-playground-pipeline { position: absolute; overflow: visible; width: 100%; height: 100%; left: 0; top: 0; } .gedit-playground-pipeline::before { content: ''; position: absolute; width: 1px; height: 100%; left: 0; top: 0; } .gedit-playground-layer { position: absolute; overflow: visible; } .gedit-selector-box { position: absolute; left: 0; top: 0; width: 0; height: 0; z-index: 33; outline: 1px solid var(--g-playground-selectBox-outline); background-color: var(--g-playground-selectBox-background); } .gedit-selector-box-block { position: absolute; left: 0; top: 0; width: 0; height: 0; z-index: 9999; display: none; background-color: rgba(0, 0, 0, 0); } .gedit-selector-bounds-background { position: absolute; left: 0; top: 0; width: 0; height: 0; outline: 1px solid var(--g-playground-selectBox-outline); background-color: #f0f4ff; } .gedit-selector-bounds-foreground { position: absolute; left: 0; top: 0; width: 0; height: 0; z-index: 33; background: rgba(255, 255, 255, 0); } .gedit-grid-svg { display: block; position: absolute; left: 20px; top: 20px; width: 0; height: 0; } ================================================ FILE: packages/client/playground-react/package.json ================================================ { "name": "@flowgram.ai/playground-react", "version": "0.1.8", "homepage": "https://flowgram.ai/", "repository": "https://github.com/bytedance/flowgram.ai", "license": "MIT", "exports": { ".": { "types": "./dist/index.d.ts", "require": "./dist/index.js", "import": "./dist/esm/index.js" }, "./index.css": { "import": "./index.css", "require": "./index.css" } }, "main": "./dist/index.js", "module": "./dist/esm/index.js", "types": "./dist/index.d.ts", "files": [ "dist", "index.css" ], "scripts": { "build": "npm run build:fast -- --dts-resolve", "build:fast": "tsup src/index.ts --format cjs,esm --sourcemap --legacy-output", "build:watch": "npm run build:fast -- --dts-resolve", "clean": "rimraf dist", "test": "exit 0", "test:cov": "exit 0", "ts-check": "tsc --noEmit", "watch": "npm run build:fast -- --dts-resolve --watch --ignore-watch dist" }, "dependencies": { "@flowgram.ai/background-plugin": "workspace:*", "@flowgram.ai/core": "workspace:*", "@flowgram.ai/shortcuts-plugin": "workspace:*", "@flowgram.ai/utils": "workspace:*", "inversify": "^6.0.1", "reflect-metadata": "~0.2.2" }, "devDependencies": { "@flowgram.ai/eslint-config": "workspace:*", "@flowgram.ai/ts-config": "workspace:*", "@types/bezier-js": "4.1.3", "@types/lodash-es": "^4.17.12", "@types/react": "^18", "@types/react-dom": "^18", "@vitest/coverage-v8": "^3.2.4", "eslint": "^9.0.0", "react": "^18", "react-dom": "^18", "tsup": "^8.0.1", "typescript": "^5.8.3", "vitest": "^3.2.4" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" }, "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" } } ================================================ FILE: packages/client/playground-react/src/components/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export { PlaygroundReact, PlaygroundRef } from './playground-react'; export { PlaygroundReactContent, PlaygroundReactContentProps } from './playground-react-content'; ================================================ FILE: packages/client/playground-react/src/components/playground-react-content.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React, { useMemo } from 'react'; import { usePlayground } from '@flowgram.ai/core'; import { PlaygroundContentLayer, PlaygroundReactContentProps, } from '../layers/playground-content-layer'; export { PlaygroundReactContentProps }; export const PlaygroundReactContent: React.FC = props => { const playground = usePlayground(); useMemo(() => { const layer = playground.getLayer(PlaygroundContentLayer)!; layer.updateOptions(props); }, [props]); return <>; }; ================================================ FILE: packages/client/playground-react/src/components/playground-react.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React, { useMemo, forwardRef } from 'react'; import { createPlaygroundPlugin, PlaygroundReactProvider, PlaygroundReactRenderer, PluginContext, } from '@flowgram.ai/core'; import { PlaygroundReactProps, createPlaygroundReactPreset } from '../preset'; import { PlaygroundContentLayer } from '../layers/playground-content-layer'; export type PlaygroundRef = PluginContext; export const PlaygroundReact = forwardRef( function PlaygroundReact(props, ref) { const { parentContainer, children, ...others } = props; const contentLoadPlugin = useMemo( () => createPlaygroundPlugin({ onInit(ctx) { ctx.playground.registerLayer(PlaygroundContentLayer); }, }), [], ); const preset = useMemo(() => createPlaygroundReactPreset(others, [contentLoadPlugin]), []); return ( {children} ); }, ); ================================================ FILE: packages/client/playground-react/src/hooks/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export { usePlaygroundTools } from './use-playground-tools'; ================================================ FILE: packages/client/playground-react/src/hooks/use-playground-tools.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { useCallback, useEffect, useState } from 'react'; import { DisposableCollection } from '@flowgram.ai/utils'; import { EditorState, EditorStateConfigEntity, PlaygroundInteractiveType, useConfigEntity, usePlayground, } from '@flowgram.ai/core'; export interface PlaygroundToolsPropsType { /** * 最大缩放比,默认 2 */ maxZoom?: number; /** * 最小缩放比,默认 0.25 */ minZoom?: number; } export interface PlaygroundTools { /** * 缩放 zoom 大小比例 */ zoom: number; /** * 放大 */ zoomin: (easing?: boolean) => void; /** * 缩小 */ zoomout: (easing?: boolean) => void; /** * 设置缩放比例 * @param zoom */ updateZoom: (newZoom: number, easing?: boolean, easingDuration?: number) => void; /** * 当前的交互模式, 鼠标友好模式 和 触摸板模式 */ interactiveType: PlaygroundInteractiveType; /** * 切换交互模式 */ toggleIneractiveType: () => void; } export function usePlaygroundTools(props?: PlaygroundToolsPropsType): PlaygroundTools { const { maxZoom, minZoom } = props || {}; const playground = usePlayground(); const editorState = useConfigEntity(EditorStateConfigEntity, true); const [zoom, setZoom] = useState(1); const handleZoomOut = useCallback( (easing?: boolean) => { playground.config.zoomout(easing); }, [playground] ); const handleZoomIn = useCallback( (easing?: boolean) => { playground.config.zoomin(easing); }, [playground] ); const handleUpdateZoom = useCallback( (value: number, easing?: boolean, easingDuration?: number) => { playground.config.updateZoom(value, easing, easingDuration); }, [playground] ); const handleToggleIneractiveType = useCallback(() => { if (editorState.isMouseFriendlyMode()) { editorState.changeState(EditorState.STATE_SELECT.id); } else { editorState.changeState(EditorState.STATE_MOUSE_FRIENDLY_SELECT.id); } }, [editorState]); useEffect(() => { const dispose = new DisposableCollection(); dispose.push(playground.onZoom((z) => setZoom(z))); return () => dispose.dispose(); }, [playground]); useEffect(() => { const config = playground.config.config; playground.config.updateConfig({ maxZoom: maxZoom !== undefined ? maxZoom : config.maxZoom, minZoom: minZoom !== undefined ? minZoom : config.minZoom, }); }, [playground, maxZoom, minZoom]); return { zoomin: handleZoomIn, zoomout: handleZoomOut, updateZoom: handleUpdateZoom, zoom, interactiveType: editorState.isMouseFriendlyMode() ? 'MOUSE' : 'PAD', toggleIneractiveType: handleToggleIneractiveType, }; } ================================================ FILE: packages/client/playground-react/src/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import 'reflect-metadata'; /* 核心 模块导出 */ export { useRefresh, Emitter, Event, Disposable } from '@flowgram.ai/utils'; export * from '@flowgram.ai/core'; export { usePlaygroundTools } from './hooks'; export { PlaygroundReact, PlaygroundReactContent, PlaygroundReactContentProps, PlaygroundRef, } from './components'; export { PlaygroundReactProps, createPlaygroundReactPreset } from './preset'; ================================================ FILE: packages/client/playground-react/src/layers/playground-content-layer.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React from 'react'; import { injectable } from 'inversify'; import { Layer } from '@flowgram.ai/core'; import { domUtils } from '@flowgram.ai/utils'; export interface PlaygroundReactContentProps { className?: string; style?: React.CSSProperties; children?: React.ReactNode; } @injectable() export class PlaygroundContentLayer extends Layer { static type = 'PlaygroundContentLayer'; readonly node = domUtils.createDivWithClass( 'gedit-playground-layer gedit-playground-content-layer', ); onZoom(scale: number): void { this.node.style.transform = `scale(${scale})`; } onReady() { this.node.style.left = '0px'; this.node.style.top = '0px'; } updateOptions(opts: PlaygroundReactContentProps) { this.options = opts; this.render(); } render(): JSX.Element { return (
{this.options.children}
); } } ================================================ FILE: packages/client/playground-react/src/preset/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export { PlaygroundReactProps } from './playground-react-props'; export { createPlaygroundReactPreset } from './playground-react-preset'; ================================================ FILE: packages/client/playground-react/src/preset/playground-react-preset.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { createShortcutsPlugin } from '@flowgram.ai/shortcuts-plugin'; import { PluginContext, PluginsProvider, Plugin, createPlaygroundPlugin, PlaygroundConfig, PlaygroundLayer, } from '@flowgram.ai/core'; import { createBackgroundPlugin } from '@flowgram.ai/background-plugin'; import { PlaygroundReactProps } from './playground-react-props'; export function createPlaygroundReactPreset( opts: PlaygroundReactProps, plugins: Plugin[] = [] ): PluginsProvider { return (ctx: CTX) => { plugins = plugins.slice(); /** * 注册背景 (放前面插入), 默认打开 */ if (opts.background || opts.background === undefined) { const backgroundOptions = typeof opts.background === 'object' ? opts.background : {}; plugins.push(createBackgroundPlugin(backgroundOptions)); } /** * 注册快捷键 */ if (opts.shortcuts) { plugins.push( createShortcutsPlugin({ registerShortcuts: (registry) => opts.shortcuts!(registry, ctx), }) ); } /** * 注册三方插件 */ if (opts.plugins) { plugins.push(...opts.plugins(ctx)); } /** * 画布生命周期注册 */ plugins.push( createPlaygroundPlugin({ onBind: (bindConfig) => { opts.onBind?.(bindConfig); }, onInit: (ctx) => { const playgroundConfig = ctx.get(PlaygroundConfig); if (opts.playground) { if (opts.playground.autoFocus !== undefined) { playgroundConfig.autoFocus = opts.playground.autoFocus; } if (opts.playground.autoResize !== undefined) { playgroundConfig.autoResize = opts.playground.autoResize; } } playgroundConfig.autoFocus = false; ctx.playground.registerLayer(PlaygroundLayer, opts.playground); if (opts.layers) { ctx.playground.registerLayers(...opts.layers); } if (opts.onInit) opts.onInit(ctx); }, onReady(ctx) { if (opts.onReady) opts.onReady(ctx); }, onAllLayersRendered() { if (opts.onAllLayersRendered) opts.onAllLayersRendered(ctx); }, onDispose() { if (opts.onDispose) opts.onDispose(ctx); }, containerModules: opts.containerModules || [], }) ); return plugins; }; } ================================================ FILE: packages/client/playground-react/src/preset/playground-react-props.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React from 'react'; import { interfaces } from 'inversify'; import { ShortcutsRegistry } from '@flowgram.ai/shortcuts-plugin'; import { PlaygroundLayerOptions, Plugin, PluginBindConfig, PluginContext, LayerRegistry, } from '@flowgram.ai/core'; import { BackgroundLayerOptions } from '@flowgram.ai/background-plugin'; /** * 画布配置配置 */ export interface PlaygroundReactProps { /** * 背景开关,默认打开 */ background?: BackgroundLayerOptions | boolean; /** * 画布相关配置 */ playground?: PlaygroundLayerOptions & { autoFocus?: boolean; // 默认是否聚焦 autoResize?: boolean; // 是否自动 resize 画布 }; /** * 注册快捷键 */ shortcuts?: (shortcutsRegistry: ShortcutsRegistry, ctx: CTX) => void; /** * 插件 IOC 注册,等价于 containerModule */ onBind?: (bindConfig: PluginBindConfig) => void; /** * 画布模块注册阶段 */ onInit?: (ctx: CTX) => void; /** * 画布事件注册阶段 (一般用于注册 dom 事件) */ onReady?: (ctx: CTX) => void; /** * 画布所有 layer 第一次渲染完成后触发 */ onAllLayersRendered?: (ctx: CTX) => void; /** * 画布销毁阶段 */ onDispose?: (ctx: CTX) => void; /** * 插件扩展 * @param ctx */ plugins?: (ctx: CTX) => Plugin[]; /** * 注册 layer */ layers?: LayerRegistry[]; /** * IOC 模块,用于更底层的插件扩展 */ containerModules?: interfaces.ContainerModule[]; children?: React.ReactNode; /** * 父 IOC 容器 */ parentContainer?: interfaces.Container; } ================================================ FILE: packages/client/playground-react/tsconfig.json ================================================ { "extends": "@flowgram.ai/ts-config/tsconfig.flow.path.json", "compilerOptions": { "types": [], }, "include": ["./src"], "exclude": ["node_modules"] } ================================================ FILE: packages/client/playground-react/vitest.config.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ const path = require('path'); import { defineConfig } from 'vitest/config'; export default defineConfig({ build: { commonjsOptions: { transformMixedEsModules: true, }, }, test: { globals: true, mockReset: false, environment: 'jsdom', setupFiles: [path.resolve(__dirname, './vitest.setup.ts')], include: ['**/?(*.){test,spec}.?(c|m)[jt]s?(x)'], exclude: [ '**/__mocks__**', '**/node_modules/**', '**/dist/**', '**/lib/**', // lib 编译结果忽略掉 '**/cypress/**', '**/.{idea,git,cache,output,temp}/**', '**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*', ], }, }); ================================================ FILE: packages/client/playground-react/vitest.setup.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import 'reflect-metadata'; ================================================ FILE: packages/common/command/eslint.config.js ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ const { defineFlatConfig } = require('@flowgram.ai/eslint-config'); module.exports = defineFlatConfig({ preset: 'web', packageRoot: __dirname, rules: { 'no-restricted-syntax': [ 'error', { selector: 'ExportAllDeclaration', message: 'Do not re-export everything from another modules, you should explicitly specify the members to be exported.', }, ], }, }); ================================================ FILE: packages/common/command/package.json ================================================ { "name": "@flowgram.ai/command", "version": "0.1.8", "homepage": "https://flowgram.ai/", "repository": "https://github.com/bytedance/flowgram.ai", "license": "MIT", "exports": { "types": "./dist/index.d.ts", "import": "./dist/esm/index.js", "require": "./dist/index.js" }, "main": "./dist/index.js", "module": "./dist/esm/index.js", "types": "./dist/index.d.ts", "files": [ "dist" ], "scripts": { "build": "npm run build:fast -- --dts-resolve", "build:fast": "tsup src/index.ts --format cjs,esm --sourcemap --legacy-output", "build:watch": "npm run build:fast -- --dts-resolve", "clean": "rimraf dist", "ts-check": "tsc --noEmit", "watch": "npm run build:fast -- --dts-resolve --watch --ignore-watch dist" }, "dependencies": { "@flowgram.ai/utils": "workspace:*", "inversify": "^6.0.1", "reflect-metadata": "~0.2.2" }, "devDependencies": { "@flowgram.ai/eslint-config": "workspace:*", "@flowgram.ai/ts-config": "workspace:*", "@types/lodash-es": "^4.17.12", "@types/node": "^18", "@types/react": "^18", "@types/react-dom": "^18", "@vitest/coverage-v8": "^3.2.4", "eslint": "^9.0.0", "jsdom": "^26.1.0", "tsup": "^8.0.1", "typescript": "^5.8.3", "vitest": "^3.2.4" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" }, "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" } } ================================================ FILE: packages/common/command/src/command-container-module.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { ContainerModule } from 'inversify'; import { bindContributionProvider } from '@flowgram.ai/utils'; import { CommandService } from './command-service'; import { CommandRegistry, CommandRegistryFactory, CommandContribution } from './command'; export const CommandContainerModule = new ContainerModule(bind => { bindContributionProvider(bind, CommandContribution); bind(CommandRegistry).toSelf().inSingletonScope(); bind(CommandService).toService(CommandRegistry); bind(CommandRegistryFactory).toFactory(ctx => () => ctx.container.get(CommandRegistry)); }); ================================================ FILE: packages/common/command/src/command-service.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { type Disposable, type Event } from '@flowgram.ai/utils'; import { type CommandEvent } from './command'; export const CommandService = Symbol('CommandService'); /** * command service 执行接口 */ export interface CommandService extends Disposable { /** * command 事件执行前触发事件 */ readonly onWillExecuteCommand: Event; /** * command 事件执行完成后触发 */ readonly onDidExecuteCommand: Event; /** * 执行 command */ executeCommand(command: string, ...args: any[]): Promise; } ================================================ FILE: packages/common/command/src/command.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { injectable, multiInject, optional } from 'inversify'; import { Disposable, DisposableCollection, Emitter } from '@flowgram.ai/utils'; import { type CommandService } from './command-service'; export interface Command { /** * id,唯一 key */ id: string; /** * 展示用 label */ label?: string; /** * 在一些明确的场景下,部分只展示简短的 label 即可 */ shortLabel?: string; /** * 展示用 command icon */ icon?: string | React.ReactNode | ((props: any) => React.ReactNode); /** * 暂不使用 */ category?: string; } export namespace Command { export enum Default { ZOOM_IN = 'ZOOM_IN', ZOOM_OUT = 'ZOOM_OUT', DELETE = 'DELETE', COPY = 'COPY', PASTE = 'PASTE', UNDO = 'UNDO', REDO = 'REDO', VIEW_CLOSE_ALL_WIDGET = 'view.closeAllWidget', VIEW_CLOSE_CURRENT_WIDGET = 'view.closeCurrentWidget', VIEW_REOPEN_LAST_WIDGET = 'view.reopenLastWidget', VIEW_CLOSE_OTHER_WIDGET = 'view.closeOtherWidget', VIEW_CLOSE_BOTTOM_PANEL = 'view.closeBottomPannel', VIEW_OPEN_NEXT_TAB = 'view.openNextTab', VIEW_OEPN_LAST_TAB = 'view.openLastTab', VIEW_FULL_SCREEN = 'view.fullScreen', VIEW_SAVING_WIDGET_CLOSE_CONFIRM = 'view.savingWidgetCloseConfirm', VIEW_SHORTCUTS = 'view.shortcuts', VIEW_PREFERENCES = 'view.preferences', VIEW_LOG = 'view.log', VIEW_PROBLEMS = 'view.problems', } /** * 判断是否是 command */ export function is(arg: Command | any): arg is Command { return !!arg && arg === Object(arg) && 'id' in arg; } } export interface CommandHandler { /** * handler 执行函数 */ execute(...args: any[]): any; /** * 该 handler 是否可以执行 */ isEnabled?(...args: any[]): boolean; /** * 预留 contextMenu 用,该 handler 是否可见 */ isVisible?(...args: any[]): boolean; /** * 预留 contextMenu 用,该 handler 是否可以触发 */ isToggled?(...args: any[]): boolean; } export interface CommandEvent { /** * commandId */ commandId: string; /** * 参数 */ args: any[]; } export const CommandContribution = Symbol('CommandContribution'); export interface CommandContribution { /** * 注册 command */ registerCommands(commands: CommandService): void; } /** * 当前正在运行的 command */ interface CommandExecuting { /** * commandid */ id: string; /** * 参数 */ args: any[]; /** * 正在进行的 promise */ promise?: Promise; } namespace CommandExecuting { /** * 获取正在运行的 command 单个实例 */ export function findSimple( arrs: Set, newCmd: CommandExecuting, ): CommandExecuting | undefined { for (const item of arrs.values()) { if ( item.id === newCmd.id && item.args.length === newCmd.args.length && item.args.every((arg, index) => (newCmd as any)[index] === arg) ) { return item; } } } } export const CommandRegistryFactory = 'CommandRegistryFactory'; @injectable() export class CommandRegistry implements CommandService { protected readonly _handlers: { [id: string]: CommandHandler[] } = {}; protected readonly _commands: { [id: string]: Command } = {}; protected readonly _commandExecutings = new Set(); protected readonly toUnregisterCommands = new Map(); protected readonly onDidExecuteCommandEmitter = new Emitter(); readonly onDidExecuteCommand = this.onDidExecuteCommandEmitter.event; protected readonly onWillExecuteCommandEmitter = new Emitter(); readonly onWillExecuteCommand = this.onWillExecuteCommandEmitter.event; @multiInject(CommandContribution) @optional() protected readonly contributions: CommandContribution[]; init() { for (const contrib of this.contributions) { contrib.registerCommands(this); } } /** * 当前所有 command */ get commands(): Command[] { const commands: Command[] = []; for (const id of this.commandIds) { const cmd = this.getCommand(id); if (cmd) { commands.push(cmd); } } return commands; } /** * 当前所有 commandid */ get commandIds(): string[] { return Object.keys(this._commands); } /** * registerCommand */ registerCommand(id: string, handler?: CommandHandler): Disposable; registerCommand(command: Command, handler?: CommandHandler): Disposable; registerCommand(commandOrId: string | Command, handler?: CommandHandler): Disposable { const command: Command = typeof commandOrId === 'string' ? { id: commandOrId } : commandOrId; if (this._commands[command.id]) { console.warn(`A command ${command.id} is already registered.`); return Disposable.NULL; } const toDispose = new DisposableCollection(this.doRegisterCommand(command)); if (handler) { toDispose.push(this.registerHandler(command.id, handler)); } this.toUnregisterCommands.set(command.id, toDispose); toDispose.push(Disposable.create(() => this.toUnregisterCommands.delete(command.id))); return toDispose; } /** * unregisterCommand */ unregisterCommand(command: Command): void; unregisterCommand(id: string): void; unregisterCommand(commandOrId: Command | string): void { const id = Command.is(commandOrId) ? commandOrId.id : commandOrId; const toUnregister = this.toUnregisterCommands.get(id); if (toUnregister) { toUnregister.dispose(); } } /** * 注册 handler */ registerHandler(commandId: string, handler: CommandHandler): Disposable { let handlers = this._handlers[commandId]; if (!handlers) { this._handlers[commandId] = handlers = []; } handlers.unshift(handler); return { dispose: () => { const idx = handlers.indexOf(handler); if (idx >= 0) { handlers.splice(idx, 1); } }, }; } /** * 预留 contextMenu 用,该 handler 是否可见 */ isVisible(command: string, ...args: any[]): boolean { return typeof this.getVisibleHandler(command, ...args) !== 'undefined'; } /** * command 是否可用 */ isEnabled(command: string, ...args: any[]): boolean { return typeof this.getActiveHandler(command, ...args) !== 'undefined'; } /** * 预留 contextMenu 用,该 handler 是否可以触发 */ isToggled(command: string, ...args: any[]): boolean { return typeof this.getToggledHandler(command, ...args) !== 'undefined'; } /** * 执行 command,会先判断是否可以执行,不会重复执行 */ async executeCommand(commandId: string, ...args: any[]): Promise { const handler = this.getActiveHandler(commandId, ...args); const execInfo: CommandExecuting = { id: commandId, args }; const simpleExecInfo = CommandExecuting.findSimple(this._commandExecutings, execInfo); if (simpleExecInfo) { return execInfo.promise; } if (handler) { try { this._commandExecutings.add(execInfo); this.onWillExecuteCommandEmitter.fire({ commandId, args }); const promise = handler.execute(...args); execInfo.promise = promise; const result = await promise; this.onDidExecuteCommandEmitter.fire({ commandId, args }); return result; } finally { this._commandExecutings.delete(execInfo); } } } getVisibleHandler(commandId: string, ...args: any[]): CommandHandler | undefined { const handlers = this._handlers[commandId]; if (handlers) { for (const handler of handlers) { try { if (!handler.isVisible || handler.isVisible(...args)) { return handler; } } catch (error) { console.error(error); } } } return undefined; } getActiveHandler(commandId: string, ...args: any[]): CommandHandler | undefined { const handlers = this._handlers[commandId]; if (handlers) { for (const handler of handlers) { try { if (!handler.isEnabled || handler.isEnabled(...args)) { return handler; } } catch (error) { console.error(error); } } } return undefined; } /** * 获取 command 对应的所有 handler */ getAllHandlers(commandId: string): CommandHandler[] { const handlers = this._handlers[commandId]; return handlers ? handlers.slice() : []; } getToggledHandler(commandId: string, ...args: any[]): CommandHandler | undefined { const handlers = this._handlers[commandId]; if (handlers) { for (const handler of handlers) { try { if (handler.isToggled && handler.isToggled(...args)) { return handler; } } catch (error) { console.error(error); } } } return undefined; } /** * 获取 command */ getCommand(id: string): Command | undefined { return this._commands[id]; } protected doRegisterCommand(command: Command): Disposable { this._commands[command.id] = command; return { dispose: () => { delete this._commands[command.id]; }, }; } /** * 更新 command */ public updateCommand(id: string, command: Partial>) { if (this._commands[id]) { this._commands[id] = { ...this._commands[id], ...command, }; } } dispose() { this.onWillExecuteCommandEmitter.dispose(); this.onDidExecuteCommandEmitter.dispose(); } } ================================================ FILE: packages/common/command/src/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export { CommandContribution, type CommandHandler, CommandRegistry, Command, CommandRegistryFactory, } from './command'; export { CommandService } from './command-service'; export { CommandContainerModule } from './command-container-module'; ================================================ FILE: packages/common/command/tsconfig.json ================================================ { "extends": "@flowgram.ai/ts-config/tsconfig.flow.path.json", } ================================================ FILE: packages/common/command/vitest.config.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { defineConfig } from 'vitest/config'; export default defineConfig({ build: { commonjsOptions: { transformMixedEsModules: true, }, }, test: { globals: true, mockReset: false, environment: 'jsdom', include: ['**/?(*.){test,spec}.?(c|m)[jt]s?(x)'], exclude: [ '**/node_modules/**', '**/dist/**', '**/lib/**', // lib 编译结果忽略掉 '**/cypress/**', '**/.{idea,git,cache,output,temp}/**', '**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*', ], }, }); ================================================ FILE: packages/common/command/vitest.setup.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import 'reflect-metadata'; ================================================ FILE: packages/common/history/__mocks__/editor.mock.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { decorate, inject, injectable, postConstruct } from 'inversify'; import { HistoryService, Operation, OperationContribution, OperationMeta, OperationRegistry, StackOperation, } from '../src'; interface Node { id: number; data: any; children: Node[]; } interface NodeOperationValue { parentId: number; index: number; node: Node; } interface TextOperationValue { index: number; text: string; } enum OperationType { insertNode = 'insert-node', deleteNode = 'delete-node', insertText = 'insert-text', deleteText = 'delete-text', selection = 'selection', mergeByTime = 'mergeByTime', } export function defaultRoot() { return { id: 1, data: 'test', children: [], } } export const MOCK_URI = 'file:///mock URI' @injectable() export class Editor { @inject(HistoryService) private historyService: HistoryService; @postConstruct() init() { this.historyService.context.source = this; } public node: Node = defaultRoot(); public text: string = ''; reset() { this.node = defaultRoot() } async undo() { await this.historyService.undo() } async redo() { await this.historyService.redo() } canRedo() { return this.historyService.canRedo() } canUndo() { return this.historyService.canUndo() } getHistoryOperations() { return this.historyService.getHistoryOperations() } handleSelection() { this.historyService.pushOperation({ type: OperationType.selection, value: {} }) } handleInsert(value: NodeOperationValue) { this.historyService.pushOperation({ type: OperationType.insertNode, value }) } handleInsertText(value: TextOperationValue, uri?: string, noApply?: boolean) { this.historyService.pushOperation({ type: OperationType.insertText, value, uri }, { noApply }) } handleDeleteText(value: TextOperationValue) { this.historyService.pushOperation({ type: OperationType.deleteText, value }, { noApply: true}) } handleMultiOperation() { this.historyService.pushOperation({ type: OperationType.mergeByTime, value: { test: 1} }) this.historyService.pushOperation({ type: OperationType.mergeByTime, value: { test: 2} }) this.historyService.pushOperation({ type: OperationType.mergeByTime, value: { test: 3} }) this.historyService.pushOperation({ type: OperationType.mergeByTime, value: { test: 4} }) } insertNode(value: NodeOperationValue) { const { parentId, index, node } = value; const parent = this.findNodeById(parentId); if (!parent) { return } parent.children.splice(index, 0, node); } deleteNode(value: NodeOperationValue) { const { parentId, index } = value; const parent = this.findNodeById(parentId); if (!parent) { return } parent.children.splice(index, 1); } insertText(value: TextOperationValue) { const { index, text } = value; this.text = this.text.slice(0, index) + text + this.text.slice(index); } deleteText(value: TextOperationValue) { const { index, text } = value; this.text = this.text.slice(0, index) + this.text.slice(index + text.length); } findNodeById(id: number): Node | null { const nodes = [this.node]; while (nodes.length) { const node = nodes.shift() as Node; if (node.id === id) return node nodes.push(...node.children); } return null; } testTransact() { this.historyService.transact(() => { this.handleInsertText({ index: 0, text: 'test' }) this.handleInsertText({ index: 4, text: 'test' }) }) } } export const insertNodeOperationMeta: OperationMeta = { type: OperationType.insertNode, inverse: (op: Operation) => ({ type: OperationType.deleteNode, value: op.value }), apply: (op: Operation, source: Editor) => { source.insertNode(op.value as NodeOperationValue) }, getLabel: (op: Operation) => { const value = op.value as NodeOperationValue; return `插入节点${value?.node?.id}` } }; export const deleteNodeOperationMeta: OperationMeta = { type: OperationType.deleteNode, inverse: (op: Operation) => ({ type: OperationType.insertNode, value: op.value }), apply: (op: Operation, source: Editor) => { source.deleteNode(op.value as NodeOperationValue) }, }; export const insertTextOperationMeta: OperationMeta = { type: OperationType.insertText, inverse: (op: Operation) => ({ type: OperationType.deleteText, value: op.value }), apply: (op: Operation, source: Editor) => { source.insertText(op.value as TextOperationValue) }, shouldMerge: (op: Operation, prev: Operation | undefined) => true, getURI: () => MOCK_URI, }; export const deleteTextOperationMeta: OperationMeta = { type: OperationType.deleteText, inverse: (op: Operation) => ({ type: OperationType.deleteText, value: op.value }), apply: (op: Operation, source: Editor) => { source.deleteText(op.value as TextOperationValue) }, shouldMerge: (op: Operation, prev: Operation | undefined) => op, }; export const selectionOperationMeta: OperationMeta = { type: OperationType.selection, inverse: (op: Operation) => ({ type: OperationType.selection, value: op.value }), apply: (op: Operation, source: Editor) => { }, shouldSave: (op: Operation) => false, }; export const mergeByTimeOperationMeta: OperationMeta = { type: OperationType.mergeByTime, inverse: (op: Operation) => ({ type: OperationType.mergeByTime, value: op.value }), apply: (op: Operation, source: Editor) => { }, shouldMerge: (op: Operation, prev: Operation | undefined, stackItem: StackOperation) => { if (Date.now() - stackItem.getTimestamp() < 100) { return true } return false }, }; export class EditorRegister implements OperationContribution { registerOperationMeta(operationRegistry: OperationRegistry): void { operationRegistry.registerOperationMeta(insertNodeOperationMeta); operationRegistry.registerOperationMeta(deleteNodeOperationMeta); operationRegistry.registerOperationMeta(insertTextOperationMeta); operationRegistry.registerOperationMeta(deleteTextOperationMeta); operationRegistry.registerOperationMeta(selectionOperationMeta); operationRegistry.registerOperationMeta(mergeByTimeOperationMeta); } } decorate(injectable(), EditorRegister); ================================================ FILE: packages/common/history/__mocks__/history-container.mock.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { Container, ContainerModule, type interfaces } from 'inversify'; import { bindContributions } from '@flowgram.ai/utils'; import { EditorRegister, Editor } from './editor.mock' import { OperationContribution, HistoryContainerModule, } from '../src'; const TestContainerModule = new ContainerModule(bind => { bind(Editor).toSelf().inSingletonScope(); bindContributions(bind, EditorRegister, [OperationContribution]); }); export function createHistoryContainer(name?: string, parent?: interfaces.Container): interfaces.Container { const container = new Container(); if (parent) { container.parent = parent; } if (name) { (container as any).name = name } container.load(HistoryContainerModule); container.load(TestContainerModule); return container; } ================================================ FILE: packages/common/history/__tests__/__snapshots__/history-manager.test.ts.snap ================================================ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`history-manager > merge operation should update history stack 1`] = ` [ { "operations": [ { "type": "insert-text", "uri": "file:///mock URI", "value": { "index": 0, "text": "test", }, }, { "type": "insert-text", "uri": "file:///mock URI", "value": { "index": 4, "text": "test", }, }, ], "type": "push", "uri": "file:///editor2", }, { "operations": [ { "type": "insert-text", "uri": "file:///mock URI", "value": { "index": 0, "text": "test", }, }, { "type": "insert-text", "uri": "file:///mock URI", "value": { "index": 4, "text": "test", }, }, ], "type": "push", "uri": "file:///editor1", }, ] `; ================================================ FILE: packages/common/history/__tests__/__snapshots__/history-service.test.ts.snap ================================================ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`history-service > merge by time 1`] = ` [ [ { "type": "mergeByTime", "value": { "test": 1, }, }, { "type": "mergeByTime", "value": { "test": 2, }, }, { "type": "mergeByTime", "value": { "test": 3, }, }, { "type": "mergeByTime", "value": { "test": 4, }, }, ], ] `; exports[`history-service > push operation should return correct history options 1`] = ` [ { "label": "插入节点2", "type": "insert-node", "value": { "index": 0, "node": { "children": [], "data": "test2", "id": 2, }, "parentId": 1, }, }, { "label": "插入节点3", "type": "insert-node", "value": { "index": 0, "node": { "children": [], "data": "test3", "id": 3, }, "parentId": 2, }, }, ] `; exports[`history-service > push operation when limited should not increase the length 1`] = ` [ [ { "type": "insert-node", "value": { "index": 0, "node": { "children": [], "data": "test3", "id": 3, }, "parentId": 2, }, }, ], [ { "type": "insert-node", "value": { "index": 0, "node": { "children": [], "data": "test4", "id": 4, }, "parentId": 2, }, }, ], ] `; exports[`history-service > push undo redo multi times should get correct tree and history options 1`] = ` { "children": [ { "children": [ { "children": [], "data": "test4", "id": 4, }, { "children": [], "data": "test3", "id": 3, }, ], "data": "test2", "id": 2, }, ], "data": "test", "id": 1, } `; exports[`history-service > push undo redo multi times should get correct tree and history options 2`] = ` [ { "label": "插入节点2", "type": "insert-node", "value": { "index": 0, "node": { "children": [], "data": "test2", "id": 2, }, "parentId": 1, }, }, { "label": "插入节点3", "type": "insert-node", "value": { "index": 0, "node": { "children": [], "data": "test3", "id": 3, }, "parentId": 2, }, }, { "label": "插入节点4", "type": "insert-node", "value": { "index": 0, "node": { "children": [], "data": "test4", "id": 4, }, "parentId": 2, }, }, { "label": "插入节点5", "type": "insert-node", "value": { "index": 0, "node": { "children": [], "data": "test5", "id": 5, }, "parentId": 2, }, }, { "label": "delete-node", "type": "delete-node", "value": { "index": 0, "node": { "children": [], "data": "test5", "id": 5, }, "parentId": 2, }, }, { "label": "delete-node", "type": "delete-node", "value": { "index": 0, "node": { "children": [], "data": "test4", "id": 4, }, "parentId": 2, }, }, { "label": "插入节点4", "type": "insert-node", "value": { "index": 0, "node": { "children": [], "data": "test4", "id": 4, }, "parentId": 2, }, }, { "label": "delete-node", "type": "delete-node", "value": { "index": 0, "node": { "children": [], "data": "test4", "id": 4, }, "parentId": 2, }, }, { "label": "插入节点4", "type": "insert-node", "value": { "index": 0, "node": { "children": [], "data": "test4", "id": 4, }, "parentId": 2, }, }, ] `; exports[`history-service > transact 1`] = ` [ { "label": "插入节点2", "type": "insert-node", "value": { "index": 0, "node": { "children": [], "data": "test2", "id": 2, }, "parentId": 1, }, }, { "label": "插入节点3", "type": "insert-node", "value": { "index": 0, "node": { "children": [], "data": "test3", "id": 3, }, "parentId": 2, }, }, { "label": "插入节点4", "type": "insert-node", "value": { "index": 0, "node": { "children": [], "data": "test4", "id": 4, }, "parentId": 2, }, }, { "label": "插入节点5", "type": "insert-node", "value": { "index": 0, "node": { "children": [], "data": "test5", "id": 5, }, "parentId": 2, }, }, { "label": "delete-node", "type": "delete-node", "value": { "index": 0, "node": { "children": [], "data": "test5", "id": 5, }, "parentId": 2, }, }, { "label": "delete-node", "type": "delete-node", "value": { "index": 0, "node": { "children": [], "data": "test4", "id": 4, }, "parentId": 2, }, }, { "label": "delete-node", "type": "delete-node", "value": { "index": 0, "node": { "children": [], "data": "test3", "id": 3, }, "parentId": 2, }, }, { "label": "delete-node", "type": "delete-node", "value": { "index": 0, "node": { "children": [], "data": "test2", "id": 2, }, "parentId": 1, }, }, ] `; ================================================ FILE: packages/common/history/__tests__/__snapshots__/undo-redo-service.test.ts.snap ================================================ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`operation-registry > change event 1`] = ` [ "push", "undo", "redo", ] `; ================================================ FILE: packages/common/history/__tests__/history-manager.test.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { Container, interfaces } from 'inversify'; import { HistoryManager } from '../src/history/history-manager'; import { HistoryContainerModule, HistoryService, HistoryStack } from '../src'; import { createHistoryContainer } from '../__mocks__/history-container.mock'; import { Editor } from '../__mocks__/editor.mock'; function getStackSnapshot(historyStack: HistoryStack) { return historyStack.items.map((item) => ({ operations: item.operations.map((op) => ({ type: op.type, value: op.value, uri: op.uri?.toString(), })), type: item.type, uri: item.uri?.toString(), })); } describe('history-manager', () => { let ide_container: interfaces.Container; let containers: interfaces.Container[]; let editor1: Editor; let historyService1: HistoryService; let editor2: Editor; let historyService2: HistoryService; let historyManager: HistoryManager; beforeEach(() => { ide_container = new Container(); (ide_container as any).name = 'ide_container'; ide_container.load(HistoryContainerModule); const container1 = createHistoryContainer('container1', ide_container); const container2 = createHistoryContainer('container2', ide_container); containers = [container1, container2]; editor1 = container1.get(Editor); editor2 = container2.get(Editor); historyService1 = container1.get(HistoryService); historyService1.context.uri = 'file:///editor1'; historyService2 = container2.get(HistoryService); historyService2.context.uri = 'file:///editor2'; historyManager = ide_container.get(HistoryManager); }); it('different container instances should not be the same', () => { expect(editor1 === editor2).toBeFalsy(); expect(historyService1 === historyService2).toBeFalsy(); }); it('different editor operations should not be the same', () => { editor1.handleInsertText({ index: 0, text: 'test' }); expect(editor1.canUndo()).toBeTruthy(); expect(editor2.canUndo()).toBeFalsy(); }); it('different editor operations should all be captured in history manager', async () => { const fn = vi.fn(); historyManager.historyStack.onChange(fn); editor1.handleInsertText({ index: 0, text: 'test' }); editor2.handleInsertText({ index: 0, text: 'test' }); await editor1.undo(); await editor2.undo(); await editor1.redo(); await editor2.redo(); expect(historyManager.historyStack.items).toHaveLength(6); expect(fn).toBeCalledTimes(6); }); it('dispose', () => { historyService1.dispose(); expect((historyManager as any)._historyServices).toHaveLength(1); historyManager.dispose(); expect(historyManager.historyStack.items).toHaveLength(0); expect((historyManager as any).historyStack._toDispose.disposed).toBeTruthy(); }); it('unrRegisterHistoryService', () => { historyManager.unregisterHistoryService(historyService1); const services = Array.from((historyManager as any)._historyServices.keys()); expect(services).toHaveLength(1); expect(services[0]).toEqual(historyService2); }); it('limit', async () => { historyManager.historyStack.limit = 2; editor1.handleInsertText({ index: 0, text: 'test' }); editor2.handleInsertText({ index: 0, text: 'test' }); await editor1.undo(); await editor2.undo(); await editor2.redo(); expect(historyManager.historyStack.items.map((s) => s.type)).toEqual(['redo', 'undo']); }); it('merge operation should update history stack', () => { editor1.testTransact(); editor2.testTransact(); expect(getStackSnapshot(historyManager.historyStack)).toMatchSnapshot(); }); it('clear should not be recorded', async () => { editor1.handleInsertText({ index: 0, text: 'test' }); await editor1.undo(); historyService1.clear(); expect(historyManager.historyStack.items).toHaveLength(2); }); }); ================================================ FILE: packages/common/history/__tests__/history-service.test.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { omit } from 'lodash-es'; import { HistoryService, UndoRedoService } from '../src'; import { createHistoryContainer } from '../__mocks__/history-container.mock'; import { Editor, MOCK_URI, defaultRoot } from '../__mocks__/editor.mock'; describe('history-service', () => { let container; let editor: Editor; let undoRedoService: UndoRedoService; let historyService: HistoryService; beforeEach(() => { container = createHistoryContainer(); editor = container.get(Editor); undoRedoService = container.get(UndoRedoService); historyService = container.get(HistoryService); }); it('push insert operation should apply editor insert', () => { const node1 = { id: 2, data: 'test2', children: [] }; editor.handleInsert({ parentId: 1, index: 0, node: node1 }); expect(editor.node.children[0]).toEqual(node1); }); it('undo insert operation should delete node', async () => { const node1 = { id: 2, data: 'test2', children: [] }; editor.handleInsert({ parentId: 1, index: 0, node: node1 }); await editor.undo(); expect(editor.node.children).toEqual([]); }); it('undo operation then push operation should clear redo stack', async () => { const node1 = { id: 2, data: 'test2', children: [] }; editor.handleInsert({ parentId: 1, index: 0, node: node1 }); await editor.undo(); editor.handleInsert({ parentId: 1, index: 0, node: node1 }); expect(editor.canRedo()).toBeFalsy(); }); it('push twice undo once should remain the first insert node', async () => { const node1 = { id: 2, data: 'test2', children: [] }; editor.handleInsert({ parentId: 1, index: 0, node: node1 }); const node2 = { id: 3, data: 'test3', children: [] }; editor.handleInsert({ parentId: 1, index: 0, node: node2 }); await editor.undo(); expect(editor.node.children).toEqual([node1]); }); it('push save disabled operation should push no operation', async () => { editor.handleSelection(); expect(editor.canUndo()).toBeFalsy(); }); it('push operation with merge should be merged', async () => { editor.handleInsertText({ index: 0, text: 'test' }); editor.handleInsertText({ index: 4, text: 'aaa' }); expect(undoRedoService.getUndoStack().length).toEqual(1); expect(undoRedoService.getLastElement().getOperations().length).toEqual(2); expect(editor.text).toEqual('testaaa'); await editor.undo(); expect(editor.text).toEqual(''); }); it('push operation with merge operation should be merged', async () => { editor.handleDeleteText({ index: 0, text: 'test' }); editor.handleDeleteText({ index: 4, text: 'aaa' }); expect(undoRedoService.getUndoStack().length).toEqual(1); expect(undoRedoService.getLastElement().getOperations().length).toEqual(1); expect(editor.text).toEqual(''); await editor.undo(); expect(editor.text).toEqual(''); }); it('push no registered operation should throw error', () => { expect(() => historyService.pushOperation({ type: 'test', value: {} })).toThrowError( 'Operation meta test has not registered.' ); }); it('push operation should return correct history options', async () => { const node1 = { id: 2, data: 'test2', children: [] }; editor.handleInsert({ parentId: 1, index: 0, node: node1 }); const node2 = { id: 3, data: 'test3', children: [] }; editor.handleInsert({ parentId: 2, index: 0, node: node2 }); expect(editor.getHistoryOperations()).toMatchSnapshot(); }); it('push undo redo multi times should get correct tree and history options', async () => { const node1 = { id: 2, data: 'test2', children: [] }; editor.handleInsert({ parentId: 1, index: 0, node: node1 }); const node2 = { id: 3, data: 'test3', children: [] }; editor.handleInsert({ parentId: 2, index: 0, node: node2 }); const node3 = { id: 4, data: 'test4', children: [] }; editor.handleInsert({ parentId: 2, index: 0, node: node3 }); const node4 = { id: 5, data: 'test5', children: [] }; editor.handleInsert({ parentId: 2, index: 0, node: node4 }); await editor.undo(); await editor.undo(); await editor.redo(); await editor.undo(); await editor.redo(); expect(editor.node).toMatchSnapshot(); expect(editor.getHistoryOperations()).toMatchSnapshot(); }); it('transact', async () => { editor.reset(); historyService.transact(() => { const node1 = { id: 2, data: 'test2', children: [] }; editor.handleInsert({ parentId: 1, index: 0, node: node1 }); const node2 = { id: 3, data: 'test3', children: [] }; editor.handleInsert({ parentId: 2, index: 0, node: node2 }); const node3 = { id: 4, data: 'test4', children: [] }; editor.handleInsert({ parentId: 2, index: 0, node: node3 }); const node4 = { id: 5, data: 'test5', children: [] }; editor.handleInsert({ parentId: 2, index: 0, node: node4 }); historyService.transact(() => { const node5 = { id: 6, data: 'test6', children: [] }; editor.handleInsert({ parentId: 2, index: 0, node: node5 }); }); }); await editor.undo(); expect(editor.node).toEqual(defaultRoot()); expect(editor.getHistoryOperations()).toMatchSnapshot(); }); it('transact no operation', async () => { historyService.transact(() => {}); expect(historyService.canUndo()).toEqual(false); }); it('clear should clear all', () => { const node1 = { id: 2, data: 'test2', children: [] }; editor.handleInsert({ parentId: 1, index: 0, node: node1 }); expect(historyService.canUndo()).toEqual(true); expect(historyService.getHistoryOperations().length).toEqual(1); historyService.clear(); expect(historyService.canUndo()).toEqual(false); }); it('operation should not be recorded when stopped', () => { historyService.clear(); const node1 = { id: 2, data: 'test2', children: [] }; historyService.stop(); editor.handleInsert({ parentId: 1, index: 0, node: node1 }); expect(historyService.canUndo()).toEqual(false); historyService.start(); editor.handleInsert({ parentId: 1, index: 0, node: node1 }); expect(historyService.canUndo()).toEqual(true); }); it('push operation when limited should not increase the length', () => { historyService.limit(2); const node1 = { id: 2, data: 'test2', children: [] }; editor.handleInsert({ parentId: 1, index: 0, node: node1 }); const node2 = { id: 3, data: 'test3', children: [] }; editor.handleInsert({ parentId: 2, index: 0, node: node2 }); const node3 = { id: 4, data: 'test4', children: [] }; editor.handleInsert({ parentId: 2, index: 0, node: node3 }); const undoStack = (historyService as any).undoRedoService.getUndoStack(); expect(undoStack.length).toEqual(2); expect( undoStack.map((item) => item.getOperations().map((op) => omit(op, 'id'))) ).toMatchSnapshot(); }); it('merge by time', () => { editor.handleMultiOperation(); const undoStack = (historyService as any).undoRedoService.getUndoStack(); expect(undoStack.length).toEqual(1); expect( undoStack.map((item) => item.getOperations().map((op) => omit(op, 'id'))) ).toMatchSnapshot(); }); it('push with uri', () => { let uri = 'test'; editor.handleInsertText({ index: 0, text: 'test' }, uri); expect( historyService.undoRedoService.getLastElement().getLastOperation().uri === uri ).toBeTruthy(); editor.handleInsertText({ index: 4, text: 'aaa' }); expect( historyService.undoRedoService.getLastElement().getLastOperation().uri === MOCK_URI ).toBeTruthy(); }); it('push operation with noApply', async () => { const text = editor.text; editor.handleInsertText({ index: 0, text: 'test' }, undefined, true); expect(editor.text).toEqual(text); editor.handleInsertText({ index: 0, text: 'test' }, undefined, false); expect(editor.text).not.toEqual(text); }); it('dispose', async () => { const disposables = (historyService as any)._toDispose; historyService.dispose(); expect(disposables.disposed).toEqual(true); }); }); ================================================ FILE: packages/common/history/__tests__/operation-registry.test.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { describe, it, expect, beforeEach } from 'vitest'; import { OperationRegistry, Operation } from '../src'; import { createHistoryContainer } from '../__mocks__/history-container.mock'; import { insertNodeOperationMeta } from '../__mocks__/editor.mock'; describe('operation-registry', () => { let operationRegistry: OperationRegistry; let container; beforeEach(() => { container = createHistoryContainer(); operationRegistry = container.get(OperationRegistry); }); it('registerOperationMeta success should return correct operationMeta', () => { const operationMeta = { type: 'test', inverse: (op: Operation) => op, label: 'test', description: 'test', apply: () => {}, }; operationRegistry.registerOperationMeta(operationMeta); expect(operationRegistry.getOperationMeta(operationMeta.type)).toEqual(operationMeta); }); it('register by contribution success should return correct operationMeta', () => { expect(operationRegistry.getOperationMeta(insertNodeOperationMeta.type)).toEqual( insertNodeOperationMeta, ); }); }); ================================================ FILE: packages/common/history/__tests__/operation-service.test.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { OperationRegistry, Operation, OperationService } from '../src'; import { createHistoryContainer } from '../__mocks__/history-container.mock'; const fn = vi.fn(); describe('operation-service', () => { let operationRegistry: OperationRegistry; let operationService: OperationService; let container; beforeEach(() => { container = createHistoryContainer(); operationService = container.get(OperationService); operationRegistry = container.get(OperationRegistry); const operationMeta = { type: 'test', inverse: (op: Operation) => op, description: 'test', apply: fn, getLabel: () => 'test1', }; operationRegistry.registerOperationMeta(operationMeta); }); it('get operation label should return correct label', () => { expect(operationService.getOperationLabel({ type: 'test' } as any)).toEqual('test1'); }); it('no apply', () => { operationService.applyOperation({ type: 'test' } as any, { noApply: true }); expect(fn).toBeCalledTimes(0); operationService.applyOperation({ type: 'test' } as any, { noApply: false }); expect(fn).toBeCalledTimes(1); operationService.applyOperation({ type: 'test' } as any); expect(fn).toBeCalledTimes(2); }); it('on apply', () => { const handleApply = vi.fn(); operationService.onApply(handleApply); operationService.applyOperation({ type: 'test' } as any); expect(handleApply).toBeCalledTimes(1); operationService.applyOperation({ type: 'test' } as any, { noApply: true }); expect(handleApply).toBeCalledTimes(2); }); it('apply with origin', () => { const handleApply = vi.fn(); operationService.onApply(handleApply); const op = { type: 'test', origin: Symbol('origin'), value: {} }; operationService.applyOperation(op); expect(handleApply).toBeCalledWith(op); }); }); ================================================ FILE: packages/common/history/__tests__/undo-redo-service.test.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { OperationService, StackOperation, UndoRedoChangeEvent, UndoRedoChangeType, UndoRedoService, } from '../src'; import { createHistoryContainer } from '../__mocks__/history-container.mock'; describe('operation-registry', () => { let undoRedoService: UndoRedoService; let container; let createStackOperation = (operations = []) => new StackOperation(container.get(OperationService), operations); beforeEach(() => { container = createHistoryContainer(); undoRedoService = container.get(UndoRedoService); }); it('pushElement', () => { const element = createStackOperation(); undoRedoService.pushElement(element); expect(undoRedoService.getUndoStack()).toEqual([element]); }); it('getUndoStack', () => { const element = createStackOperation(); undoRedoService.pushElement(element); expect(undoRedoService.getUndoStack()).toEqual([element]); }); it('getRedoStack', () => { const element = createStackOperation(); undoRedoService.pushElement(element); expect(undoRedoService.getRedoStack()).toEqual([]); }); it('clearRedoStack', () => { const element = createStackOperation(); undoRedoService.pushElement(element); undoRedoService.clearRedoStack(); expect(undoRedoService.getRedoStack()).toEqual([]); }); it('undo', async () => { const element = createStackOperation(); await undoRedoService.undo(); undoRedoService.pushElement(element); await undoRedoService.undo(); expect(undoRedoService.getUndoStack()).toEqual([]); }); it('undo twice will only revert once', async () => { const element = createStackOperation(); undoRedoService.pushElement(element); undoRedoService.pushElement(element); undoRedoService.undo(); undoRedoService.undo(); expect(undoRedoService.getUndoStack()).toEqual([element]); }); it('redo', async () => { const element = createStackOperation(); undoRedoService.pushElement(element); await undoRedoService.undo(); await undoRedoService.redo(); expect(undoRedoService.getUndoStack()).toEqual([element]); }); it('canUndo', () => { const element = createStackOperation(); expect(undoRedoService.canUndo()).toEqual(false); undoRedoService.pushElement(element); expect(undoRedoService.canUndo()).toEqual(true); }); it('canRedo', async () => { const element = createStackOperation(); expect(undoRedoService.canRedo()).toEqual(false); undoRedoService.pushElement(element); expect(undoRedoService.canRedo()).toEqual(false); await undoRedoService.undo(); expect(undoRedoService.canRedo()).toEqual(true); }); it('change event', async () => { const element = createStackOperation(); const events: UndoRedoChangeEvent[] = []; undoRedoService.onChange(e => events.push(e)); undoRedoService.pushElement(element); await undoRedoService.undo(); await undoRedoService.redo(); expect(events.map(e => e.type)).toMatchSnapshot(); }); it('clear', () => { const fn = vi.fn(); const element = createStackOperation(); undoRedoService.pushElement(element); undoRedoService.pushElement(element); undoRedoService.onChange(event => { if (event.type === UndoRedoChangeType.CLEAR) { fn(); } }); undoRedoService.undo(); undoRedoService.clear(); expect(undoRedoService.getUndoStack()).toEqual([]); expect(undoRedoService.getRedoStack()).toEqual([]); expect(fn).toBeCalledTimes(1); }); }); ================================================ FILE: packages/common/history/eslint.config.js ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ const { defineFlatConfig } = require('@flowgram.ai/eslint-config'); module.exports = defineFlatConfig({ preset: 'web', packageRoot: __dirname, }); ================================================ FILE: packages/common/history/package.json ================================================ { "name": "@flowgram.ai/history", "version": "0.1.8", "homepage": "https://flowgram.ai/", "repository": "https://github.com/bytedance/flowgram.ai", "license": "MIT", "exports": { "types": "./dist/index.d.ts", "import": "./dist/esm/index.js", "require": "./dist/index.js" }, "main": "./dist/index.js", "module": "./dist/esm/index.js", "types": "./dist/index.d.ts", "files": [ "dist" ], "scripts": { "build": "npm run build:fast -- --dts-resolve", "build:fast": "tsup src/index.ts --format cjs,esm --sourcemap --legacy-output", "build:watch": "npm run build:fast -- --dts-resolve", "clean": "rimraf dist", "test": "vitest run", "test:cov": "vitest run --coverage", "test:update": "vitest run --update", "ts-check": "tsc --noEmit", "watch": "npm run build:fast -- --dts-resolve --watch --ignore-watch dist" }, "dependencies": { "@flowgram.ai/core": "workspace:*", "@flowgram.ai/utils": "workspace:*", "inversify": "^6.0.1", "reflect-metadata": "~0.2.2", "lodash-es": "^4.17.21", "nanoid": "^5.0.9" }, "devDependencies": { "@flowgram.ai/eslint-config": "workspace:*", "@flowgram.ai/ts-config": "workspace:*", "@types/lodash-es": "^4.17.12", "@vitest/coverage-v8": "^3.2.4", "eslint": "^9.0.0", "jsdom": "^26.1.0", "tsup": "^8.0.1", "typescript": "^5.8.3", "vitest": "^3.2.4" }, "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" } } ================================================ FILE: packages/common/history/src/create-history-plugin.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { definePluginCreator, PluginContext } from '@flowgram.ai/core'; import { Operation, OperationService } from './operation'; import { HistoryContainerModule } from './history-container-module'; export interface HistoryPluginOptions { enable?: boolean; enableChangeNode?: boolean; onApply?: (ctx: T, operation: Operation) => void; } export const createHistoryPlugin = definePluginCreator({ onInit: (ctx, opts) => { if (opts.onApply) { ctx.get(OperationService).onApply(opts.onApply.bind(null, ctx)); } }, containerModules: [HistoryContainerModule], }); ================================================ FILE: packages/common/history/src/history/history-manager.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { inject, injectable } from 'inversify'; import { type Disposable, DisposableCollection } from '@flowgram.ai/utils'; import { OperationWithId } from '../operation'; import { HistoryConfig } from '../history-config'; import { type UndoRedoChangeEvent } from './types'; import { type IHistoryManager, type HistoryStackItem, UndoRedoChangeType, HistoryMergeEventType, HistoryMergeEvent, } from './types'; import { StackOperation } from './stack-operation'; import { HistoryStack } from './history-stack'; import { type HistoryService } from './history-service'; @injectable() export class HistoryManager implements IHistoryManager { @inject(HistoryStack) readonly historyStack: HistoryStack; @inject(HistoryConfig) readonly historyConfig: HistoryConfig; private _historyServices = new Map(); private _toDispose = new DisposableCollection(); registerHistoryService(service: HistoryService): void { const toDispose = new DisposableCollection(); toDispose.pushAll([ service.undoRedoService.onChange((event: UndoRedoChangeEvent) => { if (event.type === UndoRedoChangeType.CLEAR) { return; } const { type, element } = event; const operations = element.getChangeOperations(type); const historyStackItem: HistoryStackItem = { id: type === UndoRedoChangeType.PUSH ? (element as StackOperation).id : this.historyConfig.generateId(), type, uri: service.context.uri, operations, timestamp: Date.now(), }; this.historyStack.add(service, historyStackItem); }), service.onMerge((event) => { this._handleMerge(service, event); }), ]); this._historyServices.set(service, toDispose); this._toDispose.push( service.onWillDispose(() => { this.unregisterHistoryService(service); }) ); } unregisterHistoryService(service: HistoryService): void { const disposable = this._historyServices.get(service); if (!disposable) { return; } disposable.dispose(); this._historyServices.delete(service); } getHistoryServiceByURI(uri: string) { for (const service of this._historyServices.keys()) { if (service.context.uri === uri) { return service; } } } getFirstHistoryService() { for (const service of this._historyServices.keys()) { return service; } } dispose(): void { this._toDispose.dispose(); this.historyStack.dispose(); this._historyServices.forEach((service) => service.dispose()); this._historyServices.clear(); } _handleMerge(service: HistoryService, event: HistoryMergeEvent) { const { element, operation } = event.value; const find = this.historyStack.findById((element as StackOperation).id); if (!find) { return; } if (!(operation as OperationWithId).id) { console.warn('no operation id found'); return; } if (event.type === HistoryMergeEventType.UPDATE) { this.historyStack.updateOperation( service, (element as StackOperation).id, operation as OperationWithId ); } if (event.type === HistoryMergeEventType.ADD) { this.historyStack.addOperation( service, (element as StackOperation).id, operation as OperationWithId ); } } } ================================================ FILE: packages/common/history/src/history/history-service.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { pick } from 'lodash-es'; import { injectable, inject, postConstruct } from 'inversify'; import { DisposableCollection, Emitter } from '@flowgram.ai/utils'; import { OperationService } from '../operation/operation-service'; import { OperationMeta, OperationRegistry, PushOperationOptions } from '../operation'; import { Operation } from '../operation'; import { HistoryContext } from '../history-context'; import { HistoryConfig } from '../history-config'; import { UndoRedoService } from './undo-redo-service'; import { HistoryMergeEvent, HistoryMergeEventType, HistoryRecord, IHistoryService, IUndoRedoElement, } from './types'; import { StackOperation } from './stack-operation'; import { HistoryManager } from './history-manager'; @injectable() export class HistoryService implements IHistoryService { @inject(UndoRedoService) readonly undoRedoService: UndoRedoService; @inject(OperationRegistry) readonly operationRegistry: OperationRegistry; @inject(OperationService) readonly operationService: OperationService; @inject(HistoryContext) readonly context: HistoryContext; @inject(HistoryConfig) readonly config: HistoryConfig; @inject(HistoryManager) historyManager: HistoryManager; private _toDispose = new DisposableCollection(); private _transacting: boolean = false; private _transactOperation: StackOperation | null = null; private _locked: boolean = false; private _willDisposeEmitter = new Emitter(); private _mergeEmitter = new Emitter(); onWillDispose = this._willDisposeEmitter.event; onMerge = this._mergeEmitter.event; get onApply() { return this.operationService.onApply; } @postConstruct() init() { this._toDispose.push(this._willDisposeEmitter); this._toDispose.push(this._mergeEmitter); } start() { this._locked = false; } stop() { this._locked = true; } limit(num: number) { this.undoRedoService.setLimit(num); } startTransaction() { if (this._transacting) { return; } this._transacting = true; const stackOperation = new StackOperation(this.operationService, []); this._transactOperation = stackOperation; } endTransaction() { const stackOperation = this._transactOperation; if (!stackOperation) { return; } if (stackOperation.getOperations().length !== 0) { this._pushStackOperation(stackOperation); } this._transactOperation = null; this._transacting = false; } transact(transaction: () => void) { if (this._transacting) { return; } this.startTransaction(); transaction(); this.endTransaction(); } pushOperation(operation: Operation, options?: PushOperationOptions): any { if (!this._canPush()) { return; } const prev = this._transactOperation || this.undoRedoService.getLastElement(); const operationMeta = this.operationRegistry.getOperationMeta(operation.type) as OperationMeta; if (!operationMeta) { throw new Error(`Operation meta ${operation.type} has not registered.`); } if (operationMeta.shouldSave && !operationMeta.shouldSave(operation)) { return operationMeta.apply(operation, this.context.source); } const res = this.operationService.applyOperation(operation, { noApply: options?.noApply }); if (operationMeta.getURI && !operation.uri) { operation.uri = operationMeta.getURI(operation, this.context.source); } const shouldMerge = this._shouldMerge(operation, prev, operationMeta); if (shouldMerge) { if (typeof shouldMerge === 'object') { const operation = prev.getLastOperation(); operation.value = shouldMerge.value; this._mergeEmitter.fire({ type: HistoryMergeEventType.UPDATE, value: { element: prev, operation: operation, value: shouldMerge.value, }, }); } else { const op = prev.pushOperation(operation); this._mergeEmitter.fire({ type: HistoryMergeEventType.ADD, value: { element: prev, operation: op, }, }); } } else { const stackOperation = new StackOperation(this.operationService, [operation]); this._pushStackOperation(stackOperation); } return res; } getHistoryOperations(): Operation[] { return this.historyManager.historyStack.items .reverse() .map((item) => item.operations.map((o) => ({ ...pick(o, ['type', 'value']), label: o.label || o.type, })) ) .flat(); } async undo(): Promise { await this.undoRedoService.undo(); } async redo(): Promise { await this.undoRedoService.redo(); } canUndo(): boolean { return this.undoRedoService.canUndo(); } canRedo(): boolean { return this.undoRedoService.canRedo(); } getSnapshot(): unknown { return this.config.getSnapshot(); } getRecords(): Promise { throw new Error('Method not implemented.'); } restore(historyRecord: HistoryRecord): Promise { throw new Error('Method not implemented.'); } clear() { this.undoRedoService.clear(); } dispose(): void { this._willDisposeEmitter.fire(this); this._toDispose.dispose(); } private _canPush() { if (this._locked) { return false; } return this.undoRedoService.canPush(); } private _pushStackOperation(stackOperation: StackOperation) { this.undoRedoService.pushElement(stackOperation); this.undoRedoService.clearRedoStack(); } private _shouldMerge(operation: Operation, prev: IUndoRedoElement, operationMeta: OperationMeta) { if (!prev) { return false; } if (this._transacting) { return true; } return ( operationMeta.shouldMerge && operationMeta.shouldMerge(operation, prev.getLastOperation(), prev as StackOperation) ); } } ================================================ FILE: packages/common/history/src/history/history-stack.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { cloneDeep } from 'lodash-es'; import { injectable, inject } from 'inversify'; import { DisposableCollection, Emitter } from '@flowgram.ai/utils'; import { HistoryOperation, Operation, OperationWithId } from '../operation'; import { HistoryConfig } from '../history-config'; import { type HistoryItem, HistoryStackChangeType, type HistoryStackItem, HistoryStackChangeEvent, UndoRedoChangeType, } from './types'; import { type HistoryService } from './history-service'; /** * 历史栈,聚合所有历史操作 */ @injectable() export class HistoryStack { @inject(HistoryConfig) historyConfig: HistoryConfig; private _items: HistoryItem[] = []; readonly onChangeEmitter = new Emitter(); readonly onChange = this.onChangeEmitter.event; private _toDispose: DisposableCollection = new DisposableCollection(); limit = 100; constructor() { this._toDispose.push(this.onChangeEmitter); } get items(): HistoryItem[] { return this._items; } add(service: HistoryService, item: HistoryStackItem) { const historyItem = this._getHistoryItem(service, item); this._items.unshift(historyItem); if (this._items.length > this.limit) { this._items.pop(); } this.onChangeEmitter.fire({ type: HistoryStackChangeType.ADD, value: historyItem, service, }); return historyItem; } findById(id: string): HistoryItem | undefined { return this._items.find((item) => item.id === id); } changeByIndex(index: number, service: HistoryService, item: HistoryStackItem) { const historyItem = this._getHistoryItem(service, item); this._items[index] = historyItem; this.onChangeEmitter.fire({ type: HistoryStackChangeType.UPDATE, value: historyItem, service, }); } addOperation(service: HistoryService, id: string, op: OperationWithId) { const historyItem = this._items.find((item) => item.id === id); if (!historyItem) { console.warn('no history item found'); return; } const newOperatopn = this._getHistoryOperation(service, op); historyItem.operations.push(newOperatopn); this.onChangeEmitter.fire({ type: HistoryStackChangeType.ADD_OPERATION, value: { historyItem, operation: newOperatopn, }, service, }); } updateOperation(service: HistoryService, id: string, op: OperationWithId) { const historyItem = this._items.find((item) => item.id === id); if (!historyItem) { console.warn('no history item found'); return; } const index = historyItem.operations.findIndex((op) => op.id === op.id); if (index < 0) { console.warn('no operation found'); return; } const newOperatopn = this._getHistoryOperation(service, op); historyItem.operations.splice(index, 1, newOperatopn); this.onChangeEmitter.fire({ type: HistoryStackChangeType.UPDATE_OPERATION, value: { historyItem, operation: newOperatopn, }, service, }); } clear() { this._items = []; } dispose() { this._items = []; this._toDispose.dispose(); } private _getHistoryItem(service: HistoryService, item: HistoryStackItem): HistoryItem { return { ...item, uri: service.context.uri, time: HistoryStack.dateFormat(item.timestamp), operations: item.operations.map((op) => this._getHistoryOperation(service, op, item.type !== UndoRedoChangeType.PUSH) ), }; } private _getHistoryOperation( service: HistoryService, op: Operation, generateId: boolean = false ): HistoryOperation { let id; if (generateId) { id = this.historyConfig.generateId(); } else { const oldId = (op as OperationWithId).id; if (!oldId) { throw new Error('no operation id found'); } id = oldId; } return { ...cloneDeep(op), id, label: service.operationService.getOperationLabel(op), description: service.operationService.getOperationDescription(op), timestamp: Date.now(), }; } static dateFormat(timestamp: number) { return new Date(timestamp).toLocaleString(); } } ================================================ FILE: packages/common/history/src/history/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export * from './undo-redo-service'; export * from './types'; export * from './history-service'; export * from './stack-operation'; export * from './history-manager'; export * from './history-stack'; export * from '../history-config'; ================================================ FILE: packages/common/history/src/history/stack-operation.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { cloneDeep } from 'lodash-es'; import { DisposableCollection } from '@flowgram.ai/utils'; import { OperationService } from '../operation/operation-service'; import { Operation, OperationWithId } from '../operation'; import { IUndoRedoElement, UndoRedoChangeType } from './types'; export class StackOperation implements IUndoRedoElement { label?: string | undefined; description?: string | undefined; private _operations: OperationWithId[]; private _toDispose = new DisposableCollection(); private _timestamp: number = Date.now(); private _operationService: OperationService; private _id: string; get id() { return this._id; } constructor(operationService: OperationService, operations: Operation[] = []) { this._operationService = operationService; this._operations = operations.map((op) => this._operation(op)); this._id = operationService.config.generateId(); } getTimestamp(): number { return this._timestamp; } pushOperation(operation: Operation): OperationWithId { const op = this._operation(operation); this._operations.push(op); return op; } getOperations(): Operation[] { return this._operations; } getChangeOperations(type: UndoRedoChangeType): Operation[] { if (type === UndoRedoChangeType.UNDO) { return this._operationService.inverseOperations(this._operations); } return this._operations; } getFirstOperation(): Operation { return this._operations[0]; } getLastOperation(): Operation { return this._operations[this._operations.length - 1]; } async undo(): Promise { const inverseOps = this._operationService.inverseOperations(this._operations); for (const op of inverseOps) { await this._apply(op); } } async redo(): Promise { for (const op of this._operations) { await this._apply(op); } } revert(type: UndoRedoChangeType): void | Promise { let operations: Operation[] = this._operations; if (type !== UndoRedoChangeType.UNDO) { operations = this._operations.map((op) => this._inverse(op)).reverse(); } for (const op of operations) { this._apply(op); } } private _inverse(op: Operation): Operation { return this._operationService.inverseOperation(op); } private async _apply(op: Operation) { await this._operationService.applyOperation(op); } private _operation(op: Operation) { return { ...op, value: cloneDeep(op.value), id: this._operationService.config.generateId(), }; } dispose(): void { this._toDispose.dispose(); } } ================================================ FILE: packages/common/history/src/history/types.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { Disposable } from '@flowgram.ai/utils'; import { HistoryOperation, Operation } from '../operation'; import { HistoryService } from './history-service'; export interface HistoryRecord { snapshot: any; stack: any[]; } export interface HistoryItem extends HistoryStackItem { id: string; time: string; operations: HistoryOperation[]; } /** * 历史服务管理 */ export interface IHistoryManager { /** * 注册历史服务 * @param service 历史服务示例 */ registerHistoryService(service: IHistoryService): void; /** * 取消注册历史服务 * @param service 历史服务示例 */ unregisterHistoryService(service: HistoryService): void; } /** * 历史服务 */ export interface IHistoryService extends Disposable { /** * 添加操作 * @param operation 操作 */ pushOperation(operation: Operation): void | Promise; /** * 获取所有历史操作 */ getHistoryOperations(): Operation[]; /** * 撤回 */ undo(): void | Promise; /** * 重做 */ redo(): void | Promise; /** * 是否有可撤销的操作 */ canUndo(): boolean; /** * 是否有可重做的操作 */ canRedo(): boolean; /** * 获取历史记录 */ getRecords(): Promise; /** * 根据历史版本重新存储历史记录 * @param historyRecord 历史记录 */ restore(historyRecord: HistoryRecord): Promise; /** * 清空undo/redo */ clear(): void; /** * 最大数量限制 * @param num 数量 */ limit(num: number): void; /** * 返回快照 */ getSnapshot(): unknown; } export interface IOperationService { pushOperation(operation: Operation): void; } /** * UndoRedo服务 */ export interface IUndoRedoService extends Disposable { /** * 添加一个undo/redo元素 * @param element 可undo/redo的元素 */ pushElement(element: IUndoRedoElement): void; /** * 获取最后一个可undo的元素 */ getLastElement(): IUndoRedoElement; /** * 获取undo栈 */ getUndoStack(): IUndoRedoElement[]; /** * 获取redo栈 */ getRedoStack(): IUndoRedoElement[]; /** * 清空redo栈 */ clearRedoStack(): void; /** * 是否可undo */ canUndo(): boolean; /** * 执行undo */ undo(): Promise | void; /** * 是否可redo */ canRedo(): boolean; /** * 执行redo */ redo(): Promise | void; /** * 清空 undo和redo栈 */ clear(): void; } /** * UndoRedo元素 */ export interface IUndoRedoElement extends Disposable { /** * 操作标题 */ readonly label?: string; /** * 操作描述 */ readonly description?: string; /** * 撤销 */ undo(): Promise | void; /** * 重做 */ redo(): Promise | void; /** * 添加一个操作 * @param operation 操作 */ pushOperation(operation: Operation): Operation; /** * 获取所有操作 */ getOperations(): Operation[]; /** * 获取第一个操作 */ getFirstOperation(): Operation; /** * 获取最后一个操作 */ getLastOperation(): Operation; /** * 获取修改的操作 */ getChangeOperations(type: UndoRedoChangeType): Operation[]; } /** * 操作注册 */ export interface IOperationRegistry { register(type: string, factory: IUndoRedoElementFactory): void; } /** * 操作工厂 */ export type IUndoRedoElementFactory = ( operation: Operation ) => IUndoRedoElement; /** * undo redo 类型 */ export enum UndoRedoChangeType { UNDO = 'undo', REDO = 'redo', PUSH = 'push', CLEAR = 'clear', } /** * 带element的事件 */ export interface UndoRedoChangeElementEvent { type: UndoRedoChangeType.PUSH | UndoRedoChangeType.UNDO | UndoRedoChangeType.REDO; element: IUndoRedoElement; } /** * 清空事件 */ export interface UndoRedoClearEvent { type: UndoRedoChangeType.CLEAR; } /** * undo redo变化事件 */ export type UndoRedoChangeEvent = UndoRedoChangeElementEvent | UndoRedoClearEvent; export interface HistoryStackItem { id: string; type: UndoRedoChangeType; timestamp: number; operations: Operation[]; uri?: string; } /** * 历史栈变化类型 */ export enum HistoryStackChangeType { ADD = 'add', UPDATE = 'update', CLEAR = 'clear', ADD_OPERATION = 'add_operation', UPDATE_OPERATION = 'update_operation', } /** * 历史栈变化事件基础 */ export interface HistoryStackBaseEvent { type: HistoryStackChangeType; value?: any; service: HistoryService; } /** * 添加历史事件 */ export interface HistoryStackAddEvent extends HistoryStackBaseEvent { type: HistoryStackChangeType.ADD; value: HistoryItem; } /** * 更新历史事件 */ export interface HistoryStackUpdateEvent extends HistoryStackBaseEvent { type: HistoryStackChangeType.UPDATE; value: HistoryItem; } /** * 添加操作事件 */ export interface HistoryStackAddOperationEvent extends HistoryStackBaseEvent { type: HistoryStackChangeType.ADD_OPERATION; value: { historyItem: HistoryItem; operation: HistoryOperation; }; } /** * 更新操作事件 */ export interface HistoryStackUpdateOperationEvent extends HistoryStackBaseEvent { type: HistoryStackChangeType.UPDATE_OPERATION; value: { historyItem: HistoryItem; operation: HistoryOperation; }; } /** * 历史记录变化事件 */ export type HistoryStackChangeEvent = | HistoryStackAddEvent | HistoryStackUpdateEvent | HistoryStackAddOperationEvent | HistoryStackUpdateOperationEvent; export enum HistoryMergeEventType { ADD = 'ADD', UPDATE = 'UPDATE', } /** * 历史合并事件 */ export type HistoryMergeEvent = | { type: HistoryMergeEventType.ADD; value: { element: IUndoRedoElement; operation: Operation; }; } | { type: HistoryMergeEventType.UPDATE; value: { element: IUndoRedoElement; operation: Operation; value: any; }; }; ================================================ FILE: packages/common/history/src/history/undo-redo-service.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { injectable } from 'inversify'; import { Emitter, DisposableCollection } from '@flowgram.ai/utils'; import { IUndoRedoElement, IUndoRedoService, UndoRedoChangeType, UndoRedoChangeEvent, UndoRedoClearEvent, } from './types'; @injectable() export class UndoRedoService implements IUndoRedoService { private _undoStack: IUndoRedoElement[]; private _redoStack: IUndoRedoElement[]; private _undoing: boolean = false; private _redoing: boolean = false; private _limit: number = 100; protected onChangeEmitter = new Emitter(); readonly onChange = this.onChangeEmitter.event; readonly _toDispose = new DisposableCollection(); constructor() { this._undoStack = []; this._redoStack = []; this._toDispose.push(this.onChangeEmitter); } setLimit(limit: number) { this._limit = limit; } pushElement(element: IUndoRedoElement): void { this._redoStack = []; this._stackPush(this._undoStack, element); this._toDispose.push(element); this._emitChange(UndoRedoChangeType.PUSH, element); } getUndoStack() { return this._undoStack; } getRedoStack() { return this._redoStack; } getLastElement() { return this._undoStack[this._undoStack.length - 1]; } /** * 执行undo * @returns void */ async undo(): Promise { if (!this.canUndo()) { return; } if (this._undoing) { return; } this._undoing = true; const item = this._undoStack.pop() as IUndoRedoElement; try { await item.undo(); } finally { this._stackPush(this._redoStack, item); this._emitChange(UndoRedoChangeType.UNDO, item); this._undoing = false; } } /** * 执行redo * @returns void */ async redo(): Promise { if (!this.canRedo()) { return; } if (this._redoing) { return; } this._redoing = true; const item = this._redoStack.pop() as IUndoRedoElement; try { await item.redo(); } finally { this._stackPush(this._undoStack, item); this._emitChange(UndoRedoChangeType.REDO, item); this._redoing = false; } } /** * 是否可undo * @returns true代表可以,false代表不可以 */ canUndo(): boolean { return this._undoStack.length > 0; } /** * 是否可redo * @returns true代表可以,false代表不可以 */ canRedo(): boolean { return this._redoStack.length > 0; } /** * 是否可以push * @returns true代表可以,false代表不可以 */ canPush(): boolean { return !this._redoing && !this._undoing; } /** * 清空 */ clear() { this.clearRedoStack(); this.clearUndoStack(); this._emitChange(UndoRedoChangeType.CLEAR); } /** * 清空redo栈 */ clearRedoStack(): void { this._redoStack.forEach(element => { element.dispose(); }); this._redoStack = []; } /** * 清空undo栈 */ clearUndoStack(): void { this._undoStack.forEach(element => { element.dispose(); }); this._undoStack = []; } /** * 销毁 */ dispose(): void { this.clear(); this._toDispose.dispose(); } private _stackPush(stack: IUndoRedoElement[], element: IUndoRedoElement) { stack.push(element); if (stack.length > this._limit) { stack.shift(); } } private _emitChange(type: UndoRedoChangeType, element?: IUndoRedoElement) { if (element) { this.onChangeEmitter.fire({ type, element }); } else { this.onChangeEmitter.fire({ type } as UndoRedoClearEvent); } } } ================================================ FILE: packages/common/history/src/history-config.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { nanoid } from 'nanoid'; import { injectable } from 'inversify'; @injectable() export class HistoryConfig { generateId: () => string = () => nanoid(); getSnapshot: () => unknown = () => ''; } ================================================ FILE: packages/common/history/src/history-container-module.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { ContainerModule } from 'inversify'; import { OperationRegistry, OperationService } from './operation'; import { HistoryContext } from './history-context'; import { HistoryService, HistoryStack, HistoryManager, UndoRedoService, HistoryConfig, } from './history'; export const HistoryContainerModule = new ContainerModule( (bind, _unbind, _isBound, _rebind, _unbindAsync, onActivation, _onDeactivation) => { bind(OperationRegistry).toSelf().inSingletonScope(); bind(OperationService).toSelf().inSingletonScope(); bind(UndoRedoService).toSelf().inSingletonScope(); bind(HistoryService).toSelf().inSingletonScope(); bind(HistoryContext).toSelf().inSingletonScope(); bind(HistoryManager).toSelf().inSingletonScope(); bind(HistoryStack).toSelf().inSingletonScope(); bind(HistoryConfig).toSelf().inSingletonScope(); onActivation(HistoryService, (ctx, historyService) => { let historyManager; if (ctx.container?.parent?.isBound(HistoryManager)) { historyManager = ctx.container?.parent?.get(HistoryManager); } else { historyManager = ctx.container.get(HistoryManager); } if (!historyManager) { return historyService; } historyService.historyManager = historyManager; historyManager.registerHistoryService(historyService); return historyService; }); } ); ================================================ FILE: packages/common/history/src/history-context.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { injectable } from 'inversify'; @injectable() export class HistoryContext { /** * 所属uri */ uri?: string; /** * 操作触发的源对象,如编辑器对象 */ source?: unknown; } ================================================ FILE: packages/common/history/src/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export * from './operation'; export * from './history'; export * from './history-container-module'; export * from './create-history-plugin'; export * from './history-context'; ================================================ FILE: packages/common/history/src/operation/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export * from './operation-contribution'; export * from './operation-registry'; export * from './operation-service'; export * from './types'; ================================================ FILE: packages/common/history/src/operation/operation-contribution.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { OperationRegistry } from './operation-registry'; export const OperationContribution = Symbol('OperationContribution'); export interface OperationContribution { registerOperationMeta?(operationRegistry: OperationRegistry): void; } ================================================ FILE: packages/common/history/src/operation/operation-registry.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { injectable, multiInject, optional, postConstruct } from 'inversify'; import { Disposable, DisposableCollection } from '@flowgram.ai/utils'; import { OperationMeta } from './types'; import { OperationContribution } from './operation-contribution'; @injectable() export class OperationRegistry { private readonly _operationMetas: Map = new Map(); @multiInject(OperationContribution) @optional() protected readonly contributions: OperationContribution[] = []; @postConstruct() protected init() { for (const contrib of this.contributions) { contrib.registerOperationMeta?.(this); } } /** * 注册操作的元数据 * @param operationMeta 操作的元数据 * @returns 销毁函数 */ registerOperationMeta(operationMeta: OperationMeta): Disposable { if (this._operationMetas.has(operationMeta.type)) { console.warn(`A operation meta ${operationMeta.type} is already registered.`); return Disposable.NULL; } const toDispose = new DisposableCollection(this._doRegisterOperationMetaMeta(operationMeta)); return toDispose; } /** * 获取操作的元数据 * @param type 操作类型 * @returns 操作的元数据 */ getOperationMeta(type: string): OperationMeta | undefined { return this._operationMetas.get(type); } private _doRegisterOperationMetaMeta(operationMeta: OperationMeta): Disposable { this._operationMetas.set(operationMeta.type, operationMeta); return { dispose: () => { this._operationMetas.delete(operationMeta.type); }, }; } } ================================================ FILE: packages/common/history/src/operation/operation-service.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { injectable, inject, postConstruct } from 'inversify'; import { DisposableCollection, Emitter } from '@flowgram.ai/utils'; import { HistoryContext } from '../history-context'; import { HistoryConfig } from '../history-config'; import { Operation } from './types'; import { OperationRegistry } from './operation-registry'; @injectable() export class OperationService { @inject(OperationRegistry) readonly operationRegistry: OperationRegistry; @inject(HistoryContext) readonly context: HistoryContext; @inject(HistoryConfig) config: HistoryConfig; readonly applyEmitter = new Emitter(); readonly onApply = this.applyEmitter.event; private _toDispose = new DisposableCollection(); @postConstruct() init() { this._toDispose.push(this.applyEmitter); } /** * 执行操作 * @param op * @returns */ applyOperation(op: Operation, options?: { noApply?: boolean }): any { const meta = this.operationRegistry.getOperationMeta(op.type); if (!meta) { throw new Error(`Operation meta ${op.type} has not registered.`); } let res; if (!options?.noApply) { res = meta.apply(op, this.context.source); } this.applyEmitter.fire(op); return res; } /** * 根据操作类型获取操作的label * @param operation 操作 * @returns */ getOperationLabel(operation: Operation): string | undefined { const operationMeta = this.operationRegistry.getOperationMeta(operation.type); if (operationMeta && operationMeta.getLabel) { return operationMeta.getLabel(operation, this.context.source); } } /** * 根据操作类型获取操作的description * @param operation 操作 * @returns */ getOperationDescription(operation: Operation): string | undefined { const operationMeta = this.operationRegistry.getOperationMeta(operation.type); if (operationMeta && operationMeta.getDescription) { return operationMeta.getDescription(operation, this.context.source); } } /** * 操作取反 * @param operations * @returns */ inverseOperations(operations: Operation[]) { return operations.map(op => this.inverseOperation(op)).reverse(); } inverseOperation(op: Operation): Operation { const meta = this.operationRegistry.getOperationMeta(op.type); if (!meta) { throw new Error(`Operation meta ${op.type} has not registered.`); } return meta.inverse(op); } dispose() { this._toDispose.dispose(); } } ================================================ FILE: packages/common/history/src/operation/types.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { StackOperation } from '../history'; /** * 操作 */ export interface Operation { /** * 操作的类型 如insert_node, move_node等 */ type: string; /** * 操作的值 外部自定义 */ value: OperationValue; /** * 资源唯一标志 */ uri?: string; /** * 操作触发源头 */ origin?: string | Symbol; } export type OperationWithId = Operation & { id: string }; /** * push操作配置 */ export interface PushOperationOptions { noApply?: boolean; } /** * 操作历史 */ export interface HistoryOperation extends Operation { /** * 唯一id */ id: string; /** * 显示名称 */ label?: string; /** * 描述 */ description?: string; /** * 时间戳 */ timestamp: number; } /** * 操作元数据 */ export interface OperationMeta { /** * 操作类型 需要唯一 */ type: string; /** * 将一个操作转换成另一个逆操作, 如insert转成delete * @param op 操作 * @returns 逆操作 */ inverse: (op: Operation) => Operation; /** * 判断是否可以合并 * @param op 操作 * @param prev 上一个操作 * @returns true表示可以合并 返回一个操作表示直接用新操作替换之前的操作 */ shouldMerge?: ( op: Operation, prev: Operation | undefined, stackItem: StackOperation ) => boolean | Operation; /** * 判断是否需要保存,如选中等操作可以不保存 * @param op 操作 * @returns true表示可以保存 */ shouldSave?: (op: Operation) => boolean; /** * 执行操作 * @param operation 操作 */ apply(operation: Operation, source: Source): ApplyResult | Promise; /** * 获取标签 */ getLabel?: (operation: Operation, source: Source) => string; /** * 获取描述 */ getDescription?: (operation: Operation, source: Source) => string; /** * 获取uri */ getURI?: (operation: Operation, source: Source) => string | undefined; } ================================================ FILE: packages/common/history/tsconfig.json ================================================ { "extends": "@flowgram.ai/ts-config/tsconfig.flow.path.json", "compilerOptions": { "types": ["vitest/globals"] }, "include": ["./src", "./__mocks__"], "exclude": ["node_modules"] } ================================================ FILE: packages/common/history/vitest.config.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ const path = require('path'); import { defineConfig } from 'vitest/config'; export default defineConfig({ build: { commonjsOptions: { transformMixedEsModules: true, }, }, test: { coverage: { exclude: ['setup/**', '**/*.mock.*'], }, globals: true, mockReset: false, environment: 'jsdom', include: ['**/?(*.){test,spec}.?(c|m)[jt]s?(x)'], setupFiles: [path.resolve(__dirname, './vitest.setup.ts')], exclude: [ '**/node_modules/**', '**/dist/**', '**/lib/**', // lib 编译结果忽略掉 '**/cypress/**', '**/.{idea,git,cache,output,temp}/**', '**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*', ], }, }); ================================================ FILE: packages/common/history/vitest.setup.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import 'reflect-metadata'; ================================================ FILE: packages/common/history-storage/eslint.config.js ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ const { defineFlatConfig } = require('@flowgram.ai/eslint-config'); module.exports = defineFlatConfig({ preset: 'web', packageRoot: __dirname, }); ================================================ FILE: packages/common/history-storage/package.json ================================================ { "name": "@flowgram.ai/history-storage", "version": "0.1.8", "homepage": "https://flowgram.ai/", "repository": "https://github.com/bytedance/flowgram.ai", "license": "MIT", "exports": { "types": "./dist/index.d.ts", "import": "./dist/esm/index.js", "require": "./dist/index.js" }, "main": "./dist/index.js", "module": "./dist/esm/index.js", "types": "./dist/index.d.ts", "files": [ "dist" ], "scripts": { "build": "npm run build:fast -- --dts-resolve", "build:fast": "tsup src/index.ts --format cjs,esm --sourcemap --legacy-output", "build:watch": "npm run build:fast -- --dts-resolve", "clean": "rimraf dist", "test": "vitest run", "test:cov": "vitest run --coverage", "test:update": "vitest run --update", "ts-check": "tsc --noEmit", "watch": "npm run build:fast -- --dts-resolve --watch --ignore-watch dist" }, "dependencies": { "@flowgram.ai/core": "workspace:*", "@flowgram.ai/history": "workspace:*", "@flowgram.ai/utils": "workspace:*", "dexie": "4.0.4", "dexie-react-hooks": "1.1.7", "inversify": "^6.0.1", "reflect-metadata": "~0.2.2", "lodash-es": "^4.17.21", "nanoid": "^5.0.9" }, "devDependencies": { "@flowgram.ai/eslint-config": "workspace:*", "@flowgram.ai/ts-config": "workspace:*", "@types/lodash-es": "^4.17.12", "@vitest/coverage-v8": "^3.2.4", "eslint": "^9.0.0", "fake-indexeddb": "5.0.2", "jsdom": "^26.1.0", "tsup": "^8.0.1", "typescript": "^5.8.3", "vitest": "^3.2.4" }, "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" } } ================================================ FILE: packages/common/history-storage/src/__mocks__/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export const MOCK_RESOURCE_URI1 = 'resource-uri1' export const MOCK_RESOURCE_URI2 = 'resource-uri2' export const MOCK_HISTORY1 = { resourceURI: MOCK_RESOURCE_URI1, uuid: 'history1', timestamp: 111, type: 'push', resourceJSON: 'resourceJSON', } export const MOCK_HISTORY2 = { resourceURI: MOCK_RESOURCE_URI2, uuid: 'history2', timestamp: 111, type: 'push', resourceJSON: 'resourceJSON', } export const MOCK_OPERATION1 = { historyId: 'history1', uri: 'test-1', uuid: 'operation1', type: 'addFromNode', value: 'value1', resourceURI: MOCK_RESOURCE_URI1, label: 'operation1-label', description: 'operation1-description', timestamp: 1, } export const MOCK_OPERATION2 = { historyId: 'history1', uri: 'test-2', uuid: 'operation2', type: 'deleteFromNode', value: 'value2', resourceURI: MOCK_RESOURCE_URI1, label: 'operation2-label', description: 'operation2-description', timestamp: 2, } export const MOCK_OPERATION3 = { historyId: 'history1', uri: 'test-3', uuid: 'operation3', type: 'addText', value: 'value3', resourceURI: MOCK_RESOURCE_URI1, label: 'operation3-label', description: 'operation3-description', timestamp: 3, } ================================================ FILE: packages/common/history-storage/src/__tests__/history-database.test.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { describe, it, beforeEach } from 'vitest'; import { cloneDeep, omit } from 'lodash-es'; import { HistoryOperationRecord, HistoryRecord } from '../types'; import { HistoryDatabase } from '../history-database'; import { MOCK_HISTORY1, MOCK_HISTORY2, MOCK_OPERATION1, MOCK_OPERATION2, MOCK_OPERATION3, MOCK_RESOURCE_URI1, } from '../__mocks__'; describe('history-database', () => { let db: HistoryDatabase; let history1: HistoryRecord; let history2: HistoryRecord; let operation1: HistoryOperationRecord; let operation2: HistoryOperationRecord; beforeEach(async () => { db = new HistoryDatabase(); await db.reset(); history1 = cloneDeep(MOCK_HISTORY1); history2 = cloneDeep(MOCK_HISTORY2); operation1 = cloneDeep(MOCK_OPERATION1); operation2 = cloneDeep(MOCK_OPERATION2); }); it('addHistoryRecord allHistoryByResourceURI allOperationByResourceURI', async () => { const operations = [operation1, operation2]; const res = await db.addHistoryRecord(history1, operations); await db.addHistoryRecord(history2, []); expect(res.length).toEqual(2); const [dbHistory] = await db.allHistoryByResourceURI(MOCK_RESOURCE_URI1); expect(MOCK_HISTORY1).toEqual(omit(dbHistory, ['id'])); const dbOperations = await db.allOperationByResourceURI(MOCK_RESOURCE_URI1); expect(operations).toEqual(dbOperations.map((o) => omit(o, ['id']))); const operation3 = cloneDeep(MOCK_OPERATION3); await db.addOperationRecord(operation3); const dbOperations3 = await db.allOperationByResourceURI(MOCK_RESOURCE_URI1); expect([MOCK_OPERATION1, MOCK_OPERATION2, MOCK_OPERATION3]).toEqual( dbOperations3.map((o) => omit(o, ['id'])) ); }); it('getHistoryByUUID', async () => { await db.addHistoryRecord(history1, []); const res = await db.getHistoryByUUID(history1.uuid); expect(omit(res, ['id'])).toEqual(MOCK_HISTORY1); }); it('updateHistoryByUUID', async () => { await db.addHistoryRecord(history1, []); const dbHistory = await db.getHistoryByUUID(history1.uuid); if (!dbHistory) { throw new Error('no dbHistory'); } const resourceJSON = 'newResourceJSON'; await db.updateHistoryByUUID(dbHistory.uuid, { resourceJSON, }); const [dbHistory1] = await db.allHistoryByResourceURI(MOCK_RESOURCE_URI1); expect(dbHistory1.resourceJSON).toEqual(resourceJSON); }); it('addOperationRecord', async () => { await db.addOperationRecord(operation1); await db.addOperationRecord(operation2); const dbOperations = await db.allOperationByResourceURI(MOCK_RESOURCE_URI1); expect([MOCK_OPERATION1, MOCK_OPERATION2]).toEqual(dbOperations.map((o) => omit(o, ['id']))); }); it('updateOperationRecord', async () => { await db.addOperationRecord(operation1); await db.allOperationByResourceURI(MOCK_RESOURCE_URI1); await db.updateOperationRecord({ ...MOCK_OPERATION2, uuid: MOCK_OPERATION1.uuid }); const [dbUpdatedOperation1] = await db.allOperationByResourceURI(MOCK_RESOURCE_URI1); expect(omit(MOCK_OPERATION2, ['uuid'])).toEqual(omit(dbUpdatedOperation1, ['id', 'uuid'])); }); it('reset', async () => { await db.addHistoryRecord(history1, [operation1, operation2]); await db.reset(); const dbOperation = await db.allOperationByResourceURI(MOCK_RESOURCE_URI1); const dbHistory = await db.allHistoryByResourceURI(MOCK_RESOURCE_URI1); expect(dbOperation.length).toEqual(0); expect(dbHistory.length).toEqual(0); }); it('resetByResourceURI', async () => { await db.addHistoryRecord(history1, [operation1, operation2]); await db.resetByResourceURI(MOCK_RESOURCE_URI1); const dbOperation = await db.allOperationByResourceURI(MOCK_RESOURCE_URI1); const dbHistory = await db.allHistoryByResourceURI(MOCK_RESOURCE_URI1); expect(dbOperation.length).toEqual(0); expect(dbHistory.length).toEqual(0); }); it('resourceStorageLimit', async () => { db.resourceStorageLimit = 1; await db.addHistoryRecord(history1, []); await db.addHistoryRecord(history2, []); const res = await db.allHistoryByResourceURI(MOCK_RESOURCE_URI1); expect(res.length).toEqual(1); }); }); ================================================ FILE: packages/common/history-storage/src/create-history-storage-plugin.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { definePluginCreator } from '@flowgram.ai/core'; import { HistoryStoragePluginOptions } from './types'; import { HistoryStorageManager } from './history-storage-manager'; import { HistoryStorageContainerModule } from './history-storage-container-module'; export const createHistoryStoragePlugin = definePluginCreator({ onBind: ({ bind, rebind }) => {}, onInit(ctx, opts): void { const historyStorageManager = ctx.get(HistoryStorageManager); historyStorageManager.onInit(ctx, opts); }, onDispose(ctx) { const historyStorageManager = ctx.get(HistoryStorageManager); historyStorageManager.dispose(); }, containerModules: [HistoryStorageContainerModule], }); ================================================ FILE: packages/common/history-storage/src/history-database.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import Dexie, { type Table } from 'dexie'; import { HistoryOperationRecord, HistoryRecord } from './types'; /** * 历史数据库 */ export class HistoryDatabase extends Dexie { readonly history: Table; readonly operation: Table; resourceStorageLimit: number = 100; constructor(databaseName: string = 'ide-history-storage') { super(databaseName); this.version(1).stores({ history: '++id, &uuid, resourceURI', operation: '++id, &uuid, historyId, uri, resourceURI', }); } /** * 某个uri下所有的history记录 * @param resourceURI 资源uri * @returns */ allHistoryByResourceURI(resourceURI: string) { return this.history.where({ resourceURI }).toArray(); } /** * 根据uuid获取历史 * @param uuid * @returns */ getHistoryByUUID(uuid: string) { return this.history.get({ uuid }); } /** * 某个uri下所有的operation记录 * @param resourceURI 资源uri * @returns */ allOperationByResourceURI(resourceURI: string) { return this.operation.where({ resourceURI }).toArray(); } /** * 添加历史记录 * @param history 历史记录 * @param operations 操作记录 * @returns */ addHistoryRecord(history: HistoryRecord, operations: HistoryOperationRecord[]) { return this.transaction('rw', this.history, this.operation, async () => { const count = await this.history.where({ resourceURI: history.resourceURI }).count(); if (count >= this.resourceStorageLimit) { const limit = count - this.resourceStorageLimit; const items = await this.history .where({ resourceURI: history.resourceURI }) .limit(limit) .toArray(); const ids = items.map(i => i.id); const uuid = items.map(i => i.uuid); await Promise.all([ this.history.bulkDelete(ids), ...uuid.map(async uuid => { await this.operation.where({ historyId: uuid }).delete(); }), ]); } return Promise.all([this.history.add(history), this.operation.bulkAdd(operations)]); }); } /** * 更新历史记录 * @param historyRecord * @returns */ async updateHistoryByUUID(uuid: string, historyRecord: Partial) { const history = await this.getHistoryByUUID(uuid); if (!history) { console.warn('no history record found'); return; } return this.history.update(history.id, historyRecord); } /** * 添加操作记录 * @param record 操作记录 * @returns */ addOperationRecord(record: HistoryOperationRecord) { return this.operation.add(record); } /** * 更新操作记录 * @param record 操作记录 * @returns */ async updateOperationRecord(record: HistoryOperationRecord) { const op = await this.operation.where({ uuid: record.uuid }).first(); if (!op) { console.warn('no operation record found'); return; } return this.operation.put({ id: op.id, ...record, }); } /** * 重置数据库 * @returns */ reset() { return this.transaction('rw', this.history, this.operation, async () => { await Promise.all(this.tables.map(table => table.clear())); }); } /** * 清空某个资源下所有的数据 * @param resourceURI * @returns */ resetByResourceURI(resourceURI: string) { return this.transaction('rw', this.history, this.operation, async () => { await Promise.all(this.tables.map(table => table.where({ resourceURI }).delete())); }); } } ================================================ FILE: packages/common/history-storage/src/history-storage-container-module.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { ContainerModule } from 'inversify'; import { HistoryStorageManager } from './history-storage-manager'; export const HistoryStorageContainerModule = new ContainerModule(bind => { bind(HistoryStorageManager).toSelf().inSingletonScope(); }); ================================================ FILE: packages/common/history-storage/src/history-storage-manager.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { inject, injectable } from 'inversify'; import { DisposableCollection } from '@flowgram.ai/utils'; import { HistoryItem, HistoryManager, HistoryOperation, HistoryStackChangeType, HistoryService, HistoryStackAddOperationEvent, HistoryStackUpdateOperationEvent, } from '@flowgram.ai/history'; import { PluginContext } from '@flowgram.ai/core'; import { HistoryOperationRecord, HistoryRecord, HistoryStoragePluginOptions } from './types'; import { HistoryDatabase } from './history-database'; /** * 历史存储管理 */ @injectable() export class HistoryStorageManager { private _toDispose = new DisposableCollection(); db: HistoryDatabase; @inject(HistoryManager) protected historyManager: HistoryManager; /** * 初始化 * @param ctx */ onInit(_ctx: PluginContext, opts: HistoryStoragePluginOptions) { this.db = new HistoryDatabase(opts?.databaseName); if (opts?.resourceStorageLimit) { this.db.resourceStorageLimit = opts.resourceStorageLimit; } this._toDispose.push( this.historyManager.historyStack.onChange(event => { if (event.type === HistoryStackChangeType.ADD) { const [history, operations] = this.historyItemToRecord(event.service, event.value); this.db.addHistoryRecord(history, operations).catch(console.error); } // operation merge的时候需要更新snapshot if ( [HistoryStackChangeType.ADD_OPERATION, HistoryStackChangeType.UPDATE_OPERATION].includes( event.type, ) ) { const { service, value: { historyItem }, } = event as HistoryStackAddOperationEvent | HistoryStackUpdateOperationEvent; // 更新快照 this.db .updateHistoryByUUID(historyItem.id, { resourceJSON: service.getSnapshot() || '', }) .catch(console.error); } if (event.type === HistoryStackChangeType.ADD_OPERATION) { const operationRecord: HistoryOperationRecord = this.historyOperationToRecord( event.value.historyItem, event.value.operation, ); this.db.addOperationRecord(operationRecord).catch(console.error); } if (event.type === HistoryStackChangeType.UPDATE_OPERATION) { const operationRecord: HistoryOperationRecord = this.historyOperationToRecord( event.value.historyItem, event.value.operation, ); this.db.updateOperationRecord(operationRecord).catch(console.error); } }), ); } /** * 内存历史转数据表记录 * @param historyItem * @returns */ historyItemToRecord( historyService: HistoryService, historyItem: HistoryItem, ): [HistoryRecord, HistoryOperationRecord[]] { const operations = historyItem.operations.map(op => this.historyOperationToRecord(historyItem, op), ); return [ { uuid: historyItem.id, timestamp: historyItem.timestamp, type: historyItem.type, resourceURI: historyItem.uri?.toString() || '', resourceJSON: historyService.getSnapshot() || '', }, operations, ]; } /** * 内存操作转数据表操作 * @param historyItem * @param op * @returns */ historyOperationToRecord(historyItem: HistoryItem, op: HistoryOperation): HistoryOperationRecord { return { uuid: op.id, type: op.type, timestamp: op.timestamp, label: op.label || '', uri: op?.uri?.toString() || '', resourceURI: historyItem.uri?.toString() || '', description: op.description || '', value: JSON.stringify(op.value), historyId: historyItem.id, }; } /** * 销毁 */ dispose() { this._toDispose.dispose(); } } ================================================ FILE: packages/common/history-storage/src/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export * from './create-history-storage-plugin'; export * from './use-storage-hisotry-items'; export * from './types'; export * from './history-database'; export * from './history-storage-container-module'; export * from './history-storage-manager'; ================================================ FILE: packages/common/history-storage/src/types.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export interface HistoryRecord { /** * 自增id */ id?: number; /** * 唯一标识 */ uuid: string; /** * 类型 如 push undo redo */ type: string; /** * 时间戳 */ timestamp: number; /** * 资源uri */ resourceURI: string; /** * 资源json */ resourceJSON: unknown; } export interface HistoryOperationRecord { /** * 自增id */ id?: number; /** * 唯一标识 */ uuid: string; /** * 历史记录唯一标志,记录的uuid */ historyId: string; /** * 类型,如 addFromNode deleteFromNode */ type: string; /** * 操作值,不同类型不同,json字符串 */ value: string; /** * uri操作对象uri,如某个node的uri */ uri: string; /** * 操作资源uri,如某个流程的uri */ resourceURI: string; /** * 操作显示标题 */ label: string; /** * 操作显示描述 */ description: string; /** * 时间戳 */ timestamp: number; } /** * 插件配置 */ export interface HistoryStoragePluginOptions { /** * 数据库名称 */ databaseName?: string; /** * 每个资源最大历史记录数量 */ resourceStorageLimit?: number; } ================================================ FILE: packages/common/history-storage/src/use-storage-hisotry-items.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { groupBy } from 'lodash-es'; import { useLiveQuery } from 'dexie-react-hooks'; import { HistoryItem, HistoryOperation, HistoryStack } from '@flowgram.ai/history'; import { HistoryStorageManager } from './history-storage-manager'; export function useStorageHistoryItems( historyStorageManager: HistoryStorageManager, resourceURI: string ): { items: HistoryItem[]; } { const items: HistoryItem[] = useLiveQuery(async () => { const [historyItems, operations] = await Promise.all([ historyStorageManager.db.allHistoryByResourceURI(resourceURI), historyStorageManager.db.allOperationByResourceURI(resourceURI), ]); const grouped = groupBy( operations.map((o) => ({ id: o.uuid, timestamp: o.timestamp, type: o.type, label: o.label, description: o.description, value: o.value ? JSON.parse(o.value) : undefined, uri: o.uri, historyId: o.historyId, })), 'historyId' ); return historyItems .sort((a, b) => (b.id as number) - (a.id as number)) .map( (historyItem) => ({ id: historyItem.uuid, type: historyItem.type, timestamp: historyItem.timestamp, operations: grouped[historyItem.uuid] || [], time: HistoryStack.dateFormat(historyItem.timestamp), uri: historyItem.resourceURI, } as HistoryItem) ); }, [resourceURI]) || []; return { items, }; } ================================================ FILE: packages/common/history-storage/tsconfig.json ================================================ { "extends": "@flowgram.ai/ts-config/tsconfig.flow.path.json", "compilerOptions": { "types": ["vitest/globals"] }, "include": ["./src", "./__mocks__"], "exclude": ["node_modules"] } ================================================ FILE: packages/common/history-storage/vitest.config.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ const path = require('path'); import { defineConfig } from 'vitest/config'; export default defineConfig({ build: { commonjsOptions: { transformMixedEsModules: true, }, }, test: { coverage: { exclude: ['setup/**', '**/*.mock.*'], }, globals: true, mockReset: false, environment: 'jsdom', include: ['**/?(*.){test,spec}.?(c|m)[jt]s?(x)'], setupFiles: [path.resolve(__dirname, './vitest.setup.ts')], exclude: [ '**/node_modules/**', '**/dist/**', '**/lib/**', // lib 编译结果忽略掉 '**/cypress/**', '**/.{idea,git,cache,output,temp}/**', '**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*', ], }, }); ================================================ FILE: packages/common/history-storage/vitest.setup.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import 'reflect-metadata'; import 'fake-indexeddb/auto'; ================================================ FILE: packages/common/i18n/__tests__/i18n.test.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { describe, it, expect } from 'vitest'; import { I18n } from '../src'; describe('i18n', () => { it('default', () => { expect(I18n.locale).toBe('en-US'); }); it('setLocal', () => { let changeTimes = 0; let dispose = I18n.onLanguageChange((langId) => { changeTimes++; }); I18n.locale = 'en-US'; expect(changeTimes).toEqual(0); I18n.locale = 'zh-CN'; expect(changeTimes).toEqual(1); dispose.dispose(); I18n.locale = 'en-US'; expect(changeTimes).toEqual(1); }); it('translation', () => { expect(I18n.t('Yes')).toEqual('Yes'); I18n.locale = 'zh-CN'; expect(I18n.t('Yes')).toEqual('是'); expect(I18n.t('Unknown')).toEqual('Unknown'); expect(I18n.t('Unknown', { defaultValue: '' })).toEqual(''); I18n.addLanguage({ languageId: 'zh-CN', contents: { Unknown: '未知', }, }); expect(I18n.t('Unknown')).toEqual('未知'); expect(I18n.t('Unknown', { defaultValue: '' })).toEqual('未知'); }); it('missingStrictMode', () => { I18n.locale = 'en-US'; I18n.missingStrictMode = true; expect(I18n.t('Unknown')).toEqual('[missing "en-US.Unknown" translation]'); I18n.missingStrictMode = false; expect(I18n.t('Unknown')).toEqual('Unknown'); }); }); ================================================ FILE: packages/common/i18n/eslint.config.js ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ const { defineFlatConfig } = require('@flowgram.ai/eslint-config'); module.exports = defineFlatConfig({ preset: 'web', packageRoot: __dirname, }); ================================================ FILE: packages/common/i18n/package.json ================================================ { "name": "@flowgram.ai/i18n", "version": "0.1.8", "homepage": "https://flowgram.ai/", "repository": "https://github.com/bytedance/flowgram.ai", "license": "MIT", "exports": { "types": "./dist/index.d.ts", "import": "./dist/esm/index.js", "require": "./dist/index.js" }, "main": "./dist/index.js", "module": "./dist/esm/index.js", "types": "./dist/index.d.ts", "files": [ "dist" ], "scripts": { "build": "npm run build:fast -- --dts-resolve", "build:fast": "tsup src/index.ts --format cjs,esm --sourcemap --legacy-output", "build:watch": "npm run build:fast -- --dts-resolve", "clean": "rimraf dist", "test": "vitest run", "test:cov": "vitest run --coverage", "watch": "npm run build:fast -- --dts-resolve --watch --ignore-watch dist" }, "dependencies": { "@flowgram.ai/utils": "workspace:*", "i18n-js": "^4.5.1" }, "devDependencies": { "@flowgram.ai/eslint-config": "workspace:*", "@flowgram.ai/ts-config": "workspace:*", "@testing-library/react": "^12", "@testing-library/react-hooks": "^8.0.1", "@types/lodash-es": "^4.17.12", "@types/react": "^18", "@types/react-dom": "^18", "@vitest/coverage-v8": "^3.2.4", "eslint": "^9.0.0", "tsup": "^8.0.1", "typescript": "^5.8.3", "vitest": "^3.2.4" }, "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" } } ================================================ FILE: packages/common/i18n/src/i18n/en-US.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export default { languageId: 'en-US', languageName: 'English', localizedLanguageName: 'English', contents: { Yes: 'Yes', No: 'No', }, }; ================================================ FILE: packages/common/i18n/src/i18n/zh-CN.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export default { languageId: 'zh-CN', languageName: 'Chinese', localizedLanguageName: '中文(中国)', contents: { Yes: '是', No: '否', }, }; ================================================ FILE: packages/common/i18n/src/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { I18n as I18nStore } from 'i18n-js'; import { Emitter } from '@flowgram.ai/utils'; type Scope = Readonly; interface TranslateOptions { defaultValue?: any; [key: string]: any; } interface I18nLanguage { languageId: string; languageName?: string; localizedLanguageName?: string; contents: Record; } import zhCNLanguageDefault from './i18n/zh-CN'; import enUSLanguageDefault from './i18n/en-US'; function getDefaultLanugage(): string { if (typeof navigator !== 'object') return 'en-US'; const defaultLanguage = navigator.language; if (defaultLanguage === 'en' || defaultLanguage === 'en-US') { return 'en-US'; } if (defaultLanguage === 'zh' || defaultLanguage === 'zh-CN') { return 'zh-CN'; } return defaultLanguage; } class I18nImpl { public i18n = new I18nStore(); private _onLanguageChangeEmitter = new Emitter(); readonly onLanguageChange = this._onLanguageChangeEmitter.event; constructor(languages: I18nLanguage[]) { this.addLanguages(languages); this.locale = getDefaultLanugage(); this.i18n.onChange(() => { this._onLanguageChangeEmitter.fire(this.i18n.locale); }); } /** * missing check */ missingStrictMode = false; /** * @param key * @param options */ t(key: Scope, options?: TranslateOptions): string { return this.i18n.t(key, { defaultValue: this.missingStrictMode ? undefined : key, ...options, }); } get locale(): string { return this.i18n.locale; } set locale(locale: string) { this.i18n.locale = locale; } addLanguages(newLanguage: I18nLanguage[]): void { this.i18n.store( newLanguage.reduce( (dict, lang) => Object.assign(dict, { [lang.languageId]: { languageName: lang.languageName, localizedLanguageName: lang.localizedLanguageName, ...lang.contents, }, }), {} ) ); } addLanguage(language: I18nLanguage) { this.addLanguages([language]); } } const I18n = new I18nImpl([enUSLanguageDefault, zhCNLanguageDefault]); export { I18n, I18nLanguage }; ================================================ FILE: packages/common/i18n/tsconfig.json ================================================ { "extends": "@flowgram.ai/ts-config/tsconfig.flow.path.json", "compilerOptions": { "types": ["vitest/globals"], }, } ================================================ FILE: packages/common/i18n/vitest.config.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { defineConfig } from 'vitest/config'; export default defineConfig({ build: { commonjsOptions: { transformMixedEsModules: true, }, }, test: { globals: true, mockReset: false, environment: 'jsdom', }, }); ================================================ FILE: packages/common/reactive/README.md ================================================ # Reactive ## Usage ### 创建响应式数据并做依赖追踪 ```typescript import { ReactiveState, Tracker } from '@flowgram.ai/reactive' // 创建 数据 const reactiveState = new ReactiveState<{ a: number, b: number }>({ a: 0, b: 0 }) // 监听函数 const result = Tracker.autorun(() => { console.log('run: ', reactiveState.value, reactiveState.value.a) }) // 更新字典数据 a 会自动执行上边的 autorun reactiveState.value.a = 1 // 更新数据 b 则不会执行,因为 autorun 函数里没有依赖 reactiveState.value.b = 1 ``` ### react 中使用 ```typescript jsx import { useReactiveState, observe } from '@flowgram.ai/reactive' const SomeComp = ({ state }) => { return
{state.a}
} function App() { const state = useReactiveState<{ a: number, b: number }>({ a: 0, b: 0 }); useEffect(() => { // 触发 SompeComp 更新 state.value.a = 1 // 不触发 SompeComp 更新 state.value.b = 1 }) return } ``` ================================================ FILE: packages/common/reactive/__tests__/hooks.test.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import * as React from 'react'; import { describe, it, expect, afterEach } from 'vitest'; import { render, cleanup } from '@testing-library/react'; import { useReactiveState, useReadonlyReactiveState, Tracker, ReactiveState } from '../src'; describe('hooks', () => { afterEach(() => cleanup()); it('useReactiveState update more times', () => { let renderTimes = 0; const Comp = () => { renderTimes++; const value = useReactiveState({ a: 0, b: 0 }); React.useEffect(() => { value.a = 1; value.a = 2; value.b = 1; value.b = 2; }, []); return (
{value.a} - {value.b}
); }; const result = render(); Tracker.flush(); expect(renderTimes).toEqual(2); // batch update expect(result.asFragment().textContent).toEqual('2 - 2'); }); it('useReactiveState sub component', () => { let comp1RenderTimes = 0; let comp2RenderTimes = 0; const Comp1 = ({ value }: any) => { comp1RenderTimes++; React.useEffect(() => { value.a = 2; }, []); return
{value.a}
; }; const Comp2 = () => { comp2RenderTimes++; const value = useReactiveState({ a: 0 }); return ; }; const result = render(); function checkTimes(a: number, b: number) { expect(comp1RenderTimes).toEqual(a); expect(comp2RenderTimes).toEqual(b); } checkTimes(1, 1); Tracker.flush(); checkTimes(2, 2); expect(result.asFragment().textContent).toEqual('2'); }); it('useReactiveState from outside', () => { let comp1RenderTimes = 0; let comp2RenderTimes = 0; const state = new ReactiveState({ a: 0, b: 0 }); const Comp1 = ({ value }: any) => { comp1RenderTimes++; return
{comp1RenderTimes >= 3 ? '-' : value.a}
; }; const Comp2 = () => { comp2RenderTimes++; const value = useReactiveState(state); return ; }; const result = render(); function checkTimes(a: number, b: number) { expect(comp1RenderTimes).toEqual(a); expect(comp2RenderTimes).toEqual(b); } checkTimes(1, 1); state.value.b = 1; Tracker.flush(); checkTimes(1, 1); // b 没有依赖所有不更新 state.value.a = 1; Tracker.flush(); checkTimes(2, 2); state.value.a = 2; Tracker.flush(); checkTimes(3, 3); expect(result.asFragment().textContent).toEqual('-'); state.value.a = 3; Tracker.flush(); checkTimes(3, 3); // a 不再依赖所以不更新 }); it('useReactiveState nested', () => { const state = new ReactiveState({ a: 0 }); let comp1RenderTimes = 0; let comp2RenderTimes = 0; const Comp1 = () => { comp1RenderTimes++; const value = useReactiveState(state); React.useEffect(() => { value.a = 1; }, []); return
{value.a}
; }; const Comp2 = () => { comp2RenderTimes++; const value = useReactiveState(state); React.useEffect(() => { value.a = 2; }, []); return (
- {value.a}
); }; function checkTimes(a: number, b: number) { expect(comp1RenderTimes).toEqual(a); expect(comp2RenderTimes).toEqual(b); } const result = render(); Tracker.flush(); checkTimes(2, 2); expect(result.asFragment().textContent).toEqual('2 - 2'); }); it('useReadonlyReactiveState', () => { const state = new ReactiveState({ a: 0 }); const Comp = () => { const v = useReadonlyReactiveState(state); expect(() => { (v as any).a = 3; }).toThrowError(/readonly/); return
{v.a}
; }; render(); }); }); ================================================ FILE: packages/common/reactive/__tests__/observe.test.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import * as React from 'react'; import { describe, it, expect, afterEach } from 'vitest'; import { render, cleanup } from '@testing-library/react'; import { observe, Tracker, ReactiveBaseState } from '../src'; function nextTick(v = 0): Promise { return new Promise(res => setTimeout(res, v)); } function createComp(name: string): { Comp: any; renderTimes: number; name: string; fireRender: () => void; } { let renderTimes = 0; let state = new ReactiveBaseState(0); // let refresh: any; const Comp = observe((props: any) => { // refresh = useRefresh() renderTimes++; return ( {state.value} {typeof props.children === 'function' ? props.children() : props.children} ); }); return { Comp, name, get renderTimes(): number { return renderTimes; }, fireRender(): void { state.value += 1; // refresh() }, }; } describe('observe', () => { afterEach(() => cleanup()); it('base', async () => { const comp = createComp('comp1'); const result = render(); expect(comp.renderTimes).toEqual(1); expect(result.asFragment().textContent).toEqual('0'); comp.fireRender(); Tracker.flush(); expect(comp.renderTimes).toEqual(2); expect(result.asFragment().textContent).toEqual('1'); comp.fireRender(); Tracker.flush(); expect(comp.renderTimes).toEqual(3); expect(result.asFragment().textContent).toEqual('2'); comp.fireRender(); // use next tick to wait update await nextTick(); expect(comp.renderTimes).toEqual(4); expect(result.asFragment().textContent).toEqual('3'); }); it('render nested', () => { const comp1 = createComp('comp1'); const comp2 = createComp('comp2'); const checkTimes = (v1: number, v2: number) => { // console.log(comp1.renderTimes, comp2.renderTimes) expect(comp1.renderTimes).toEqual(v1); expect(comp2.renderTimes).toEqual(v2); }; render( , ); checkTimes(1, 1); comp1.fireRender(); Tracker.flush(); checkTimes(2, 1); comp2.fireRender(); Tracker.flush(); checkTimes(2, 2); comp1.fireRender(); comp2.fireRender(); Tracker.flush(); checkTimes(3, 3); }); it('render nested with renderProps', () => { const comp1 = createComp('comp1'); const comp2 = createComp('comp2'); const checkTimes = (v1: number, v2: number) => { // console.log(comp1.renderTimes, comp2.renderTimes) expect(comp1.renderTimes).toEqual(v1); expect(comp2.renderTimes).toEqual(v2); }; render({() => }); checkTimes(1, 1); comp1.fireRender(); Tracker.flush(); checkTimes(2, 2); comp2.fireRender(); Tracker.flush(); checkTimes(2, 3); comp1.fireRender(); comp2.fireRender(); Tracker.flush(); checkTimes(3, 4); }); }); ================================================ FILE: packages/common/reactive/__tests__/reactive-base-state.test.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { describe, it, expect } from 'vitest'; import { ReactiveBaseState, Tracker } from '../src'; describe('reactive-base-state', () => { it('base', () => { const state = new ReactiveBaseState(0); let autorunTimes = -1; const compute = Tracker.autorun(() => { autorunTimes++; return autorunTimes <= 2 ? state.value : -1; }); expect(state.hasDependents()).toEqual(true); expect(autorunTimes).toEqual(0); expect(compute.result).toEqual(0); state.value = 1; expect(compute.result).toEqual(0); expect(autorunTimes).toEqual(0); Tracker.flush(); expect(compute.result).toEqual(1); expect(autorunTimes).toEqual(1); Tracker.flush(); // Still 1! expect(compute.result).toEqual(1); expect(autorunTimes).toEqual(1); state.value = 1; Tracker.flush(); expect(compute.result).toEqual(1); expect(autorunTimes).toEqual(1); state.value = 2; Tracker.flush(); expect(compute.result).toEqual(2); expect(autorunTimes).toEqual(2); state.value = 3; Tracker.flush(); expect(compute.result).toEqual(-1); expect(autorunTimes).toEqual(3); state.value = 4; Tracker.flush(); // Still 1! expect(compute.result).toEqual(-1); expect(autorunTimes).toEqual(3); }); it('custom isEqual', () => { const state = new ReactiveBaseState(0, { isEqual: () => false, }); let autorunTimes = 0; Tracker.autorun(() => { autorunTimes++; return state.value; }); // isEqual 不再判断是否相等, 所以会触发刷新 state.value = 0; Tracker.flush(); expect(autorunTimes).toEqual(2); }); }); ================================================ FILE: packages/common/reactive/__tests__/reactive-state.test.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { describe, it, expect } from 'vitest'; import { ReactiveState, Tracker } from '../src'; describe('reactive-state', () => { it('base', () => { const state = new ReactiveState({ a: 0, b: 0 }); const { value } = state; let autorunTimes = -1; const compute = Tracker.autorun(() => { autorunTimes++; return value.a; }); expect(state.hasDependents()).toEqual(true); expect(autorunTimes).toEqual(0); expect(compute.result).toEqual(0); state.value.a = 1; expect(compute.result).toEqual(0); expect(autorunTimes).toEqual(0); Tracker.flush(); expect(compute.result).toEqual(1); expect(autorunTimes).toEqual(1); Tracker.flush(); // Still 1! expect(compute.result).toEqual(1); expect(autorunTimes).toEqual(1); state.value.a = 1; Tracker.flush(); expect(compute.result).toEqual(1); expect(autorunTimes).toEqual(1); state.value.b = 1; Tracker.flush(); expect(compute.result).toEqual(1); expect(autorunTimes).toEqual(1); }); it('keys', () => { const state = new ReactiveState<{ a: number; b: number }>({ a: 0, b: 0 }); expect(state.keys()).toEqual(['a', 'b']); expect(Object.keys(state.value)).toEqual(['a', 'b']); expect(Object.keys(state.readonlyValue)).toEqual(['a', 'b']); }); it('hasDependents', () => { const state = new ReactiveState<{ a: number; b: number }>({ a: 0, b: 0 }); expect(state.hasDependents()).toEqual(false); const compute = Tracker.autorun(() => state.value.a); expect(state.hasDependents()).toEqual(true); compute.stop(); expect(state.hasDependents()).toEqual(false); }); it('set all value', () => { const state = new ReactiveState<{ a: number; b: number }>({ a: 0, b: 0 }); const compute = Tracker.autorun(() => state.value.a); state.value = { a: 1, b: 1 }; Tracker.flush(); expect(compute.result).toEqual(1); }); it('dict state iterator (use Proxy)', () => { const { value } = new ReactiveState<{ a: number; b: number }>({ a: 0, b: 0 }); let autorunTimes = -1; const compute = Tracker.autorun<{ a: number; b: number }>(() => { autorunTimes++; const result = {}; for (let key in value) { result[key] = value[key]; } return result as any; }); expect(autorunTimes).toEqual(0); expect(compute.result).toEqual({ a: 0, b: 0 }); value.a = 1; value.b = 1; Tracker.flush(); expect(autorunTimes).toEqual(1); expect(compute.result).toEqual({ a: 1, b: 1 }); }); it('dict state iterator (use defineProperty)', () => { global.__ignoreProxy = true; const { value } = new ReactiveState<{ a: number; b: number }>({ a: 0, b: 0 }); let autorunTimes = -1; const compute = Tracker.autorun<{ a: number; b: number }>(() => { autorunTimes++; const result = {}; for (let key in value) { result[key] = value[key]; } return result as any; }); expect(Object.keys(value)).toEqual(['a', 'b']); expect(autorunTimes).toEqual(0); expect(compute.result).toEqual({ a: 0, b: 0 }); value.a = 1; value.b = 1; Tracker.flush(); expect(autorunTimes).toEqual(1); expect(compute.result).toEqual({ a: 1, b: 1 }); global.__ignoreProxy = false; }); it('set unknown field', () => { const { value } = new ReactiveState>({}); let runTimes = 0; const compute = Tracker.autorun(() => { runTimes++; return value.a; }); value.a = 'new field'; Tracker.flush(); expect(runTimes).toEqual(2); expect(compute.result).toEqual('new field'); expect(Object.keys(value)).toEqual(['a']); delete value.a; expect(Object.keys(value)).toEqual([]); }); it('readonly value (use Proxy)', () => { const originState = new ReactiveState({ a: 0, b: 0 }); const readonlyValue = originState.readonlyValue; expect(() => { (readonlyValue as any).a = 1; }).toThrow(/readonly field/); }); it('readonly value (use define property)', () => { global.__ignoreProxy = true; const originState = new ReactiveState({ a: 0, b: 0 }); const readonlyValue = originState.readonlyValue; expect(() => { (readonlyValue as any).a = 1; }).toThrow(/readonly field/); global.__ignoreProxy = false; }); }); ================================================ FILE: packages/common/reactive/__tests__/tracker.test.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { test, expect, describe } from 'vitest'; import { Tracker } from '../src'; function expectTrue(value: any): void { expect(value).toEqual(true); } function expectFalse(value: any): void { expect(value).toEqual(false); } function expectEqual(v1: any, v2: any, msg?: string) { expect(v1).toEqual(v2); } function createPromiseDelegate(): { promise: Promise; complete: () => void } { let complete: () => void; const promise = new Promise(res => { complete = res; }); return { promise, complete, }; } function nextTick(v = 0): Promise { return new Promise(res => setTimeout(res, v)); } /** * fork from: https://github.com/meteor/meteor/blob/devel/packages/tracker/tracker_tests.js */ describe('Tracker', () => { test('tracker - run', function () { var d = new Tracker.Dependency(); var x = 0; var handle = Tracker.autorun(function () { d.depend(); ++x; }); // 默认会先执行一次 expect(x).toEqual(1); Tracker.flush(); expect(x).toEqual(1); d.changed(); expect(x).toEqual(1); Tracker.flush(); expect(x).toEqual(2); d.changed(); expect(x).toEqual(2); Tracker.flush(); expect(x).toEqual(3); d.changed(); // Prevent the function from running further. handle.stop(); Tracker.flush(); expect(x).toEqual(3); d.changed(); Tracker.flush(); expect(x).toEqual(3); Tracker.autorun(function (internalHandle) { d.depend(); ++x; if (x == 6) internalHandle.stop(); }); expect(x).toEqual(4); d.changed(); Tracker.flush(); expect(x).toEqual(5); d.changed(); // Increment to 6 and stop. Tracker.flush(); expect(x).toEqual(6); d.changed(); Tracker.flush(); // Still 6! expect(x).toEqual(6); }); test('tracker - nested run', function () { var a = new Tracker.Dependency(); var b = new Tracker.Dependency(); var c = new Tracker.Dependency(); var d = new Tracker.Dependency(); var e = new Tracker.Dependency(); var f = new Tracker.Dependency(); var buf = ''; Tracker.autorun(function () { a.depend(); buf += 'a'; Tracker.autorun(function () { b.depend(); buf += 'b'; Tracker.autorun(function () { c.depend(); buf += 'c'; var c2 = Tracker.autorun(function () { d.depend(); buf += 'd'; Tracker.autorun(function () { e.depend(); buf += 'e'; Tracker.autorun(function () { f.depend(); buf += 'f'; }); }); Tracker.onInvalidate(function () { // only run once c2.stop(); }); }); }); }); Tracker.onInvalidate(function (c1) { c1.stop(); }); }); const expectAndClear = function (str: string) { expect(buf).toEqual(str); buf = ''; }; expectAndClear('abcdef'); expect(a.hasDependents()).toEqual(true); expect(b.hasDependents()).toEqual(true); expect(c.hasDependents()).toEqual(true); expect(d.hasDependents()).toEqual(true); expect(e.hasDependents()).toEqual(true); expect(f.hasDependents()).toEqual(true); b.changed(); expectAndClear(''); // didn't flush yet Tracker.flush(); expectAndClear('bcdef'); c.changed(); Tracker.flush(); expectAndClear('cdef'); var changeAndExpect = function (v, str) { v.changed(); Tracker.flush(); expectAndClear(str); }; // should cause running changeAndExpect(e, 'ef'); changeAndExpect(f, 'f'); // invalidate inner context changeAndExpect(d, ''); // no more running! changeAndExpect(e, ''); changeAndExpect(f, ''); expectTrue(a.hasDependents()); expectTrue(b.hasDependents()); expectTrue(c.hasDependents()); expectFalse(d.hasDependents()); expectFalse(e.hasDependents()); expectFalse(f.hasDependents()); // rerun C changeAndExpect(c, 'cdef'); changeAndExpect(e, 'ef'); changeAndExpect(f, 'f'); // rerun B changeAndExpect(b, 'bcdef'); changeAndExpect(e, 'ef'); changeAndExpect(f, 'f'); expectTrue(a.hasDependents()); expectTrue(b.hasDependents()); expectTrue(c.hasDependents()); expectTrue(d.hasDependents()); expectTrue(e.hasDependents()); expectTrue(f.hasDependents()); // kill A a.changed(); changeAndExpect(f, ''); changeAndExpect(e, ''); changeAndExpect(d, ''); changeAndExpect(c, ''); changeAndExpect(b, ''); changeAndExpect(a, ''); expectFalse(a.hasDependents()); expectFalse(b.hasDependents()); expectFalse(c.hasDependents()); expectFalse(d.hasDependents()); expectFalse(e.hasDependents()); expectFalse(f.hasDependents()); }); test('tracker - flush', function () { var buf = ''; var c1 = Tracker.autorun(function (c) { buf += 'a'; // invalidate first time if (c.firstRun) c.invalidate(); }); expectEqual(buf, 'a'); Tracker.flush(); expectEqual(buf, 'aa'); Tracker.flush(); expectEqual(buf, 'aa'); c1.stop(); Tracker.flush(); expectEqual(buf, 'aa'); ////// buf = ''; var c2 = Tracker.autorun(function (c) { buf += 'a'; // invalidate first time if (c.firstRun) c.invalidate(); Tracker.onInvalidate(function () { buf += '*'; }); }); expectEqual(buf, 'a*'); Tracker.flush(); expectEqual(buf, 'a*a'); c2.stop(); expectEqual(buf, 'a*a*'); Tracker.flush(); expectEqual(buf, 'a*a*'); ///// // Can flush a different run from a run; // no current computation in afterFlush buf = ''; var c3 = Tracker.autorun(function (c) { buf += 'a'; // invalidate first time if (c.firstRun) c.invalidate(); Tracker.afterFlush(function () { buf += Tracker.isActive() ? '1' : '0'; }); }); Tracker.afterFlush(function () { buf += 'c'; }); var c4 = Tracker.autorun(function (c) { c4 = c; buf += 'b'; }); Tracker.flush(); expectEqual(buf, 'aba0c0'); c3.stop(); c4.stop(); Tracker.flush(); // cases where flush throws var ran = false; Tracker.afterFlush(function (arg) { ran = true; expectEqual(typeof arg, 'undefined'); expect(function () { Tracker.flush(); // illegal nested flush }).toThrowError(); }); Tracker.flush(); expectTrue(ran); expect(function () { Tracker.autorun(function () { Tracker.flush(); // illegal to flush from a computation }); }).toThrowError(); expect(function () { Tracker.autorun(function () { Tracker.autorun(function () {}); Tracker.flush(); }); }).toThrowError(); }); test('tracker - lifecycle', function () { expectFalse(Tracker.isActive()); expectEqual(undefined, Tracker.getCurrentComputation()); var runCount = 0; var firstRun = true; var buf = []; var cbId = 1; var makeCb = function () { var id = cbId++; return function () { buf.push(id); }; }; var shouldStop = false; var c1 = Tracker.autorun(function (c) { expectTrue(Tracker.isActive()); expectEqual(c, Tracker.getCurrentComputation()); expectEqual(c.stopped, false); expectEqual(c.invalidated, false); expectEqual(c.firstRun, firstRun); Tracker.onInvalidate(makeCb()); // 1, 6, ... Tracker.afterFlush(makeCb()); // 2, 7, ... Tracker.autorun(function (x) { x.stop(); c.onInvalidate(makeCb()); // 3, 8, ... Tracker.onInvalidate(makeCb()); // 4, 9, ... Tracker.afterFlush(makeCb()); // 5, 10, ... }); runCount++; if (shouldStop) c.stop(); }); firstRun = false; expectEqual(runCount, 1); expectEqual(buf, [4]); c1.invalidate(); expectEqual(runCount, 1); expectEqual(c1.invalidated, true); expectEqual(c1.stopped, false); expectEqual(buf, [4, 1, 3]); Tracker.flush(); expectEqual(runCount, 2); expectEqual(c1.invalidated, false); expectEqual(buf, [4, 1, 3, 9, 2, 5, 7, 10]); // test self-stop buf.length = 0; shouldStop = true; c1.invalidate(); expectEqual(buf, [6, 8]); Tracker.flush(); expectEqual(buf, [6, 8, 14, 11, 13, 12, 15]); }); test('tracker - onInvalidate', function () { var buf = ''; var c1 = Tracker.autorun(function () { buf += '*'; }); var append = function ( x, expectedComputation?: Tracker.Computation, ): Tracker.IComputationCallback { return function (givenComputation) { expectFalse(Tracker.isActive()); expectEqual(givenComputation, expectedComputation || c1); buf += x; }; }; c1.onStop(append('s')); c1.onInvalidate(append('a')); c1.onInvalidate(append('b')); expectEqual(buf, '*'); Tracker.autorun(function (me) { Tracker.onInvalidate(append('z', me)); me.stop(); expectEqual(buf, '*z'); c1.invalidate(); }); expectEqual(buf, '*zab'); c1.onInvalidate(append('c')); c1.onInvalidate(append('d')); expectEqual(buf, '*zabcd'); Tracker.flush(); expectEqual(buf, '*zabcd*'); // afterFlush ordering buf = ''; c1.onInvalidate(append('a')); c1.onInvalidate(append('b')); Tracker.afterFlush(function () { append('x')(c1); c1.onInvalidate(append('c')); c1.invalidate(); Tracker.afterFlush(function () { append('y')(c1); c1.onInvalidate(append('d')); c1.invalidate(); }); }); Tracker.afterFlush(function () { append('z')(c1); c1.onInvalidate(append('e')); c1.invalidate(); }); expectEqual(buf, ''); Tracker.flush(); expectEqual(buf, 'xabc*ze*yd*'); buf = ''; c1.onInvalidate(append('m')); Tracker.flush(); expectEqual(buf, ''); c1.stop(); expectEqual(buf, 'ms'); // s is from onStop Tracker.flush(); expectEqual(buf, 'ms'); c1.onStop(append('S')); expectEqual(buf, 'msS'); }); test('tracker - invalidate at flush time', function () { // Test this sentence of the docs: Functions are guaranteed to be // called at a time when there are no invalidated computations that // need rerunning. var buf = []; Tracker.afterFlush(function () { buf.push('C'); }); // When c1 is invalidated, it invalidates c2, then stops. var c1 = Tracker.autorun(function (c) { if (!c.firstRun) { buf.push('A'); c2.invalidate(); c.stop(); } }); var c2 = Tracker.autorun(function (c) { if (!c.firstRun) { buf.push('B'); c.stop(); } }); // Invalidate c1. If all goes well, the re-running of // c2 should happen before the afterFlush. c1.invalidate(); Tracker.flush(); expectEqual(buf.join(''), 'ABC'); }); test('tracker - throwFirstError', function (test) { var d = new Tracker.Dependency(); Tracker.autorun(function (c) { d.depend(); if (!c.firstRun) throw new Error('foo'); }); d.changed(); Tracker.flush(); d.changed(); expect(function () { Tracker.flush({ throwFirstError: true }); }).toThrowError(/foo/); }); test('tracker - no infinite recomputation', async function () { var reran = false; var c = Tracker.autorun(function (c) { if (!c.firstRun) reran = true; c.invalidate(); }); expectFalse(reran); await new Promise(res => { setTimeout(function () { c.stop(); Tracker.afterFlush(function () { expectTrue(reran); expectTrue(c.stopped); res(null); }); }, 100); }); }); test('tracker - Tracker.flush finishes', function () { // Currently, _runFlush will "yield" every 1000 computations... unless run in // Tracker.flush. So this test validates that Tracker.flush is capable of // running 2000 computations. Which isn't quite the same as infinity, but it's // getting there. var n = 0; var c = Tracker.autorun(function (c) { if (++n < 2000) { c.invalidate(); } }); expectEqual(n, 1); Tracker.flush(); expectEqual(n, 2000); }); // test('tracker - Tracker.autorun, onError option', async function (ctx) { var d = new Tracker.Dependency(); const promiseDelegate = createPromiseDelegate(); var c = Tracker.autorun( function (c) { d.depend(); if (!c.firstRun) throw new Error('foo'); }, { onError: function (err) { expectEqual(err.message, 'foo'); promiseDelegate.complete(); }, }, ); d.changed(); Tracker.flush(); await promiseDelegate.promise; }); test('tracker - async function - basics', async function () { const promiseDelegate = createPromiseDelegate(); const computation = Tracker.autorun(async function (computation) { expectEqual(computation.firstRun, true, 'before (firstRun)'); expectEqual(Tracker.getCurrentComputation(), computation, 'before'); const x = await Promise.resolve().then(() => Tracker.withComputation(computation, () => { // The `firstRun` is `false` as soon as the first `await` happens. expectEqual(computation.firstRun, false, 'inside (firstRun)'); expectEqual(Tracker.getCurrentComputation(), computation, 'inside'); return 123; }), ); expectEqual(x, 123, 'await (value)'); expectEqual(computation.firstRun, false, 'await (firstRun)'); Tracker.withComputation(computation, () => { expectEqual(Tracker.getCurrentComputation(), computation, 'await'); }); await new Promise(resolve => setTimeout(resolve, 10)); Tracker.withComputation(computation, () => { expectEqual(computation.firstRun, false, 'sleep (firstRun)'); expectEqual(Tracker.getCurrentComputation(), computation, 'sleep'); }); try { await Promise.reject('example'); } catch (error) { Tracker.withComputation(computation, () => { expectEqual(error, 'example', 'catch (error)'); expectEqual(computation.firstRun, false, 'catch (firstRun)'); expectEqual(Tracker.getCurrentComputation(), computation, 'catch'); }); } promiseDelegate.complete(); }); expectEqual(Tracker.getCurrentComputation(), undefined, 'outside (computation)'); // test.instanceOf(computation, Tracker.Computation, 'outside (result)'); await promiseDelegate.promise; }); test('tracker - async function - interleaved', async function () { let count = 0; const limit = 100; for (let index = 0; index < limit; ++index) { Tracker.autorun(async function (computation) { expectEqual(Tracker.getCurrentComputation(), computation, `before (${index})`); await new Promise(resolve => setTimeout(resolve, Math.random() * limit)); count++; Tracker.withComputation(computation, () => { expectEqual(Tracker.getCurrentComputation(), computation, `after (${index})`); }); }); } expectEqual(count, 0, 'before resolve'); await new Promise(resolve => setTimeout(resolve, limit)); expectEqual(count, limit, 'after resolve'); }); test('tracker - async function - parallel', async function () { let resolvePromise; const promise = new Promise(resolve => { resolvePromise = resolve; }); let count = 0; const limit = 100; const dependency = new Tracker.Dependency(); for (let index = 0; index < limit; ++index) { Tracker.autorun(async function (computation) { count++; Tracker.withComputation(computation, () => { dependency.depend(); }); await promise; count--; }); } expectEqual(count, limit, 'before'); dependency.changed(); await nextTick(); expectEqual(count, limit * 2, 'changed'); resolvePromise(); await nextTick(); expectEqual(count, 0, 'after'); }); test('tracker - async function - stepped', async function () { let resolvePromise; const promise = new Promise(resolve => { resolvePromise = resolve; }); let count = 0; const limit = 100; for (let index = 0; index < limit; ++index) { Tracker.autorun(async function (computation) { expectEqual(Tracker.getCurrentComputation(), computation, `before (${index})`); await promise; count++; Tracker.withComputation(computation, () => { expectEqual(Tracker.getCurrentComputation(), computation, `after (${index})`); }); }); } expectEqual(count, 0, 'before resolve'); resolvePromise(); await nextTick(); expectEqual(count, limit, 'after resolve'); }); test('tracker - async function - synchronize - firstRunPromise', async test => { let counter = 0; await Tracker.autorun(async () => { expectEqual(counter, 0); counter += 1; expectEqual(counter, 1); await new Promise(resolve => setTimeout(resolve)); expectEqual(counter, 1); counter *= 2; expectEqual(counter, 2); }).result; await Tracker.autorun(async () => { expectEqual(counter, 2); counter += 1; expectEqual(counter, 3); await new Promise(resolve => setTimeout(resolve)); expectEqual(counter, 3); counter *= 2; expectEqual(counter, 6); }).result; }); test('computation - #flush', function () { var i = 0, j = 0, d = new Tracker.Dependency(); var c1 = Tracker.autorun(function () { d.depend(); i = i + 1; }); var c2 = Tracker.autorun(function () { d.depend(); j = j + 1; }); expectEqual(i, 1); expectEqual(j, 1); d.changed(); c1.flush(); expectEqual(i, 2); expectEqual(j, 1); Tracker.flush(); expectEqual(i, 2); expectEqual(j, 2); }); test('computation - #run', function () { var i = 0, d = new Tracker.Dependency(), d2 = new Tracker.Dependency(); var computation = Tracker.autorun(function (c) { d.depend(); i = i + 1; //when #run() is called, this dependency should be picked up if (i >= 2 && i < 4) { d2.depend(); } }); expectEqual(i, 1); computation.run(); expectEqual(i, 2); d.changed(); Tracker.flush(); expectEqual(i, 3); //we expect to depend on d2 at this point d2.changed(); Tracker.flush(); expectEqual(i, 4); //we no longer depend on d2, only d d2.changed(); Tracker.flush(); expectEqual(i, 4); d.changed(); Tracker.flush(); expectEqual(i, 5); }); }); ================================================ FILE: packages/common/reactive/eslint.config.js ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ const { defineFlatConfig } = require('@flowgram.ai/eslint-config'); module.exports = defineFlatConfig({ preset: 'web', packageRoot: __dirname, }); ================================================ FILE: packages/common/reactive/package.json ================================================ { "name": "@flowgram.ai/reactive", "version": "0.1.8", "homepage": "https://flowgram.ai/", "repository": "https://github.com/bytedance/flowgram.ai", "license": "MIT", "exports": { "types": "./dist/index.d.ts", "import": "./dist/esm/index.js", "require": "./dist/index.js" }, "main": "./dist/index.js", "module": "./dist/esm/index.js", "types": "./dist/index.d.ts", "files": [ "dist" ], "scripts": { "build": "npm run build:fast -- --dts-resolve", "build:fast": "tsup src/index.ts --format cjs,esm --sourcemap --legacy-output", "build:watch": "npm run build:fast -- --dts-resolve", "clean": "rimraf dist", "test": "vitest run", "test:cov": "vitest run --coverage", "ts-check": "tsc --noEmit", "watch": "npm run build:fast -- --dts-resolve --watch --ignore-watch dist" }, "dependencies": { "@flowgram.ai/utils": "workspace:*" }, "devDependencies": { "@flowgram.ai/eslint-config": "workspace:*", "@flowgram.ai/ts-config": "workspace:*", "@testing-library/react": "^12", "@testing-library/react-hooks": "^8.0.1", "@types/react": "^18", "@types/react-dom": "^18", "@vitest/coverage-v8": "^3.2.4", "eslint": "^9.0.0", "jsdom": "^26.1.0", "react": "^18", "react-dom": "^18", "tsup": "^8.0.1", "typescript": "^5.8.3", "vitest": "^3.2.4" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" }, "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" } } ================================================ FILE: packages/common/reactive/src/core/reactive-base-state.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { Tracker } from './tracker'; type IStateEqual = (a: any, b: any) => boolean; export class ReactiveBaseState { protected _dep = new Tracker.Dependency(); protected _value: V; protected _isEqual: IStateEqual = (a: any, b: any) => a == b; protected _addDepend(dep: Tracker.Dependency): void { if (Tracker.isActive()) { dep.depend(); } } constructor(initialValue: V, opts?: { isEqual?: IStateEqual }) { this._value = initialValue; if (opts?.isEqual) { this._isEqual = opts.isEqual; } } hasDependents(): boolean { return this._dep.hasDependents(); } get value(): V { this._addDepend(this._dep); return this._value; } set value(newValue: V) { if (!this._isEqual(this._value, newValue)) { this._value = newValue; this._dep.changed(); } } } ================================================ FILE: packages/common/reactive/src/core/reactive-state.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { Tracker } from './tracker'; import Dependency = Tracker.Dependency; import { ReactiveBaseState } from './reactive-base-state'; import { createProxy } from '../utils/create-proxy'; export class ReactiveState> extends ReactiveBaseState { private _keyDeps: Map = new Map(); set(key: K, value: V[K]): boolean { this._ensureKey(key); const oldValue = this._value[key]; if (!this._isEqual(oldValue, value)) { this._value[key] = value; this._keyDeps.get(key)!.changed(); return true; } return false; } get(key: K): V[K] { this._ensureKey(key); this._addDepend(this._keyDeps.get(key)!); return this._value[key]; } protected _ensureKey(key: keyof V & string) { if (!this._keyDeps.has(key)) { this._keyDeps.set(key, new Dependency()); } } hasDependents(): boolean { if (this._dep.hasDependents()) return true; for (const dep of this._keyDeps.values()) { if (dep.hasDependents()) return true; } return false; } keys(): string[] { return Object.keys(this._value); } set value(newValue: V) { if (!this._isEqual(this._value, newValue)) { this._value = newValue; this._keyDeps.clear(); this._dep.changed(); } } private _proxyValue: V; get value(): V { this._addDepend(this._dep); if (!this._proxyValue) { this._proxyValue = createProxy(this._value, { get: (target, key: string) => this.get(key), set: (target, key: string, newValue) => { this.set(key, newValue); return true; }, }); } return this._proxyValue; } private _proxyReadonlyValue: V; get readonlyValue(): Readonly { this._addDepend(this._dep); if (!this._proxyReadonlyValue) { this._proxyReadonlyValue = createProxy(this._value, { get: (target, key: string) => this.get(key), set: (newValue, key: string) => { throw new Error(`[ReactiveState] Cannnot set readonly field "${key}"`); }, }); } return this._proxyReadonlyValue; } } ================================================ FILE: packages/common/reactive/src/core/tracker.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ /** * Fork from: https://github.com/meteor/meteor/blob/devel/packages/tracker/tracker.js */ type ICallback = (arg: ARG) => RET; /** * Tracker 是一套 响应式依赖追踪 库,来源于 Meteor.Tracker * https://docs.meteor.com/api/Tracker.html#tracker-autorun-and-async-callbacks * https://github.com/meteor/meteor/blob/devel/packages/tracker/tracker.js * * 相关论文:https://dl.acm.org/doi/fullHtml/10.1145/3184558.3185978 */ export namespace Tracker { const _pendingComputations: Computation[] = []; const _afterFlushCallbacks: ICallback[] = []; // `true` if a Tracker.flush is scheduled, or if we are in Tracker.flush now let _willFlush = false; // `true` if we are in Tracker.flush now let _inFlush = false; // `true` if we are computing a computation now, either first time // or recompute. This matches Tracker.active unless we are inside // Tracker.nonreactive, which nullfies currentComputation even though // an enclosing computation may still be running. let _inCompute = false; let _currentComputation: Computation | undefined = undefined; // `true` if the `_throwFirstError` option was passed in to the call // to Tracker.flush that we are in. When set, throw rather than log the // first error encountered while flushing. Before throwing the error, // finish flushing (from a finally block), logging any subsequent // errors. let _throwFirstError = false; export interface FlushOptions { finishSynchronously?: boolean; throwFirstError?: boolean; } function _throwOrLog(msg: string, e: any) { if (_throwFirstError) { throw e; } else { console.error(`[Tracker error] ${msg}`, e); } } // Run all pending computations and afterFlush callbacks. If we were not called // directly via Tracker.flush, this may return before they're all done to allow // the event loop to run a little before continuing. function _runFlush(options?: FlushOptions) { // Nested flush could plausibly happen if, say, a flush causes // DOM mutation, which causes a "blur" event, which runs an // app event handler that calls Tracker.flush. At the moment // Spark blocks event handlers during DOM mutation anyway, // because the LiveRange tree isn't valid. And we don't have // any useful notion of a nested flush. if (inFlush()) throw new Error("Can't call Tracker.flush while flushing"); if (_inCompute) throw new Error("Can't flush inside Tracker.autorun"); options = options || {}; _inFlush = true; _willFlush = true; _throwFirstError = !!options.throwFirstError; var recomputedCount = 0; var finishedTry = false; try { while (_pendingComputations.length || _afterFlushCallbacks.length) { // recompute all pending computations while (_pendingComputations.length) { var comp = _pendingComputations.shift()!; comp._recompute(); if (comp._needsRecompute()) { _pendingComputations.unshift(comp); } if (!options.finishSynchronously && ++recomputedCount > 100) { finishedTry = true; return; } } if (_afterFlushCallbacks.length) { // call one afterFlush callback, which may // invalidate more computations var func = _afterFlushCallbacks.shift()!; try { func(); } catch (e: any) { _throwOrLog('afterFlush', e); } } } finishedTry = true; } finally { if (!finishedTry) { // we're erroring due to throwFirstError being true. _inFlush = false; // needed before calling `Tracker.flush()` again // finish flushing _runFlush({ finishSynchronously: options.finishSynchronously, throwFirstError: false, }); } _willFlush = false; _inFlush = false; if (_pendingComputations.length || _afterFlushCallbacks.length) { // We're yielding because we ran a bunch of computations and we aren't // required to finish synchronously, so we'd like to give the event loop a // chance. We should flush again soon. if (options.finishSynchronously) { throw new Error('still have more to do?'); // shouldn't happen } setTimeout(_requireFlush, 10); } } } function _requireFlush() { if (!_willFlush) { setTimeout(_runFlush, 0); _willFlush = true; } } /******************************** Tracker Base API ******************************************/ /** * 函数在响应式模块中执行 * @param computation * @param f */ export function withComputation( computation: Computation, f: ICallback, ): T { let previousComputation = _currentComputation; _currentComputation = computation; try { return f.call(null, computation); } finally { _currentComputation = previousComputation; } } /** * 函数在非响应式模块中执行 */ export function withoutComputation(f: ICallback): T { let previousComputation = _currentComputation; _currentComputation = undefined; try { return f(undefined); } finally { _currentComputation = previousComputation; } } export function isActive(): boolean { return !!_currentComputation; } export function getCurrentComputation(): Computation | undefined { return _currentComputation; } /** * Run a function now and rerun it later whenever its dependencies * change. Returns a Computation object that can be used to stop or observe the * rerunning. */ export function autorun( f: IComputationCallback, options?: { onError: ICallback }, ): Computation { var c = new Computation(f, _currentComputation, options?.onError); if (isActive()) Tracker.onInvalidate(function () { c.stop(); }); return c; } export function onInvalidate(f: ICallback) { if (!_currentComputation) { throw new Error('Tracker.onInvalidate requires a currentComputation'); } _currentComputation.onInvalidate(f); } /** * True if we are computing a computation now, either first time or recompute. This matches Tracker.active unless we are inside Tracker.nonreactive, which nullfies currentComputation even though an enclosing computation may still be running. */ export function inFlush(): boolean { return _inFlush; } /** * Process all reactive updates immediately and ensure that all invalidated computations are rerun. */ export function flush(options?: Omit) { _runFlush({ finishSynchronously: true, throwFirstError: options && options.throwFirstError, }); } /** * Schedules a function to be called during the next flush, or later in the current flush if one is in progress, after all invalidated computations have been rerun. The function will be run once and not on subsequent flushes unless `afterFlush` is called again. */ export function afterFlush(f: ICallback) { _afterFlushCallbacks.push(f); _requireFlush(); } /********************************************************************************************/ export type IComputationCallback = ICallback; /** * A Computation object represents code that is repeatedly rerun * in response to * reactive data changes. Computations don't have return values; they just * perform actions, such as rerendering a template on the screen. Computations * are created using Tracker.autorun. Use stop to prevent further rerunning of a * computation. */ export class Computation { private _onInvalidateCallbacks: IComputationCallback[] = []; private _onStopCallbacks: IComputationCallback[] = []; private _recomputing = false; private _result: V; /** * 是否停止 */ public stopped = false; /** * 未开始执行则返回 false */ public invalidated = false; /** * 是否第一次执行 */ public firstRun = true; constructor( private _fn: IComputationCallback, public readonly parent?: Computation, private readonly _onError?: ICallback, ) { let hasError = true; try { this._compute(); hasError = false; } finally { this.firstRun = false; if (hasError) { this.stop(); } } } onInvalidate(f: IComputationCallback): void { if (this.invalidated) { withoutComputation(f.bind(null, this)); } else { this._onInvalidateCallbacks.push(f); } } /** * @summary Invalidates this computation so that it will be rerun. */ invalidate() { if (!this.invalidated) { // if we're currently in _recompute(), don't enqueue // ourselves, since we'll rerun immediately anyway. if (!this._recomputing && !this.stopped) { _requireFlush(); _pendingComputations.push(this); } this.invalidated = true; // callbacks can't add callbacks, because // this.invalidated === true. for (var i = 0, f: IComputationCallback; (f = this._onInvalidateCallbacks[i]); i++) { withoutComputation(f.bind(null, this)); } this._onInvalidateCallbacks = []; } } /** * @summary Prevents this computation from rerunning. * @locus Client */ stop() { if (!this.stopped) { this.stopped = true; this.invalidate(); for (let i = 0, f: IComputationCallback; (f = this._onStopCallbacks[i]); i++) { withoutComputation(f.bind(null, this)); } this._onStopCallbacks = []; } } onStop(f: IComputationCallback): void { if (this.stopped) { withoutComputation(f.bind(null, this)); } else { this._onStopCallbacks.push(f); } } private _compute(): void { this.invalidated = false; var previousInCompute = _inCompute; _inCompute = true; try { this._result = Tracker.withComputation(this, this._fn); } finally { _inCompute = previousInCompute; } } _needsRecompute() { return this.invalidated && !this.stopped; } _recompute() { this._recomputing = true; try { if (this._needsRecompute()) { try { this._compute(); } catch (e: any) { if (this._onError) { this._onError(e); } else { _throwOrLog('recompute', e); } } } } finally { this._recomputing = false; } } /** * @summary Process the reactive updates for this computation immediately * and ensure that the computation is rerun. The computation is rerun only * if it is invalidated. */ flush() { if (this._recomputing) return; this._recompute(); } /** * @summary Causes the function inside this computation to run and * synchronously process all reactive updtes. * @locus Client */ run() { this.invalidate(); this.flush(); } get result(): V { return this._result; } } /** * A Dependency represents an atomic unit of reactive data that a * computation might depend on. Reactive data sources such as Session or * Minimongo internally create different Dependency objects for different * pieces of data, each of which may be depended on by multiple computations. * When the data changes, the computations are invalidated. */ export class Dependency { private _dependents: Set = new Set(); /** * Declares that the current computation (or `fromComputation` if given) depends on `dependency`. The computation will be invalidated the next time `dependency` changes. * If there is no current computation and `depend()` is called with no arguments, it does nothing and returns false. * Returns true if the computation is a new dependent of `dependency` rather than an existing one. */ depend(computation?: Computation): boolean { if (!computation) { if (!isActive()) { return false; } computation = _currentComputation; } if (!this._dependents.has(computation!)) { this._dependents.add(computation!); computation!.onInvalidate(() => { this._dependents.delete(computation!); }); return true; } return false; } /** * Invalidate all dependent computations immediately and remove them as dependents. */ changed() { for (const dep of this._dependents) { dep.invalidate(); } } /** * True if this Dependency has one or more dependent Computations, which would be invalidated if this Dependency were to change. */ hasDependents() { return this._dependents.size !== 0; } } } ================================================ FILE: packages/common/reactive/src/hooks/use-observe.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { useCallback, useEffect, useMemo } from 'react'; import { useRefresh } from '@flowgram.ai/utils'; import { createProxy } from '../utils/create-proxy'; import { Tracker } from '../core/tracker'; import Computation = Tracker.Computation; export function useObserve>(value: T | undefined): T { const refresh = useRefresh(); const computationMap = useMemo>(() => new Map(), []); const clear = useCallback(() => { computationMap.forEach((comp) => comp.stop()); computationMap.clear(); }, []); useEffect(() => clear, []); // 重新渲染需要清空依赖 clear(); return useMemo(() => { if (value === undefined) return {} as T; return createProxy(value, { get(target, key: string) { let computation = computationMap.get(key); if (!computation) { computation = new Tracker.Computation((c) => { if (!c.firstRun) { refresh(); return; } return value[key]; }); computationMap.set(key, computation); } return value[key]; }, }); }, [value]); } ================================================ FILE: packages/common/reactive/src/hooks/use-reactive-state.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { useMemo } from 'react'; import { ReactiveState } from '../core/reactive-state'; import { useObserve } from './use-observe'; export function useReactiveState>(v: ReactiveState | T): T { const state = useMemo>( () => (v instanceof ReactiveState ? v : new ReactiveState(v)), [], ); return useObserve(state.value); } ================================================ FILE: packages/common/reactive/src/hooks/use-readonly-reactive-state.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { ReactiveState } from '../core/reactive-state'; import { useObserve } from './use-observe'; export function useReadonlyReactiveState>( state: ReactiveState, ): Readonly { return useObserve(state.readonlyValue); } ================================================ FILE: packages/common/reactive/src/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { Tracker } from './core/tracker'; export { Tracker } from './core/tracker'; export { ReactiveState } from './core/reactive-state'; export { ReactiveBaseState } from './core/reactive-base-state'; export { useReactiveState } from './hooks/use-reactive-state'; export { useReadonlyReactiveState } from './hooks/use-readonly-reactive-state'; export { useObserve } from './hooks/use-observe'; export { observe } from './react/observe'; export const { Dependency, Computation } = Tracker; ================================================ FILE: packages/common/reactive/src/react/observe.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React, { useRef, useEffect } from 'react'; import { useRefresh } from '@flowgram.ai/utils'; import { Tracker } from '../core/tracker'; import Computation = Tracker.Computation; export function observe(fc: React.FC): React.FC { return function ReactiveObserver(props: T) { const childrenRef = useRef(); const computationRef = useRef(); const refresh = useRefresh(); computationRef.current?.stop(); computationRef.current = new Tracker.Computation((c) => { if (c.firstRun) { childrenRef.current = fc(props); } else { refresh(); } }); useEffect( () => () => { computationRef.current?.stop(); }, [] ); return childrenRef.current!; }; } ================================================ FILE: packages/common/reactive/src/utils/create-proxy.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ interface ProxyOptions { get?: (target: V, key: string) => any; set?: (target: V, key: string, newValue: any) => boolean; } export function createProxy>(target: V, opts: ProxyOptions): V { let useProxy = 'Proxy' in window; if (process.env.NODE_ENV === 'test') { if ((global as any).__ignoreProxy) { useProxy = false; } } if (useProxy) { return new Proxy(target, opts); } const result: V = {} as V; for (const key in target) { Object.defineProperty(result, key, { enumerable: true, get: opts.get ? () => opts.get!(target, key) : undefined, set: opts.set ? (newValue: any) => opts.set!(target, key, newValue) : undefined, }); } return result; } ================================================ FILE: packages/common/reactive/tsconfig.json ================================================ { "extends": "@flowgram.ai/ts-config/tsconfig.flow.path.json", "compilerOptions": { "types": ["vitest/globals"], "lib": [ "dom", "es5", "scripthost", "es2015.collection" ] }, "include": ["./src"], "exclude": ["node_modules"] } ================================================ FILE: packages/common/reactive/vitest.config.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { defineConfig } from 'vitest/config'; export default defineConfig({ build: { commonjsOptions: { transformMixedEsModules: true, }, }, test: { globals: true, mockReset: false, environment: 'jsdom', include: ['**/?(*.){test,spec}.?(c|m)[jt]s?(x)'], exclude: [ '**/node_modules/**', '**/dist/**', '**/lib/**', // lib 编译结果忽略掉 '**/cypress/**', '**/.{idea,git,cache,output,temp}/**', '**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*', ], }, }); ================================================ FILE: packages/common/utils/eslint.config.js ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ const { defineFlatConfig } = require('@flowgram.ai/eslint-config'); module.exports = defineFlatConfig({ preset: 'web', packageRoot: __dirname, }); ================================================ FILE: packages/common/utils/package.json ================================================ { "name": "@flowgram.ai/utils", "version": "0.1.8", "homepage": "https://flowgram.ai/", "repository": "https://github.com/bytedance/flowgram.ai", "license": "MIT", "exports": { "types": "./dist/index.d.ts", "import": "./dist/esm/index.js", "require": "./dist/index.js" }, "main": "./dist/index.js", "module": "./dist/esm/index.js", "types": "./dist/index.d.ts", "files": [ "dist" ], "scripts": { "build": "npm run build:fast -- --dts-resolve", "build:fast": "tsup src/index.ts --format cjs,esm --sourcemap --legacy-output", "build:watch": "npm run build:fast -- --dts-resolve", "clean": "rimraf dist", "test": "vitest run", "test:cov": "vitest run --coverage", "ts-check": "tsc --noEmit", "watch": "npm run build:fast -- --dts-resolve --watch --ignore-watch dist" }, "dependencies": { "clsx": "^1.1.1", "inversify": "^6.0.1", "reflect-metadata": "~0.2.2", "nanoid": "^5.0.9" }, "devDependencies": { "@flowgram.ai/eslint-config": "workspace:*", "@flowgram.ai/ts-config": "workspace:*", "@testing-library/react": "^12", "@types/react": "^18", "@types/react-dom": "^18", "@vitest/coverage-v8": "^3.2.4", "eslint": "^9.0.0", "jsdom": "^26.1.0", "react": "^18", "react-dom": "^18", "tsup": "^8.0.1", "typescript": "^5.8.3", "vitest": "^3.2.4" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" }, "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" } } ================================================ FILE: packages/common/utils/src/add-event-listener.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { Disposable } from './disposable'; type EventListener = ( this: HTMLElement, event: HTMLElementEventMap[K], ) => any; type EventListenerOrEventListenerObject = EventListener; export function addEventListener( element: HTMLElement, type: K, listener: EventListenerOrEventListenerObject, useCapture?: boolean, ): Disposable { element.addEventListener(type, listener, useCapture); return Disposable.create(() => element.removeEventListener(type, listener, useCapture)); } ================================================ FILE: packages/common/utils/src/array.spec.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { describe, test, expect } from 'vitest'; import { arrayToSet, arrayUnion, iterToArray } from './array'; describe('array', () => { test('arrayToSet', async () => { expect([...arrayToSet([])]).toEqual([]); expect([...arrayToSet([1])]).toEqual([1]); expect([...arrayToSet([1, 2])]).toEqual([1, 2]); expect([...arrayToSet([1, undefined, 3])]).toEqual([1, undefined, 3]); expect(arrayToSet([1, 2]).has(2)).toBeTruthy(); }); test('iterToArray', async () => { expect(iterToArray(arrayToSet([]).values())).toEqual([]); expect(iterToArray(arrayToSet([1]).values())).toEqual([1]); expect(iterToArray(arrayToSet([1, 2]).values())).toEqual([1, 2]); expect(iterToArray(arrayToSet([1, undefined, 3]).values())).toEqual([1, undefined, 3]); }); test('arrayUnion', async () => { expect(arrayUnion([])).toEqual([]); expect(arrayUnion([1])).toEqual([1]); expect(arrayUnion([1, 2])).toEqual([1, 2]); expect(arrayUnion([1, 2, 1])).toEqual([1, 2]); expect(arrayUnion([''])).toEqual(['']); expect(arrayUnion(['1'])).toEqual(['1']); expect(arrayUnion(['1', '2'])).toEqual(['1', '2']); expect(arrayUnion(['1', '2', '1'])).toEqual(['1', '2']); }); }); ================================================ FILE: packages/common/utils/src/array.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export function iterToArray(iter: IterableIterator): T[] { const result = []; for (const v of iter) { result.push(v); } return result; } export function arrayToSet(arr: any[]): Set { const set = new Set(); for (let i = 0, len = arr.length; i < len; i++) { set.add(arr[i]); } return set; } /** * @see https://stackoverflow.com/a/9229821 * export function arrayUnion(arr: any[]): any[] { * return [...new Set(arr)] * } */ export function arrayUnion(arr: any[]): any[] { const result: any[] = []; for (let i = 0, len = arr.length; i < len; i++) { if (!result.includes(arr[i])) result.push(arr[i]); } return result; } ================================================ FILE: packages/common/utils/src/cache.spec.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ /** * @jest-environment jsdom */ import { describe, beforeEach, test, expect } from 'vitest'; import { delay } from './promise-util'; import { Cache, type CacheOriginItem } from './cache'; interface Item extends CacheOriginItem { key: string | number; } let _uid = 0; function itemFactory(): Cache { const item: Item = { key: `${_uid++}`, }; return item; } function dispose() {} function itemWithDisposeFactory(): Cache { const item: Item = { key: `${_uid++}`, }; Cache.assign(item, { dispose }); return item; } describe('cache', () => { beforeEach(() => { _uid = 0; }); test('Cache/getFromCache', async () => { const cache = Cache.create(itemFactory); expect(cache.getFromCache()).toEqual([]); }); test('Cache/get', async () => { const cache = Cache.create(itemFactory); expect(cache.get()).toEqual({ key: '0' }); expect(cache.get()).toEqual({ key: '0' }); }); test('Cache/getMore', async () => { const cache = Cache.create(itemFactory); expect(cache.get()).toEqual({ key: '0' }); expect(cache.getMore(1)).toEqual([{ key: '0' }]); expect(cache.getMore(2)).toEqual([{ key: '0' }, { key: '1' }]); expect(cache.getMore(1)).toEqual([{ key: '0' }]); expect(cache.getMore(2)).toEqual([{ key: '0' }, { key: '2' }]); expect(cache.getMore(1, false)).toEqual([{ key: '0' }]); expect(cache.getMore(3)).toEqual([{ key: '0' }, { key: '2' }, { key: '3' }]); }); test('Cache/getMore/deleteLimit', async () => { const cache = Cache.create(itemFactory, { deleteLimit: 2 }); expect(cache.getMore(4)).toEqual([{ key: '0' }, { key: '1' }, { key: '2' }, { key: '3' }]); expect(cache.getMore(1)).toEqual([{ key: '0' }]); expect(cache.getMore(2)).toEqual([{ key: '0' }, { key: '4' }]); }); test('Cache/getFromCacheByKey', async () => { const cache = Cache.create(itemFactory); expect(cache.get()).toEqual({ key: '0' }); expect(cache.getFromCacheByKey('0')).toEqual({ key: '0' }); expect(cache.getFromCacheByKey('1')).toEqual(undefined); }); test('Cache/getMoreByItemKeys', async () => { const cache = Cache.create(itemFactory); const items = cache.getMoreByItemKeys([{ key: '0' }, { key: '1' }]); expect(items).toEqual([{ key: '0' }, { key: '1' }]); // cache.clear() items[0].key = undefined as any; (items[0] as any).dispose = dispose; expect(cache.getMoreByItemKeys([{ key: '1' }])).toEqual([{ key: '1' }]); }); test('Cache/getMoreByItems', async () => { const cache = Cache.create(itemFactory); const item1 = { key: '1' }; const items = cache.getMoreByItems([{ key: '0' }, item1]); expect(items).toEqual([{ key: { key: '0' } }, { key: { key: '1' } }]); expect(cache.getMoreByItems([item1])).toEqual([{ key: { key: '1' } }]); expect(cache.getMoreByItems([{ key: '1' }])).toEqual([{ key: { key: '1' } }]); // // cache.clear() items[0].key = undefined as any; (items[0] as any).dispose = dispose; expect(cache.getMoreByItems([{ key: '1' }])).toEqual([{ key: { key: '1' } }]); // expect(cache.getMoreByItems([{ key: '2' }])).toEqual([{ key: { key: '2' } }]); }); test('Cache/clear', async () => { const cache = Cache.create(itemFactory); expect(cache.getMore(2)).toEqual([{ key: '0' }, { key: '1' }]); expect(cache.get()).toEqual({ key: '0' }); cache.clear(); expect(cache.get()).toEqual({ key: '2' }); }); test('Cache/disposes', async () => { const cache = Cache.create(itemWithDisposeFactory); expect(cache.getMore(2)).toEqual([ { key: '0', dispose }, { key: '1', dispose }, ]); expect(cache.getMore(1)).toEqual([{ key: '0', dispose }]); cache.dispose(); expect(cache.getMore(2)).toEqual([ { key: '2', dispose }, { key: '3', dispose }, ]); expect(cache.getMoreByItemKeys([{ key: '2' }])).toEqual([{ key: '2', dispose }]); }); test('createShortCache', async () => { const cache = Cache.createShortCache(10); let id = 0; const getValue = () => ++id; expect(cache.get(getValue)).toEqual(1); expect(cache.get(getValue)).toEqual(1); await delay(20); expect(cache.get(getValue)).toEqual(2); const cache1 = Cache.createShortCache(); expect(cache1.get(getValue)).toEqual(3); }); test('createWeakCache', async () => { const cache = Cache.createWeakCache(); const el = document.createElement('div'); cache.save(el, 1); expect(cache.get(el)).toEqual(1); expect(cache.isChanged(el, 1)).toEqual(false); }); }); ================================================ FILE: packages/common/utils/src/cache.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { type Disposable } from './disposable'; import { Compare } from './compare'; export interface CacheManager extends Disposable { get(): T; getMore(count: number, autoDelete?: boolean): T[]; getMoreByItemKeys(item: ITEM[]): T[]; getMoreByItems(item: ITEM[]): T[]; /** * 从缓存中获取 * @param key */ getFromCacheByKey(key: string): T | undefined; /** * 获取所有缓存 */ getFromCache(): Cache[]; /** * 清空缓存数据 */ clear(): void; } export interface ShortCache { get(fn: () => T): T; } export interface WeakCache { get(key: any): any; save(key: any, value: any): void; isChanged(key: any, value: any): boolean; } export type Cache = { [P in keyof T]: T[P]; } & { dispose?: () => void; key?: any }; export interface CacheOpts { deleteLimit?: number; // 限制数目,只有超过这个数目,才会自动删除 } export interface CacheOriginItem { key?: any; } /** * 缓存工具: * 1. 可延迟按需创建,提升性能 * 2. 可支持多个或单个,有些动态创建多个的场景可以共享已有的实例,提升性能 * 3. 自动删除,超过一定的数目会自动做清空回收 * * @example * function htmlFactory(): Cache { * const el = document.createElement('div') * return Cache.assign(el, { dispose: () => el.remove() }) * } * const htmlCache = Cache.create(htmlFactory) * console.log(htmlCache.get() === htmlCache.get()) // true * console.log(htmlCache.getMore(3)) // [HTMLElement, HTMLElement, HTMLElement] * console.log(htmlCache.getMore(2)) // [HTMLElement, HTMLElement] 自动删除第三个 */ export namespace Cache { export function create( cacheFactory: (item?: ITEM) => Cache, opts: CacheOpts = {}, ): CacheManager { let cache: Cache[] = []; return { getFromCache(): Cache[] { return cache; }, getMore(count: number, autoDelete = true): T[] { if (count === cache.length) { // 强调互斥,统一 return cache.slice() } else if (count > cache.length) { let added = count - cache.length; while (added > 0) { cache.push(cacheFactory()); added--; } } else if (autoDelete) { const deleteLimit = opts.deleteLimit ?? 0; // 只有剩余个数超过 deleteLimit,才会自动删除 if (cache.length - count > deleteLimit) { const deleted = cache.splice(count); deleted.forEach(el => el.dispose && el.dispose()); } } return cache.slice(0, count); }, /** * 通过 key 去创建缓存 * @param items */ getMoreByItemKeys(items: ITEM[]): T[] { const newCache: Cache[] = []; const findedMap: Map = new Map(); cache.forEach(item => { const finded = items.find(i => i.key === item.key); if (finded) { findedMap.set(item.key, item); } else { item.dispose?.(); } }); items.forEach(item => { if (!item.key) throw new Error('getMoreByItemKeys need a key'); const finded = findedMap.get(item.key); if (finded) { newCache.push(finded); } else { newCache.push(cacheFactory(item)); } }); cache = newCache; return cache; }, /** * 通过 item 引用取拿缓存数据 */ getMoreByItems(items: any[]): T[] { const newCache: Cache[] = []; const findedMap: Map = new Map(); cache.forEach(cacheItem => { // 这里 key 存的是 item 的引用 const finded = items.find(ref => ref === cacheItem.key); if (finded) { findedMap.set(cacheItem.key, cacheItem); } else { cacheItem.dispose?.(); } }); items.forEach(item => { const finded = findedMap.get(item); if (finded) { newCache.push(finded); } else { newCache.push({ ...cacheFactory(item), key: item, }); } }); cache = newCache; return cache; }, get(): T { if (cache.length > 0) return cache[0]; cache.push(cacheFactory()); return cache[0]; }, getFromCacheByKey(key: string): T | undefined { return cache.find(item => item.key === key); }, dispose(): void { cache.forEach(item => item.dispose && item.dispose()); cache.length = 0; }, clear(): void { this.dispose(); }, }; } export function assign(target: T, fn: Disposable): Cache { return Object.assign(target as any, fn) as any; } /** * 短存储 * @param timeout */ export function createShortCache(timeout = 1000): ShortCache { let cache: T | undefined; let timeoutId: number | undefined; function updateTimeout(): void { if (timeoutId) clearTimeout(timeoutId); timeoutId = setTimeout(() => { timeoutId = undefined; cache = undefined; // 这里加 any 是因为在 nodejs 场景 setTimeout 返回的格式定义的不是 number, yarn dev 会报错 }, timeout) as any; } return { get(getValue: () => T): T { if (cache) { updateTimeout(); return cache; } cache = getValue(); updateTimeout(); return cache; }, }; } export function createWeakCache(): WeakCache { const weakCache: WeakMap = new WeakMap(); return { get: key => weakCache.get(key), save: (key: any, value: any) => weakCache.set(key, value), isChanged: (key: any, value: any) => Compare.isChanged(weakCache.get(key), value), }; } } ================================================ FILE: packages/common/utils/src/cancellation.spec.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { describe, test, expect } from 'vitest'; import { CancellationToken, CancellationTokenSource, type MutableToken, cancelled, checkCancelled, isCancelled, } from './cancellation'; describe('cancellation', () => { test('CancellationTokenSource', async () => { const tokenSource = new CancellationTokenSource(); tokenSource.dispose(); expect(tokenSource.token.isCancellationRequested).toBeTruthy(); }); test('CancellationTokenSource', async () => { const tokenSource = new CancellationTokenSource(); expect(tokenSource.token).toBeDefined(); tokenSource.dispose(); expect(tokenSource.token.isCancellationRequested).toBeTruthy(); }); test('CancellationTokenSource', async () => { const tokenSource = new CancellationTokenSource(); const arr: number[] = []; const listener = (): void => { arr.push(1); }; tokenSource.token.onCancellationRequested(listener); const mutableToken = tokenSource.token as MutableToken; expect(mutableToken.isCancellationRequested).toBeFalsy(); mutableToken.cancel(); expect(mutableToken.isCancellationRequested).toBeTruthy(); const shortcutEventDisposable = tokenSource.token.onCancellationRequested(listener); shortcutEventDisposable.dispose(); expect(mutableToken.isCancellationRequested).toBeTruthy(); }); test('cancelled()', async () => { expect(cancelled().message).toEqual('Cancelled'); }); test('isCancelled()', async () => { expect(isCancelled(cancelled())).toBeTruthy(); }); test('checkCancelled()', async () => { expect(checkCancelled(CancellationToken.None)).toBeUndefined(); expect(() => checkCancelled(CancellationToken.Cancelled)).toThrowError(/Cancelled/); }); }); ================================================ FILE: packages/common/utils/src/cancellation.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ /*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation and others. All rights reserved. * Licensed under the MIT License. See https://github.com/Microsoft/vscode/blob/master/LICENSE.txt for license information. * * Fork: https://github.com/Microsoft/vscode/blob/main/src/vs/base/common/cancellation.ts *--------------------------------------------------------------------------------------------*/ import { Emitter, Event } from './event'; import { type Disposable } from './disposable'; export interface CancellationToken { /** * A flag signalling is cancellation has been requested. */ readonly isCancellationRequested: boolean; /** * An event which fires when cancellation is requested. This event * only ever fires `once` as cancellation can only happen once. Listeners * that are registered after cancellation will be called (next event loop run), * but also only once. * @event */ readonly onCancellationRequested: Event; } const shortcutEvent: Event = Object.freeze(function (callback, context?): Disposable { const handle = setTimeout(callback.bind(context), 0); return { dispose() { clearTimeout(handle); }, }; }); export namespace CancellationToken { export function isCancellationToken(thing: unknown): thing is CancellationToken { if (thing === CancellationToken.None || thing === CancellationToken.Cancelled) { return true; } if (thing instanceof MutableToken) { return true; } if (!thing || typeof thing !== 'object') { return false; } return ( typeof (thing as CancellationToken).isCancellationRequested === 'boolean' && typeof (thing as CancellationToken).onCancellationRequested === 'function' ); } export const None = Object.freeze({ isCancellationRequested: false, onCancellationRequested: Event.None, }); export const Cancelled = Object.freeze({ isCancellationRequested: true, onCancellationRequested: shortcutEvent, }); } export class MutableToken implements CancellationToken { private _isCancelled = false; private _emitter?: Emitter; public cancel(): void { if (!this._isCancelled) { this._isCancelled = true; if (this._emitter) { this._emitter.fire(undefined); this.dispose(); } } } get isCancellationRequested(): boolean { return this._isCancelled; } get onCancellationRequested(): Event { if (this._isCancelled) { return shortcutEvent; } if (!this._emitter) { this._emitter = new Emitter(); } return this._emitter.event; } public dispose(): void { if (this._emitter) { this._emitter.dispose(); this._emitter = undefined; } } } export class CancellationTokenSource { private _token: CancellationToken | undefined; get token(): CancellationToken { if (!this._token) { // be lazy and create the token only when // actually needed this._token = new MutableToken(); } return this._token; } cancel(): void { if (!this._token) { // save an object by returning the default // cancelled token when cancellation happens // before someone asks for the token this._token = CancellationToken.Cancelled; } else if (this._token !== CancellationToken.Cancelled) { (this._token).cancel(); } } dispose(): void { this.cancel(); } } const cancelledMessage = 'Cancelled'; export function cancelled(): Error { return new Error(cancelledMessage); } export function isCancelled(err: Error | undefined): boolean { return !!err && err.message === cancelledMessage; } export function checkCancelled(token?: CancellationToken): void { if (!!token && token.isCancellationRequested) { throw cancelled(); } } ================================================ FILE: packages/common/utils/src/compare.spec.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { describe, test, expect } from 'vitest'; import { Compare } from './compare'; const { isChanged, isDeepChanged, isArrayShallowChanged } = Compare; describe('Compare', () => { test('isChanged', async () => { // base - types expect(isChanged({}, {})).toBeFalsy(); expect(isChanged(1, 1)).toBeFalsy(); expect(isChanged('1', '1')).toBeFalsy(); expect(isChanged(true, true)).toBeFalsy(); expect(isChanged(false, false)).toBeFalsy(); const obj = { a: 1, b: 2 }; expect(isChanged(obj, obj)).toBeFalsy(); const arr = [1, 2]; expect(isChanged(arr, arr)).toBeFalsy(); // base expect(isChanged({ a: 1 }, { a: 2 })).toBeTruthy(); const node = { v: 1, l: null, r: null }; node.v = 2; expect(isChanged({ a: node }, { a: node })).toBeFalsy(); expect(isChanged({ a: node }, { a: { ...node } })).toBeTruthy(); const node1 = { v: 1, l: null, r: null }; expect(isChanged({ a: node }, { a: node1 })).toBeTruthy(); // depth expect(isChanged({ a: 1 }, { a: 1 }, 0)).toBeTruthy(); expect(isChanged({ a: 1 }, { a: 1 }, 1)).toBeFalsy(); expect(isChanged({ a: 1 }, { a: 1 }, 2)).toBeFalsy(); expect(isChanged({ a: { b: 1 } }, { a: { b: 1 } }, 0)).toBeTruthy(); expect(isChanged({ a: { b: 1 } }, { a: { b: 1 } }, 1)).toBeTruthy(); expect(isChanged({ a: { b: 1 } }, { a: { b: 1 } }, 2)).toBeFalsy(); // partial expect(isChanged({ a: 1 }, { a: 1, b: 2 }, 1)).toBeTruthy(); expect(isChanged({ a: 1 }, { a: 1, b: 2 }, 1, false)).toBeTruthy(); }); test('isDeepChanged', async () => { expect(isDeepChanged({ a: 1 }, { a: 2 })).toBeTruthy(); const node = { v: 1, l: null, r: null }; expect(isDeepChanged({ a: node }, { a: node })).toBeFalsy(); expect(isDeepChanged({ a: node }, { a: { ...node, v: 2 } })).toBeTruthy(); const node1 = { v: 1, l: null, r: null }; expect(isDeepChanged({ a: node }, { a: node1 })).toBeFalsy(); }); test('isArrayShallowChanged', async () => { expect(isArrayShallowChanged([], [1])).toBeTruthy(); expect(isArrayShallowChanged([1], [])).toBeTruthy(); expect(isArrayShallowChanged([1], [1, 2])).toBeTruthy(); expect(isArrayShallowChanged([1, 2], [1, 3])).toBeTruthy(); expect(isArrayShallowChanged([{}], [{}])).toBeTruthy(); expect(isArrayShallowChanged([{ a: 1 }], [{ a: 1 }])).toBeTruthy(); expect(isArrayShallowChanged([], [])).toBeFalsy(); expect(isArrayShallowChanged([1], [1])).toBeFalsy(); expect(isArrayShallowChanged([1, null, 3], [1, null, 3])).toBeFalsy(); const obj = {}; expect(isArrayShallowChanged([obj], [obj])).toBeFalsy(); const obj1 = { a: 1, b: 2 }; expect(isArrayShallowChanged([obj1], [obj1])).toBeFalsy(); }); }); ================================================ FILE: packages/common/utils/src/compare.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export namespace Compare { /** * 比较,默认浅比较 * @param oldProps * @param newProps * @param depth - 比较的深度,默认是 1 * @param partial - 比较对象的局部,默认 true */ export function isChanged(oldProps: any, newProps: any, depth = 1, partial = true): boolean { if (oldProps === newProps) return false; if (depth === 0 || typeof oldProps !== 'object' || typeof newProps !== 'object') { return oldProps !== newProps; } const keys = Object.keys(newProps); if (!partial) { const oldKeys = Object.keys(oldProps); if (keys.length !== oldKeys.length) return true; } for (let i = 0, len = keys.length; i < len; i++) { const key = keys[i]; if (isChanged(oldProps[key], newProps[key], depth - 1, partial)) return true; } return false; } /** * 深度比较 * @param oldProps * @param newProps * @param partial - 比较对象的局部,默认 true */ export function isDeepChanged(oldProps: any, newProps: any, partial?: boolean): boolean { return isChanged(oldProps, newProps, Infinity, partial); } export function isArrayShallowChanged(arr1: any[], arr2: any[]): boolean { if (arr1.length !== arr2.length) return true; for (let i = 0, len = arr1.length; i < len; i++) { if (arr1[i] !== arr2[i]) { return true; } } return false; } } ================================================ FILE: packages/common/utils/src/compose.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { MaybePromise } from './'; type FuncMaybePromise = (d: D, ...others: any[]) => MaybePromise; type FuncPromise = (d: D, ...others: any[]) => Promise; type Func = (d: D, ...others: any[]) => D; export function composeAsync(...fns: FuncMaybePromise[]): FuncPromise { return async (data: D, ...others: any[]) => { let index = 0; while (fns[index]) { data = await fns[index](data, ...others); index += 1; } return data; }; } export function compose(...fns: Func[]): Func { return (data: D, ...others: any[]) => { let index = 0; while (fns[index]) { data = fns[index](data, ...others); index += 1; } return data; }; } ================================================ FILE: packages/common/utils/src/contribution-provider.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { type interfaces } from 'inversify'; export const ContributionProvider = Symbol('ContributionProvider'); export interface ContributionProvider { getContributions(): T[]; forEach(fn: (v: T) => void): void; } class ContainerContributionProviderImpl implements ContributionProvider { protected services: T[] | undefined; constructor( protected readonly container: interfaces.Container, protected readonly identifier: interfaces.ServiceIdentifier ) {} forEach(fn: (v: T) => void): void { this.getContributions().forEach(fn); } getContributions(): T[] { if (!this.services) { const currentServices: T[] = []; let { container } = this; if (container.isBound(this.identifier)) { try { currentServices.push(...container.getAll(this.identifier)); } catch (error: any) { console.error(error); } } this.services = currentServices; } return this.services; } } export function bindContributionProvider(bind: interfaces.Bind, id: symbol): void { bind(ContributionProvider) .toDynamicValue((ctx) => new ContainerContributionProviderImpl(ctx.container, id)) .inSingletonScope() .whenTargetNamed(id); } ================================================ FILE: packages/common/utils/src/decoration-style.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ function createStyleElement( styleId: string, container: HTMLElement = document.head, ): HTMLStyleElement { const style = document.createElement('style'); style.id = styleId; style.type = 'text/css'; style.media = 'screen'; style.appendChild(document.createTextNode('')); // trick for webkit container.appendChild(style); return style; } export const DecorationStyle = { createStyleElement, }; ================================================ FILE: packages/common/utils/src/disposable-collection.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { Emitter, Event } from './event'; import { Disposable } from './disposable'; export class DisposableImpl implements Disposable { readonly toDispose = new DisposableCollection(); dispose(): void { this.toDispose.dispose(); } get disposed(): boolean { return this.toDispose.disposed; } get onDispose(): Event { return this.toDispose.onDispose; } } export class DisposableCollection implements Disposable { protected readonly disposables: Disposable[] = []; protected readonly onDisposeEmitter = new Emitter(); private _disposed = false; constructor(...toDispose: Disposable[]) { toDispose.forEach((d) => this.push(d)); } get length() { return this.disposables.length; } get onDispose(): Event { return this.onDisposeEmitter.event; } get disposed(): boolean { return this._disposed; } dispose(): void { if (this.disposed) { return; } this._disposed = true; this.disposables .slice() .reverse() .forEach((disposable) => { try { disposable.dispose(); } catch (e) { console.error(e); } }); this.onDisposeEmitter.fire(undefined); this.onDisposeEmitter.dispose(); } push(disposable: Disposable): Disposable { if (this.disposed) return Disposable.NULL; if (disposable === Disposable.NULL) { return Disposable.NULL; } const { disposables } = this; if (disposables.find((d) => d === disposable)) { return Disposable.NULL; } const originalDispose = disposable.dispose; const toRemove = Disposable.create(() => { const index = disposables.indexOf(disposable); if (index !== -1) { disposables.splice(index, 1); } disposable.dispose = originalDispose; }); disposable.dispose = () => { toRemove.dispose(); disposable.dispose(); }; disposables.push(disposable); return toRemove; } pushAll(disposables: Disposable[]): Disposable[] { return disposables.map((disposable) => this.push(disposable)); } } ================================================ FILE: packages/common/utils/src/disposable.spec.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { describe, test, expect } from 'vitest'; import { DisposableCollection, DisposableImpl } from './disposable-collection'; import { Disposable } from './disposable'; describe('disposable', () => { test('Disposable', async () => { const disposable: Disposable = { dispose() {}, }; expect(disposable.dispose()).toBeUndefined(); expect(Disposable.is(disposable)).toBeTruthy(); expect(Disposable.NULL.dispose()).toBeUndefined(); expect(Disposable.create(() => {}).dispose()).toBeUndefined(); }); test('DisposableCollection', async () => { let dispose1Times = 0; let dispose3Times = 0; let disposeAllTimes = 0; const execSort: string[] = []; const disposable1: Disposable = { dispose() { dispose1Times += 1; execSort.push('1'); }, }; const disposable2: Disposable = { dispose() { execSort.push('2'); throw new Error('[ignore] disposable2 error'); }, }; const disposable3: Disposable = { dispose() { dispose3Times += 1; execSort.push('3'); }, }; const dc = new DisposableCollection(disposable1); dc.onDispose(() => { disposeAllTimes += 1; execSort.push('all'); }); dc.pushAll([disposable1, disposable2]); // disposable1 add twice; const dispose3Remove = dc.push(disposable3); dispose3Remove.dispose(); // remove; dc.dispose(); expect(dispose1Times).toEqual(1); expect(dispose3Times).toEqual(0); expect(disposeAllTimes).toEqual(1); expect(dc.disposed).toBeTruthy(); // readd dc.push(disposable1); // dupilicate dispose dc.dispose(); expect(dispose1Times).toEqual(1); expect(dispose3Times).toEqual(0); expect(disposeAllTimes).toEqual(1); expect(dc.disposed).toBeTruthy(); expect(execSort).toEqual(['2', '1', 'all']); }); test('DisposableCololection dispose inside', () => { let dispose1Times = 0; let disposeAllTimes = 0; const dc = new DisposableCollection(); const disposable1: Disposable = { dispose() { dc.dispose(); // dispose inside dispose1Times += 1; }, }; dc.onDispose(() => { dc.dispose(); // dispose inside disposeAllTimes += 1; }); dc.push(disposable1); dc.dispose(); expect(dispose1Times).toEqual(1); expect(disposeAllTimes).toEqual(1); expect(dc.disposed).toBeTruthy(); }); test('DisposableImpl', async () => { const di = new DisposableImpl(); const disposedRet: number[] = []; let isDisposed = false; di.onDispose(() => { isDisposed = true; }); di.toDispose.pushAll([ { dispose() { disposedRet.push(1); }, }, ]); di.dispose(); expect(di.disposed).toBeTruthy(); expect(disposedRet).toEqual([1]); expect(isDisposed).toBeTruthy(); }); test('DisposableCollection auto remove', () => { const dc = new DisposableCollection(); const dc2 = new DisposableCollection(); const disposable1: Disposable = { dispose() {}, }; const originalDispose = disposable1.dispose; dc.push(disposable1); dc2.push(disposable1); expect(originalDispose === disposable1.dispose).toBeFalsy(); disposable1.dispose(); expect(dc.length).toEqual(0); expect(dc2.length).toEqual(0); expect(originalDispose === disposable1.dispose).toBeTruthy(); }); test('DisposableCollection push cancel', () => { const dc = new DisposableCollection(); const disposable1: Disposable = { dispose() {}, }; const originalDispose = disposable1.dispose; const cancel = dc.push(disposable1); expect(originalDispose === disposable1.dispose).toBeFalsy(); cancel.dispose(); expect(originalDispose === disposable1.dispose).toBeTruthy(); }); test('DisposableCollection auto remove nested', () => { const dc1 = new DisposableCollection(); const dc2 = new DisposableCollection(); const disposable1: Disposable = { dispose() {}, }; dc1.push(disposable1); dc2.push(dc1); dc2.push(disposable1); dc1.dispose(); expect(dc2.length).toEqual(0); }); test('DisposableCollection push Disposbale.NULL', () => { const dc = new DisposableCollection(); dc.push(Disposable.NULL); expect(dc.length).toEqual(0); }); }); ================================================ FILE: packages/common/utils/src/disposable.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ /** * An object that performs a cleanup operation when `.dispose()` is called. * * Some examples of how disposables are used: * * - An event listener that removes itself when `.dispose()` is called. * - The return value from registering a provider. When `.dispose()` is called, the provider is unregistered. */ export interface Disposable { dispose(): void; } export namespace Disposable { export function is(thing: any): thing is Disposable { return ( typeof thing === 'object' && thing !== null && typeof ((thing)).dispose === 'function' ); } export function create(func: () => void): Disposable { return { dispose: func, }; } export const NULL = Object.freeze(create(() => {})); } ================================================ FILE: packages/common/utils/src/dom-utils.spec.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { describe, test, expect } from 'vitest'; import { domUtils as u } from './dom-utils'; describe('dom-utils', () => { test('toPixel', async () => { expect(u.toPixel(0)).toEqual('0px'); expect(u.toPixel(1)).toEqual('1px'); expect(u.toPixel(1.1)).toEqual('1.1px'); expect(u.toPixel(+0)).toEqual('0px'); expect(u.toPixel(-0)).toEqual('0px'); expect(u.toPixel(-0.1)).toEqual('-0.1px'); }); test('fromPercent', async () => { expect(u.fromPercent('0%')).toEqual(0); expect(u.fromPercent('1%')).toEqual(1); expect(u.fromPercent('1.1%')).toEqual(1.1); expect(u.fromPercent('-0.1%')).toEqual(-0.1); }); test('toPercent', async () => { expect(u.toPercent(0)).toEqual('0%'); expect(u.toPercent(1)).toEqual('1%'); expect(u.toPercent(1.1)).toEqual('1.1%'); expect(u.toPercent(+0)).toEqual('0%'); expect(u.toPercent(-0)).toEqual('0%'); expect(u.toPercent(-0.1)).toEqual('-0.1%'); }); test('enableEvent', async () => { const el = document.createElement('div'); expect(el.style.pointerEvents).toEqual(''); u.enableEvent(el); expect(el.style.pointerEvents).toEqual('all'); }); test('disableEvent', async () => { const el = document.createElement('div'); expect(el.style.pointerEvents).toEqual(''); u.disableEvent(el); expect(el.style.pointerEvents).toEqual('none'); u.enableEvent(el); expect(el.style.pointerEvents).toEqual('all'); }); test('createElement', async () => { expect(u.createElement('div', 'a', 'b').className).toEqual('a b'); }); test('createDivWithClass', async () => { expect(u.createDivWithClass('a', 'b').className).toEqual('a b'); expect(u.createDivWithClass('a', 'b').tagName).toEqual('DIV'); }); test('addClass', async () => { const el = document.createElement('div'); u.addClass(el, 'a', 'b'); expect(el.className).toEqual('a b'); el.className = 'c d'; u.addClass(el, 'a', 'b'); expect(el.className).toEqual('a b c d'); }); test('delClass', async () => { const el = document.createElement('div'); u.addClass(el, 'a', 'b'); u.delClass(el, 'a'); expect(el.className).toEqual('b'); u.delClass(el, 'b'); expect(el.className).toEqual(''); u.delClass(el, 'a', 'b'); expect(el.className).toEqual(''); }); test('coverClass', async () => { const el = document.createElement('div'); u.coverClass(el, 'a', 'b'); expect(el.className).toEqual('a b'); u.coverClass(el, 'a', 'c', 'd'); expect(el.className).toEqual('a c d'); }); test('clearChildren', async () => { const el = document.createElement('div'); el.innerHTML = 'link'; u.clearChildren(el); expect(el.innerHTML).toEqual(''); const el1 = document.createElement('div'); el1.appendChild(document.createElement('a')); expect(el1.innerHTML).toEqual(''); u.clearChildren(el1); expect(el.innerHTML).toEqual(''); }); test('translatePercent', async () => { const el = document.createElement('div'); u.translatePercent(el, 0, 1); expect(el.style.transform).toEqual('translate(0%, 1%)'); }); test('translateXPercent', async () => { const el = document.createElement('div'); u.translateXPercent(el, 0); expect(el.style.transform).toEqual('translateX(0%)'); }); test('translateYPercent', async () => { const el = document.createElement('div'); u.translateYPercent(el, 1); expect(el.style.transform).toEqual('translateY(1%)'); }); test('setStyle', async () => { const el = document.createElement('div'); u.setStyle(el, { width: 10, position: 'fixed', margin: '0 1px' }); expect(el.style.width).toEqual('10px'); expect(el.style.position).toEqual('fixed'); expect(el.style.margin).toEqual('0px 1px'); const el1 = document.createElement('div'); u.setStyle(el1, { width: undefined }); expect(el1.style.width).toEqual(''); const el2 = document.createElement('div'); u.setStyle(el2, { Width: 1, paddingTop: 1 } as any); expect(el2.style.width).toEqual(''); expect(el2.style['padding-top' as any]).toEqual('1px'); expect(el2.style['-width' as any]).toEqual(undefined); }); test('classNameWithPrefix', async () => { expect(u.classNameWithPrefix('pre')('a b')).toEqual('pre-a pre-b'); expect(u.classNameWithPrefix('pre-')('a b')).toEqual('pre--a pre--b'); }); test('addStandardDisposableListener', async () => { const el = document.createElement('div'); let called = false; const disposable = u.addStandardDisposableListener(el, 'click', () => { called = true; }); const event = document.createEvent('Event'); event.initEvent('click'); el.dispatchEvent(event); expect(called).toEqual(true); called = false; disposable.dispose(); el.dispatchEvent(event); expect(called).toEqual(false); }); test('createDOMCache', async () => { const parent = document.createElement('div'); const cache1 = u.createDOMCache(parent, 'c1'); expect(cache1.get()).toEqual(u.createDivWithClass('c1')); const el1 = cache1.get(); el1.setStyle({ width: 1 }); expect(el1.style.width).toEqual('1px'); cache1.dispose(); const cache2 = u.createDOMCache(parent, () => u.createDivWithClass('c2'), '
'); const el2 = u.createDivWithClass('c2'); el2.innerHTML = '
'; expect(cache2.get()).toEqual(el2); }); }); ================================================ FILE: packages/common/utils/src/dom-utils.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import clx from 'clsx'; import { each } from './objects'; import { Disposable } from './disposable'; import { Cache, type CacheManager } from './cache'; const toStyleKey = (key: string) => key.replace(/([A-Z])/, (k) => `-${k.toLowerCase()}`); export type CSSStyle = { [P in keyof CSSStyleDeclaration]?: string | number | undefined; }; export interface DOMCache extends HTMLElement, Disposable { setStyle(style: CSSStyle): void; key?: string | number; } export namespace domUtils { export function toPixel(num: number): string { return `${num}px`; } // export function fromPixel(pixel: string): number { // return parseInt(pixel.substring(0, pixel.length - 2)); // } export function fromPercent(percent: string): number { return parseFloat(percent.substring(0, percent.length - 1)); } export function toPercent(percent: number): string { return `${percent}%`; } export function enableEvent(element: HTMLDivElement): void { element.style.pointerEvents = 'all'; } export function disableEvent(element: HTMLDivElement): void { element.style.pointerEvents = 'none'; } export function createElement(ele: string, ...classNames: string[]): T { const element = document.createElement(ele); if (classNames.length > 0) { element.className = clx(classNames); } return element as T; } export function createDivWithClass(...classNames: string[]): HTMLDivElement { return createElement('div', ...classNames) as HTMLDivElement; } export function addClass(element: Element, ...classNames: string[]): void { element.className = clx(classNames.concat(element.className.split(' '))); } export function delClass(element: Element, ...classNames: string[]): void { classNames.forEach((name) => { element.classList.remove(name); }); element.className = element.classList.toString(); } export function coverClass(element: Element, ...classNames: string[]): void { element.className = clx(classNames); } export function clearChildren(container: HTMLDivElement): void { container.innerHTML = ''; } export function translatePercent(node: HTMLDivElement, x: number, y: number): void { node.style.transform = `translate(${x}%, ${y}%)`; } export function translateXPercent(node: HTMLDivElement, x: number): void { node.style.transform = `translateX(${x}%)`; } export function translateYPercent(node: HTMLDivElement, y: number): void { node.style.transform = `translateY(${y}%)`; } export function setStyle(node: HTMLElement, styles: CSSStyle): void { const styleStrs: string[] = []; each(styles, (value, key) => { if (value === undefined) return; if (typeof value === 'number' && key !== 'opacity' && key !== 'zIndex' && key !== 'scale') { value = toPixel(value); } styleStrs.push(`${toStyleKey(key)}:${value}`); }); const oldStyle = node.getAttribute('style'); const newStyle = styleStrs.join(';'); if (oldStyle !== newStyle) { node.setAttribute('style', newStyle); } } export function classNameWithPrefix(prefix: string): (key: string, opts?: any) => string { return (key: string, opts?: any) => clx( key .split(/\s+/) .map((s) => `${prefix}-${s}`) .join(' '), opts ); } export function addStandardDisposableListener( dom: HTMLElement | HTMLDocument, type: string, listener: EventListenerOrEventListenerObject | any, options?: boolean | any ): Disposable { dom.addEventListener(type, listener, options); return Disposable.create(() => { dom.removeEventListener(type, listener); }); } /** * dom 缓存 * @param parent * @param className */ export function createDOMCache( parent: HTMLElement, className: string | (() => HTMLElement), children?: string ): CacheManager { return Cache.create((/* item */) => { // item 悬空了? const dom = typeof className === 'string' ? domUtils.createDivWithClass(className) : className(); if (children) { dom.innerHTML = children; } parent.appendChild(dom); return Object.assign(dom, { // key: item ? item.key : undefined, dispose: () => { const { parentNode } = dom; if (parentNode) { parentNode.removeChild(dom); } }, setStyle: (style: CSSStyle) => { domUtils.setStyle(dom, style); }, }) as T; }); } } ================================================ FILE: packages/common/utils/src/event.spec.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { describe, test, expect } from 'vitest'; import { Emitter, Event } from './event'; describe('event', () => { test('emitter base', () => { const emitter = new Emitter(); expect(emitter.disposed).toBe(false); const doResult: number[] = []; const doResult2: number[] = []; const doContext = {}; function listener1(num: number) { doResult.push(num); } function listener2(num: number) { // @ts-ignore expect(this).toEqual(doContext); doResult2.push(num); } const dispose1 = emitter.event(listener1); emitter.event(listener2, doContext); emitter.fire(1); expect(doResult).toEqual([1]); expect(doResult2).toEqual([1]); emitter.fire(2); expect(doResult).toEqual([1, 2]); expect(doResult2).toEqual([1, 2]); dispose1.dispose(); emitter.fire(3); expect(doResult).toEqual([1, 2]); expect(doResult2).toEqual([1, 2, 3]); emitter.dispose(); // dispose the event; expect(emitter.disposed).toBe(true); emitter.fire(4); expect(doResult).toEqual([1, 2]); expect(doResult2).toEqual([1, 2, 3]); }); test('emitter with dispose', () => { const emitter = new Emitter(); emitter.dispose(); // dispose the event; const doResult: number[] = []; function listener1(num: number) { doResult.push(num); } const dispose1 = emitter.event(listener1); expect(Event.None(() => {}) === dispose1).toBeTruthy(); emitter.fire(1); // do nothing expect(doResult).toEqual([]); }); }); ================================================ FILE: packages/common/utils/src/event.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { NOOP } from './objects'; import { Disposable } from './disposable'; export interface EventListener { (args: T): void; } export interface Event { (listener: EventListener, thisArgs?: any): Disposable; } export namespace Event { export const None: Event = () => Disposable.NULL; } export class Emitter { private _event?: Event; private _listeners?: EventListener[]; private _disposed = false; get event(): Event { if (!this._event) { this._event = (listener: EventListener, thisArgs?: any) => { if (this._disposed) { return Disposable.NULL; } if (!this._listeners) { this._listeners = []; } const finalListener = thisArgs ? listener.bind(thisArgs) : listener; this._listeners.push(finalListener); const eventDisposable: Disposable = { dispose: () => { eventDisposable.dispose = NOOP; if (!this._disposed) { const index = this._listeners!.indexOf(finalListener); if (index !== -1) { this._listeners!.splice(index, 1); } } }, }; return eventDisposable; }; } return this._event; } fire(event: T): void { if (this._listeners) { this._listeners.forEach((listener) => listener(event)); } } get disposed(): boolean { return this._disposed; } dispose(): void { if (this._listeners) { this._listeners = undefined; } this._disposed = true; } } ================================================ FILE: packages/common/utils/src/hooks/use-refresh.spec.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React from 'react'; import { render } from '@testing-library/react'; import { useRefresh } from './use-refresh'; it('refresh nested', () => { let comp1RenderTimes = 0; let comp2RenderTimes = 0; const Comp1 = () => { comp1RenderTimes++; const refresh = useRefresh(); React.useEffect(() => { refresh(); }, []); return
; }; const Comp2 = () => { comp2RenderTimes++; const refresh = useRefresh(); React.useEffect(() => { refresh(); }, []); return (
); }; render(); expect(comp1RenderTimes).toEqual(2); expect(comp2RenderTimes).toEqual(2); }); ================================================ FILE: packages/common/utils/src/hooks/use-refresh.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { useCallback, useState } from 'react'; export function useRefresh(defaultValue?: any): (v?: any) => void { const [, update] = useState(defaultValue); return useCallback((v?: any) => update(v !== undefined ? v : {}), []); } ================================================ FILE: packages/common/utils/src/id.spec.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { describe, test, expect } from 'vitest'; import { generateLocalId, _setIdx } from './id'; describe('id', () => { test('generateLocalId', async () => { expect(generateLocalId()).toBe(0); expect(generateLocalId()).toBe(1); expect(generateLocalId()).toBe(2); expect(generateLocalId()).toBeGreaterThan(2); }); test('_setIdx', async () => { _setIdx(Number.MAX_SAFE_INTEGER - 1); expect(generateLocalId()).toBe(Number.MAX_SAFE_INTEGER - 1); expect(generateLocalId()).toBe(0); }); }); ================================================ FILE: packages/common/utils/src/id.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ let _idx = 0; export type LocalId = number; export function generateLocalId(): LocalId { // @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/MAX_SAFE_INTEGER if (_idx === Number.MAX_SAFE_INTEGER) { _idx = 0; } return _idx++; } export function _setIdx(idx: number): void { _idx = idx; } ================================================ FILE: packages/common/utils/src/index.spec.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { describe, test, expect } from 'vitest'; import { Point } from './index'; describe('utils', () => { test('Point', () => { expect(Point).toBeDefined(); }); }); ================================================ FILE: packages/common/utils/src/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export * from './math/index'; export * from './objects'; export * from './types'; export * from './event'; export * from './disposable'; export * from './disposable-collection'; export * from './cancellation'; export * from './promise-util'; export * from './cache'; export * from './compare'; export * from './schema/index'; export * from './dom-utils'; export * from './id'; export * from './array'; export { bindContributions } from './inversify-utils'; export * from './request-with-memo'; export * from './compose'; export { ContributionProvider, bindContributionProvider } from './contribution-provider'; export * from './add-event-listener'; export * from './logger'; export { DecorationStyle } from './decoration-style'; export { useRefresh } from './hooks/use-refresh'; ================================================ FILE: packages/common/utils/src/inversify-utils.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { type interfaces } from 'inversify'; export function bindContributions(bind: interfaces.Bind, target: any, contribs: any[]) { bind(target).toSelf().inSingletonScope(); contribs.forEach(contrib => bind(contrib).toService(target)); } ================================================ FILE: packages/common/utils/src/logger.spec.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { describe, test, expect } from 'vitest'; import { logger } from './logger'; describe('logger', () => { const consoleLogMock = vi.spyOn(console, 'log').mockImplementation(() => undefined); const consoleInfoMock = vi.spyOn(console, 'info').mockImplementation(() => undefined); const consoleWarnMock = vi.spyOn(console, 'warn').mockImplementation(() => undefined); const consoleErrorMock = vi.spyOn(console, 'error').mockImplementation(() => undefined); afterAll(() => { consoleLogMock.mockReset(); consoleInfoMock.mockReset(); consoleWarnMock.mockReset(); consoleErrorMock.mockReset(); }); test('log', () => { logger.log('log'); expect(consoleLogMock).not.toHaveBeenCalledOnce(); }); test('info', () => { logger.info('info'); expect(consoleInfoMock).not.toHaveBeenCalledOnce(); }); test('error', () => { logger.error('error'); expect(consoleErrorMock).toHaveBeenCalledOnce(); }); test('warn', () => { logger.warn('warn'); expect(consoleWarnMock).toHaveBeenCalledOnce(); }); test('develop', () => { vi.stubEnv('NODE_ENV', 'production'); expect(logger.isDevEnv()).toEqual(false); }); test('develop', () => { vi.stubEnv('NODE_ENV', 'development'); expect(logger.isDevEnv()).toEqual(true); }); }); ================================================ FILE: packages/common/utils/src/logger.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ class Logger { isDevEnv() { return process.env.NODE_ENV === 'development'; } info(...props: any) { if (!this.isDevEnv()) return; // eslint-disable-next-line no-console return console.info(props); } log(...props: any) { if (!this.isDevEnv()) return; // eslint-disable-next-line no-console return console.log(...props); } error(...props: any) { return console.error(...props); } warn(...props: any) { return console.warn(...props); } } export const logger = new Logger(); ================================================ FILE: packages/common/utils/src/math/IPoint.spec.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ // import { type IPoint } from './IPoint' import { describe, test } from 'vitest'; describe('IPoint', () => { test('type', () => { // expectTypeOf({ x: 1, y: 1 }).toEqualTypeOf() // expectTypeOf({ x: 1 }).not.toEqualTypeOf() }); }); ================================================ FILE: packages/common/utils/src/math/IPoint.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ /** * Common interface for points. Both Point and ObservablePoint implement it */ export interface IPoint { /** * X coord */ x: number; /** * Y coord */ y: number; } ================================================ FILE: packages/common/utils/src/math/Matrix.spec.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ // nolint: cyclo_complexity,method_line import { describe, test, expect, it } from 'vitest'; import { Transform } from './Transform'; import { Matrix as M, Matrix } from './Matrix'; import { PI } from './const'; describe('Matrix', () => { test('Matrix', async () => { expect(new M()).toEqual(M.IDENTITY); expect(new M()).toEqual(M.TEMP_MATRIX); }); test('fromArray', async () => { expect(new M().fromArray([])).toEqual(M.IDENTITY); expect(new M().fromArray([1, 2, 3])).toEqual(M.IDENTITY); expect(new M().fromArray([0, 1, 2, 3, 4, 5])).toEqual(new M(0, 1, 3, 4, 2, 5)); expect(new M().fromArray([0, 1, 2, 3, 4, 5, 6])).toEqual(new M(0, 1, 3, 4, 2, 5)); }); test('set', async () => { expect(new M().set(0, 1, 2, 3, 4, 5)).toEqual(new M(0, 1, 2, 3, 4, 5)); }); test('toArray', async () => { expect(new M(0, 1, 2, 3, 4, 5).toArray(true)).toEqual( Float32Array.from([0, 1, 0, 2, 3, 0, 4, 5, 1]), ); expect(new M(0, 1, 2, 3, 4, 5).toArray(false)).toEqual( Float32Array.from([0, 2, 4, 1, 3, 5, 0, 0, 1]), ); const arr = new Float32Array(9); new M(0, 1, 2, 3, 4, 5).toArray(false, arr); expect(arr).toEqual(Float32Array.from([0, 2, 4, 1, 3, 5, 0, 0, 1])); }); test('apply', async () => { expect(M.IDENTITY.apply({ x: 1, y: 2 })).toEqual({ x: 1, y: 2 }); // translate only expect(new M(1, 0, 0, 1, 1, 1).apply({ x: 1, y: 2 })).toEqual({ x: 2, y: 3, }); // scale only expect(new M(2, 0, 0, 2).apply({ x: 1, y: 2 })).toEqual({ x: 2, y: 4 }); // skew only expect(new M(1, 1, 1, 1).apply({ x: 1, y: 2 })).toEqual({ x: 3, y: 3 }); expect(new M(1, 1, -1, 1).apply({ x: 1, y: 2 })).toEqual({ x: -1, y: 3 }); }); test('applyInverse', async () => { expect(M.IDENTITY.applyInverse({ x: 1, y: 2 })).toEqual({ x: 1, y: 2 }); // translate only expect(new M(1, 0, 0, 1, 1, 1).applyInverse({ x: 1, y: 2 })).toEqual({ x: 0, y: 1, }); // scale only expect(new M(2, 0, 0, 2).applyInverse({ x: 1, y: 2 })).toEqual({ x: 0.5, y: 1, }); // skew only expect(new M(1, 1, -1, 1).applyInverse({ x: 1, y: 2 })).toEqual({ x: 1.5, y: 0.5, }); }); test('translate', async () => { expect(M.IDENTITY.translate(1, -2).apply({ x: 0, y: 0 })).toEqual({ x: 1, y: -2, }); }); test('scale', async () => { expect(M.IDENTITY.scale(1, -2).apply({ x: 1, y: 2 })).toEqual({ x: 1, y: -4, }); expect(M.IDENTITY.scale(0, 0).apply({ x: 1, y: 2 })).toEqual({ x: 0, y: 0, }); }); test('rotate', async () => { const r1 = M.IDENTITY.rotate(PI / 2).apply({ x: 1, y: 2 }); expect(r1.x).toBeCloseTo(-2); expect(r1.y).toBeCloseTo(1); expect(M.IDENTITY.rotate(PI / 2).apply({ x: 0, y: 0 })).toEqual({ x: 0, y: 0, }); }); test('append', async () => { expect(M.IDENTITY.append(M.IDENTITY)).toEqual(M.IDENTITY); expect(M.IDENTITY.append(new M(0, 1, 2, 3, 4, 5))).toEqual(new M(0, 1, 2, 3, 4, 5)); expect(new M(0, 1, 2, 3, 4, 5).append(M.IDENTITY)).toEqual(new M(0, 1, 2, 3, 4, 5)); expect(new M(0, 1, 2, 3, 4, 5).append(new M(0, 1, 2, 3, 4, 5))).toEqual( new M(2, 3, 6, 11, 14, 24), ); }); test('prepend', async () => { expect(M.IDENTITY.prepend(M.IDENTITY)).toEqual(M.IDENTITY); expect(M.IDENTITY.prepend(new M(0, 1, 2, 3, 4, 5))).toEqual(new M(0, 1, 2, 3, 4, 5)); expect(new M(0, 1, 2, 3, 4, 5).prepend(M.IDENTITY)).toEqual(new M(0, 1, 2, 3, 4, 5)); expect(new M(0, 1, 2, 3, 4, 5).prepend(new M(0, 1, 2, 3, 1, 2))).toEqual( new M(2, 3, 6, 11, 11, 21), ); }); test('identity', async () => { expect(new M(0, 1, 2, 3, 4, 5).identity()).toEqual(M.IDENTITY); }); test('invert', async () => { expect(new M(0, 1, 2, 3, 4, 5).invert()).toEqual(new M(-1.5, 0.5, 1, -0, 1, -2)); expect(new M(-1.5, 0.5, 1, -0, 1, -2).invert()).toEqual(new M(0, 1, 2, 3, 4, 5)); // expect(M.IDENTITY.invert()).toEqual(M.IDENTITY) }); test('copyTo', async () => { expect(new M(0, 1, 2, 3, 4, 5).copyTo(M.TEMP_MATRIX)).toEqual(new M(0, 1, 2, 3, 4, 5)); }); test('copyFrom', async () => { expect(M.TEMP_MATRIX.copyFrom(new M(0, 1, 2, 3, 4, 5))).toEqual(new M(0, 1, 2, 3, 4, 5)); }); test('isSimple', async () => { expect(new M(1, 0, 0, 1, 0, 0).isSimple()).toBeTruthy(); expect(new M(0, 1, 2, 3, 4, 5).isSimple()).toBeFalsy(); }); /** * @see https://github.com/pixijs/pixijs/blob/dev/packages/math/test/Matrix.tests.ts */ it('should create a new matrix', () => { const matrix = new Matrix(); expect(matrix.a).toEqual(1); expect(matrix.b).toEqual(0); expect(matrix.c).toEqual(0); expect(matrix.d).toEqual(1); expect(matrix.tx).toEqual(0); expect(matrix.ty).toEqual(0); const input = [0, 1, 2, 3, 4, 5]; matrix.fromArray(input); expect(matrix.a).toEqual(0); expect(matrix.b).toEqual(1); expect(matrix.c).toEqual(3); expect(matrix.d).toEqual(4); expect(matrix.tx).toEqual(2); expect(matrix.ty).toEqual(5); let output = matrix.toArray(true); expect(output.length).toEqual(9); expect(output[0]).toEqual(0); expect(output[1]).toEqual(1); expect(output[3]).toEqual(3); expect(output[4]).toEqual(4); expect(output[6]).toEqual(2); expect(output[7]).toEqual(5); output = matrix.toArray(false); expect(output.length).toEqual(9); expect(output[0]).toEqual(0); expect(output[1]).toEqual(3); expect(output[2]).toEqual(2); expect(output[3]).toEqual(1); expect(output[4]).toEqual(4); expect(output[5]).toEqual(5); }); it('should apply different transforms', () => { const matrix = new Matrix(); matrix.translate(10, 20); matrix.translate(1, 2); expect(matrix.tx).toEqual(11); expect(matrix.ty).toEqual(22); matrix.scale(2, 4); expect(matrix.a).toEqual(2); expect(matrix.b).toEqual(0); expect(matrix.c).toEqual(0); expect(matrix.d).toEqual(4); expect(matrix.tx).toEqual(22); expect(matrix.ty).toEqual(88); const m2 = matrix.clone(); expect(m2).not.toBe(matrix); expect(m2.a).toEqual(2); expect(m2.b).toEqual(0); expect(m2.c).toEqual(0); expect(m2.d).toEqual(4); expect(m2.tx).toEqual(22); expect(m2.ty).toEqual(88); matrix.setTransform(14, 15, 0, 0, 4, 2, 0, 0, 0); expect(matrix.a).toEqual(4); expect(matrix.b).toEqual(0); // Object.is cant distinguish between 0 and -0 expect(Math.abs(matrix.c)).toEqual(0); expect(matrix.d).toEqual(2); expect(matrix.tx).toEqual(14); expect(matrix.ty).toEqual(15); }); it('should allow rotatation', () => { const matrix = new Matrix(); matrix.rotate(Math.PI); expect(matrix.a).toEqual(-1); expect(matrix.b).toEqual(Math.sin(Math.PI)); expect(matrix.c).toEqual(-Math.sin(Math.PI)); expect(matrix.d).toEqual(-1); }); it('should append matrix', () => { const m1 = new Matrix(); const m2 = new Matrix(); m2.tx = 100; m2.ty = 200; m1.append(m2); expect(m1.tx).toEqual(m2.tx); expect(m1.ty).toEqual(m2.ty); }); it('should prepend matrix', () => { const m1 = new Matrix(); const m2 = new Matrix(); m2.set(2, 3, 4, 5, 100, 200); m1.prepend(m2); expect(m1.a).toEqual(m2.a); expect(m1.b).toEqual(m2.b); expect(m1.c).toEqual(m2.c); expect(m1.d).toEqual(m2.d); expect(m1.tx).toEqual(m2.tx); expect(m1.ty).toEqual(m2.ty); const m3 = new Matrix(); const m4 = new Matrix(); m3.prepend(m4); expect(m3.a).toEqual(m4.a); expect(m3.b).toEqual(m4.b); expect(m3.c).toEqual(m4.c); expect(m3.d).toEqual(m4.d); expect(m3.tx).toEqual(m4.tx); expect(m3.ty).toEqual(m4.ty); }); it('should get IDENTITY and TEMP_MATRIX', () => { expect(Matrix.IDENTITY instanceof Matrix).toBe(true); expect(Matrix.TEMP_MATRIX instanceof Matrix).toBe(true); }); it('should reset matrix to default when identity() is called', () => { const matrix = new Matrix(); matrix.set(2, 3, 4, 5, 100, 200); expect(matrix.a).toEqual(2); expect(matrix.b).toEqual(3); expect(matrix.c).toEqual(4); expect(matrix.d).toEqual(5); expect(matrix.tx).toEqual(100); expect(matrix.ty).toEqual(200); matrix.identity(); expect(matrix.a).toEqual(1); expect(matrix.b).toEqual(0); expect(matrix.c).toEqual(0); expect(matrix.d).toEqual(1); expect(matrix.tx).toEqual(0); expect(matrix.ty).toEqual(0); }); it('should have the same transform after decompose', () => { const matrix = new Matrix(); const transformInitial = new Transform(); const transformDecomposed = new Transform(); for (let x = 0; x < 50; ++x) { transformInitial.position.x = Math.random() * 1000 - 2000; transformInitial.position.y = Math.random() * 1000 - 2000; transformInitial.scale.x = Math.random() * 5 - 10; transformInitial.scale.y = Math.random() * 5 - 10; transformInitial.rotation = (Math.random() - 2) * Math.PI; transformInitial.skew.x = (Math.random() - 2) * Math.PI; transformInitial.skew.y = (Math.random() - 2) * Math.PI; matrix.setTransform( transformInitial.position.x, transformInitial.position.y, 0, 0, transformInitial.scale.x, transformInitial.scale.y, transformInitial.rotation, transformInitial.skew.x, transformInitial.skew.y, ); matrix.decompose(transformDecomposed); transformInitial.updateLocalTransform(); transformDecomposed.updateLocalTransform(); expect(transformInitial.localTransform.a).toBeCloseTo( transformDecomposed.localTransform.a, 0.0001, ); expect(transformInitial.localTransform.b).toBeCloseTo( transformDecomposed.localTransform.b, 0.0001, ); expect(transformInitial.localTransform.c).toBeCloseTo( transformDecomposed.localTransform.c, 0.0001, ); expect(transformInitial.localTransform.d).toBeCloseTo( transformDecomposed.localTransform.d, 0.0001, ); expect(transformInitial.localTransform.tx).toBeCloseTo( transformDecomposed.localTransform.tx, 0.0001, ); expect(transformInitial.localTransform.ty).toBeCloseTo( transformDecomposed.localTransform.ty, 0.0001, ); } }); it('should decompose corner case', () => { const matrix = new Matrix(); const transform = new Transform(); const result = transform.localTransform; matrix.a = -0.00001; matrix.b = -1; matrix.c = 1; matrix.d = 0; matrix.decompose(transform); transform.updateLocalTransform(); expect(result.a).toBeCloseTo(matrix.a, 0.001); expect(result.b).toBeCloseTo(matrix.b, 0.001); expect(result.c).toBeCloseTo(matrix.c, 0.001); expect(result.d).toBeCloseTo(matrix.d, 0.001); }); describe('decompose', () => { it('should be the inverse of updateLocalTransform even when pivot is set', () => { const matrix = new Matrix(0.01, 0.04, 0.04, 0.1, 2, 2); const transform = new Transform(); transform.pivot.set(40, 40); matrix.decompose(transform); transform.updateLocalTransform(); const { localTransform } = transform; expect(localTransform.a).toBeCloseTo(matrix.a, 0.001); expect(localTransform.b).toBeCloseTo(matrix.b, 0.001); expect(localTransform.c).toBeCloseTo(matrix.c, 0.001); expect(localTransform.d).toBeCloseTo(matrix.d, 0.001); // FIXME expect(localTransform.tx).toBeCloseTo(matrix.tx, 0.001) // FIXME expect(localTransform.ty).toBeCloseTo(matrix.ty, 0.001) }); }); }); ================================================ FILE: packages/common/utils/src/math/Matrix.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ /* eslint-disable prefer-destructuring */ import type { Transform } from './Transform'; import type { IPoint } from './IPoint'; import { PI_2 } from './const'; /** * The PIXIJS Matrix as a class makes it a lot faster. * * Here is a representation of it: * ```js * | a | c | tx| * | b | d | ty| * | 0 | 0 | 1 | * // default: * | 1 | 0 | 0 | * | 0 | 1 | 0 | * | 0 | 0 | 1 | * ``` */ export class Matrix { public array: Float32Array | null = null; /** * @param [a] x scale * @param [b] x skew * @param [c] y skew * @param [d] y scale * @param [tx] x translation * @param [ty] y translation */ constructor( public a = 1, public b = 0, public c = 0, public d = 1, public tx = 0, public ty = 0, ) {} /** * A default (identity) matrix */ static get IDENTITY(): Matrix { return new Matrix(); } /** * A temp matrix */ static get TEMP_MATRIX(): Matrix { return new Matrix(); } /** * Creates a Matrix object based on the given array. The Element to Matrix mapping order is as follows: * * @param array The array that the matrix will be populated from. */ fromArray(array: number[]): this { if (array.length < 6) return this; this.a = array[0]; this.b = array[1]; this.c = array[3]; this.d = array[4]; this.tx = array[2]; this.ty = array[5]; return this; } /** * sets the matrix properties * * @param a Matrix component * @param b Matrix component * @param c Matrix component * @param d Matrix component * @param tx Matrix component * @param ty Matrix component */ set(a: number, b: number, c: number, d: number, tx: number, ty: number): this { this.a = a; this.b = b; this.c = c; this.d = d; this.tx = tx; this.ty = ty; return this; } /** * Creates an array from the current Matrix object. * * @param transpose Whether we need to transpose the matrix or not * @param [out=new Float32Array(9)] If provided the array will be assigned to out * @return the newly created array which contains the matrix */ toArray(transpose: boolean, out?: Float32Array): Float32Array { if (!this.array) { this.array = new Float32Array(9); } const array = out || this.array; if (transpose) { array[0] = this.a; array[1] = this.b; array[2] = 0; array[3] = this.c; array[4] = this.d; array[5] = 0; array[6] = this.tx; array[7] = this.ty; array[8] = 1; } else { array[0] = this.a; array[1] = this.c; array[2] = this.tx; array[3] = this.b; array[4] = this.d; array[5] = this.ty; array[6] = 0; array[7] = 0; array[8] = 1; } return array; } /** * Get a new position with the current transformation applied. * Can be used to go from a child's coordinate space to the world coordinate space. (e.g. rendering) * * @param pos The origin * @param [newPos] The point that the new position is assigned to (allowed to be same as input) * @return The new point, transformed through this matrix */ apply(pos: IPoint, newPos?: IPoint): IPoint { newPos = newPos || { x: 0, y: 0 }; const { x, y } = pos; newPos.x = this.a * x + this.c * y + this.tx; newPos.y = this.b * x + this.d * y + this.ty; return newPos; } /** * Get a new position with the inverse of the current transformation applied. * Can be used to go from the world coordinate space to a child's coordinate space. (e.g. input) * * @param pos The origin * @param [newPos] The point that the new position is assigned to (allowed to be same as input) * @return The new point, inverse-transformed through this matrix */ applyInverse(pos: IPoint, newPos?: IPoint): IPoint { newPos = newPos || { x: 0, y: 0 }; const id = 1 / (this.a * this.d + this.c * -this.b); const { x } = pos; const { y } = pos; newPos.x = this.d * id * x + -this.c * id * y + (this.ty * this.c - this.tx * this.d) * id; newPos.y = this.a * id * y + -this.b * id * x + (-this.ty * this.a + this.tx * this.b) * id; return newPos; } /** * Translates the matrix on the x and y. * * @param x How much to translate x by * @param y How much to translate y by */ translate(x: number, y: number): this { this.tx += x; this.ty += y; return this; } /** * Applies a scale transformation to the matrix. * * @param x The amount to scale horizontally * @param y The amount to scale vertically */ scale(x: number, y: number): this { this.a *= x; this.d *= y; this.c *= x; this.b *= y; this.tx *= x; this.ty *= y; return this; } /** * Applies a rotation transformation to the matrix. * * @param angle The angle in radians. */ rotate(angle: number): this { const cos = Math.cos(angle); const sin = Math.sin(angle); const a1 = this.a; const c1 = this.c; const tx1 = this.tx; this.a = a1 * cos - this.b * sin; this.b = a1 * sin + this.b * cos; this.c = c1 * cos - this.d * sin; this.d = c1 * sin + this.d * cos; this.tx = tx1 * cos - this.ty * sin; this.ty = tx1 * sin + this.ty * cos; return this; } /** * 矩阵乘法,当前矩阵 * matrix * Appends the given Matrix to this Matrix. */ append(matrix: Matrix): this { const a1 = this.a; const b1 = this.b; const c1 = this.c; const d1 = this.d; this.a = matrix.a * a1 + matrix.b * c1; this.b = matrix.a * b1 + matrix.b * d1; this.c = matrix.c * a1 + matrix.d * c1; this.d = matrix.c * b1 + matrix.d * d1; this.tx = matrix.tx * a1 + matrix.ty * c1 + this.tx; this.ty = matrix.tx * b1 + matrix.ty * d1 + this.ty; return this; } /** * Sets the matrix based on all the available properties * * @param x Position on the x axis * @param y Position on the y axis * @param pivotX Pivot on the x axis * @param pivotY Pivot on the y axis * @param scaleX Scale on the x axis * @param scaleY Scale on the y axis * @param rotation Rotation in radians * @param skewX Skew on the x axis * @param skewY Skew on the y axis */ setTransform( x: number, y: number, pivotX: number, pivotY: number, scaleX: number, scaleY: number, rotation: number, skewX: number, skewY: number, ): this { this.a = Math.cos(rotation + skewY) * scaleX; this.b = Math.sin(rotation + skewY) * scaleX; this.c = -Math.sin(rotation - skewX) * scaleY; this.d = Math.cos(rotation - skewX) * scaleY; this.tx = x - (pivotX * this.a + pivotY * this.c); this.ty = y - (pivotX * this.b + pivotY * this.d); return this; } /** * 矩阵乘法,matrix * 当前矩阵 * Prepends the given Matrix to this Matrix. */ prepend(matrix: Matrix): this { const tx1 = this.tx; if (matrix.a !== 1 || matrix.b !== 0 || matrix.c !== 0 || matrix.d !== 1) { const a1 = this.a; const c1 = this.c; this.a = a1 * matrix.a + this.b * matrix.c; this.b = a1 * matrix.b + this.b * matrix.d; this.c = c1 * matrix.a + this.d * matrix.c; this.d = c1 * matrix.b + this.d * matrix.d; } this.tx = tx1 * matrix.a + this.ty * matrix.c + matrix.tx; this.ty = tx1 * matrix.b + this.ty * matrix.d + matrix.ty; return this; } /** * Decomposes the matrix (x, y, scaleX, scaleY, and rotation) and sets the properties on to a transform. * * @param transform The transform to apply the properties to. * @return The transform with the newly applied properties */ decompose(transform: Transform): Transform { // sort out rotation / skew.. const { a } = this; const { b } = this; const { c } = this; const { d } = this; const skewX = -Math.atan2(-c, d); const skewY = Math.atan2(b, a); const delta = Math.abs(skewX + skewY); if (delta < 0.00001 || Math.abs(PI_2 - delta) < 0.00001) { transform.rotation = skewY; transform.skew.x = 0; transform.skew.y = 0; } else { transform.rotation = 0; transform.skew.x = skewX; transform.skew.y = skewY; } // next set scale transform.scale.x = Math.sqrt(a * a + b * b); transform.scale.y = Math.sqrt(c * c + d * d); // next set position transform.position.x = this.tx; transform.position.y = this.ty; return transform; } /** * Inverts this matrix */ invert(): this { const a1 = this.a; const b1 = this.b; const c1 = this.c; const d1 = this.d; const tx1 = this.tx; const n = a1 * d1 - b1 * c1; this.a = d1 / n; this.b = -b1 / n; this.c = -c1 / n; this.d = a1 / n; this.tx = (c1 * this.ty - d1 * tx1) / n; this.ty = -(a1 * this.ty - b1 * tx1) / n; return this; } /** * Resets this Matrix to an identity (default) matrix. */ identity(): this { this.a = 1; this.b = 0; this.c = 0; this.d = 1; this.tx = 0; this.ty = 0; return this; } /** * 未做旋转的矩阵 */ isSimple(): boolean { return this.a === 1 && this.b === 0 && this.c === 0 && this.d === 1; } /** * Creates a new Matrix object with the same values as this one. * * @return A copy of this matrix. */ clone(): Matrix { const matrix = new Matrix(); matrix.a = this.a; matrix.b = this.b; matrix.c = this.c; matrix.d = this.d; matrix.tx = this.tx; matrix.ty = this.ty; return matrix; } /** * Changes the values of the given matrix to be the same as the ones in this matrix * * @return The matrix given in parameter with its values updated. */ copyTo(matrix: Matrix): Matrix { matrix.a = this.a; matrix.b = this.b; matrix.c = this.c; matrix.d = this.d; matrix.tx = this.tx; matrix.ty = this.ty; return matrix; } /** * Changes the values of the matrix to be the same as the ones in given matrix */ copyFrom(matrix: Matrix): this { this.a = matrix.a; this.b = matrix.b; this.c = matrix.c; this.d = matrix.d; this.tx = matrix.tx; this.ty = matrix.ty; return this; } } ================================================ FILE: packages/common/utils/src/math/ObservablePoint.spec.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ /** * @see https://github.com/pixijs/pixijs/blob/dev/packages/math/test/ObservablePoint.tests.ts */ import { vi, describe, it, expect } from 'vitest'; import { ObservablePoint } from './ObservablePoint'; describe('ObservablePoint', () => { it.skip('should create a new observable point ', () => { const cb = vi.fn(); const pt = new ObservablePoint(cb, this); expect(pt.x).toEqual(0); expect(pt.y).toEqual(0); pt.set(2, 5); expect(pt.x).toEqual(2); expect(pt.y).toEqual(5); expect(cb).toBeCalled(); pt.set(2, 6); expect(pt.x).toEqual(2); expect(pt.y).toEqual(6); pt.set(2, 0); expect(pt.x).toEqual(2); expect(pt.y).toEqual(0); pt.set(); expect(pt.x).toEqual(0); expect(pt.y).toEqual(0); expect(cb.mock.calls).toHaveLength(4); }); it('should copy a new observable point', () => { function cb() { // do nothing } const p1 = new ObservablePoint(cb, this, 10, 20); const p2 = new ObservablePoint(cb, this, 5, 2); const p3 = new ObservablePoint(cb, this, 5, 6); const p4 = new ObservablePoint(cb, this, 1, 2); p1.copyFrom(p2); expect(p1.x).toEqual(p2.x); expect(p1.y).toEqual(p2.y); p1.copyFrom(p3); expect(p1.y).toEqual(p3.y); expect(p4.clone(cb, this)).toEqual(p4); expect(p4.clone()).toEqual(p4); expect(p4.copyTo(new ObservablePoint(cb, this))).toEqual(p4); }); it('should equal to another point', () => { expect( new ObservablePoint(() => {}, this, 1, 2).equals(new ObservablePoint(() => {}, this, 1, 2)), ).toEqual(true); }); }); ================================================ FILE: packages/common/utils/src/math/ObservablePoint.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import type { IPoint } from './IPoint'; /** * The Point object represents a location in a two-dimensional coordinate system, where x represents * the horizontal axis and y represents the vertical axis. * * An ObservablePoint is a point that triggers a callback when the point's position is changed. */ export class ObservablePoint implements IPoint { public cb: (this: T) => any; public scope: any; /** * @param {Function} cb - callback when changed * @param {object} scope - owner of callback * @param {number} [x=0] - position of the point on the x axis * @param {number} [y=0] - position of the point on the y axis */ constructor(cb: (this: T) => any, scope: T, x = 0, y = 0) { this._x = x; this._y = y; this.cb = cb; this.scope = scope; } _x: number; /** * The position of the displayObject on the x axis relative to the local coordinates of the parent. */ get x(): number { return this._x; } set x(value) { if (this._x !== value) { this._x = value; this.cb.call(this.scope); } } _y: number; /** * The position of the displayObject on the x axis relative to the local coordinates of the parent. */ get y(): number { return this._y; } set y(value) { if (this._y !== value) { this._y = value; this.cb.call(this.scope); } } /** * Creates a clone of this point. * The callback and scope params can be overidden otherwise they will default * to the clone object's values. * * @override * @param {Function} [cb=null] - callback when changed * @param {object} [scope=null] - owner of callback * @return {ObservablePoint} a copy of the point */ clone(cb = this.cb, scope = this.scope): ObservablePoint { return new ObservablePoint(cb, scope, this._x, this._y); } /** * Sets the point to a new x and y position. * If y is omitted, both x and y will be set to x. * * @param {number} [x=0] - position of the point on the x axis * @param {number} [y=x] - position of the point on the y axis * @returns {this} Returns itself. */ set(x = 0, y = x): this { if (this._x !== x || this._y !== y) { this._x = x; this._y = y; this.cb.call(this.scope); } return this; } /** * Copies x and y from the given point * * @param {IPoint} p - The point to copy from. * @returns {this} Returns itself. */ copyFrom(p: IPoint): this { if (this._x !== p.x || this._y !== p.y) { this._x = p.x; this._y = p.y; this.cb.call(this.scope); } return this; } /** * Copies x and y into the given point * * @param {IPoint} p - The point to copy. * @returns {IPoint} Given point with values updated */ copyTo(p: T2): T2 { p.x = this._x; p.y = this._y; return p; } /** * Returns true if the given point is equal to this point * * @param {IPoint} p - The point to check * @returns {boolean} Whether the given point equal to this point */ equals(p: IPoint): boolean { return p.x === this._x && p.y === this._y; } } ================================================ FILE: packages/common/utils/src/math/Point.spec.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { describe, test, expect } from 'vitest'; import { Point } from './Point'; describe('Point', () => { test('Point', async () => { expect(Point).not.toBeUndefined(); expect(new Point()).toEqual({ x: 0, y: 0 }); expect(new Point(1, 2)).toEqual({ x: 1, y: 2 }); }); test('Point/clone', async () => { const p1 = new Point(1, 2); const p2 = p1.clone(); p2.y = 3; expect(p1).toEqual({ x: 1, y: 2 }); expect(p2).toEqual({ x: 1, y: 3 }); }); test('Point/copyFrom', async () => { expect(new Point().copyFrom({ x: 1, y: 2 })).toEqual({ x: 1, y: 2 }); }); test('Point/copyTo', async () => { expect(new Point(1, 2).copyTo({ x: 0, y: 0 })).toEqual({ x: 1, y: 2 }); }); test('Point/equals', async () => { expect(new Point(1, 2).equals({ x: 1, y: 2 })).toBeTruthy(); expect(new Point().equals({ x: 0, y: 0 })).toBeTruthy(); expect(new Point(1, 2).equals({ x: 0, y: 0 })).toBeFalsy(); }); test('Point/set', async () => { expect(new Point(1, 2).set()).toEqual({ x: 0, y: 0 }); expect(new Point(1, 2).set(2, 1)).toEqual({ x: 2, y: 1 }); }); test('getDistance', async () => { expect(Point.getDistance({ x: 0, y: 0 }, { x: 3, y: 4 })).toEqual(5); expect(Point.getDistance({ x: 0, y: 0 }, { x: -3, y: -4 })).toEqual(5); expect(Point.getDistance({ x: 0, y: 0 }, { x: 1, y: 1 })).toEqual(Math.sqrt(2)); }); test('getMiddlePoint', async () => { expect(Point.getMiddlePoint({ x: 0, y: 0 }, { x: 3, y: 4 })).toEqual({ x: 1.5, y: 2, }); expect(Point.getMiddlePoint({ x: 0, y: 0 }, { x: -3, y: -4 })).toEqual({ x: -1.5, y: -2, }); }); test('moveDistanceToDirection', async () => { expect(Point.moveDistanceToDirection({ x: 0, y: 0 }, { x: 3, y: 4 }, 2.5)).toEqual({ x: 1.5, y: 2, }); expect(Point.moveDistanceToDirection({ x: 0, y: 0 }, { x: 0, y: 4 }, 2)).toEqual({ x: 0, y: 2, }); expect(Point.moveDistanceToDirection({ x: 0, y: 0 }, { x: 0, y: -4 }, 2)).toEqual({ x: 0, y: -2, }); }); test('fixZero', async () => { expect(Point.fixZero({ x: -0, y: -0 })).toEqual({ x: 0, y: 0, }); }); test('move', async () => { expect(Point.move({ x: 1, y: 2 }, { x: 1, y: 1 })).toEqual({ x: 2, y: 3, }); expect(Point.move({ x: 1, y: 2 }, {})).toEqual({ x: 1, y: 2, }); expect(Point.move({ x: 1, y: 2 }, { x: 1 })).toEqual({ x: 2, y: 2, }); expect(Point.move({ x: 1, y: 2 }, { y: 1 })).toEqual({ x: 1, y: 3, }); }); }); ================================================ FILE: packages/common/utils/src/math/Point.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import type { IPoint } from './IPoint'; /** * The Point object represents a location in a two-dimensional coordinate system, where x represents * the horizontal axis and y represents the vertical axis. * * @class * @memberof PIXI * @implements IPoint */ export class Point implements IPoint { constructor(public x = 0, public y = 0) {} /** * Creates a clone of this point * * @return {Point} a copy of the point */ clone(): Point { return new Point(this.x, this.y); } /** * Copies x and y from the given point * * @param {IPoint} p - The point to copy from * @returns {this} Returns itself. */ copyFrom(p: IPoint): this { this.set(p.x, p.y); return this; } /** * Copies x and y into the given point * * @param {IPoint} p - The point to copy. * @returns {IPoint} Given point with values updated */ copyTo(p: T): T { p.x = this.x; p.y = this.y; return p; } /** * Returns true if the given point is equal to this point * * @param {IPoint} p - The point to check * @returns {boolean} Whether the given point equal to this point */ equals(p: IPoint): boolean { return p.x === this.x && p.y === this.y; } /** * Sets the point to a new x and y position. * If y is omitted, both x and y will be set to x. * * @param {number} [x=0] - position of the point on the x axis * @param {number} [y=x] - position of the point on the y axis * @returns {this} Returns itself. */ set(x = 0, y = x): this { this.x = x; this.y = y; return this; } } export namespace Point { export const EMPTY: IPoint = { x: 0, y: 0 }; /** * 获取两点间的距离 * @param p1 * @param p2 */ export function getDistance(p1: IPoint, p2: IPoint): number { return Math.sqrt((p1.x - p2.x) ** 2 + (p1.y - p2.y) ** 2); } /** * 获取两点间的中间点 * @param p1 * @param p2 */ export function getMiddlePoint(p1: IPoint, p2: IPoint): IPoint { return getRatioPoint(p1, p2, 0.5); } /** * 按一定比例,获取两点间的中间点 * @param p1 * @param p2 */ export function getRatioPoint(p1: IPoint, p2: IPoint, ratio: number): IPoint { return { x: p1.x + ratio * (p2.x - p1.x), y: p1.y + ratio * (p2.y - p1.y), }; } export function fixZero(output: IPoint): IPoint { // fix: -0 if (output.x === 0) output.x = 0; if (output.y === 0) output.y = 0; return output; } /** * 往目标点移动 distance 距离 * @param current * @param direction */ export function move(current: IPoint, m: Partial): IPoint { return { x: current.x + (m.x || 0), y: current.y + (m.y || 0), }; } /** * 往目标点移动 distance 距离 * @param current * @param direction */ export function moveDistanceToDirection( current: IPoint, direction: IPoint, distance: number, ): IPoint { const deltaX = direction.x - current.x; const deltaY = direction.y - current.y; const distanceX = deltaX === 0 ? 0 : Math.sqrt(distance ** 2 / (1 + deltaY ** 2 / deltaX ** 2)); const moveX = deltaX > 0 ? distanceX : -distanceX; const distanceY = deltaX === 0 ? distance : Math.abs((distanceX * deltaY) / deltaX); const moveY = deltaY > 0 ? distanceY : -distanceY; return { x: current.x + moveX, y: current.y + moveY, }; } } ================================================ FILE: packages/common/utils/src/math/Transform.spec.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ /** * @see https://github.com/pixijs/pixijs/blob/dev/packages/math/test/Transform.tests.ts */ import { describe, it, expect } from 'vitest'; import { Transform } from './Transform'; describe('Transform', () => { describe('setFromMatrix', () => { it('should decompose negative scale into rotation', () => { const eps = 1e-3; const transform = new Transform(); const parent = new Transform(); const otherTransform = new Transform(); transform.position.set(20, 10); transform.scale.set(-2, -3); transform.rotation = Math.PI / 6; transform.updateTransform(parent); otherTransform.setFromMatrix(transform.worldTransform); const { position } = otherTransform; const { scale } = otherTransform; const { skew } = otherTransform; expect(position.x).toBeCloseTo(20, eps); expect(position.y).toBeCloseTo(10, eps); expect(scale.x).toBeCloseTo(2, eps); expect(scale.y).toBeCloseTo(3, eps); expect(skew.x).toEqual(0); expect(skew.y).toEqual(0); expect(otherTransform.rotation).toBeCloseTo((-5 * Math.PI) / 6, eps); }); it('should decompose mirror into skew', () => { const eps = 1e-3; const transform = new Transform(); const parent = new Transform(); const otherTransform = new Transform(); transform.position.set(20, 10); transform.scale.set(2, -3); transform.rotation = Math.PI / 6; transform.updateTransform(parent); otherTransform.setFromMatrix(transform.worldTransform); const { position } = otherTransform; const { scale } = otherTransform; const { skew } = otherTransform; expect(position.x).toBeCloseTo(20, eps); expect(position.y).toBeCloseTo(10, eps); expect(scale.x).toBeCloseTo(2, eps); expect(scale.y).toBeCloseTo(3, eps); expect(skew.x).toBeCloseTo((5 * Math.PI) / 6, eps); expect(skew.y).toBeCloseTo(Math.PI / 6, eps); expect(otherTransform.rotation).toEqual(0); }); it('should apply skew before scale, like in adobe animate and spine', () => { // this example looks the same in CSS and in pixi, made with pixi-animate by @bigtimebuddy const eps = 1e-3; const transform = new Transform(); const parent = new Transform(); const otherTransform = new Transform(); transform.position.set(387.8, 313.95); transform.scale.set(0.572, 4.101); transform.skew.set(-0.873, 0.175); transform.updateTransform(parent); const mat = transform.worldTransform; expect(mat.a).toBeCloseTo(0.563, eps); expect(mat.b).toBeCloseTo(0.1, eps); expect(mat.c).toBeCloseTo(-3.142, eps); expect(mat.d).toBeCloseTo(2.635, eps); expect(mat.tx).toBeCloseTo(387.8, eps); expect(mat.ty).toBeCloseTo(313.95, eps); otherTransform.setFromMatrix(transform.worldTransform); const { position } = otherTransform; const { scale } = otherTransform; const { skew } = otherTransform; expect(position.x).toBeCloseTo(387.8, eps); expect(position.y).toBeCloseTo(313.95, eps); expect(scale.x).toBeCloseTo(0.572, eps); expect(scale.y).toBeCloseTo(4.101, eps); expect(skew.x).toBeCloseTo(-0.873, eps); expect(skew.y).toBeCloseTo(0.175, eps); expect(otherTransform.rotation).toEqual(0); }); }); }); ================================================ FILE: packages/common/utils/src/math/Transform.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { ObservablePoint } from './ObservablePoint'; import { Matrix } from './Matrix'; /** * Transform that takes care about its versions * * @class * @memberof PIXI */ export class Transform { /** * A default (identity) transform * * @static * @constant * @member {PIXI.Transform} */ public static readonly IDENTITY = new Transform(); public worldTransform: Matrix; public localTransform: Matrix; public position: ObservablePoint; public scale: ObservablePoint; public pivot: ObservablePoint; public skew: ObservablePoint; public _parentID: number; _worldID: number; protected _rotation: number; protected _cx: number; protected _sx: number; protected _cy: number; protected _sy: number; protected _localID: number; protected _currentLocalID: number; constructor() { /** * The world transformation matrix. * * @member {PIXI.Matrix} */ this.worldTransform = new Matrix(); /** * The local transformation matrix. * * @member {PIXI.Matrix} */ this.localTransform = new Matrix(); /** * The coordinate of the object relative to the local coordinates of the parent. * * @member {PIXI.ObservablePoint} */ this.position = new ObservablePoint(this.onChange, this, 0, 0); /** * The scale factor of the object. * * @member {PIXI.ObservablePoint} */ this.scale = new ObservablePoint(this.onChange, this, 1, 1); /** * The pivot point of the displayObject that it rotates around. * * @member {PIXI.ObservablePoint} */ this.pivot = new ObservablePoint(this.onChange, this, 0, 0); /** * The skew amount, on the x and y axis. * * @member {PIXI.ObservablePoint} */ this.skew = new ObservablePoint(this.updateSkew, this, 0, 0); /** * The rotation amount. * * @protected * @member {number} */ this._rotation = 0; /** * The X-coordinate value of the normalized local X axis, * the first column of the local transformation matrix without a scale. * * @protected * @member {number} */ this._cx = 1; /** * The Y-coordinate value of the normalized local X axis, * the first column of the local transformation matrix without a scale. * * @protected * @member {number} */ this._sx = 0; /** * The X-coordinate value of the normalized local Y axis, * the second column of the local transformation matrix without a scale. * * @protected * @member {number} */ this._cy = 0; /** * The Y-coordinate value of the normalized local Y axis, * the second column of the local transformation matrix without a scale. * * @protected * @member {number} */ this._sy = 1; /** * The locally unique ID of the local transform. * * @protected * @member {number} */ this._localID = 0; /** * The locally unique ID of the local transform * used to calculate the current local transformation matrix. * * @protected * @member {number} */ this._currentLocalID = 0; /** * The locally unique ID of the world transform. * * @protected * @member {number} */ this._worldID = 0; /** * The locally unique ID of the parent's world transform * used to calculate the current world transformation matrix. * * @protected * @member {number} */ this._parentID = 0; } /** * Called when a value changes. * * @protected */ protected onChange(): void { this._localID++; } /** * Called when the skew or the rotation changes. * * @protected */ protected updateSkew(): void { this._cx = Math.cos(this._rotation + this.skew.y); this._sx = Math.sin(this._rotation + this.skew.y); this._cy = -Math.sin(this._rotation - this.skew.x); // cos, added PI/2 this._sy = Math.cos(this._rotation - this.skew.x); // sin, added PI/2 this._localID++; } /** * Updates the local transformation matrix. */ updateLocalTransform(): void { const lt = this.localTransform; if (this._localID !== this._currentLocalID) { // get the matrix values of the displayobject based on its transform properties.. lt.a = this._cx * this.scale.x; lt.b = this._sx * this.scale.x; lt.c = this._cy * this.scale.y; lt.d = this._sy * this.scale.y; lt.tx = this.position.x - (this.pivot.x * lt.a + this.pivot.y * lt.c); lt.ty = this.position.y - (this.pivot.x * lt.b + this.pivot.y * lt.d); this._currentLocalID = this._localID; // force an update.. this._parentID = -1; } } /** * Updates the local and the world transformation matrices. * * @param {PIXI.Transform} parentTransform - The parent transform */ updateTransform(parentTransform: Transform): void { const lt = this.localTransform; if (this._localID !== this._currentLocalID) { // get the matrix values of the displayobject based on its transform properties.. lt.a = this._cx * this.scale.x; lt.b = this._sx * this.scale.x; lt.c = this._cy * this.scale.y; lt.d = this._sy * this.scale.y; lt.tx = this.position.x - (this.pivot.x * lt.a + this.pivot.y * lt.c); lt.ty = this.position.y - (this.pivot.x * lt.b + this.pivot.y * lt.d); this._currentLocalID = this._localID; // force an update.. this._parentID = -1; } if (this._parentID !== parentTransform._worldID) { // concat the parent matrix with the objects transform. const pt = parentTransform.worldTransform; const wt = this.worldTransform; wt.a = lt.a * pt.a + lt.b * pt.c; wt.b = lt.a * pt.b + lt.b * pt.d; wt.c = lt.c * pt.a + lt.d * pt.c; wt.d = lt.c * pt.b + lt.d * pt.d; wt.tx = lt.tx * pt.a + lt.ty * pt.c + pt.tx; wt.ty = lt.tx * pt.b + lt.ty * pt.d + pt.ty; this._parentID = parentTransform._worldID; // update the id of the transform.. this._worldID++; } } /** * Decomposes a matrix and sets the transforms properties based on it. * * @param {PIXI.Matrix} matrix - The matrix to decompose */ setFromMatrix(matrix: Matrix): void { matrix.decompose(this); this._localID++; } /** * The rotation of the object in radians. * * @member {number} */ get rotation(): number { return this._rotation; } set rotation(value: number) { if (this._rotation !== value) { this._rotation = value; this.updateSkew(); } } } ================================================ FILE: packages/common/utils/src/math/Vector2.spec.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { describe, expect, test } from 'vitest'; import { Vector2 } from './Vector2'; describe('Vector2', () => { test('Vector2', async () => { expect(new Vector2()).toEqual({ x: 0, y: 0 }); expect(new Vector2(1, 2)).toEqual({ x: 1, y: 2 }); }); test('Vector2/sub', async () => { expect(new Vector2().sub(new Vector2(1, 2))).toEqual({ x: -1, y: -2 }); expect(new Vector2(1, 2).sub(new Vector2(1, 2))).toEqual({ x: 0, y: 0 }); }); test('Vector2/dot', async () => { expect(new Vector2().dot(new Vector2(1, 2))).toEqual(0); expect(new Vector2(1, 2).dot(new Vector2(1, 2))).toEqual(5); }); }); ================================================ FILE: packages/common/utils/src/math/Vector2.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export class Vector2 { constructor(public x = 0, public y = 0) {} /** * 向量减法 */ sub(v: Vector2): Vector2 { return new Vector2(this.x - v.x, this.y - v.y); } /** * 向量点乘 */ dot(v: Vector2): number { return this.x * v.x + this.y * v.y; } /** * 向量叉乘 */ // cross(v: Vector2): number { // } } ================================================ FILE: packages/common/utils/src/math/angle.spec.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { describe, expect, test } from 'vitest'; import { PI } from './const'; import { Angle } from './angle'; describe('Angle', () => { test('wrap', async () => { expect(Angle.wrap(-PI * 2)).toEqual(0); expect(Angle.wrap(-PI)).toEqual(-PI); expect(Angle.wrap(0)).toEqual(0); expect(Angle.wrap(PI / 2)).toEqual(PI / 2); expect(Angle.wrap(PI)).toEqual(-PI); expect(Angle.wrap(PI * 2)).toEqual(0); }); test('wrapDegrees', async () => { expect(Angle.wrapDegrees(-180 * 2)).toEqual(0); expect(Angle.wrapDegrees(-180)).toEqual(-180); expect(Angle.wrapDegrees(0)).toEqual(0); expect(Angle.wrapDegrees(180 / 2)).toEqual(180 / 2); expect(Angle.wrapDegrees(180)).toEqual(-180); expect(Angle.wrapDegrees(180 * 2)).toEqual(0); }); test('betweenPoints', async () => { expect(Angle.betweenPoints({ x: 1, y: 1 }, { x: 2, y: 2 })).toEqual(0); expect(Angle.betweenPoints({ x: 1, y: 0 }, { x: 0, y: 1 })).toEqual(PI / 2); expect(Angle.betweenPoints({ x: 0, y: 1 }, { x: 1, y: 0 })).toEqual(-PI / 2); expect(Angle.betweenPoints({ x: -1, y: 0 }, { x: 1, y: 0 })).toEqual(-PI); expect(Angle.betweenPoints({ x: 1, y: 0 }, { x: -1, y: 0 })).toEqual(PI); }); }); ================================================ FILE: packages/common/utils/src/math/angle.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { wrap as mathWrap } from './wrap'; import { type IPoint } from './IPoint'; export namespace Angle { /** * Wrap an angle. * * Wraps the angle to a value in the range of -PI to PI. * * @param angle - The angle to wrap, in radians. * @return The wrapped angle, in radians. */ export function wrap(angle: number): number { return mathWrap(angle, -Math.PI, Math.PI); } /** * Wrap an angle in degrees. * * Wraps the angle to a value in the range of -180 to 180. * * @param angle - The angle to wrap, in degrees. * @return The wrapped angle, in degrees. */ export function wrapDegrees(angle: number): number { return mathWrap(angle, -180, 180); } /** * 计算两个点的夹角 * * @return The angle in radians. */ export function betweenPoints( point1: IPoint, point2: IPoint, originPoint: IPoint = { x: 0, y: 0 }, ): number { const p1 = { x: point1.x - originPoint.x, y: point1.y - originPoint.y, }; const p2 = { x: point2.x - originPoint.x, y: point2.y - originPoint.y, }; // return Math.atan2(p2.y, p2.x) - Math.atan2(p1.y, p1.x) return Math.atan2(p1.x * p2.y - p1.y * p2.x, p1.x * p2.x + p1.y * p2.y); } } ================================================ FILE: packages/common/utils/src/math/const.spec.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { describe, expect, test } from 'vitest'; import { DEG_TO_RAD, PI, PI_2, RAD_TO_DEG, SHAPES } from './const'; describe('const', () => { test('PI_2', async () => { expect(PI_2).toEqual(PI * 2); }); test('RAD_TO_DEG', async () => { expect((PI / 2) * RAD_TO_DEG).toEqual(90); expect(PI * RAD_TO_DEG).toEqual(180); }); test('DEG_TO_RAD', async () => { expect(180 * DEG_TO_RAD).toEqual(PI); expect(90 * DEG_TO_RAD).toEqual(PI / 2); }); test('SHAPES', async () => { expect(SHAPES.RECT).toEqual(1); expect(SHAPES.RREC).toEqual(4); }); }); ================================================ FILE: packages/common/utils/src/math/const.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export const { PI } = Math; /** Two Pi. */ export const PI_2 = PI * 2; /** Conversion factor for converting radians to degrees. */ export const RAD_TO_DEG = 180 / PI; /** Conversion factor for converting degrees to radians. */ export const DEG_TO_RAD = PI / 180; /** Constants that identify shapes. */ export enum SHAPES { /** Polygon */ POLY = 0, /** Rectangle */ RECT = 1, /** Circle */ CIRC = 2, /** Ellipse */ ELIP = 3, /** Rounded Rectangle */ RREC = 4, } ================================================ FILE: packages/common/utils/src/math/index.spec.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { describe, test, expect } from 'vitest'; import { Point } from './index'; describe('math', () => { test('Point', () => { expect(Point).toBeDefined(); }); }); ================================================ FILE: packages/common/utils/src/math/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export * from './shapes'; export * from './Matrix'; export * from './Point'; export * from './Transform'; export * from './angle'; export * from './const'; export * from './IPoint'; ================================================ FILE: packages/common/utils/src/math/shapes/Circle.spec.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { describe, test, expect } from 'vitest'; import { Rectangle } from './Rectangle'; import { Circle } from './Circle'; describe('Circle', () => { test('Circle', async () => { expect(new Circle()).toEqual({ radius: 0, type: 2, x: 0, y: 0 }); }); test('clone', async () => { const c = new Circle(); const c1 = c.clone(); c.radius = 2; expect(c1.radius).toEqual(0); }); test('contains', async () => { const r = new Circle(0, 0, 1); expect(r.contains(0, 0)).toEqual(true); expect(r.contains(0.5, 0.5)).toEqual(true); // const d = Math.sqrt(2) / 2 // expect(r.contains(d, d)).toEqual(true) // why not true expect(r.contains(0.707, 0.707)).toEqual(true); expect(r.contains(0, 1)).toEqual(true); expect(r.contains(1, 0)).toEqual(true); expect(r.contains(1, 1)).toEqual(false); expect(new Circle().contains(0, 0)).toEqual(false); }); test('getBounds', async () => { const r = new Circle(0, 0, 1); expect(r.getBounds()).toEqual(new Rectangle(-1, -1, 2, 2)); }); }); ================================================ FILE: packages/common/utils/src/math/shapes/Circle.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { SHAPES } from '../const'; import { Rectangle } from './Rectangle'; /** * The Circle object is used to help draw graphics and can also be used to specify a hit area for displayObjects. */ export class Circle { /** * The type of the object, mainly used to avoid `instanceof` checks */ public readonly type = SHAPES.CIRC; /** * @param x Circle center x * @param y Circle center y */ constructor(public x = 0, public y = 0, public radius = 0) {} /** * Creates a clone of this Circle instance * * @return a copy of the Circle */ clone(): Circle { return new Circle(this.x, this.y, this.radius); } /** * Checks whether the x and y coordinates given are contained within this circle * * @return Whether the (x, y) coordinates are within this Circle */ contains(x: number, y: number): boolean { if (this.radius <= 0) { return false; } const r2 = this.radius * this.radius; let dx = this.x - x; let dy = this.y - y; dx *= dx; dy *= dy; return dx + dy <= r2; } /** * Returns the framing rectangle of the circle as a Rectangle object * * @return the framing rectangle */ getBounds(): Rectangle { return new Rectangle( this.x - this.radius, this.y - this.radius, this.radius * 2, this.radius * 2, ); } } ================================================ FILE: packages/common/utils/src/math/shapes/Rectangle.spec.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ // nolint: cyclo_complexity,method_line import { describe, test, expect } from 'vitest'; import { Vector2 } from '../Vector2'; import { Point } from '../Point'; import { type IPoint } from '../IPoint'; import { PI } from '../const'; import { OBBRect, Rectangle as R, RectangleAlignType } from './Rectangle'; describe('Rectangle', () => { describe('Rectangle Class', () => { test('Rectangle', async () => { expect(R).not.toBeUndefined(); expect(new R()).toEqual({ x: 0, y: 0, width: 0, height: 0, type: 1 }); expect(new R(1)).toEqual({ x: 1, y: 0, width: 0, height: 0, type: 1 }); expect(new R(1, 2)).toEqual({ x: 1, y: 2, width: 0, height: 0, type: 1 }); expect(new R(1, 2, 3)).toEqual({ x: 1, y: 2, width: 3, height: 0, type: 1, }); expect(new R(1, 2, 3, 4)).toEqual({ x: 1, y: 2, width: 3, height: 4, type: 1, }); }); test('EMPTY', async () => { expect(R.EMPTY).toEqual({ x: 0, y: 0, width: 0, height: 0, type: 1 }); }); test('left/right/top/bottom', async () => { const r = new R(1, 2, 3, 4); expect(r.left).toEqual(1); expect(r.right).toEqual(4); expect(r.top).toEqual(2); expect(r.bottom).toEqual(6); }); test('clone', async () => { const r = new R(1, 2, 3, 4); const r1 = r.clone(); r.y = 5; expect(r).toEqual({ x: 1, y: 5, width: 3, height: 4, type: 1 }); expect(r1).toEqual({ x: 1, y: 2, width: 3, height: 4, type: 1 }); }); test('copyFrom', async () => { const r = new R(1, 2, 3, 4); const r1 = new R().copyFrom(r); r.y = 5; expect(r).toEqual({ x: 1, y: 5, width: 3, height: 4, type: 1 }); expect(r1).toEqual({ x: 1, y: 2, width: 3, height: 4, type: 1 }); }); test('copyTo', async () => { const r = new R(1, 2, 3, 4); const r1 = r.copyTo(new R()); r.y = 5; expect(r).toEqual({ x: 1, y: 5, width: 3, height: 4, type: 1 }); expect(r1).toEqual({ x: 1, y: 2, width: 3, height: 4, type: 1 }); }); test('contains', async () => { const r = new R(0, 0, 1, 1); expect(r.contains(0, 0)).toEqual(true); expect(r.contains(0.5, 0.5)).toEqual(true); expect(r.contains(0, 1)).toEqual(true); expect(r.contains(1, 0)).toEqual(true); expect(r.contains(1, 1)).toEqual(true); expect(r.contains(2, 2)).toEqual(false); expect(R.EMPTY.contains(0, 0)).toEqual(false); }); test('isEqual', async () => { expect(R.EMPTY.isEqual(R.EMPTY)).toEqual(true); expect(new R(1, 2, 3, 4).isEqual(new R(1, 2, 3, 4))).toEqual(true); expect(new R(1, 2, 3, 4).isEqual(new R(1, 2))).toEqual(false); }); test('containsRectangle', async () => { expect(R.EMPTY.containsRectangle(R.EMPTY)).toEqual(true); expect(new R(1, 2, 3, 4).containsRectangle(new R(1, 2, 3, 4))).toEqual(true); expect(new R(0, 0, 2, 2).containsRectangle(new R(1, 1, 1, 1))).toEqual(true); expect(new R(0, 0, 2, 2).containsRectangle(new R(1, 1, 2, 2))).toEqual(false); }); test('pad', async () => { expect(new R().pad()).toEqual(new R()); expect(new R().pad(1)).toEqual(new R(-1, -1, 2, 2)); expect(new R().pad(1, 2)).toEqual(new R(-1, -2, 2, 4)); }); test('fit', async () => { expect(new R(0, 0, 2, 2).fit(new R(0, 0, 1, 1))).toEqual(new R(0, 0, 1, 1)); expect(new R(0, 0, 2, 2).fit(new R(1, 1, 2, 2))).toEqual(new R(1, 1, 1, 1)); expect(new R(0, 0, 2, 2).fit(new R(3, 3, 1, 1))).toEqual(new R(3, 3, 0, 0)); }); test('ceil', async () => { expect(new R(0.1, 0.2, 2, 2).ceil()).toEqual(new R(0, 0, 3, 3)); expect(new R(0.5, 0.6, 2, 2).ceil()).toEqual(new R(0, 0, 3, 3)); }); test('enlarge', async () => { expect(new R().enlarge(R.EMPTY)).toEqual(R.EMPTY); expect(new R().enlarge(new R(0, 0, 1, 1))).toEqual(new R(0, 0, 1, 1)); expect(new R(0, 0, 1, 1).enlarge(new R(1, 1, 1, 1))).toEqual(new R(0, 0, 2, 2)); expect(new R(0, 0, 2, 2).enlarge(new R(1, 1, 2, 2))).toEqual(new R(0, 0, 3, 3)); }); test('center...crossDistance', async () => { const r = new R(1, 1, 4, 4); expect(r.center).toEqual({ x: 3, y: 3 }); expect(r.rightBottom).toEqual({ x: 5, y: 5 }); expect(r.leftBottom).toEqual({ x: 1, y: 5 }); expect(r.rightTop).toEqual({ x: 5, y: 1 }); expect(r.leftTop).toEqual({ x: 1, y: 1 }); expect(r.bottomCenter).toEqual({ x: 3, y: 5 }); expect(r.topCenter).toEqual({ x: 3, y: 1 }); expect(r.rightCenter).toEqual({ x: 5, y: 3 }); expect(r.leftCenter).toEqual({ x: 1, y: 3 }); expect(r.crossDistance).toEqual(Math.sqrt(32)); }); test('update', async () => { expect( new R(1, 1, 4, 4).update((r) => { r.x = 0; r.y = 0; return r; }) ).toEqual(new R(0, 0, 4, 4)); }); test('toStyleStr', async () => { expect(new R(1, 1, 4, 4).toStyleStr()).toEqual( 'left: 1px; top: 1px; width: 4px; height: 4px;' ); }); test('withPadding', async () => { expect(new R(1, 1, 4, 4).withPadding({ left: 1, right: 1, top: 1, bottom: 1 })).toEqual( new R(0, 0, 6, 6) ); }); test('withoutPadding', async () => { expect( new R(1, 1, 4, 4).withoutPadding({ left: 1, right: 1, top: 1, bottom: 1, }) ).toEqual(new R(2, 2, 2, 2)); }); test('withHeight', async () => { expect(new R(1, 1, 4, 4).withHeight(5)).toEqual(new R(1, 1, 4, 5)); }); test('clearSpace', async () => { expect(new R(1, 1, 4, 4).clearSpace()).toEqual(new R(1, 1, 0, 0)); }); }); // test('Rectangle namespace', async () => { // expect(R._test).toEqual({}) // R._test.a = 1 // expect(R._test).toEqual({ a: 1 }) // }) test('align', async () => { const r1 = new R(0, 0, 1, 1); const r2 = new R(1, 1, 2, 2); expect(R.align([r1.clone(), r2.clone()], RectangleAlignType.ALIGN_BOTTOM)).toEqual([ new R(0, 2, 1, 1), new R(1, 1, 2, 2), ]); expect(R.align([r1.clone(), r2.clone()], RectangleAlignType.ALIGN_CENTER)).toEqual([ new R(1, 0, 1, 1), new R(0.5, 1, 2, 2), ]); expect(R.align([r1.clone(), r2.clone()], RectangleAlignType.ALIGN_LEFT)).toEqual([ new R(0, 0, 1, 1), new R(0, 1, 2, 2), ]); expect(R.align([r1.clone(), r2.clone()], RectangleAlignType.ALIGN_MIDDLE)).toEqual([ new R(0, 1, 1, 1), new R(1, 0.5, 2, 2), ]); expect(R.align([r1.clone(), r2.clone()], RectangleAlignType.ALIGN_RIGHT)).toEqual([ new R(2, 0, 1, 1), new R(1, 1, 2, 2), ]); expect(R.align([r1.clone(), r2.clone()], RectangleAlignType.ALIGN_TOP)).toEqual([ new R(0, 0, 1, 1), new R(1, 0, 2, 2), ]); expect( R.align( [new R(0, 0, 1, 1), new R(2, 0, 1, 1), new R(6, 0, 1, 1)], RectangleAlignType.DISTRIBUTE_HORIZONTAL ) ).toEqual([new R(0, 0, 1, 1), new R(3, 0, 1, 1), new R(6, 0, 1, 1)]); expect( R.align( [new R(0, 0, 1, 1), new R(0.5, 0, 1, 1), new R(0.5, 0, 1, 1)], RectangleAlignType.DISTRIBUTE_HORIZONTAL ) ).toEqual([new R(0, 0, 1, 1), new R(0.25, 0, 1, 1), new R(0.5, 0, 1, 1)]); expect(R.align([r1.clone(), r2.clone()], RectangleAlignType.DISTRIBUTE_HORIZONTAL)).toEqual([ r1, r2, ]); expect( R.align( [new R(0, 0, 1, 1), new R(0, 2, 1, 1), new R(0, 6, 1, 1)], RectangleAlignType.DISTRIBUTE_VERTICAL ) ).toEqual([new R(0, 0, 1, 1), new R(0, 3, 1, 1), new R(0, 6, 1, 1)]); expect(R.align([r1.clone(), r2.clone()], RectangleAlignType.DISTRIBUTE_VERTICAL)).toEqual([ r1, r2, ]); expect(R.align([r1.clone()], RectangleAlignType.DISTRIBUTE_VERTICAL)).toEqual([r1]); expect(R.align([r1.clone(), r2.clone()], '' as any)).toEqual([r1, r2]); // override default }); test('enlarge', async () => { expect(R.enlarge([new R(0, 0, 1, 1), new R(1, 1, 1, 1)])).toEqual(new R(0, 0, 2, 2)); expect(R.enlarge([new R(0, 0, 1, 1)])).toEqual(new R(0, 0, 1, 1)); expect(R.enlarge([])).toEqual(new R()); }); test('intersects', async () => { expect(R.intersects(new R(0, 0, 2, 2), new R(1, 1, 2, 2))).toEqual(true); expect(R.intersects(new R(0, 0, 2, 2), new R(1, 1, 2, 2), 'horizontal')).toEqual(true); expect(R.intersects(new R(0, 0, 2, 2), new R(1, 1, 2, 2), 'vertical')).toEqual(true); expect(R.intersects(new R(0, 0, 2, 2), new R(3, 3, 2, 2))).toEqual(false); expect(R.intersects(new R(0, 0, 2, 2), new R(3, 3, 2, 2), 'horizontal')).toEqual(false); expect(R.intersects(new R(0, 0, 2, 2), new R(3, 3, 2, 2), 'vertical')).toEqual(false); }); test('OBBRect', async () => { expect( new OBBRect(new Point(0, 0), 1, 1, 0).getProjectionRadius(new Vector2(0, 1)) ).toBeCloseTo(0.5); expect( new OBBRect(new Point(0, 0), 1, 1, 0).getProjectionRadius(new Vector2(1, 0)) ).toBeCloseTo(0.5); expect(new OBBRect(new Point(0, 0), 1, 1, 0).getProjectionRadius(new Vector2(1, 1))).toEqual(1); expect( new OBBRect(new Point(0, 0), 1, 1, PI / 4).getProjectionRadius(new Vector2(1, 1)) ).toBeCloseTo(Math.sqrt(2) / 2); expect( new OBBRect(new Point(0, 0), 1, 1, PI / 2).getProjectionRadius(new Vector2(1, 1)) ).toEqual(1); }); test('intersectsWithRotation', async () => { expect(R.intersects(new R(0, 0, 10, 10), new R(8, 8, 4, 4))).toEqual(true); expect(new R(0, 0, 10, 10).fit(new R(8, 8, 4, 4))).toEqual(new R(8, 8, 2, 2)); expect(R.intersectsWithRotation(new R(0, 0, 10, 10), 0, new R(8, 8, 4, 4), 0)).toEqual(true); expect( R.intersectsWithRotation(new R(0, 0, 10, 10), PI / 4, new R(8, 8, 4, 4), PI / 4) ).toEqual(false); expect( R.intersectsWithRotation(new R(0, 0, 10, 10), PI / 2, new R(8, 8, 4, 4), PI / 2) ).toEqual(true); expect(R.intersectsWithRotation(new R(0, 0, 10, 10), PI, new R(8, 8, 4, 4), PI)).toEqual(true); // expect(R.intersectsWithRotation(new R(0, 0, 10, 10), PI / 4, new R(8, -12, 4, 4), PI / 4)).toEqual(false) // expect(R.intersectsWithRotation(new R(0, 0, 10, 10), PI / 4, new R(-12, 8, 4, 4), PI / 4)).toEqual(false) }); test('isViewportVisible', async () => { expect(R.isViewportVisible(new R(0, 0, 1, 1), new R(0.5, 0.5, 1, 1))).toEqual(true); expect(R.isViewportVisible(new R(0, 0, 1, 1), new R(0.5, 0.5, 1, 1), 0, true)).toEqual(false); expect(R.isViewportVisible(new R(0, 0, 1, 1), new R(0.5, 0.5, 1, 1), PI / 4)).toEqual(true); }); test('createRectangleWithTwoPoints', async () => { expect(R.createRectangleWithTwoPoints({ x: 0, y: 0 }, { x: 1, y: 1 })).toEqual( new R(0, 0, 1, 1) ); expect(R.createRectangleWithTwoPoints({ x: 1, y: 1 }, { x: 0, y: 0 })).toEqual( new R(0, 0, 1, 1) ); }); test('OBBRect', async () => { expect( new OBBRect(new Point(0, 0), 1, 1, 0).getProjectionRadius(new Vector2(0, 1)) ).toBeCloseTo(0.5); expect( new OBBRect(new Point(0, 0), 1, 1, 0).getProjectionRadius(new Vector2(1, 0)) ).toBeCloseTo(0.5); expect(new OBBRect(new Point(0, 0), 1, 1, 0).getProjectionRadius(new Vector2(1, 1))).toEqual(1); expect( new OBBRect(new Point(0, 0), 1, 1, PI / 4).getProjectionRadius(new Vector2(1, 1)) ).toBeCloseTo(Math.sqrt(2) / 2); expect( new OBBRect(new Point(0, 0), 1, 1, PI / 2).getProjectionRadius(new Vector2(1, 1)) ).toEqual(1); }); test('setViewportVisible', () => { const viewport = new R(0, 0, 100, 100); function check( rect: { x: number; y: number; width: number; height: number }, pos: IPoint, padding = 0 ) { const bounds = new R(rect.x, rect.y, rect.width, rect.height); R.setViewportVisible(bounds, viewport, padding); expect({ x: bounds.x, y: bounds.y }).toEqual(pos); } // no change check({ x: 0, y: 0, width: 10, height: 10 }, { x: 0, y: 0 }); // left check({ x: -10, y: 0, width: 10, height: 10 }, { x: 0, y: 0 }); // top check({ x: 0, y: -10, width: 10, height: 10 }, { x: 0, y: 0 }); // right check({ x: 110, y: 0, width: 10, height: 10 }, { x: 90, y: 0 }); // bottom check({ x: 0, y: 110, width: 10, height: 10 }, { x: 0, y: 90 }); // 贴到边界,如果有 padding 也往下移动 check({ x: 0, y: 0, width: 10, height: 10 }, { x: 10, y: 10 }, 10); // left with padding check({ x: -10, y: 0, width: 10, height: 10 }, { x: 10, y: 10 }, 10); // top with padding check({ x: 0, y: -10, width: 10, height: 10 }, { x: 10, y: 10 }, 10); // right with padding check({ x: 110, y: 0, width: 10, height: 10 }, { x: 80, y: 10 }, 10); // bottom with padding check({ x: 0, y: 110, width: 10, height: 10 }, { x: 10, y: 80 }, 10); }); }); ================================================ FILE: packages/common/utils/src/math/shapes/Rectangle.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { Vector2 } from '../Vector2'; import { Point } from '../Point'; import { type IPoint } from '../IPoint'; import { SHAPES } from '../const'; import { type PaddingSchema } from '../../schema'; /** * Size object, contains width and height */ export type ISize = { width: number; height: number }; /** * Rectangle object is an area defined by its position, as indicated by its top-left corner * point (x, y) and by its width and its height. */ export class Rectangle { /** * The type of the object, mainly used to avoid `instanceof` checks */ public readonly type = SHAPES.RECT; /** * @param [x] - The X coordinate of the upper-left corner of the rectangle * @param [y] - The Y coordinate of the upper-left corner of the rectangle * @param [width] - The overall width of this rectangle * @param [height] - The overall height of this rectangle */ constructor(public x = 0, public y = 0, public width = 0, public height = 0) {} // static _empty: Rectangle = Object.freeze(new Rectangle(0, 0, 0, 0)) /** * A constant empty rectangle. MUST NOT modify properties! */ static get EMPTY(): Rectangle { return new Rectangle(0, 0, 0, 0); } get left(): number { return this.x; } get right(): number { return this.x + this.width; } get top(): number { return this.y; } get bottom(): number { return this.y + this.height; } /** * Creates a clone of this Rectangle. * * @return a copy of the rectangle */ clone(): Rectangle { return new Rectangle(this.x, this.y, this.width, this.height); } /** * Copies another rectangle to this one. * * @return Returns itself. */ copyFrom(rectangle: Rectangle): Rectangle { this.x = rectangle.x; this.y = rectangle.y; this.width = rectangle.width; this.height = rectangle.height; return this; } /** * Copies this rectangle to another one. * * @return Returns given rectangle. */ copyTo(rectangle: Rectangle): Rectangle { rectangle.x = this.x; rectangle.y = this.y; rectangle.width = this.width; rectangle.height = this.height; return rectangle; } /** * Checks whether the x and y coordinates given are contained within this Rectangle * * @param x - The X coordinate of the point to test * @param y - The Y coordinate of the point to test * @return Whether the x/y coordinates are within this Rectangle */ contains(x: number, y: number): boolean { if (this.width <= 0 || this.height <= 0) { return false; } if (x >= this.x && x <= this.right) { if (y >= this.y && y <= this.bottom) { return true; } } return false; } isEqual(rect: Rectangle): boolean { return ( this.x === rect.x && this.y === rect.y && this.width === rect.width && this.height === rect.height ); } containsRectangle(rect: Rectangle): boolean { return ( rect.left >= this.left && rect.right <= this.right && rect.top >= this.top && rect.bottom <= this.bottom ); } /** * Pads the rectangle making it grow in all directions. * If paddingY is omitted, both paddingX and paddingY will be set to paddingX. * * @param [paddingX] - The horizontal padding amount. * @param [paddingY] - The vertical padding amount. */ pad(paddingX = 0, paddingY = paddingX): this { this.x -= paddingX; this.y -= paddingY; this.width += paddingX * 2; this.height += paddingY * 2; return this; } /** * Fits this rectangle around the passed one. * Intersection 交集 */ fit(rectangle: Rectangle): this { const x1 = Math.max(this.x, rectangle.x); const x2 = Math.min(this.x + this.width, rectangle.x + rectangle.width); const y1 = Math.max(this.y, rectangle.y); const y2 = Math.min(this.y + this.height, rectangle.y + rectangle.height); this.x = x1; this.width = Math.max(x2 - x1, 0); this.y = y1; this.height = Math.max(y2 - y1, 0); return this; } /** * Enlarges rectangle that way its corners lie on grid */ ceil(resolution = 1, precision = 0.001): this { const x2 = Math.ceil((this.x + this.width - precision) * resolution) / resolution; const y2 = Math.ceil((this.y + this.height - precision) * resolution) / resolution; this.x = Math.floor((this.x + precision) * resolution) / resolution; this.y = Math.floor((this.y + precision) * resolution) / resolution; this.width = x2 - this.x; this.height = y2 - this.y; return this; } /** * Enlarges this rectangle to include the passed rectangle. */ enlarge(rectangle: Rectangle): this { const x1 = Math.min(this.x, rectangle.x); const x2 = Math.max(this.x + this.width, rectangle.x + rectangle.width); const y1 = Math.min(this.y, rectangle.y); const y2 = Math.max(this.y + this.height, rectangle.y + rectangle.height); this.x = x1; this.width = x2 - x1; this.y = y1; this.height = y2 - y1; return this; } get center(): IPoint { return { x: this.x + this.width / 2, y: this.y + this.height / 2, }; } get rightBottom(): IPoint { return { x: this.right, y: this.bottom, }; } get leftBottom(): IPoint { return { x: this.left, y: this.bottom, }; } get rightTop(): IPoint { return { x: this.right, y: this.top, }; } get leftTop(): IPoint { return { x: this.left, y: this.top, }; } get bottomCenter(): IPoint { return { x: this.x + this.width / 2, y: this.bottom, }; } get topCenter(): IPoint { return { x: this.x + this.width / 2, y: this.top, }; } get rightCenter(): IPoint { return { x: this.right, y: this.y + this.height / 2, }; } get leftCenter(): IPoint { return { x: this.left, y: this.y + this.height / 2, }; } update(fn: (rect: Rectangle) => Rectangle): Rectangle { return fn(this); } get crossDistance(): number { return Point.getDistance(this.leftTop, this.rightBottom); } toStyleStr(): string { return `left: ${this.x}px; top: ${this.y}px; width: ${this.width}px; height: ${this.height}px;`; } withPadding(padding: PaddingSchema) { this.x -= padding.left; this.y -= padding.top; this.width += padding.left + padding.right; this.height += padding.top + padding.bottom; return this; } withoutPadding(padding: PaddingSchema) { this.x += padding.left; this.y += padding.top; this.width = this.width - padding.left - padding.right; this.height = this.height - padding.top - padding.bottom; return this; } withHeight(height: number) { this.height = height; return this; } clearSpace() { this.width = 0; this.height = 0; return this; } } export enum RectangleAlignType { ALIGN_LEFT = 'align-left', ALIGN_CENTER = 'align-center', ALIGN_RIGHT = 'align-right', ALIGN_TOP = 'align-top', ALIGN_MIDDLE = 'align-middle', ALIGN_BOTTOM = 'align-bottom', DISTRIBUTE_HORIZONTAL = 'distribute-horizontal', DISTRIBUTE_VERTICAL = 'distribute-vertical', } export enum RectangleAlignTitle { ALIGN_LEFT = '左对齐', ALIGN_CENTER = '左右居中对齐', ALIGN_RIGHT = '右对齐', ALIGN_TOP = '上对齐', ALIGN_MIDDLE = '上下居中对齐', ALIGN_BOTTOM = '下对齐', DISTRIBUTE_HORIZONTAL = '水平平均分布', DISTRIBUTE_VERTICAL = '垂直平均分布', } // `branch not covered` // @see https://github.com/istanbuljs/nyc/issues/1209 export namespace Rectangle { /** * 矩形对齐 */ export function align(rectangles: Rectangle[], type: RectangleAlignType): Rectangle[] { if (rectangles.length <= 1) return rectangles; switch (type) { /** * 下对齐 */ case RectangleAlignType.ALIGN_BOTTOM: const maxBottom = Math.max(...rectangles.map((r) => r.bottom)); rectangles.forEach((rect) => { rect.y = maxBottom - rect.height; }); break; /** * 左右居中对齐 */ case RectangleAlignType.ALIGN_CENTER: const centerX = enlarge(rectangles).center.x; rectangles.forEach((rect) => { rect.x = centerX - rect.width / 2; }); break; /** * 左对齐 */ case RectangleAlignType.ALIGN_LEFT: const minLeft = Math.min(...rectangles.map((r) => r.left)); rectangles.forEach((rect) => { rect.x = minLeft; }); break; /** * 上下居中对齐 */ case RectangleAlignType.ALIGN_MIDDLE: const centerY = enlarge(rectangles).center.y; rectangles.forEach((rect) => { rect.y = centerY - rect.height / 2; }); break; /** * 右对齐 */ case RectangleAlignType.ALIGN_RIGHT: const maxRight = Math.max(...rectangles.map((r) => r.right)); rectangles.forEach((rect) => { rect.x = maxRight - rect.width; }); break; /** * 上对齐 */ case RectangleAlignType.ALIGN_TOP: const minTop = Math.min(...rectangles.map((r) => r.top)); rectangles.forEach((rect) => { rect.y = minTop; }); break; /** * 水平平均分布 */ case RectangleAlignType.DISTRIBUTE_HORIZONTAL: // 只支持大于三个 if (rectangles.length <= 2) break; const sort = rectangles.slice().sort((r1, r2) => r1.left - r2.left); const bounds = enlarge(rectangles); const space = rectangles.reduce((s, rect) => s - rect.width, bounds.width) / (rectangles.length - 1); sort.reduce((left, rect) => { rect.x = left; return left + rect.width + space; }, bounds.x); break; /** * 垂直平均分布 */ case RectangleAlignType.DISTRIBUTE_VERTICAL: if (rectangles.length <= 2) break; const sort2 = rectangles.slice().sort((r1, r2) => r1.top - r2.top); const bounds2 = enlarge(rectangles); const space2 = rectangles.reduce((s, rect) => s - rect.height, bounds2.height) / (rectangles.length - 1); sort2.reduce((top, rect) => { rect.y = top; return top + rect.height + space2; }, bounds2.y); break; default: break; } return rectangles; } /** * 获取所有矩形的外围最大边框 */ export function enlarge(rectangles: Rectangle[]): Rectangle { const result = Rectangle.EMPTY.clone(); if (!rectangles.length) return result; const lefts: number[] = []; const tops: number[] = []; const rights: number[] = []; const bottoms: number[] = []; rectangles.forEach((r) => { lefts.push(r.left); rights.push(r.right); bottoms.push(r.bottom); tops.push(r.top); }); // 使用原生的 apply 减少一次复制 // eslint-disable-next-line prefer-spread const left = Math.min.apply(Math, lefts); // eslint-disable-next-line prefer-spread const right = Math.max.apply(Math, rights); // eslint-disable-next-line prefer-spread const top = Math.min.apply(Math, tops); // eslint-disable-next-line prefer-spread const bottom = Math.max.apply(Math, bottoms); result.x = left; result.width = right - left; result.y = top; result.height = bottom - top; return result; } /** * 判断矩形相交 * * @param [direction] 判断单一方向 */ export function intersects( target1: Rectangle, target2: Rectangle, direction?: 'horizontal' | 'vertical' ): boolean { const left1 = target1.left; const top1 = target1.top; const right1 = target1.right; const bottom1 = target1.bottom; const left2 = target2.left; const top2 = target2.top; const right2 = target2.right; const bottom2 = target2.bottom; if (direction === 'horizontal') return right1 > left2 && left1 < right2; if (direction === 'vertical') return bottom1 > top2 && top1 < bottom2; if (right1 > left2 && left1 < right2) { if (bottom1 > top2 && top1 < bottom2) { return true; } } return false; } /** * 使用 OBB 算法判断两个旋转矩形是否相交 * @param rotate1 单位 radian * @param rotate2 单位 radian */ export function intersectsWithRotation( rect1: Rectangle, rotate1: number, rect2: Rectangle, rotate2: number ): boolean { const obb1 = new OBBRect(rect1.center, rect1.width, rect1.height, rotate1); const obb2 = new OBBRect(rect2.center, rect2.width, rect2.height, rotate2); const nv = obb1.centerPoint.sub(obb2.centerPoint); const axisA1 = obb1.axesX; if ( obb1.getProjectionRadius(axisA1) + obb2.getProjectionRadius(axisA1) <= Math.abs(nv.dot(axisA1)) ) return false; const axisA2 = obb1.axesY; if ( obb1.getProjectionRadius(axisA2) + obb2.getProjectionRadius(axisA2) <= Math.abs(nv.dot(axisA2)) ) return false; const axisB1 = obb2.axesX; if ( obb1.getProjectionRadius(axisB1) + obb2.getProjectionRadius(axisB1) <= Math.abs(nv.dot(axisB1)) ) return false; const axisB2 = obb2.axesY; if ( obb1.getProjectionRadius(axisB2) + obb2.getProjectionRadius(axisB2) <= Math.abs(nv.dot(axisB2)) ) return false; return true; } /** * 判断指定 rect 是否在 viewport 可见 * * @param rotation rect 旋转,单位 radian * @param isContains 整个 bounds 是否全部可见 */ export function isViewportVisible( rect: Rectangle, viewport: Rectangle, rotation = 0, isContains = false ): boolean { if (isContains) { return viewport.containsRectangle(rect); } if (rotation === 0) return Rectangle.intersects(rect, viewport); return Rectangle.intersectsWithRotation(rect, rotation, viewport, 0); } /** * 保证bounds 永远在 viewport 里边 * * @param bounds * @param viewport * @param padding 距离 viewport 的安全边界 */ export function setViewportVisible( bounds: Rectangle, viewport: Rectangle, padding = 0 ): Rectangle { const { left: tLeft, right: tRight, top: tTop, bottom: tBottom, width, height } = bounds; const { left: vLeft, right: vRight, top: vTop, bottom: vBottom } = viewport; if (tLeft <= vLeft) { // 最左边 bounds.x = vLeft + padding; } else if (tRight >= vRight) { // 最右边 bounds.x = vRight - padding - width; } if (tTop <= vTop) { // 最上边 bounds.y = vTop + padding; } else if (tBottom >= vBottom) { // 最下边 bounds.y = vBottom - padding - height; } return bounds; } /** * 根据两点创建矩形 */ export function createRectangleWithTwoPoints(point1: IPoint, point2: IPoint): Rectangle { const x = point1.x < point2.x ? point1.x : point2.x; const y = point1.y < point2.y ? point1.y : point2.y; const width = Math.abs(point1.x - point2.x); const height = Math.abs(point1.y - point2.y); return new Rectangle(x, y, width, height); } } /** * Oriented Bounding Box (OBB) * @see https://en.wikipedia.org/wiki/Bounding_volume */ export class OBBRect { readonly axesX: Vector2; readonly axesY: Vector2; readonly centerPoint: Vector2; /** * @param rotation in radian */ constructor( centerPoint: IPoint, protected width: number, protected height: number, rotation: number ) { this.centerPoint = new Vector2(centerPoint.x, centerPoint.y); this.axesX = new Vector2(Math.cos(rotation), Math.sin(rotation)); this.axesY = new Vector2(-1 * this.axesX.y, this.axesX.x); } /** * 计算投影半径 */ getProjectionRadius(axis: Vector2): number { return ( (this.width / 2) * Math.abs(axis.dot(this.axesX)) + (this.height / 2) * Math.abs(axis.dot(this.axesY)) ); } } ================================================ FILE: packages/common/utils/src/math/shapes/index.spec.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { describe, test, expect } from 'vitest'; import { Circle } from './index'; describe('shapes', () => { test('Circle', () => { expect(Circle).toBeDefined(); }); }); ================================================ FILE: packages/common/utils/src/math/shapes/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export * from './Circle'; export * from './Rectangle'; ================================================ FILE: packages/common/utils/src/math/wrap.spec.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { describe, expect, test } from 'vitest'; import { wrap } from './wrap'; describe('wrap', () => { test('wrap', async () => { expect(wrap(-1, 1, 10)).toBe(8); expect(wrap(0, 1, 10)).toBe(9); expect(wrap(1, 1, 10)).toBe(1); expect(wrap(2, 1, 10)).toBe(2); expect(wrap(3, 1, 10)).toBe(3); expect(wrap(4, 1, 10)).toBe(4); expect(wrap(5, 1, 10)).toBe(5); expect(wrap(6, 1, 10)).toBe(6); expect(wrap(7, 1, 10)).toBe(7); expect(wrap(8, 1, 10)).toBe(8); expect(wrap(9, 1, 10)).toBe(9); expect(wrap(10, 1, 10)).toBe(1); expect(wrap(11, 1, 10)).toBe(2); }); }); ================================================ FILE: packages/common/utils/src/math/wrap.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ /** * Wrap the given `value` between `min` and `max`. * value ∈ [min, max) * e.g. * expect(wrap(0, 1, 10)).toBe(9) * expect(wrap(1, 1, 10)).toBe(1) * expect(wrap(10, 1, 10)).toBe(1) * * @return The wrapped value. */ export function wrap(value: number, min: number, max: number): number { const range = max - min; return min + ((((value - min) % range) + range) % range); } ================================================ FILE: packages/common/utils/src/objects.spec.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { describe, test, expect } from 'vitest'; import { NOOP, deepFreeze, each, filter, getByKey, isEmpty, isPlainObject, mapKeys, mapValues, notEmpty, omit, pick, reduce, setByKey, values, } from './objects'; describe('objects', () => { test('deepFreeze', async () => { const obj1 = { a: { b: 2 } }; deepFreeze(obj1); expect(() => { obj1.a.b = 3; }).toThrow(); expect(deepFreeze(null)).toBeNull(); expect(deepFreeze(1)).toEqual(1); }); test('notEmpty', async () => { expect(notEmpty({})).toBeTruthy(); expect(notEmpty([])).toBeTruthy(); expect(notEmpty(() => {})).toBeTruthy(); expect(notEmpty(undefined)).toBeFalsy(); expect(notEmpty(null)).toBeFalsy(); }); test('isEmpty', async () => { expect(isEmpty({})).toBeTruthy(); expect(isEmpty({ a: 1 })).toBeFalsy(); // WARNING: just for plain object expect(isEmpty(() => 1)).toBeFalsy(); }); const obj = Object.freeze({ a: 1, b: 2, c: 3 }); test('each', async () => { const ret: any[] = []; each(obj, (v, k) => ret.push([k, v])); expect(ret).toEqual([ ['a', 1], ['b', 2], ['c', 3], ]); }); test('values', async () => { expect(values(obj)).toEqual([1, 2, 3]); const _values = Object.values; Object.values = null as any; expect(values(obj)).toEqual([1, 2, 3]); Object.values = _values; }); test('filter', async () => { expect(filter(obj, (v, k) => v > 1)).toEqual({ b: 2, c: 3 }); const dest = {}; expect(filter(obj, (v, k) => v > 1, dest)).toEqual({ b: 2, c: 3 }); expect(dest).toEqual({ b: 2, c: 3 }); }); test('pick', async () => { expect(pick(obj, ['b', 'c'])).toEqual({ b: 2, c: 3 }); expect(pick(obj, ['a', 'b', 'c'])).toEqual(obj); expect(pick(obj, [])).toEqual({}); const dest = {}; expect(pick(obj, ['b', 'c'], dest)).toEqual({ b: 2, c: 3 }); expect(dest).toEqual({ b: 2, c: 3 }); }); test('omit', async () => { expect(omit(obj, ['a'])).toEqual({ b: 2, c: 3 }); expect(omit(obj, ['a', 'b', 'c', 'd'])).toEqual({}); expect(omit(obj, [])).toEqual(obj); const dest = {}; expect(omit(obj, ['a'], dest)).toEqual({ b: 2, c: 3 }); expect(dest).toEqual({ b: 2, c: 3 }); }); test('reduce', async () => { // sum expect(reduce(obj, (res, v) => res + v, 0)).toEqual(6); // v + 1 expect( reduce(obj, (res, v, k) => { res[k] = v + 1; return res; }), ).toEqual({ a: 2, b: 3, c: 4 }); // entries expect( reduce( obj, (res, v, k) => { res.push([k, v]); return res; }, [] as [string, number][], ), ).toEqual([ ['a', 1], ['b', 2], ['c', 3], ]); }); test('mapValues', async () => { expect(mapValues(obj, v => v + 1)).toEqual({ a: 2, b: 3, c: 4 }); expect(mapValues(obj, (v, k) => `${k}${v}`)).toEqual({ a: 'a1', b: 'b2', c: 'c3', }); }); test('mapKeys', async () => { expect(mapKeys(obj, (v, k) => `${k}1`)).toEqual({ a1: 1, b1: 2, c1: 3 }); expect(mapKeys(obj, (v, k) => `${k}${v}`)).toEqual({ a1: 1, b2: 2, c3: 3 }); }); test('getByKey', async () => { const obj1 = Object.freeze({ a: { b: { c: 1 } } }); expect(getByKey(obj1, 'a.b.c')).toEqual(1); expect(getByKey(obj1, 'a.b')).toEqual({ c: 1 }); expect(getByKey(obj1, 'a')).toEqual({ b: { c: 1 } }); // return undefined expect(getByKey(1, 'a')).toBeUndefined(); expect(getByKey(obj1, '')).toBeUndefined(); expect(getByKey(obj1, 'b')).toBeUndefined(); expect(getByKey(obj1, 'a.d')).toBeUndefined(); expect(getByKey(obj1, 'a.d.e')).toBeUndefined(); expect(getByKey(obj1, 'a.b.c.d')).toBeUndefined(); }); test('setByKey', async () => { expect(setByKey({ a: { b: { c: 1 } } }, 'a.b.c', 2)).toEqual({ a: { b: { c: 2 } }, }); const obj1 = { a: { b: { c: 1 } } }; expect(setByKey(obj1, 'a.b.c', 2, true, true)).toEqual({ a: { b: { c: 2 } }, }); expect(obj1).toEqual({ a: { b: { c: 1 } } }); const arr = [1] as any; arr.b = 2; expect(setByKey({ a: [1] }, 'a.b', 2, true, true)).toEqual({ a: arr }); expect(setByKey(1, 'a.b.c', 2)).toEqual(1); expect(setByKey({ a: { b: { c: 1 } } }, '', 2)).toEqual({ a: { b: { c: 1 } }, }); expect(setByKey({ a: { b: { c: 1 } } }, 'a.b.d', 2)).toEqual({ a: { b: { c: 1, d: 2 } }, }); expect(setByKey({ a: { b: { c: 1 } } }, 'a.b.d', 2, false)).toEqual({ a: { b: { c: 1, d: 2 } }, }); expect(setByKey({ a: { b: { c: 1 } } }, 'a.b.c.d', 2)).toEqual({ a: { b: { c: { d: 2 } } }, }); expect(setByKey({ a: { b: { c: 1 } } }, 'a.b.c.d', 2, false)).toEqual({ a: { b: { c: 1 } }, }); }); test('NOOP', async () => { expect(NOOP()).toBeUndefined(); }); test('isPlainObject', async () => { expect(isPlainObject({})).toBeTruthy(); expect(isPlainObject({ a: 1 })).toBeTruthy(); expect(isPlainObject({ a: { b: 1 } })).toBeTruthy(); // eslint-disable-next-line prefer-object-spread expect(isPlainObject(Object.assign({}, { a: 1 }))).toBeTruthy(); // eslint-disable-next-line prefer-arrow-callback expect(isPlainObject(function test1() {})).toBeFalsy(); expect(isPlainObject(() => {})).toBeFalsy(); expect(isPlainObject([])).toBeFalsy(); expect(isPlainObject(null)).toBeFalsy(); expect(isPlainObject(undefined)).toBeFalsy(); expect(isPlainObject('')).toBeFalsy(); expect(isPlainObject('1')).toBeFalsy(); expect(isPlainObject(0)).toBeFalsy(); expect(isPlainObject(1)).toBeFalsy(); expect(isPlainObject(BigInt(0))).toBeFalsy(); expect(isPlainObject(BigInt(1))).toBeFalsy(); expect(isPlainObject(false)).toBeFalsy(); expect(isPlainObject(true)).toBeFalsy(); expect(isPlainObject(Symbol(''))).toBeFalsy(); }); }); ================================================ FILE: packages/common/utils/src/objects.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ const { keys } = Object; export function deepFreeze(obj: T): T { if (!obj || typeof obj !== 'object') { return obj; } const stack: any[] = [obj]; while (stack.length > 0) { const objectToFreeze = stack.shift(); Object.freeze(objectToFreeze); for (const key in objectToFreeze) { if (_hasOwnProperty.call(objectToFreeze, key)) { const prop = objectToFreeze[key]; if (typeof prop === 'object' && !Object.isFrozen(prop)) { stack.push(prop); } } } } return obj; } const _hasOwnProperty = Object.prototype.hasOwnProperty; export function notEmpty(arg: T | undefined | null): arg is T { return arg !== undefined && arg !== null; } /** * filter dangerous key, prevent prototype pollution injection * @param key key to be filtered * @returns filtered key */ export const safeKey = (key: string): string => { const dangerousProps = [ '__proto__', 'constructor', 'prototype', '__defineGetter__', '__defineSetter__', '__lookupGetter__', '__lookupSetter__', 'hasOwnProperty', 'isPrototypeOf', 'propertyIsEnumerable', 'toString', 'valueOf', 'toLocaleString', ]; if (dangerousProps.includes(key.toLowerCase())) { return ''; } return key; }; /** * `true` if the argument is an empty object. Otherwise, `false`. */ export function isEmpty(arg: Object): boolean { return keys(arg).length === 0 && arg.constructor === Object; } export const each = (obj: any, fn: (value: T, key: K) => void) => keys(obj).forEach((key) => fn(obj[key], key as any)); export const values = (obj: any) => Object.values ? Object.values(obj) : keys(obj).map((k) => obj[k]); export const filter = (obj: any, fn: (value: any, key: string) => boolean, dest?: any) => keys(obj).reduce( (output, key) => (fn(obj[key], key) ? Object.assign(output, { [key]: obj[key] }) : output), dest || {} ); export const pick = (obj: any, fields: string[], dest?: any) => filter(obj, (n, k) => fields.indexOf(k) !== -1, dest); export const omit = (obj: any, fields: string[], dest?: any) => filter(obj, (n, k) => fields.indexOf(k) === -1, dest); export const reduce = ( obj: any, fn: (res: R, value: V, key: string) => any, res: R = {} as R ) => keys(obj).reduce((r, k) => fn(r, obj[k], k), res); export const mapValues = (obj: any, fn: (value: V, key: string) => any) => reduce(obj, (res, value, key) => Object.assign(res, { [key]: fn(value, key) })); export const mapKeys = (obj: any, fn: (value: V, key: string) => any) => reduce(obj, (res, value, key) => Object.assign(res, { [fn(value, key)]: value })); /** * @param target * @param key * @example * const obj = { * position: { * x: 0 * y: 0 * } * } * getByKey(ob, 'position.x') // 0 */ export function getByKey(target: any, key: string): any | undefined { if (typeof target !== 'object' || !key) return undefined; return key.split('.').reduce((v: any, k: string) => { if (typeof v !== 'object') return undefined; return v[k]; }, target); } /** * @param target * @param key * @param newValue * @param autoCreateObject * @example * const obj = { * position: { * x: 0 * y: 0 * } * } * setByKey(ob, 'position.x', 100) // true * setByKey(obj, 'size.width', 100) // false * setBeyKey(obj, 'size.width', 100, true) // true */ export function setByKey( target: any, key: string, newValue: any, autoCreateObject = true, clone = false ): any { if (typeof target !== 'object' || !key) return target; if (clone) { target = { ...target }; } const originTarget = target; const targetKeys = key.split('.'); while (targetKeys.length > 0) { key = targetKeys.shift()!; if (targetKeys.length === 0) { target[safeKey(key)] = newValue; return originTarget; } if (typeof target[key] !== 'object') { if (!autoCreateObject) return originTarget; target[safeKey(key)] = {}; } if (clone) { if (Array.isArray(target[key])) { target[safeKey(key)] = target[key].slice(); } else { target[safeKey(key)] = { ...target[key] }; } } target = target[key]; } return originTarget; } export const NOOP = () => {}; /** * @param obj The object to inspect. * @returns True if the argument appears to be a plain object. */ export function isPlainObject(obj: any): boolean { if (typeof obj !== 'object' || obj === null) return false; let proto = obj; while (Object.getPrototypeOf(proto) !== null) { proto = Object.getPrototypeOf(proto); } return Object.getPrototypeOf(obj) === proto; } ================================================ FILE: packages/common/utils/src/promise-util.spec.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { describe, test, expect } from 'vitest'; import { PromisePool, type PromiseTask, retry, delay } from './promise-util'; import { Emitter } from './event'; describe('promise utils', () => { test('timeout', async () => { const cancelEmitter = new Emitter(); cancelEmitter.event(() => {}); await delay(1, { isCancellationRequested: false, onCancellationRequested: cancelEmitter.event, }); cancelEmitter.fire(cancelEmitter.event); }); test('retry', async () => { const K = 'retry-task'; const task = () => { throw new Error(K); }; expect(() => retry(task, 1, 2)).rejects.toThrow(K); const task1 = () => new Promise((resolve, reject) => { reject(new Error(K)); }); expect(() => retry(task1, 1, 2)).rejects.toThrow(K); }); test('PromisePool/basic', async () => { const pool = new PromisePool(); expect(await pool.run([])).toEqual([]); expect( await pool.run([ () => new Promise(resolve => { setTimeout(() => { resolve(1); }, 1); }), ]), ).toEqual([1]); }); test('PromisePool/retry', async () => { let execTimes = 0; const tasks: PromiseTask[] = Array(10) .fill(0) .map( (t, i) => () => new Promise(resolve => { setTimeout(() => { execTimes += 1; resolve(i); }, 10); }), ); const pool = new PromisePool({ intervalCount: 3, intervalTime: 100, retries: 3, }); const checkIfRetry = (res: number) => { if (res === 8) return true; return false; }; const result = await pool.run(tasks, checkIfRetry); expect(execTimes).toEqual(12); expect(result).toEqual( Array(10) .fill(0) .map((t, i) => i), ); }); }); ================================================ FILE: packages/common/utils/src/promise-util.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { CancellationToken, cancelled } from './cancellation'; /** * Simple implementation of the deferred pattern. * An object that exposes a promise and functions to resolve and reject it. */ export class PromiseDeferred { resolve: (value?: T | PromiseLike) => void; reject: (err?: any) => void; promise = new Promise((resolve, reject) => { // @ts-ignore this.resolve = resolve; this.reject = reject; }); } export const Deferred = PromiseDeferred; /** * @returns resolves after a specified number of milliseconds * @throws cancelled if a given token is cancelled before a specified number of milliseconds */ export function delay(ms: number, token = CancellationToken.None): Promise { const deferred = new PromiseDeferred(); const handle = setTimeout(() => deferred.resolve(), ms); token.onCancellationRequested(() => { clearTimeout(handle); deferred.reject(cancelled()); }); return deferred.promise; } export async function retry( task: () => Promise, delayTime: number, retries: number, shouldRetry?: (res: T) => boolean, ): Promise { let lastError: Error | undefined; let result: T; for (let i = 0; i < retries; i++) { try { // eslint-disable-next-line no-await-in-loop result = await task(); if (shouldRetry && shouldRetry(result)) { // eslint-disable-next-line no-await-in-loop await delay(delayTime); // eslint-disable-next-line no-continue continue; } return result; } catch (error: any) { lastError = error; // eslint-disable-next-line no-await-in-loop await delay(delayTime); } } if (lastError) { throw lastError; } return result!; } export interface PromiseTask { (): Promise; } export interface PromisePoolOpts { intervalCount?: number; // 每批数目 intervalTime?: number; // 执行一批后的间隔时间, 默认没有间隔 retries?: number; // 如果某个执行失败, 尝试的次数,默认不尝试 retryDelay?: number; } const PromisePoolOptsDefault: Required = { intervalCount: 10, // 每批数目 intervalTime: 0, retries: 0, retryDelay: 10, }; export class PromisePool { protected opts: Required; constructor(opts: PromisePoolOpts = PromisePoolOptsDefault) { this.opts = { ...PromisePoolOptsDefault, ...opts }; } protected async tryToExec( task: PromiseTask, checkIfRetry?: (res: T) => boolean, ): Promise { if (this.opts.retries === 0) return task(); return retry(task, this.opts.retryDelay, this.opts.retries, checkIfRetry); } /** * @param tasks 执行任务 * @param checkIfRetry 判断结果是否需要重试 */ async run(tasks: PromiseTask[], checkIfRetry?: (res: T) => boolean): Promise { if (tasks.length === 0) return []; const curTasks = tasks.slice(0, this.opts.intervalCount); const promises = curTasks.map(task => this.tryToExec(task, checkIfRetry)); const result: T[] = await Promise.all(promises); const nextTasks = tasks.slice(this.opts.intervalCount); if (nextTasks.length === 0) return result; if (this.opts.intervalTime !== 0) await delay(this.opts.intervalTime); return result.concat(await this.run(nextTasks, checkIfRetry)); } } ================================================ FILE: packages/common/utils/src/request-with-memo.spec.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { vi, describe, test, expect } from 'vitest'; import { clearRequestCache, requestWithMemo } from './request-with-memo'; function delay(time: number): Promise { return new Promise(res => { setTimeout(res, time); }); } describe('request with memo', () => { test('base', async () => { const cb = vi.fn(); const requestMock = async () => cb(); const newRequest = requestWithMemo(requestMock); await newRequest(); await newRequest(); expect(cb.mock.calls.length).toEqual(1); clearRequestCache(); await newRequest(); expect(cb.mock.calls.length).toEqual(2); }); test('timeout clear', async () => { const cb = vi.fn(); const requestMock = async () => cb(); const newRequest = requestWithMemo(requestMock, 0); await newRequest(); await delay(10); await newRequest(); expect(cb.mock.calls.length).toEqual(2); }); test('request with error', async () => { const cb = vi.fn(); const requestMock = async () => { cb(); throw new Error('requestError'); }; const newRequest = requestWithMemo(requestMock, 0); let errorTimes = 0; try { await newRequest(); } catch (e) { errorTimes += 1; } try { await newRequest(); } catch (e) { errorTimes += 1; } expect(errorTimes).toEqual(2); // 错误发生不会被缓存 expect(cb.mock.calls.length).toEqual(2); }); }); ================================================ FILE: packages/common/utils/src/request-with-memo.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ type RequestFn = (...args: any[]) => Promise; /** * 请求缓存 * @param req */ // eslint-disable-next-line import/no-mutable-exports export const RequestCache = new Map>(); const CACHE_TIME = 10000; // 缓存过期时间 export function clearRequestCache(): void { RequestCache.clear(); } export function requestWithMemo( req: RequestFn, cacheTime = CACHE_TIME, createCacheKey?: (...args: any[]) => any, ): RequestFn { return (...args: any[]) => { const cacheKey = createCacheKey ? createCacheKey(...args) : req; if (RequestCache.has(cacheKey)) { return Promise.resolve(RequestCache.get(cacheKey)); } const result = req(...args); const time = setTimeout(() => RequestCache.delete(cacheKey), cacheTime); const withErrorResult = result.catch(e => { // 请求错误情况下不缓存 RequestCache.delete(cacheKey); clearTimeout(time); throw e; }); RequestCache.set(cacheKey, withErrorResult); return withErrorResult; }; } ================================================ FILE: packages/common/utils/src/schema/index.spec.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { describe, test, expect } from 'vitest'; import { Schema } from './index'; describe('schema', () => { test('Schema', () => { expect(Schema).toBeDefined(); }); }); ================================================ FILE: packages/common/utils/src/schema/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export * from './schema'; export * from './schema-transform'; export * from './schema-base'; ================================================ FILE: packages/common/utils/src/schema/schema-base.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { type PositionSchema, type SizeSchema } from './schema-transform'; import { type SchemaDecoration } from './schema'; export type OpacitySchema = number; export interface FlipSchema { x: boolean; y: boolean; } export interface ShadowSchema { color: string; offsetX: number; offsetY: number; blur: number; } export interface PaddingSchema { left: number; right: number; top: number; bottom: number; } export namespace PaddingSchema { export const empty = () => ({ left: 0, right: 0, top: 0, bottom: 0 }); } export type MarginSchema = PaddingSchema; export interface TintSchema { topLeft: string; topRight: string; bottomLeft: string; bottomRight: string; } export namespace TintSchema { export function isEmpty(tint: Partial | undefined): boolean { if (!tint) return true; return ( tint.topLeft === undefined && tint.topRight === undefined && tint.bottomLeft === undefined && tint.bottomRight === undefined ); } } export const CropSchemaDecoration: SchemaDecoration = { label: '裁剪', properties: { width: { label: '宽', type: 'integer' }, height: { label: '高', type: 'integer' }, x: { label: 'x', type: 'integer' }, y: { label: 'y', type: 'integer' }, }, type: 'object', }; export const FlipSchemaDecoration: SchemaDecoration = { label: '镜像替换', properties: { x: { label: '水平镜像替换', default: false, type: 'boolean' }, y: { label: '垂直镜像替换', default: false, type: 'boolean' }, }, type: 'object', }; export const PaddingSchemaDecoration: SchemaDecoration = { label: '留白', properties: { left: { label: '左', default: 0, type: 'integer' }, top: { label: '上', default: 0, type: 'integer' }, right: { label: '右', default: 0, type: 'integer' }, bottom: { label: '下', default: 0, type: 'integer' }, }, type: 'object', }; export const ShadowSchemaDecoration: SchemaDecoration = { label: '阴影', properties: { offsetX: { label: 'X', type: 'integer' }, offsetY: { label: 'Y', type: 'integer' }, blur: { label: '模糊', type: 'integer' }, color: { label: '颜色', type: 'color' }, }, type: 'object', }; export const TintSchemaDecoration: SchemaDecoration = { label: '颜色', properties: { topLeft: { label: '左上', type: 'color' }, topRight: { label: '右上', type: 'color' }, bottomLeft: { label: '左下', type: 'color' }, bottomRight: { label: '右下', type: 'color' }, }, type: 'object', }; export const OpacitySchemaDecoration: SchemaDecoration = { label: '透明度', type: 'float', min: 0, max: 1, default: 1, }; ================================================ FILE: packages/common/utils/src/schema/schema-transform.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { Schema, type SchemaDecoration } from './schema'; export interface PositionSchema { x: number; y: number; } export type RotationSchema = number; export interface OriginSchema { x: number; y: number; } export interface ScaleSchema { x: number; y: number; } export interface ScrollSchema { scrollX: number; scrollY: number; } export interface SizeSchema { width: number; height: number; locked?: boolean; // 是否开启等比锁 } export interface SkewSchema { x: number; y: number; } export interface TransformSchema { position: PositionSchema; size: SizeSchema; origin: OriginSchema; scale: ScaleSchema; skew: SkewSchema; rotation: RotationSchema; } export const SizeSchemaDecoration: SchemaDecoration = { label: '大小', properties: { width: { label: '宽', default: 0, type: 'float' }, height: { label: '高', default: 0, type: 'float' }, locked: { label: '等比锁', default: false, type: 'boolean' }, }, type: 'object', }; export const OriginSchemaDecoration: SchemaDecoration = { label: '原点', description: '用于设置旋转的中心位置', properties: { x: { label: 'x', default: 0.5, type: 'float' }, y: { label: 'y', default: 0.5, type: 'float' }, }, type: 'object', }; export const PositionSchemaDecoration: SchemaDecoration = { label: '位置', properties: { x: { label: 'x', default: 0, type: 'float' }, y: { label: 'y', default: 0, type: 'float' }, }, type: 'object', }; export const RotationSchemaDecoration: SchemaDecoration = { label: '旋转', type: 'float', default: 0, }; export const ScaleSchemaDecoration: SchemaDecoration = { label: '缩放', properties: { x: { label: 'x', default: 1, type: 'float' }, y: { label: 'y', default: 1, type: 'float' }, }, type: 'object', }; export const SkewSchemaDecoration: SchemaDecoration = { label: '倾斜', properties: { x: { label: 'x', default: 0, type: 'float' }, y: { label: 'y', default: 0, type: 'float' }, }, type: 'object', }; export const TransformSchemaDecoration: SchemaDecoration = { properties: { position: PositionSchemaDecoration, size: SizeSchemaDecoration, origin: OriginSchemaDecoration, scale: ScaleSchemaDecoration, skew: SkewSchemaDecoration, rotation: RotationSchemaDecoration, }, type: 'object', }; export namespace TransformSchema { export function createDefault(): TransformSchema { return Schema.createDefault(TransformSchemaDecoration); } export function toJSON(obj: TransformSchema): TransformSchema { return { position: { x: obj.position.x, y: obj.position.y }, size: { width: obj.size.width, height: obj.size.height, locked: obj.size.locked, }, origin: { x: obj.origin.x, y: obj.origin.y }, scale: { x: obj.scale.x, y: obj.scale.y }, skew: { x: obj.skew.x, y: obj.skew.y }, rotation: obj.rotation, }; } export function getDelta( oldTransform: TransformSchema, newTransform: TransformSchema, ): TransformSchema { return { position: { x: newTransform.position.x - oldTransform.position.x, y: newTransform.position.y - oldTransform.position.y, }, size: { width: newTransform.size.width - oldTransform.size.width, height: newTransform.size.height - oldTransform.size.height, }, origin: { x: newTransform.origin.x - oldTransform.origin.x, y: newTransform.origin.y - oldTransform.origin.y, }, scale: { x: newTransform.scale.x - oldTransform.scale.x, y: newTransform.scale.y - oldTransform.scale.y, }, skew: { x: newTransform.skew.x - oldTransform.skew.x, y: newTransform.skew.y - oldTransform.skew.y, }, rotation: newTransform.rotation - oldTransform.rotation, }; } export function mergeDelta( oldTransform: TransformSchema, newTransformDelta: TransformSchema, toFixedNum?: number, ): TransformSchema { const toFixed = toFixedNum !== undefined ? (v: number) => Math.round(v * 100) / 100 : (v: number) => v; return { position: { x: toFixed(newTransformDelta.position.x + oldTransform.position.x), y: toFixed(newTransformDelta.position.y + oldTransform.position.y), }, size: { width: toFixed(newTransformDelta.size.width + oldTransform.size.width), height: toFixed(newTransformDelta.size.height + oldTransform.size.height), locked: oldTransform.size.locked, }, origin: { x: toFixed(newTransformDelta.origin.x + oldTransform.origin.x), y: toFixed(newTransformDelta.origin.y + oldTransform.origin.y), }, scale: { x: toFixed(newTransformDelta.scale.x + oldTransform.scale.x), y: toFixed(newTransformDelta.scale.y + oldTransform.scale.y), }, skew: { x: toFixed(newTransformDelta.skew.x + oldTransform.skew.x), y: toFixed(newTransformDelta.skew.y + oldTransform.skew.y), }, rotation: newTransformDelta.rotation + oldTransform.rotation, }; } export function is(obj: object): obj is TransformSchema { return ( obj && (obj as TransformSchema).position && (obj as TransformSchema).size && typeof (obj as TransformSchema).position.x === 'number' && typeof (obj as TransformSchema).size.width === 'number' ); } } export namespace SizeSchema { /** * 适配父节点宽高 * * @return 返回需要缩放的比例,为 1 则不缩放 */ export function fixSize(currentSize: SizeSchema, parentSize: SizeSchema): number { if (currentSize.width <= parentSize.width && currentSize.height <= parentSize.height) return 1; const wScale = currentSize.width / parentSize.width; const hScale = currentSize.height / parentSize.height; const scale = wScale > hScale ? wScale : hScale; return 1 / scale; } /** * 填充父节点的宽高 * * @return 返回放大的比例 */ export function coverSize(currentSize: SizeSchema, parentSize: SizeSchema): number { const wScale = currentSize.width / parentSize.width; const hScale = currentSize.height / parentSize.height; const scale = wScale < hScale ? wScale : hScale; return 1 / scale; } export function empty(): SizeSchema { return { width: 0, height: 0 }; } } ================================================ FILE: packages/common/utils/src/schema/schema.spec.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ // nolint: cyclo_complexity,method_line import { describe, test, expect } from 'vitest'; import { deepFreeze } from '../objects'; import { SizeSchema, TransformSchema } from './schema-transform'; import { PaddingSchema, TintSchema } from './schema-base'; import { Schema, SchemaDecoration } from './schema'; describe('schema', () => { test('TintSchema', async () => { expect(TintSchema.isEmpty(undefined)).toEqual(true); expect(TintSchema.isEmpty({})).toEqual(true); expect(TintSchema.isEmpty({ topLeft: '' })).toEqual(false); }); test('PaddingSchema', async () => { expect(PaddingSchema.empty()).toEqual({ left: 0, right: 0, top: 0, bottom: 0, }); }); test('SchemaDecoration', async () => { expect(SchemaDecoration).not.toBeUndefined(); expect(SchemaDecoration.create({})).toEqual({ type: 'object', properties: {}, mixinDefaults: {}, }); expect( SchemaDecoration.create( { tip: { type: 'string' } }, { type: 'string', properties: { tip: { type: 'integer' } }, mixinDefaults: { test: 0 }, }, { test: 1 }, ), ).toEqual({ type: 'object', properties: { tip: { type: 'string' } }, mixinDefaults: { test: 1 }, }); }); describe('Schema', () => { test('Schema', async () => { expect(Schema).not.toBeUndefined(); expect(Schema.createDefault({ type: 'object' })).toEqual(undefined); expect( Schema.createDefault({ type: 'object', default: { tik: 11, tok: '22' }, }), ).toEqual({ tik: 11, tok: '22', }); expect( Schema.createDefault({ type: 'object', default: () => ({ tik: 11, tok: '22' }), }), ).toEqual({ tik: 11, tok: '22', }); expect( Schema.createDefault({ type: 'object', properties: { tik: { type: 'integer', default: 1 }, tok: { type: 'string', default: '2' }, }, }), ).toEqual({ tik: 1, tok: '2', }); expect( Schema.createDefault( { type: 'object', properties: { tik: { type: 'integer' }, tok: { type: 'string' }, }, mixinDefaults: { tik: 111, tok: '222' }, }, { tik: 1111, tok: '2222' }, ), ).toEqual({ tik: 1111, tok: '2222', }); expect( Schema.createDefault( { type: 'object', properties: { tik: { type: 'integer' }, tok: { type: 'string' }, }, mixinDefaults: { 'pre.tik': 111, 'pre.tok': '222' }, }, { 'pre.tik': 1111, 'pre.tok': '2222' }, 'pre', ), ).toEqual({ tik: 1111, tok: '2222', }); }); test('isBaseType', () => { expect(Schema.isBaseType({ type: 'string' })).toBeTruthy(); expect(Schema.isBaseType({ type: 'array' })).toBeFalsy(); expect(Schema.isBaseType({ type: 'object' })).toBeFalsy(); }); }); describe('TransformSchema', () => { test('TransformSchema', () => { expect(TransformSchema).not.toBeUndefined(); }); const def = deepFreeze({ origin: { x: 0.5, y: 0.5 }, position: { x: 0, y: 0 }, rotation: 0, scale: { x: 1, y: 1 }, size: { width: 0, height: 0, locked: false }, skew: { x: 0, y: 0 }, }); test('createDefault', () => { expect(TransformSchema.createDefault()).toEqual(def); }); test('toJSON', () => { const schema1 = { ...def, test: 1 }; expect(TransformSchema.toJSON(schema1)).toEqual(def); }); test('getDelta', () => { const oldTransform = def; const newTransform = TransformSchema.createDefault(); expect(TransformSchema.getDelta(oldTransform, newTransform)).toEqual({ origin: { x: 0, y: 0 }, position: { x: 0, y: 0 }, rotation: 0, scale: { x: 0, y: 0 }, size: { width: 0, height: 0 }, skew: { x: 0, y: 0 }, }); const newTransform1: TransformSchema = { origin: { x: 1, y: 0.5 }, position: { x: 1, y: 0 }, rotation: 1, scale: { x: 2, y: 1 }, size: { width: 1, height: 0, locked: false }, skew: { x: 1, y: 0 }, }; expect(TransformSchema.getDelta(oldTransform, newTransform1)).toEqual({ origin: { x: 0.5, y: 0 }, position: { x: 1, y: 0 }, rotation: 1, scale: { x: 1, y: 0 }, size: { width: 1, height: 0 }, skew: { x: 1, y: 0 }, }); }); test('mergeDelta', () => { const oldTransform = def; const delta = { origin: { x: 0.5, y: 0 }, position: { x: 1, y: 0 }, rotation: 1, scale: { x: 1, y: 0 }, size: { width: 1, height: 0 }, skew: { x: 1, y: 0 }, }; expect(TransformSchema.mergeDelta(oldTransform, delta)).toEqual({ origin: { x: 1, y: 0.5 }, position: { x: 1, y: 0 }, rotation: 1, scale: { x: 2, y: 1 }, size: { width: 1, height: 0, locked: false }, skew: { x: 1, y: 0 }, }); expect(TransformSchema.mergeDelta(oldTransform, delta, 1)).toEqual({ origin: { x: 1, y: 0.5 }, position: { x: 1, y: 0 }, rotation: 1, scale: { x: 2, y: 1 }, size: { width: 1, height: 0, locked: false }, skew: { x: 1, y: 0 }, }); }); test('is', () => { expect(TransformSchema.is(def)).toBeTruthy(); expect(TransformSchema.is({})).toBeFalsy(); // FIXME? expect( TransformSchema.is({ position: { x: 0 }, size: { width: 0 }, }), ).toBeTruthy(); }); }); describe('SizeSchema', () => { test('fixSize', () => { expect(SizeSchema.fixSize({ width: 1, height: 1 }, { width: 1, height: 1 })).toBe(1); expect(SizeSchema.fixSize({ width: 2, height: 1 }, { width: 1, height: 1 })).toBeCloseTo(0.5); expect(SizeSchema.fixSize({ width: 2, height: 4 }, { width: 1, height: 1 })).toBeCloseTo( 0.25, ); }); test('coverSize', () => { expect(SizeSchema.coverSize({ width: 1, height: 1 }, { width: 1, height: 1 })).toBe(1); expect(SizeSchema.coverSize({ width: 2, height: 1 }, { width: 1, height: 1 })).toBeCloseTo(1); expect(SizeSchema.coverSize({ width: 2, height: 4 }, { width: 1, height: 1 })).toBeCloseTo( 0.5, ); }); test('empty', () => { expect(SizeSchema.empty()).toEqual({ width: 0, height: 0 }); }); }); }); ================================================ FILE: packages/common/utils/src/schema/schema.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { mapValues } from '../objects'; export type SchemaType = | 'string' | 'integer' | 'float' | 'boolean' | 'enum' | 'object' | 'range' | 'color' | 'array'; interface SchemaMixinDefaults { [defaultKey: string]: any; } export interface SchemaDecoration { type: SchemaType; label?: string; // 显示的名字 description?: string; // 更多描述,用于 tooltip 展示 properties?: { [K in keyof SCHEMA]: SchemaDecoration & { priority?: number }; }; enumValues?: (string | number)[]; // only for enum enumType?: string | number; enumLabels?: string[]; rangeStep?: number; // range 一步大小 max?: number; // 最大值,只适用于数字 min?: number; // 最小值,只适用于数字 disabled?: boolean; // 是否屏蔽 default?: SCHEMA; // 默认值 mixinDefaults?: SchemaMixinDefaults; } export namespace SchemaDecoration { /** * 扩展 SchemaDecoration * * @param properties - 定义新的属性 * @param baseDecoration - 基类 * @param mixinDefaults - 修改默认值 * @example * const MySchemaDecoration = SchemaDecoration.create({ * myProp: { label: '', default: 1, type: 'number' } * }, * TransformSchemaDecoration, // 继承 Transform * { * 'size.width': 100, // 修改 size 的默认值 * 'size.height': 100, * }) */ export function create( properties: { [key: string]: SchemaDecoration }, baseDecoration?: SchemaDecoration, mixinDefaults?: SchemaMixinDefaults, ): SchemaDecoration { return { type: 'object', properties: { ...baseDecoration?.properties, ...properties, }, mixinDefaults: { ...baseDecoration?.mixinDefaults, ...mixinDefaults, }, } as SchemaDecoration; } } export namespace Schema { export function createDefault( decoration: SchemaDecoration, mixinDefaults?: SchemaMixinDefaults, _key?: string, ): T { mixinDefaults = { ...decoration.mixinDefaults, ...mixinDefaults }; const prefixKey = _key ? `${_key}.` : ''; if (decoration.properties) { return mapValues(decoration.properties, (v, k) => { const childKey = prefixKey + k; if (mixinDefaults && mixinDefaults[childKey] !== undefined) { return mixinDefaults[childKey]; } return createDefault(v, mixinDefaults, childKey); }) as T; } return typeof decoration.default === 'function' ? decoration.default() : decoration.default; } /** * 非 object 类 */ export function isBaseType(decoration: SchemaDecoration): boolean { return ( decoration.type === 'string' || decoration.type === 'float' || decoration.type === 'integer' || decoration.type === 'boolean' || decoration.type === 'enum' || decoration.type === 'color' || decoration.type === 'range' ); } } ================================================ FILE: packages/common/utils/src/types.spec.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { describe, test, expect } from 'vitest'; import { isNumber, isFunction, isString, getTag } from './types'; describe('types', () => { test('isNumber', () => { expect(isNumber(undefined)).toBeFalsy(); expect(isNumber(123)).toBeTruthy(); expect(isNumber(Number(123))).toBeTruthy(); expect(isNumber('123')).toBeFalsy(); }); test('isFunction', () => { expect(isFunction(undefined)).toBeFalsy(); expect(isFunction(() => {})).toBeTruthy(); }); test('isString', () => { expect(isString(undefined)).toBeFalsy(); expect(isString('')).toBeTruthy(); }); test('getTag', () => { expect(getTag(undefined)).toEqual('[object Undefined]'); expect(getTag(null)).toEqual('[object Null]'); expect(getTag(Number(123))).toEqual('[object Number]'); }); }); ================================================ FILE: packages/common/utils/src/types.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export interface AsClass { new (...args: any[]): T; } type UnknownObject = Record & { [K in keyof T]: unknown; }; export function isObject(v: unknown): v is UnknownObject { return typeof v === 'object' && v !== null; } export function isString(v: unknown): v is string { return typeof v === 'string' || v instanceof String; } export function isFunction unknown>(v: unknown): v is T { return typeof v === 'function'; } const toString = Object.prototype.toString; export function getTag(v: unknown) { if (v == null) { return v === undefined ? '[object Undefined]' : '[object Null]'; } return toString.call(v); } export function isNumber(v: unknown): v is number { return typeof v === 'number' || (isObject(v) && getTag(v) === '[object Number]'); } export type MaybeArray = T | T[]; export type MaybePromise = T | PromiseLike; export type RecursivePartial = { [P in keyof T]?: T[P] extends Array ? Array> : RecursivePartial; }; type Without = { [P in Exclude]?: never }; export type Xor = T | U extends object ? (Without & U) | (Without & T) : T | U; ================================================ FILE: packages/common/utils/tsconfig.json ================================================ { "extends": "@flowgram.ai/ts-config/tsconfig.flow.path.json", "compilerOptions": { "types": ["vitest/globals"], }, } ================================================ FILE: packages/common/utils/vitest.config.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { defineConfig } from 'vitest/config'; export default defineConfig({ build: { commonjsOptions: { transformMixedEsModules: true, }, }, test: { globals: true, mockReset: false, environment: 'jsdom', include: ['**/?(*.){test,spec}.?(c|m)[jt]s?(x)'], exclude: [ '**/node_modules/**', '**/dist/**', '**/lib/**', // lib 编译结果忽略掉 '**/cypress/**', '**/.{idea,git,cache,output,temp}/**', '**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*', ], }, }); ================================================ FILE: packages/materials/coze-editor/eslint.config.js ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ const { defineFlatConfig } = require('@flowgram.ai/eslint-config'); module.exports = defineFlatConfig({ preset: 'web', packageRoot: __dirname, rules: { 'no-console': 'off', 'react/no-deprecated': 'off', '@flowgram.ai/e2e-data-testid': 'off', }, }); ================================================ FILE: packages/materials/coze-editor/package.json ================================================ { "name": "@flowgram.ai/coze-editor", "version": "0.1.8", "description": "This is the proxy for @coze-editor/editor, to make sure the version of coze-editor is the same as @flowgram.ai/form-materials", "homepage": "https://flowgram.ai/", "repository": "https://github.com/bytedance/flowgram.ai", "license": "MIT", "exports": { ".": { "types": "./dist/types/index.d.ts", "require": "./dist/cjs/index.js", "import": "./dist/esm/index.mjs" }, "./react": { "types": "./dist/types/react.d.ts", "require": "./dist/cjs/react.js", "import": "./dist/esm/react.mjs" }, "./react-merge": { "types": "./dist/types/react-merge.d.ts", "require": "./dist/cjs/react-merge.js", "import": "./dist/esm/react-merge.mjs" }, "./vscode": { "types": "./dist/types/vscode.d.ts", "require": "./dist/cjs/vscode.js", "import": "./dist/esm/vscode.mjs" }, "./language-typescript": { "types": "./dist/types/language-typescript.d.ts", "require": "./dist/cjs/language-typescript.js", "import": "./dist/esm/language-typescript.mjs" }, "./language-typescript/worker": { "types": "./dist/types/language-typescript/worker.d.ts", "require": "./dist/cjs/language-typescript/worker.js", "import": "./dist/esm/language-typescript/worker.mjs" }, "./language-json": { "types": "./dist/types/language-json.d.ts", "require": "./dist/cjs/language-json.js", "import": "./dist/esm/language-json.mjs" }, "./language-shell": { "types": "./dist/types/language-shell.d.ts", "require": "./dist/cjs/language-shell.js", "import": "./dist/esm/language-shell.mjs" }, "./language-python": { "types": "./dist/types/language-python.d.ts", "require": "./dist/cjs/language-python.js", "import": "./dist/esm/language-python.mjs" }, "./language-sql": { "types": "./dist/types/language-sql.d.ts", "require": "./dist/cjs/language-sql.js", "import": "./dist/esm/language-sql.mjs" }, "./preset-universal": { "types": "./dist/types/preset-universal.d.ts", "require": "./dist/cjs/preset-universal.js", "import": "./dist/esm/preset-universal.mjs" }, "./preset-none": { "types": "./dist/types/preset-none.d.ts", "require": "./dist/cjs/preset-none.js", "import": "./dist/esm/preset-none.mjs" }, "./preset-expression": { "types": "./dist/types/preset-expression.d.ts", "require": "./dist/cjs/preset-expression.js", "import": "./dist/esm/preset-expression.mjs" }, "./preset-prompt": { "types": "./dist/types/preset-prompt.d.ts", "require": "./dist/cjs/preset-prompt.js", "import": "./dist/esm/preset-prompt.mjs" }, "./preset-variable": { "types": "./dist/types/preset-variable.d.ts", "require": "./dist/cjs/preset-variable.js", "import": "./dist/esm/preset-variable.mjs" }, "./preset-code": { "types": "./dist/types/preset-code.d.ts", "require": "./dist/cjs/preset-code.js", "import": "./dist/esm/preset-code.mjs" } }, "main": "./dist/esm/index.js", "module": "./dist/esm/index.js", "types": "./dist/index.d.ts", "files": [ "dist" ], "scripts": { "build": "cross-env NODE_ENV=production rslib build", "build:fast": "cross-env NODE_ENV=development rslib build", "build:watch": "npm run build:fast", "clean": "rimraf dist", "gen": "node scripts/gen.js", "test": "exit 0", "test:cov": "exit 0", "ts-check": "tsc --noEmit", "watch": "npm run build:fast -- --dts-resolve --watch --ignore-watch dist" }, "dependencies": { "@coze-editor/editor": "0.1.0-alpha.868621", "@coze-editor/code-language-typescript": "0.1.0-alpha.868621", "typescript": "^5.8.3" }, "devDependencies": { "@flowgram.ai/eslint-config": "workspace:*", "@flowgram.ai/ts-config": "workspace:*", "@types/react": "^18", "@types/react-dom": "^18", "eslint": "^9.0.0", "react": "^18", "react-dom": "^18", "typescript": "^5.8.3", "vitest": "^3.2.4", "cross-env": "~7.0.3", "@rslib/core": "~0.12.4" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8", "styled-components": ">=5" }, "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" } } ================================================ FILE: packages/materials/coze-editor/rslib.config.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import path from 'path'; import { defineConfig } from '@rslib/core'; type RsbuildConfig = Parameters[0]; const commonConfig: Partial = { source: { entry: { index: ['./src/**/*.{ts,tsx}'], }, exclude: [], decorators: { version: 'legacy', }, }, bundle: false, dts: { distPath: path.resolve(__dirname, './dist/types'), bundle: false, build: true, }, tools: {}, }; const formats: Partial[] = [ { format: 'esm', output: { distPath: { root: path.resolve(__dirname, './dist/esm'), }, }, }, { dts: false, format: 'cjs', output: { distPath: { root: path.resolve(__dirname, './dist/cjs'), }, }, }, ].map((r) => ({ ...commonConfig, ...r })); export default defineConfig({ lib: formats, output: { cleanDistPath: process.env.NODE_ENV === 'production', }, }); ================================================ FILE: packages/materials/coze-editor/scripts/gen.js ================================================ #!/usr/bin/env node const fs = require('fs'); const path = require('path'); async function main() { try { console.log('🚀 Starting to generate export files...'); // 1. Get the package.json path of @coze-editor/editor package const packagePath = path.resolve(__dirname, '../node_modules/@coze-editor/editor/package.json'); if (!fs.existsSync(packagePath)) { console.error('❌ @coze-editor/editor package not found, please run npm install first'); process.exit(1); } // 2. Read package.json content const packageContent = fs.readFileSync(packagePath, 'utf8'); const packageJson = JSON.parse(packageContent); if (!packageJson.exports) { console.error('❌ No exports field found in @coze-editor/editor package.json'); process.exit(1); } console.log(`📦 Found ${Object.keys(packageJson.exports).length} export items`); // 3. Process each export item const exports = packageJson.exports; const srcDir = path.resolve(__dirname, '../src'); // Ensure src directory exists if (!fs.existsSync(srcDir)) { fs.mkdirSync(srcDir, { recursive: true }); } // Read current package.json const currentPackagePath = path.resolve(__dirname, '../package.json'); const currentPackageContent = fs.readFileSync(currentPackagePath, 'utf8'); const currentPackageJson = JSON.parse(currentPackageContent); let newExportsAdded = 0; for (const [exportPath, exportConfig] of Object.entries(exports)) { // Get export name (remove leading ./, root directory uses index) const exportName = exportPath === '.' ? 'index' : exportPath.replace(/^\.\//, ''); // 3.1 Create corresponding .ts file const filePath = path.join(srcDir, `${exportName}.ts`); const contentParts = []; const baseImportPath = exportPath === '.' ? '@coze-editor/editor' : `@coze-editor/editor/${exportName}`; contentParts.push(`export * from '${baseImportPath}';`); // if is preset, add default export if (exportName.startsWith('preset')) { contentParts.push(`export { default } from '@coze-editor/editor/${exportName}';`); } const fileContent = contentParts.join('\n') + '\n'; // Ensure directory exists (handle subdirectory case) const fileDir = path.dirname(filePath); if (!fs.existsSync(fileDir)) { fs.mkdirSync(fileDir, { recursive: true }); } // Only create file when it doesn't exist if (!fs.existsSync(filePath)) { fs.writeFileSync(filePath, fileContent); console.log(`✅ Created file: ${path.relative(process.cwd(), filePath)}`); } else { console.log(`⏭️ File already exists: ${path.relative(process.cwd(), filePath)}`); } // 3.2 Update exports field in package.json const exportKey = exportName === 'index' ? '.' : `./${exportName}`; if (!currentPackageJson.exports[exportKey]) { currentPackageJson.exports[exportKey] = { types: `./dist/types/${exportName}.d.ts`, require: `./dist/cjs/${exportName}.js`, import: `./dist/esm/${exportName}.mjs`, }; newExportsAdded++; console.log(`📝 Added export configuration: ${exportKey}`); } else { console.log(`⏭️ Export configuration already exists: ${exportKey}`); } } // Save updated package.json fs.writeFileSync(currentPackagePath, JSON.stringify(currentPackageJson, null, 2) + '\n'); console.log(`\n🎉 Completed!`); console.log(`📁 Created ${Object.keys(exports).length} export files`); console.log(`📦 Added ${newExportsAdded} new export configurations`); console.log(`💡 Remember to run npm run build to build new exports`); } catch (error) { console.error('❌ Error occurred:', error.message); process.exit(1); } } // Run this script directly if (require.main === module) { main(); } module.exports = { main }; ================================================ FILE: packages/materials/coze-editor/src/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export * from '@coze-editor/editor'; ================================================ FILE: packages/materials/coze-editor/src/language-json.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export * from '@coze-editor/editor/language-json'; ================================================ FILE: packages/materials/coze-editor/src/language-python.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export * from '@coze-editor/editor/language-python'; ================================================ FILE: packages/materials/coze-editor/src/language-shell.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export * from '@coze-editor/editor/language-shell'; ================================================ FILE: packages/materials/coze-editor/src/language-sql.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export * from '@coze-editor/editor/language-sql'; ================================================ FILE: packages/materials/coze-editor/src/language-typescript/worker.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ /* eslint-disable import/no-extraneous-dependencies */ // Copyright (c) 2025 coze-dev // SPDX-License-Identifier: MIT // @ts-expect-error no members are exported from this path export * from '@coze-editor/code-language-typescript/dist/esm/worker.js'; ================================================ FILE: packages/materials/coze-editor/src/language-typescript.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export * from '@coze-editor/editor/language-typescript'; ================================================ FILE: packages/materials/coze-editor/src/preset-code.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export * from '@coze-editor/editor/preset-code'; export { default } from '@coze-editor/editor/preset-code'; ================================================ FILE: packages/materials/coze-editor/src/preset-expression.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export * from '@coze-editor/editor/preset-expression'; export { default } from '@coze-editor/editor/preset-expression'; ================================================ FILE: packages/materials/coze-editor/src/preset-none.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export * from '@coze-editor/editor/preset-none'; export { default } from '@coze-editor/editor/preset-none'; ================================================ FILE: packages/materials/coze-editor/src/preset-prompt.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export * from '@coze-editor/editor/preset-prompt'; export { default } from '@coze-editor/editor/preset-prompt'; ================================================ FILE: packages/materials/coze-editor/src/preset-universal.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export * from '@coze-editor/editor/preset-universal'; export { default } from '@coze-editor/editor/preset-universal'; ================================================ FILE: packages/materials/coze-editor/src/preset-variable.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export * from '@coze-editor/editor/preset-variable'; export { default } from '@coze-editor/editor/preset-variable'; ================================================ FILE: packages/materials/coze-editor/src/react-merge.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export * from '@coze-editor/editor/react-merge'; ================================================ FILE: packages/materials/coze-editor/src/react.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export * from '@coze-editor/editor/react'; ================================================ FILE: packages/materials/coze-editor/src/vscode.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export * from '@coze-editor/editor/vscode'; ================================================ FILE: packages/materials/coze-editor/tsconfig.json ================================================ { "extends": "@flowgram.ai/ts-config/tsconfig.flow.path.json", "compilerOptions": { "jsx": "react", "rootDir": "./src", "outDir": "./dist/types", }, "include": [ "./src" ], "exclude": [ "node_modules" ] } ================================================ FILE: packages/materials/coze-editor/vitest.config.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ const path = require('path'); import { defineConfig } from 'vitest/config'; export default defineConfig({ build: { commonjsOptions: { transformMixedEsModules: true, }, }, test: { globals: true, mockReset: false, environment: 'jsdom', setupFiles: [path.resolve(__dirname, './vitest.setup.ts')], include: ['**/?(*.){test,spec}.?(c|m)[jt]s?(x)'], exclude: [ '**/__mocks__**', '**/node_modules/**', '**/dist/**', '**/lib/**', // lib 编译结果忽略掉 '**/cypress/**', '**/.{idea,git,cache,output,temp}/**', '**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*', ], }, }); ================================================ FILE: packages/materials/coze-editor/vitest.setup.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import 'reflect-metadata'; ================================================ FILE: packages/materials/fixed-semi-materials/eslint.config.js ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ const { defineFlatConfig } = require('@flowgram.ai/eslint-config'); module.exports = defineFlatConfig({ preset: 'web', packageRoot: __dirname, rules: { 'no-console': 'off', 'react/no-deprecated': 'off', '@flowgram.ai/e2e-data-testid': 'off', }, }); ================================================ FILE: packages/materials/fixed-semi-materials/package.json ================================================ { "name": "@flowgram.ai/fixed-semi-materials", "version": "0.1.8", "homepage": "https://flowgram.ai/", "repository": "https://github.com/bytedance/flowgram.ai", "license": "MIT", "exports": { "types": "./dist/index.d.ts", "import": "./dist/esm/index.js", "require": "./dist/index.js" }, "main": "./dist/index.js", "module": "./dist/esm/index.js", "types": "./dist/index.d.ts", "files": [ "dist" ], "scripts": { "build": "npm run build:fast -- --dts-resolve", "build:fast": "tsup src/index.ts --format cjs,esm --sourcemap --legacy-output", "build:watch": "npm run build:fast -- --dts-resolve", "clean": "rimraf dist", "test": "exit 0", "test:cov": "exit 0", "ts-check": "tsc --noEmit", "watch": "npm run build:fast -- --dts-resolve --watch --ignore-watch dist" }, "dependencies": { "@douyinfe/semi-icons": "^2.80.0", "@douyinfe/semi-ui": "^2.80.0", "@flowgram.ai/fixed-layout-editor": "workspace:*", "lodash-es": "^4.17.21", "nanoid": "^5.0.9" }, "devDependencies": { "@flowgram.ai/eslint-config": "workspace:*", "@flowgram.ai/ts-config": "workspace:*", "@types/lodash-es": "^4.17.12", "@types/react": "^18", "@types/react-dom": "^18", "@types/styled-components": "^5", "eslint": "^9.0.0", "react": "^18", "react-dom": "^18", "styled-components": "^5", "tsup": "^8.0.1", "typescript": "^5.8.3", "vitest": "^3.2.4" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8", "styled-components": ">=5" }, "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" } } ================================================ FILE: packages/materials/fixed-semi-materials/src/assets/ellipsis.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React from 'react'; export function Ellipse() { return ( ); } ================================================ FILE: packages/materials/fixed-semi-materials/src/assets/icons.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React from 'react'; export function IconStyleBorder(props: any) { return ( ); } export function IconParkRightBranch(props: any) { return ( ); } export function PhCircleBold(props: any) { return ( ); } export function BiCloud(props: any) { return ( ); } export function BiBootstrapReboot(props: any) { return ( ); } export function FeAlignCenter(props: any) { return ( ); } export function Arrow({ color, circleColor }: { color: string; circleColor: string }) { return ( ); } ================================================ FILE: packages/materials/fixed-semi-materials/src/assets/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export { Ellipse } from './ellipsis'; export { Arrow, IconStyleBorder, IconParkRightBranch, PhCircleBold, BiCloud, BiBootstrapReboot, FeAlignCenter, } from './icons'; ================================================ FILE: packages/materials/fixed-semi-materials/src/components/adder/index.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React, { useState } from 'react'; import { nanoid } from 'nanoid'; import { type FlowNodeEntity, FlowNodeTransformData, usePlayground, useService, FlowOperationService, } from '@flowgram.ai/fixed-layout-editor'; import { Popover } from '@douyinfe/semi-ui'; import Nodes from '../nodes'; import { AdderWrap, IconPlus } from './styles'; export default function Adder(props: { from: FlowNodeEntity; to?: FlowNodeEntity; hoverActivated: boolean; }) { const { from } = props; const [visible, setVisible] = useState(false); const playground = usePlayground(); const flowOperationService = useService(FlowOperationService) as FlowOperationService; const add = (addProps: any) => { const block = flowOperationService.addFromNode(from, { id: addProps.type + nanoid(5), type: addProps.type, blocks: addProps.blocks ? addProps.blocks() : undefined, }); setTimeout(() => { playground.scrollToView({ bounds: block.getData(FlowNodeTransformData)!.bounds, scrollToCenter: true, }); }, 10); }; if (playground.config.readonlyOrDisabled) return null; return ( } placement="right" trigger="click" overlayStyle={{ padding: 0, }} > e.stopPropagation()} onClick={() => { setVisible(true); }} > {props.hoverActivated ? : null} ); } ================================================ FILE: packages/materials/fixed-semi-materials/src/components/adder/styles.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import styled from 'styled-components'; import { IconPlusCircle } from '@douyinfe/semi-icons'; export const AdderWrap = styled.div<{ hovered?: boolean }>` width: ${(props) => (props.hovered ? 15 : 6)}px; height: ${(props) => (props.hovered ? 15 : 6)}px; background-color: rgb(143, 149, 158); color: #fff; border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: pointer; `; export const IconPlus = styled(IconPlusCircle)` color: #3370ff; background-color: #fff; border-radius: 15px; `; ================================================ FILE: packages/materials/fixed-semi-materials/src/components/branch-adder/index.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React from 'react'; import { nanoid } from 'nanoid'; import { type FlowNodeEntity, FlowNodeRenderData, FlowNodeTransformData, FlowOperationService, usePlayground, useService, } from '@flowgram.ai/fixed-layout-editor'; import { IconPlus } from '@douyinfe/semi-icons'; import { Container } from './styles'; interface PropsType { activated?: boolean; node: FlowNodeEntity; } export default function BranchAdder(props: PropsType) { const { activated, node } = props; const nodeData = node.firstChild?.getData(FlowNodeRenderData); const playground = usePlayground(); const flowOperationService = useService(FlowOperationService) as FlowOperationService; const { isVertical } = node; function addBranch() { const block = flowOperationService.addBlock(node, { id: nanoid(5) }); setTimeout(() => { playground.scrollToView({ bounds: block.getData(FlowNodeTransformData)!.bounds, scrollToCenter: true, }); }, 10); } if (playground.config.readonlyOrDisabled) return null; return ( nodeData?.toggleMouseEnter()} onMouseLeave={() => nodeData?.toggleMouseLeave()} >
{ addBranch(); }} aria-hidden="true" style={{ flexGrow: 1, textAlign: 'center', cursor: 'pointer' }} >
); } ================================================ FILE: packages/materials/fixed-semi-materials/src/components/branch-adder/styles.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import styled from 'styled-components'; export const Container = styled.div<{ activated?: boolean; isVertical: boolean }>` width: 28px; height: 18px; background: ${(props) => (props.activated ? '#82A7FC' : 'rgb(187, 191, 196)')}; display: flex; border-radius: 9px; justify-content: space-evenly; align-items: center; color: #fff; font-size: 10px; font-weight: bold; transform: ${(props) => (props.isVertical ? '' : 'rotate(90deg)')}; div { display: flex; justify-content: center; align-items: center; svg { width: 12px; height: 12px; } } `; ================================================ FILE: packages/materials/fixed-semi-materials/src/components/collapse/index.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React from 'react'; import { FlowNodeRenderData, FlowNodeTransformData, usePlayground, type CollapseProps, } from '@flowgram.ai/fixed-layout-editor'; import { Arrow } from '../../assets'; import { Container } from './styles'; function Collapse(props: CollapseProps): JSX.Element { const { collapseNode, activateNode, hoverActivated, style } = props; const playground = usePlayground(); const activateData = activateNode?.getData(FlowNodeRenderData); const transform = collapseNode.getData(FlowNodeTransformData)!; if (!transform) { return <>; } const scrollToActivateNode = () => { setTimeout(() => { playground.config.scrollToView({ position: activateNode?.getData(FlowNodeTransformData)?.outputPoint, scrollToCenter: true, }); }, 100); }; const collapseBlock = () => { transform.collapsed = true; activateData?.toggleMouseLeave(); scrollToActivateNode(); }; const openBlock = () => { transform.collapsed = false; scrollToActivateNode(); }; // expand if (transform.collapsed) { const childCount = collapseNode.allCollapsedChildren.filter( (child) => !child.hidden && child !== activateNode ).length; return ( ); } // dark: var(--semi-color-black) // light: var(--semi-color-white) const circleColor = 'var(--semi-color-white)'; const color = hoverActivated ? '#82A7FC' : '#BBBFC4'; // collapse return ( ); } export default Collapse; ================================================ FILE: packages/materials/fixed-semi-materials/src/components/collapse/styles.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import styled from 'styled-components'; export const Container = styled.div<{ hoverActivated?: boolean; isVertical?: boolean; isCollapse?: boolean; }>` width: 16px; height: 16px; font-size: 10px; border-radius: 9px; display: flex; color: #fff; cursor: pointer; justify-content: center; align-items: center; background: ${(props) => (props.hoverActivated ? '#82A7FC' : '#BBBFC4')}; transform: ${(props) => (!props.isVertical && props.isCollapse ? 'rotate(-90deg)' : '')}; `; ================================================ FILE: packages/materials/fixed-semi-materials/src/components/constants.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export const primary = 'hsl(252 62% 54.9%)'; export const primaryOpacity09 = 'hsl(252deg 62% 55% / 9%)'; ================================================ FILE: packages/materials/fixed-semi-materials/src/components/drag-highlight-adder/index.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React from 'react'; import { min } from 'lodash-es'; import { type FlowNodeEntity, FlowNodeTransformData } from '@flowgram.ai/fixed-layout-editor'; import { Ellipse } from '../../assets'; import { UILineContainer, UILine } from './styles'; const getMinSize = (preWidth: number, nextWidth: number): number => { if (!preWidth || preWidth < 0) { return 0; } if (!nextWidth || nextWidth < 0) { return preWidth; } return min([preWidth, nextWidth]) || 0; }; export default function DragHighlightAdder({ node }: { node: FlowNodeEntity }): JSX.Element { const transformBounds = node.getData(FlowNodeTransformData)?.bounds; const { isVertical } = node; if (isVertical) { const preWidth = (transformBounds?.width || 0) - 16; const nextNodeBounds = node?.next?.getData(FlowNodeTransformData)?.bounds?.width; const nextWidth = (nextNodeBounds || 0) - 16; const LineDom = UILine(getMinSize(preWidth, nextWidth), 2); return ( ); } const preHeight = (transformBounds?.height || 0) - 16; const nextNodeBounds = node?.next?.getData(FlowNodeTransformData)?.bounds?.height; const nextHeight = (nextNodeBounds || 0) - 16; const LineDom = UILine(2, getMinSize(preHeight, nextHeight)); return ( ); } ================================================ FILE: packages/materials/fixed-semi-materials/src/components/drag-highlight-adder/styles.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import styled from 'styled-components'; export const UILineContainer = styled.div` display: flex; align-items: center; `; export const UILine = (width: number, height: number) => styled.div` width: ${width}px; height: ${height}px; background: #3370ff; `; ================================================ FILE: packages/materials/fixed-semi-materials/src/components/drag-node/index.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React from 'react'; import type { FlowNodeEntity, FlowNodeJSON, Xor } from '@flowgram.ai/fixed-layout-editor'; import { UIDragNodeContainer, UIDragCounts } from './styles'; export type PropsType = Xor< { dragStart: FlowNodeEntity; }, { dragJSON: FlowNodeJSON; } > & { dragNodes: FlowNodeEntity[]; }; export default function DragNode(props: PropsType): JSX.Element { const { dragStart, dragNodes, dragJSON } = props; const dragLength = (dragNodes || []) .map((_node) => _node.allCollapsedChildren.length ? _node.allCollapsedChildren.filter((_n) => !_n.hidden).length : 1 ) .reduce((acm, curr) => acm + curr, 0); return ( {dragStart?.id || dragJSON?.id} {dragLength > 1 && ( <> {dragLength} )} ); } ================================================ FILE: packages/materials/fixed-semi-materials/src/components/drag-node/styles.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import styled from 'styled-components'; import { primary, primaryOpacity09 } from '../constants'; export const UIDragNodeContainer = styled.div` position: relative; height: 32px; border-radius: 5px; display: flex; align-items: center; cursor: pointer; font-size: 19px; border: 1px solid ${primary}; padding: 0 15px; &:hover: { background-color: ${primaryOpacity09}; color: ${primary}; } `; export const UIDragCounts = styled.div` position: absolute; top: -8px; right: -8px; text-align: center; line-height: 16px; width: 16px; height: 16px; border-radius: 8px; font-size: 12px; color: #fff; background-color: ${primary}; `; ================================================ FILE: packages/materials/fixed-semi-materials/src/components/dragging-adder/index.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React from 'react'; import { FlowDragLayer, usePlayground } from '@flowgram.ai/fixed-layout-editor'; import { UIDragNodeContainer } from './styles'; export default function DraggingAdder(props: any): JSX.Element { const playground = usePlayground(); const layer = playground.getLayer(FlowDragLayer); if (!layer) return <>; if ( layer.options.canDrop && !layer.options.canDrop({ dragNodes: layer.dragEntities || [], dropNode: props.from, isBranch: false, }) ) { return <>; } return ; } ================================================ FILE: packages/materials/fixed-semi-materials/src/components/dragging-adder/styles.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import styled from 'styled-components'; export const UIDragNodeContainer = styled.div` width: 16px; height: 16px; border-radius: 100px; background-color: white; border: 1px dashed #b8bcc1; `; ================================================ FILE: packages/materials/fixed-semi-materials/src/components/index.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { FlowRendererKey } from '@flowgram.ai/fixed-layout-editor'; import { Ellipse } from '../assets'; import TryCatchCollapse from './try-catch-collapse'; import { SlotCollapse } from './slot-collapse'; import { SlotAdder } from './slot-adder'; import DraggingAdder from './dragging-adder'; import DragNode from './drag-node'; import DragHighlightAdder from './drag-highlight-adder'; import Collapse from './collapse'; import BranchAdder from './branch-adder'; import Adder from './adder'; export const defaultFixedSemiMaterials = { [FlowRendererKey.ADDER]: Adder, [FlowRendererKey.COLLAPSE]: Collapse, [FlowRendererKey.TRY_CATCH_COLLAPSE]: TryCatchCollapse, [FlowRendererKey.BRANCH_ADDER]: BranchAdder, [FlowRendererKey.DRAG_NODE]: DragNode, [FlowRendererKey.DRAGGABLE_ADDER]: DraggingAdder, [FlowRendererKey.DRAG_HIGHLIGHT_ADDER]: DragHighlightAdder, [FlowRendererKey.DRAG_BRANCH_HIGHLIGHT_ADDER]: Ellipse, [FlowRendererKey.SLOT_COLLAPSE]: SlotCollapse, [FlowRendererKey.SLOT_ADDER]: SlotAdder, }; ================================================ FILE: packages/materials/fixed-semi-materials/src/components/metadata.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React from 'react'; import { nanoid } from 'nanoid'; import { BiBootstrapReboot, BiCloud, FeAlignCenter, IconStyleBorder, IconParkRightBranch, PhCircleBold, } from '../assets'; const metadata = { nodes: [ { type: 'start', label: 'Start', icon: , }, { type: 'dynamicSplit', label: 'Split Branch', icon: , blocks() { return [ { id: nanoid(5), }, { id: nanoid(5), }, ]; }, }, { type: 'end', label: 'Branch End', icon: , branchEnd: true, }, { type: 'loop', schemaType: 'loop', label: 'Loop', icon: , }, { type: 'tryCatch', schemaType: 'tryCatch', label: 'TryCatch', icon: , blocks() { return [ { id: `try_${nanoid(5)}`, // try branch }, { id: `catch_${nanoid(5)}`, // catch branch 1 }, { id: `catch_${nanoid(5)}`, // catch branch 2 }, ]; }, }, { type: 'noop', label: 'Noop Node', icon: , }, { type: 'end', label: 'End', icon: , }, ], find: function find(type: any) { return metadata.nodes.find((m) => m.type === type); }, }; export default metadata; ================================================ FILE: packages/materials/fixed-semi-materials/src/components/nodes/index.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React from 'react'; import metadata from '../metadata'; import { NodeWrap, NodeLabel, NodesWrap } from './styles'; function Node(props: { label: string; icon: JSX.Element; onClick: () => void }) { return (
{props.icon}
{props.label}
); } const addings = metadata.nodes.filter((node) => node.type !== 'start'); export default function Nodes(props: { onSelect: (meta: any) => void }) { return ( {addings.map((n, i) => ( // eslint-disable-next-line react/no-array-index-key props.onSelect?.(n)} /> ))} ); } ================================================ FILE: packages/materials/fixed-semi-materials/src/components/nodes/styles.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import styled from 'styled-components'; import { primary, primaryOpacity09 } from '../constants'; export const NodeWrap = styled.div` width: 100%; height: 32px; border-radius: 5px; display: flex; align-items: center; cursor: pointer; font-size: 19px; padding: 0 15px; &:hover: { background-color: ${primaryOpacity09}; color: ${primary}; }, `; export const NodeLabel = styled.div` font-size: 12px; margin-left: 10px; `; export const NodesWrap = styled.div` max-height: 500px; overflow: auto; &::-webkit-scrollbar { display: none; } `; ================================================ FILE: packages/materials/fixed-semi-materials/src/components/slot-adder.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React from 'react'; import { nanoid } from 'nanoid'; import { type FlowNodeEntity, FlowNodeRenderData, FlowDocument, useService, } from '@flowgram.ai/fixed-layout-editor'; import { Button } from '@douyinfe/semi-ui'; import { IconPlus } from '@douyinfe/semi-icons'; interface PropsType { node: FlowNodeEntity; } export function SlotAdder(props: PropsType) { const { node } = props; const nodeData = node.firstChild?.getData(FlowNodeRenderData); const document = useService(FlowDocument) as FlowDocument; async function addPort() { document.addNode({ id: nanoid(5), type: 'custom', parent: node, }); } return (
nodeData?.toggleMouseEnter()} onMouseLeave={() => nodeData?.toggleMouseLeave()} >
); } ================================================ FILE: packages/materials/fixed-semi-materials/src/components/slot-collapse.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React, { useState } from 'react'; import { type FlowNodeEntity, FlowNodeRenderData, FlowNodeTransformData, } from '@flowgram.ai/fixed-layout-editor'; import Collapse from './collapse'; export function SlotCollapse({ node }: { node: FlowNodeEntity }) { const [hoverActivated, setHoverActivated] = useState(false); const icon = node.firstChild!; const iconActivated = icon.getData(FlowNodeRenderData).activated; const iconHeight = icon.getData(FlowNodeTransformData).size.height; const isChildVisible = node.collapsed || hoverActivated || iconActivated; return (
setHoverActivated(true)} onMouseLeave={() => setHoverActivated(false)} > {isChildVisible && ( )}
); } ================================================ FILE: packages/materials/fixed-semi-materials/src/components/tools.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React from 'react'; import { usePlaygroundTools } from '@flowgram.ai/fixed-layout-editor'; import { Checkbox, IconButton, Space, Tooltip } from '@douyinfe/semi-ui'; import { IconUndo, IconRedo, IconShrink, IconExpand, IconGridView } from '@douyinfe/semi-icons'; export const PlaygroundTools = ({ layoutText }: { layoutText?: string }) => { const tools = usePlaygroundTools(); const { zoom } = tools; return ( tools.changeLayout()} checked={!tools.isVertical}> {layoutText || 'isHorizontal'} } onClick={() => tools.fitView()} /> } onClick={() => tools.zoomout()} /> } onClick={() => tools.zoomin()} /> } disabled={tools.canUndo} onClick={() => tools.undo()} /> } disabled={tools.canRedo} onClick={() => tools.redo()} /> {Math.floor(zoom * 100)}% ); }; ================================================ FILE: packages/materials/fixed-semi-materials/src/components/try-catch-collapse.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React, { useState } from 'react'; import { FlowNodeRenderData, FlowNodeTransformData, type CustomLabelProps, useBaseColor, FlowTextKey, usePlayground, FlowRendererRegistry, } from '@flowgram.ai/fixed-layout-editor'; import { IconChevronLeft } from '@douyinfe/semi-icons'; function TryCatchCollapse(props: CustomLabelProps): JSX.Element { const { node } = props; const { baseColor, baseActivatedColor } = useBaseColor(); const playground = usePlayground(); const activateData = node.getData(FlowNodeRenderData)!; const transform = node.getData(FlowNodeTransformData)!; const [hoverActivated, setHoverActivated] = useState(false); if (!transform || !transform.parent) { return <>; } // hotzone width & height const width = transform.inputPoint.x - transform.parent.inputPoint.x; const height = 40; const scrollToActivateNode = () => { setTimeout(() => { playground.config.scrollToView({ position: node?.getData(FlowNodeTransformData)?.inputPoint, scrollToCenter: true, }); }, 100); }; const collapseBlock = () => { transform.collapsed = true; activateData.activated = false; scrollToActivateNode(); }; const openBlock = () => { transform.collapsed = false; scrollToActivateNode(); }; const handleMouseEnter = () => { setHoverActivated(true); activateData.activated = true; }; const handleMouseLeave = () => { setHoverActivated(false); activateData.activated = false; }; const renderCollapse = () => { // Expand if (transform.collapsed) { const childCount = node.allCollapsedChildren.filter( (child) => !child.hidden && child !== node ).length; return ( ); } // Collapse if (hoverActivated) { return ( ); } return <>; }; // Collapse return (
{node.getService(FlowRendererRegistry).getText(FlowTextKey.CATCH_TEXT)}
{renderCollapse()}
); } export default TryCatchCollapse; ================================================ FILE: packages/materials/fixed-semi-materials/src/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export { defaultFixedSemiMaterials } from './components'; export { PlaygroundTools } from './components/tools'; ================================================ FILE: packages/materials/fixed-semi-materials/tsconfig.json ================================================ { "extends": "@flowgram.ai/ts-config/tsconfig.flow.path.json", "compilerOptions": { "jsx": "react", }, "include": ["./src"], "exclude": ["node_modules"] } ================================================ FILE: packages/materials/fixed-semi-materials/vitest.config.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ const path = require('path'); import { defineConfig } from 'vitest/config'; export default defineConfig({ build: { commonjsOptions: { transformMixedEsModules: true, }, }, test: { globals: true, mockReset: false, environment: 'jsdom', setupFiles: [path.resolve(__dirname, './vitest.setup.ts')], include: ['**/?(*.){test,spec}.?(c|m)[jt]s?(x)'], exclude: [ '**/__mocks__**', '**/node_modules/**', '**/dist/**', '**/lib/**', // lib 编译结果忽略掉 '**/cypress/**', '**/.{idea,git,cache,output,temp}/**', '**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*', ], }, }); ================================================ FILE: packages/materials/fixed-semi-materials/vitest.setup.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import 'reflect-metadata'; ================================================ FILE: packages/materials/form-antd-materials/eslint.config.js ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ const { defineFlatConfig } = require('@flowgram.ai/eslint-config'); module.exports = defineFlatConfig({ preset: 'web', packageRoot: __dirname, rules: { 'no-console': 'off', 'react/no-deprecated': 'off', '@flowgram.ai/e2e-data-testid': 'off', }, }); ================================================ FILE: packages/materials/form-antd-materials/package.json ================================================ { "name": "@flowgram.ai/form-antd-materials", "version": "0.1.8", "homepage": "https://flowgram.ai/", "repository": "https://github.com/bytedance/flowgram.ai", "license": "MIT", "exports": { "types": "./dist/index.d.ts", "import": "./dist/esm/index.js", "require": "./dist/index.js" }, "main": "./dist/index.js", "module": "./dist/esm/index.js", "types": "./dist/index.d.ts", "files": [ "dist", "src" ], "scripts": { "build": "npm run build:fast -- --dts-resolve", "build:fast": "tsup src/index.ts --format cjs,esm --sourcemap --legacy-output", "build:watch": "npm run build:fast -- --dts-resolve", "clean": "rimraf dist", "test": "exit 0", "test:cov": "exit 0", "ts-check": "tsc --noEmit", "watch": "npm run build:fast -- --dts-resolve --watch --ignore-watch dist" }, "dependencies": { "@ant-design/icons": "5.x", "@flowgram.ai/editor": "workspace:*", "antd": "^5.25.4", "lodash-es": "^4.17.21" }, "devDependencies": { "@flowgram.ai/eslint-config": "workspace:*", "@flowgram.ai/ts-config": "workspace:*", "@types/lodash-es": "^4.17.12", "@types/node": "^18", "@types/react": "^18", "@types/react-dom": "^18", "@types/styled-components": "^5", "eslint": "^9.0.0", "react": "^18", "react-dom": "^18", "reflect-metadata": "~0.2.2", "styled-components": "^5", "tsup": "^8.0.1", "typescript": "^5.8.3", "vitest": "^3.2.4" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8", "styled-components": ">=5" }, "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" } } ================================================ FILE: packages/materials/form-antd-materials/src/components/batch-variable-selector/config.json ================================================ { "name": "batch-variable-selector", "depMaterials": ["variable-selector"], "depPackages": [] } ================================================ FILE: packages/materials/form-antd-materials/src/components/batch-variable-selector/index.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React from 'react'; import { PrivateScopeProvider } from '@flowgram.ai/editor'; import { VariableSelector, VariableSelectorProps } from '../variable-selector'; import { IJsonSchema } from '../../typings'; const batchVariableSchema: IJsonSchema = { type: 'array', extra: { weak: true }, }; export function BatchVariableSelector(props: VariableSelectorProps) { return ( ); } ================================================ FILE: packages/materials/form-antd-materials/src/components/condition-row/config.json ================================================ { "name": "condition-row", "depMaterials": ["variable-selector", "dynamic-value-input", "flow-value", "utils/json-schema", "typings/json-schema"], "depPackages": ["styled-components"] } ================================================ FILE: packages/materials/form-antd-materials/src/components/condition-row/constants.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { IRules, Op, OpConfigs } from './types'; export const rules: IRules = { string: { [Op.EQ]: 'string', [Op.NEQ]: 'string', [Op.CONTAINS]: 'string', [Op.NOT_CONTAINS]: 'string', [Op.IN]: 'array', [Op.NIN]: 'array', [Op.IS_EMPTY]: 'string', [Op.IS_NOT_EMPTY]: 'string', }, number: { [Op.EQ]: 'number', [Op.NEQ]: 'number', [Op.GT]: 'number', [Op.GTE]: 'number', [Op.LT]: 'number', [Op.LTE]: 'number', [Op.IN]: 'array', [Op.NIN]: 'array', [Op.IS_EMPTY]: null, [Op.IS_NOT_EMPTY]: null, }, integer: { [Op.EQ]: 'number', [Op.NEQ]: 'number', [Op.GT]: 'number', [Op.GTE]: 'number', [Op.LT]: 'number', [Op.LTE]: 'number', [Op.IN]: 'array', [Op.NIN]: 'array', [Op.IS_EMPTY]: null, [Op.IS_NOT_EMPTY]: null, }, boolean: { [Op.EQ]: 'boolean', [Op.NEQ]: 'boolean', [Op.IS_TRUE]: null, [Op.IS_FALSE]: null, [Op.IN]: 'array', [Op.NIN]: 'array', [Op.IS_EMPTY]: null, [Op.IS_NOT_EMPTY]: null, }, object: { [Op.IS_EMPTY]: null, [Op.IS_NOT_EMPTY]: null, }, array: { [Op.IS_EMPTY]: null, [Op.IS_NOT_EMPTY]: null, }, map: { [Op.IS_EMPTY]: null, [Op.IS_NOT_EMPTY]: null, }, }; export const opConfigs: OpConfigs = { [Op.EQ]: { label: 'Equal', abbreviation: '=', }, [Op.NEQ]: { label: 'Not Equal', abbreviation: '≠', }, [Op.GT]: { label: 'Greater Than', abbreviation: '>', }, [Op.GTE]: { label: 'Greater Than or Equal', abbreviation: '>=', }, [Op.LT]: { label: 'Less Than', abbreviation: '<', }, [Op.LTE]: { label: 'Less Than or Equal', abbreviation: '<=', }, [Op.IN]: { label: 'In', abbreviation: '∈', }, [Op.NIN]: { label: 'Not In', abbreviation: '∉', }, [Op.CONTAINS]: { label: 'Contains', abbreviation: '⊇', }, [Op.NOT_CONTAINS]: { label: 'Not Contains', abbreviation: '⊉', }, [Op.IS_EMPTY]: { label: 'Is Empty', abbreviation: '=', rightDisplay: 'Empty', }, [Op.IS_NOT_EMPTY]: { label: 'Is Not Empty', abbreviation: '≠', rightDisplay: 'Empty', }, [Op.IS_TRUE]: { label: 'Is True', abbreviation: '=', rightDisplay: 'True', }, [Op.IS_FALSE]: { label: 'Is False', abbreviation: '=', rightDisplay: 'False', }, }; ================================================ FILE: packages/materials/form-antd-materials/src/components/condition-row/hooks/styles.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import styled from 'styled-components'; import type { SelectProps } from 'antd/es/select'; import { Select } from 'antd'; export const OpSelect: React.ComponentType = styled(Select)` width: 100%; height: 22px; width: 24px; & .ant-select-selector { padding: 0 !important; text-align: center; } & .ant-select-arrow { right: 6px; & > .anticon { pointer-events: none !important; } } `; ================================================ FILE: packages/materials/form-antd-materials/src/components/condition-row/hooks/useOp.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React, { useMemo } from 'react'; import { theme } from 'antd'; import { DownOutlined } from '@ant-design/icons'; import { IRule, Op } from '../types'; import { opConfigs } from '../constants'; import { OpSelect } from './styles'; const { useToken } = theme; interface HookParams { rule?: IRule; op?: Op; onChange: (op: Op) => void; readonly?: boolean; } export function useOp({ rule, op, onChange, readonly }: HookParams) { const options = useMemo( () => Object.keys(rule || {}).map((_op) => ({ ...(opConfigs[_op as Op] || {}), value: _op, })), [rule] ); const opConfig = useMemo(() => opConfigs[op as Op], [op]); const renderOpSelect = () => { const { token } = useToken(); return ( { onChange(v as Op); }} labelRender={({ value }) => {opConfig?.abbreviation || }} suffixIcon={op ? null : undefined} /> ); }; return { renderOpSelect, opConfig }; } ================================================ FILE: packages/materials/form-antd-materials/src/components/condition-row/hooks/useRule.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ 'use client'; import { useMemo } from 'react'; import { useScopeAvailable } from '@flowgram.ai/editor'; import { rules } from '../constants'; import { JsonSchemaUtils } from '../../../utils'; import { IFlowRefValue, JsonSchemaBasicType } from '../../../typings'; export function useRule(left?: IFlowRefValue) { const available = useScopeAvailable(); const variable = useMemo(() => { if (!left) return undefined; return available.getByKeyPath(left.content); }, [available, left]); const rule = useMemo(() => { if (!variable) return undefined; const schema = JsonSchemaUtils.astToSchema(variable.type, { drilldown: false, }); return rules[schema?.type as JsonSchemaBasicType]; }, [variable?.type]); return { rule }; } ================================================ FILE: packages/materials/form-antd-materials/src/components/condition-row/index.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ 'use client'; import React, { useMemo } from 'react'; import { VariableSelector } from '../variable-selector'; import { DynamicValueInput } from '../dynamic-value-input'; import { UIInput } from '../constant-input/styles'; import { JsonSchemaBasicType } from '../../typings'; import { ConditionRowValueType, Op } from './types'; import { UIContainer, UILeft, UIOperator, UIRight, UIValues } from './styles'; import { useRule } from './hooks/useRule'; import { useOp } from './hooks/useOp'; interface PropTypes { value?: ConditionRowValueType; onChange: (value?: ConditionRowValueType) => void; style?: React.CSSProperties; readonly?: boolean; } export function ConditionRow({ style, value, onChange, readonly }: PropTypes) { const { left, operator, right } = value || {}; const { rule } = useRule(left); const { renderOpSelect, opConfig } = useOp({ rule, op: operator, onChange: (v) => onChange({ ...value, operator: v }), readonly, }); const targetSchema = useMemo(() => { const targetType: JsonSchemaBasicType | null = rule?.[operator as Op] || null; return targetType ? { type: targetType, extra: { weak: true } } : null; }, [rule, opConfig]); return ( {renderOpSelect()} onChange({ ...value, left: { type: 'ref', content: v, }, }) } allowClear={true} /> {targetSchema ? ( onChange({ ...value, right: v })} /> ) : ( )} ); } export type { ConditionRowValueType, Op, VariableSelector }; ================================================ FILE: packages/materials/form-antd-materials/src/components/condition-row/styles.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import styled from 'styled-components'; export const UIContainer = styled.div` display: flex; align-items: center; gap: 4px; `; export const UIOperator = styled.div``; export const UILeft = styled.div` width: 100%; `; export const UIRight = styled.div` width: 100%; `; export const UIValues = styled.div` flex-grow: 1; display: flex; flex-direction: column; align-items: center; gap: 4px; `; ================================================ FILE: packages/materials/form-antd-materials/src/components/condition-row/types.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { IFlowConstantRefValue, IFlowRefValue, JsonSchemaBasicType } from '../../typings'; export enum Op { EQ = 'eq', NEQ = 'neq', GT = 'gt', GTE = 'gte', LT = 'lt', LTE = 'lte', IN = 'in', NIN = 'nin', CONTAINS = 'contains', NOT_CONTAINS = 'not_contains', IS_EMPTY = 'is_empty', IS_NOT_EMPTY = 'is_not_empty', IS_TRUE = 'is_true', IS_FALSE = 'is_false', } export interface OpConfig { label: string; abbreviation: string; // When right is not a value, display this text rightDisplay?: string; } export type OpConfigs = Record; export type IRule = Partial>; export type IRules = Record; export interface ConditionRowValueType { left?: IFlowRefValue; operator?: Op; right?: IFlowConstantRefValue; } ================================================ FILE: packages/materials/form-antd-materials/src/components/constant-input/config.json ================================================ { "name": "constant-input", "depMaterials": ["typings/json-schema"], "depPackages": [] } ================================================ FILE: packages/materials/form-antd-materials/src/components/constant-input/index.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ /* eslint-disable react/prop-types */ import React, { useMemo } from 'react'; import { PropsType, Strategy } from './types'; import { UIInput, UIInputNumber, UISelect } from './styles'; import { I18n } from '@flowgram.ai/editor'; const defaultStrategies: Strategy[] = [ { hit: (schema) => schema?.type === 'string', Renderer: (props) => ( ), }, { hit: (schema) => schema?.type === 'number', Renderer: (props) => ( ), }, { hit: (schema) => schema?.type === 'integer', Renderer: (props) => ( ), }, { hit: (schema) => schema?.type === 'boolean', Renderer: (props) => { const { value, onChange, ...rest } = props; return ( onChange?.(!!value)} {...rest} /> ); }, }, ]; export function ConstantInput(props: PropsType) { const { value, onChange, schema, strategies: extraStrategies, readonly, ...rest } = props; const strategies = useMemo( () => [...defaultStrategies, ...(extraStrategies || [])], [extraStrategies] ); const Renderer = useMemo(() => { const strategy = strategies.find((_strategy) => _strategy.hit(schema)); return strategy?.Renderer; }, [strategies, schema]); if (!Renderer) { return ; } return ; } ================================================ FILE: packages/materials/form-antd-materials/src/components/constant-input/styles.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import styled from 'styled-components'; import type { SelectProps } from 'antd/es/select'; import type { InputNumberProps } from 'antd/es/input-number'; import { Input, InputNumber, Select } from 'antd'; const commonStyle = ` width: 100%; height: 22px; border-radius: 6px; padding: 4px 11px; display: flex; justify-content: center; align-items: center; `; export const UIInput = styled(Input)` ${commonStyle} `; export const UIInputNumber: React.ComponentType = styled(InputNumber)` ${commonStyle} padding: 4px 4px; `; export const UISelect: React.ComponentType = styled(Select)` ${commonStyle} `; ================================================ FILE: packages/materials/form-antd-materials/src/components/constant-input/types.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { IJsonSchema } from '../../typings'; export interface Strategy { hit: (schema: IJsonSchema) => boolean; Renderer: React.FC>; } export interface RendererProps { value?: Value; onChange?: (value: Value) => void; readonly?: boolean; } export interface PropsType extends RendererProps { schema: IJsonSchema; strategies?: Strategy[]; [key: string]: any; } ================================================ FILE: packages/materials/form-antd-materials/src/components/dynamic-value-input/config.json ================================================ { "name": "dynamic-value-input", "depMaterials": ["flow-value", "constant-input", "variable-selector"], "depPackages": ["styled-components"] } ================================================ FILE: packages/materials/form-antd-materials/src/components/dynamic-value-input/index.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React, { useMemo } from 'react'; import { theme } from 'antd'; import { SettingFilled } from '@ant-design/icons'; import { VariableSelector } from '../variable-selector'; import { Strategy } from '../constant-input/types'; import { ConstantInput } from '../constant-input'; import { IFlowConstantRefValue } from '../../typings/flow-value'; import { IJsonSchema } from '../../typings'; import { UIContainer, UIMain, UITrigger } from './styles'; const { useToken } = theme; interface PropsType { value?: IFlowConstantRefValue; onChange: (value?: IFlowConstantRefValue) => void; readonly?: boolean; hasError?: boolean; style?: React.CSSProperties; schema?: IJsonSchema; constantProps?: { strategies?: Strategy[]; [key: string]: any; }; } export function DynamicValueInput({ value, onChange, readonly, style, schema, constantProps, }: PropsType) { const { token } = useToken(); // When is number type, include integer as well const includeSchema = useMemo(() => { if (schema?.type === 'number') { return [schema, { type: 'integer' }]; } return schema; }, [schema]); const renderMain = () => { if (value?.type === 'ref') { // Display Variable Or Delete return ( onChange(_v ? { type: 'ref', content: _v } : undefined)} includeSchema={includeSchema} readonly={readonly} /> ); } return ( onChange({ type: 'constant', content: _v })} schema={schema || { type: 'string' }} readonly={readonly} {...constantProps} /> ); }; const renderTrigger = () => ( onChange({ type: 'ref', content: _v })} includeSchema={includeSchema} readonly={readonly} triggerRender={() => } /> ); return ( {renderMain()} {renderTrigger()} ); } ================================================ FILE: packages/materials/form-antd-materials/src/components/dynamic-value-input/styles.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import styled from 'styled-components'; export const UIContainer = styled.div` display: flex; align-items: center; gap: 5px; `; export const UIMain = styled.div` flex-grow: 1; `; export const UITrigger = styled.div` outline: none; height: 22px; min-height: 22px; line-height: 22px; & .ant-select-selection-wrap { display: none; } & .ant-select-arrow { right: 6px; & > .anticon { pointer-events: none !important; } } `; ================================================ FILE: packages/materials/form-antd-materials/src/components/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export * from './variable-selector'; export * from './type-selector'; export * from './json-schema-editor'; export * from './batch-variable-selector'; export * from './constant-input'; export * from './dynamic-value-input'; export * from './condition-row'; ================================================ FILE: packages/materials/form-antd-materials/src/components/json-schema-editor/components/blur-input.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React, { useEffect, useState } from 'react'; import { Input, InputProps } from 'antd'; export function BlurInput(props: InputProps) { const [value, setValue] = useState(''); useEffect(() => { setValue(props.value as string); }, [props.value]); return ( { setValue((value as any).target?.value || ''); }} /> ); } ================================================ FILE: packages/materials/form-antd-materials/src/components/json-schema-editor/config.json ================================================ { "name": "json-schema-editor", "depMaterials": ["type-selector", "typings/json-schema"], "depPackages": ["styled-components"] } ================================================ FILE: packages/materials/form-antd-materials/src/components/json-schema-editor/default-value.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React, { useCallback, useRef, useState } from 'react'; import { Button, Tooltip, theme } from 'antd'; import { CodeOutlined } from '@ant-design/icons'; import { ConstantInput } from '../constant-input'; import { IJsonSchema } from '../../typings'; import { getValueType } from './utils'; import { ConstantInputWrapper, JSONHeader, JSONHeaderLeft, JSONHeaderRight, JSONViewerWrapper, } from './styles'; const { useToken } = theme; /** * 根据不同的数据类型渲染对应的默认值输入组件。 * @param props - 组件属性,包括 value, type, placeholder, onChange。 * @returns 返回对应类型的输入组件或 null。 */ export function DefaultValue(props: { value: any; schema?: IJsonSchema; name?: string; type?: string; placeholder?: string; jsonFormatText?: string; onChange: (value: any) => void; }) { const { token } = useToken(); const { value, schema, type, onChange, placeholder, jsonFormatText } = props; const wrapperRef = useRef(null); // TODO add JsonViewer // const JsonViewerRef = useRef(null); // 为 JsonViewer 添加状态管理 const [internalJsonValue, setInternalJsonValue] = useState( getValueType(value) === 'string' ? value : '' ); // 使用 useCallback 创建稳定的回调函数 // const handleJsonChange = useCallback((val: string) => { // // 只在值真正改变时才更新状态 // if (val !== internalJsonValue) { // setInternalJsonValue(val); // } // }, []); // 处理编辑完成事件 const handleEditComplete = useCallback(() => { // 只有当存在key,编辑完成时才触发父组件的 onChange onChange(internalJsonValue); // 确保在更新后移除焦点 requestAnimationFrame(() => { // JsonViewerRef.current?.format(); wrapperRef.current?.blur(); }); // setJsonReadOnly(true); }, [internalJsonValue, onChange]); // const [jsonReadOnly, setJsonReadOnly] = useState(true); const handleFormatJson = useCallback(() => { try { const parsed = JSON.parse(internalJsonValue); const formatted = JSON.stringify(parsed, null, 4); setInternalJsonValue(formatted); onChange(formatted); } catch (error) { console.error('Invalid JSON:', error); } }, [internalJsonValue, onChange]); return type === 'object' ? ( <> json ); } function PropertyEdit(props: { value?: PropertyValueType; config?: ConfigType; onChange?: (value: PropertyValueType) => void; onRemove?: () => void; $isLast?: boolean; $index?: number; $isFirst?: boolean; $parentExpand?: boolean; $parentType?: string; $showLine?: boolean; $level?: number; // 添加层级属性 }) { const { value, config, $level = 0, onChange: onChangeProps, onRemove, $index, $isFirst, $isLast, $parentExpand = false, $parentType = '', $showLine, } = props; const [expand, setExpand] = useState(false); const [collapse, setCollapse] = useState(false); const { name, type, items, default: defaultValue, description, isPropertyRequired } = value || {}; const typeSelectorValue = useMemo(() => ({ type, items }), [type, items]); const { propertyList, isDrilldownObject, onAddProperty, onRemoveProperty, onEditProperty } = usePropertiesEdit(value, onChangeProps); const onChange = (key: string, _value: any) => { onChangeProps?.({ ...(value || {}), [key]: _value, }); }; const showCollapse = isDrilldownObject && propertyList.length > 0; return ( <> {showCollapse && ( setCollapse((_collapse) => !_collapse)}> {collapse ? : } )} onChange('name', value)} /> { onChangeProps?.({ ...(value || {}), ..._value, }); }} /> onChange('isPropertyRequired', e.target.checked)} /> // )} // treeData={options} value={selectValue} // leafOnly={true} onChange={(value) => { onChange(parseTypeSelectValue(value as string[])); }} /> ); } export { ArrayIcons, VariableTypeIcons, getSchemaIcon }; ================================================ FILE: packages/materials/form-antd-materials/src/components/variable-selector/config.json ================================================ { "name": "variable-selector", "depMaterials": ["type-selector", "utils/json-schema", "typings/json-schema"], "depPackages": ["styled-components"] } ================================================ FILE: packages/materials/form-antd-materials/src/components/variable-selector/index.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ 'use client'; import React from 'react'; import type { TreeSelectProps, TreeNodeProps } from 'antd'; import { DownOutlined } from '@ant-design/icons'; import { IJsonSchema } from '../../typings/json-schema'; import { useVariableTree } from './use-variable-tree'; import { UITreeSelect } from './styles'; interface TriggerRenderProps { value: string[]; } interface PropTypes { value?: string[]; config?: { placeholder?: string; notFoundContent?: string; }; onChange: (value?: string[]) => void; includeSchema?: IJsonSchema | IJsonSchema[]; excludeSchema?: IJsonSchema | IJsonSchema[]; readonly?: boolean; allowClear?: boolean; hasError?: boolean; style?: React.CSSProperties; triggerRender?: (props: TriggerRenderProps) => React.ReactNode; } export type VariableSelectorProps = PropTypes; export const VariableSelector = ({ value, config = {}, onChange, style, readonly = false, allowClear = false, includeSchema, excludeSchema, hasError, triggerRender, }: PropTypes) => { const treeData = useVariableTree({ includeSchema, excludeSchema }); const onPopupScroll: TreeSelectProps['onPopupScroll'] = (e) => { console.log('onPopupScroll', e); }; return ( ( )} /> ); }; ================================================ FILE: packages/materials/form-antd-materials/src/components/variable-selector/styles.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import styled from 'styled-components'; import type { TreeSelectProps } from 'antd/es/tree-select'; import { Tag, TreeSelect } from 'antd'; export const UIRootTitle = styled.span` margin-right: 4px; `; export const UITag = styled(Tag)` width: 100%; display: flex; align-items: center; justify-content: flex-start; `; export const UITreeSelect: React.ComponentType> = styled(TreeSelect)` height: 22px; min-height: 22px; line-height: 22px; & .ant-select-clear { right: 20px; display: flex; align-items: center; justify-content: center; } & .ant-select-arrow { right: 6px; & > .anticon { pointer-events: none !important; } } `; export const ImgIconWrapper = styled.div` display: inline-flex; align-items: center; justify-content: center; `; ================================================ FILE: packages/materials/form-antd-materials/src/components/variable-selector/types.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import type { ReactElement } from 'react'; export interface TreeNodeData { value: string | number; title: string; disabled?: boolean; disableCheckbox?: boolean; selectable?: boolean; checkable?: boolean; children?: TreeNodeData[]; icon: ReactElement; key: string; keyPath: string[]; rootMeta: VariableMeta; } ================================================ FILE: packages/materials/form-antd-materials/src/components/variable-selector/use-variable-tree.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React, { useCallback } from 'react'; import { ASTMatch, BaseVariableField, useScopeAvailable } from '@flowgram.ai/editor'; import { ArrayIcons, VariableTypeIcons } from '../type-selector/constants'; import { JsonSchemaUtils } from '../../utils/json-schema'; import { SvgIcon } from '../../utils'; import { IJsonSchema } from '../../typings/json-schema'; import { TreeNodeData } from './types'; import { ImgIconWrapper } from './styles'; type VariableField = BaseVariableField<{ icon?: string; title?: string; }>; export function useVariableTree(params: { includeSchema?: IJsonSchema | IJsonSchema[]; excludeSchema?: IJsonSchema | IJsonSchema[]; }): TreeNodeData[] { const { includeSchema, excludeSchema } = params; const available = useScopeAvailable(); const getVariableTypeIcon = useCallback((variable: VariableField) => { if (variable.meta?.icon) { return ( ); } const _type = variable.type; if (ASTMatch.isArray(_type)) { return ( ); } if (ASTMatch.isCustomType(_type)) { return ; } return ; }, []); const renderVariable = ( variable: VariableField, parentFields: VariableField[] = [] ): TreeNodeData | null => { let type = variable?.type; if (!type) { return null; } let children: TreeNodeData[] | undefined; if (ASTMatch.isObject(type)) { children = (type.properties || []) .map((_property) => renderVariable(_property as VariableField, [...parentFields, variable])) .filter(Boolean) as TreeNodeData[]; } const keyPath = [...parentFields.map((_field) => _field.key), variable.key]; const key = keyPath.join('.'); const isSchemaInclude = includeSchema ? JsonSchemaUtils.isASTMatchSchema(type, includeSchema) : true; const isSchemaExclude = excludeSchema ? JsonSchemaUtils.isASTMatchSchema(type, excludeSchema) : false; const isSchemaMatch = isSchemaInclude && !isSchemaExclude; // If not match, and no children, return null if (!isSchemaMatch && !children?.length) { return null; } return { key: key, title: variable.meta?.title || variable.key, value: key, keyPath, icon: getVariableTypeIcon(variable), // TODO children, disabled: !isSchemaMatch, rootMeta: parentFields[0]?.meta, }; }; return [...available.variables.slice(0).reverse()] .map((_variable) => renderVariable(_variable as VariableField)) .filter(Boolean) as TreeNodeData[]; } ================================================ FILE: packages/materials/form-antd-materials/src/effects/auto-rename-ref/config.json ================================================ { "name": "auto-rename-ref", "depMaterials": [ "flow-value" ], "depPackages": [ "lodash-es" ] } ================================================ FILE: packages/materials/form-antd-materials/src/effects/auto-rename-ref/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { isArray, isObject } from 'lodash-es'; import { DataEvent, Effect, EffectOptions, VariableFieldKeyRenameService, } from '@flowgram.ai/editor'; import { IFlowRefValue } from '../../typings'; /** * Auto rename ref when form item's key is renamed * * Example: * * formMeta: { * effects: { * "inputsValues": autoRenameRefEffect, * } * } */ export const autoRenameRefEffect: EffectOptions[] = [ { event: DataEvent.onValueInit, effect: ((params) => { const { context, form, name } = params; const renameService = context.node.getService(VariableFieldKeyRenameService); const disposable = renameService.onRename(({ before, after }) => { const beforeKeyPath = [ ...before.parentFields.map((_field) => _field.key).reverse(), before.key, ]; const afterKeyPath = [ ...after.parentFields.map((_field) => _field.key).reverse(), after.key, ]; // traverse rename refs inside form item 'name' traverseRef(name, form.getValueIn(name), (_drilldownName, _v) => { if (isRefMatch(_v, beforeKeyPath)) { _v.content = [...afterKeyPath, ...(_v.content || [])?.slice(beforeKeyPath.length)]; form.setValueIn(_drilldownName, _v); } }); }); return () => { disposable.dispose(); }; }) as Effect, }, ]; /** * If ref value's keyPath is the under as targetKeyPath * @param value * @param targetKeyPath * @returns */ function isRefMatch(value: IFlowRefValue, targetKeyPath: string[]) { return targetKeyPath.every((_key, index) => _key === value.content?.[index]); } /** * If value is ref * @param value * @returns */ function isRef(value: any): value is IFlowRefValue { return ( value?.type === 'ref' && Array.isArray(value?.content) && typeof value?.content[0] === 'string' ); } /** * Traverse value to find ref * @param value * @param options * @returns */ function traverseRef(name: string, value: any, cb: (name: string, _v: IFlowRefValue) => void) { if (isObject(value)) { if (isRef(value)) { cb(name, value); return; } Object.entries(value).forEach(([_key, _value]) => { traverseRef(`${name}.${_key}`, _value, cb); }); return; } if (isArray(value)) { value.forEach((_value, idx) => { traverseRef(`${name}[${idx}]`, _value, cb); }); return; } return; } ================================================ FILE: packages/materials/form-antd-materials/src/effects/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export * from './provide-batch-input'; export * from './provide-batch-outputs'; export * from './auto-rename-ref'; export * from './provide-json-schema-outputs'; export * from './sync-variable-title'; ================================================ FILE: packages/materials/form-antd-materials/src/effects/provide-batch-input/config.json ================================================ { "name": "provide-batch-input", "depMaterials": ["flow-value"], "depPackages": [] } ================================================ FILE: packages/materials/form-antd-materials/src/effects/provide-batch-input/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { ASTFactory, EffectOptions, FlowNodeRegistry, createEffectFromVariableProvider, } from '@flowgram.ai/editor'; import { IFlowRefValue } from '../../typings'; export const provideBatchInputEffect: EffectOptions[] = createEffectFromVariableProvider({ private: true, parse: (value: IFlowRefValue, ctx) => [ ASTFactory.createVariableDeclaration({ key: `${ctx.node.id}_locals`, meta: { title: ctx.node.form?.getValueIn('title'), icon: ctx.node.getNodeRegistry().info?.icon, }, type: ASTFactory.createObject({ properties: [ ASTFactory.createProperty({ key: 'item', initializer: ASTFactory.createEnumerateExpression({ enumerateFor: ASTFactory.createKeyPathExpression({ keyPath: value.content || [], }), }), }), ASTFactory.createProperty({ key: 'index', type: ASTFactory.createNumber(), }), ], }), }), ], }); ================================================ FILE: packages/materials/form-antd-materials/src/effects/provide-batch-outputs/config.json ================================================ { "name": "provide-batch-outputs", "depMaterials": ["flow-value"], "depPackages": [] } ================================================ FILE: packages/materials/form-antd-materials/src/effects/provide-batch-outputs/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { ASTFactory, EffectOptions, FlowNodeRegistry, createEffectFromVariableProvider, } from '@flowgram.ai/editor'; import { IFlowRefValue } from '../../typings'; export const provideBatchOutputsEffect: EffectOptions[] = createEffectFromVariableProvider({ parse: (value: Record, ctx) => [ ASTFactory.createVariableDeclaration({ key: `${ctx.node.id}`, meta: { title: ctx.node.form?.getValueIn('title'), icon: ctx.node.getNodeRegistry().info?.icon, }, type: ASTFactory.createObject({ properties: Object.entries(value).map(([_key, value]) => ASTFactory.createProperty({ key: _key, initializer: ASTFactory.createWrapArrayExpression({ wrapFor: ASTFactory.createKeyPathExpression({ keyPath: value.content || [], }), }), }) ), }), }), ], }); ================================================ FILE: packages/materials/form-antd-materials/src/effects/provide-json-schema-outputs/config.json ================================================ { "name": "provide-json-schema-outputs", "depMaterials": [ "typings/json-schema", "utils/json-schema" ], "depPackages": [] } ================================================ FILE: packages/materials/form-antd-materials/src/effects/provide-json-schema-outputs/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { ASTFactory, EffectOptions, FlowNodeRegistry, createEffectFromVariableProvider, } from '@flowgram.ai/editor'; import { JsonSchemaUtils } from '../../utils'; import { IJsonSchema } from '../../typings'; export const provideJsonSchemaOutputs: EffectOptions[] = createEffectFromVariableProvider({ parse: (value: IJsonSchema, ctx) => [ ASTFactory.createVariableDeclaration({ key: `${ctx.node.id}`, meta: { title: ctx.node.form?.getValueIn('title') || ctx.node.id, icon: ctx.node.getNodeRegistry().info?.icon, }, type: JsonSchemaUtils.schemaToAST(value), }), ], }); ================================================ FILE: packages/materials/form-antd-materials/src/effects/sync-variable-title/config.json ================================================ { "name": "sync-variable-title", "depMaterials": [], "depPackages": [] } ================================================ FILE: packages/materials/form-antd-materials/src/effects/sync-variable-title/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { DataEvent, Effect, EffectOptions, FlowNodeRegistry, FlowNodeVariableData, } from '@flowgram.ai/editor'; export const syncVariableTitle: EffectOptions[] = [ { event: DataEvent.onValueChange, effect: (({ value, context }) => { context.node.getData(FlowNodeVariableData).allScopes.forEach((_scope) => { _scope.output.variables.forEach((_var) => { _var.updateMeta({ title: value || context.node.id, icon: context.node.getNodeRegistry().info?.icon, }); }); }); }) as Effect, }, ]; ================================================ FILE: packages/materials/form-antd-materials/src/form-plugins/batch-outputs-plugin/config.json ================================================ { "name": "batch-outputs-plugin", "depMaterials": [ "flow-value" ], "depPackages": [] } ================================================ FILE: packages/materials/form-antd-materials/src/form-plugins/batch-outputs-plugin/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { ASTFactory, createEffectFromVariableProvider, defineFormPluginCreator, FlowNodeRegistry, getNodePrivateScope, getNodeScope, ScopeChainTransformService, type EffectOptions, type FormPluginCreator, FlowNodeScopeType, } from '@flowgram.ai/editor'; import { IFlowRefValue } from '../../typings'; export const provideBatchOutputsEffect: EffectOptions[] = createEffectFromVariableProvider({ parse: (value: Record, ctx) => [ ASTFactory.createVariableDeclaration({ key: `${ctx.node.id}`, meta: { title: ctx.node.form?.getValueIn('title'), icon: ctx.node.getNodeRegistry().info?.icon, }, type: ASTFactory.createObject({ properties: Object.entries(value).map(([_key, value]) => ASTFactory.createProperty({ key: _key, initializer: ASTFactory.createWrapArrayExpression({ wrapFor: ASTFactory.createKeyPathExpression({ keyPath: value?.content || [], }), }), }) ), }), }), ], }); /** * Free Layout only right now */ export const createBatchOutputsFormPlugin: FormPluginCreator<{ outputKey: string }> = defineFormPluginCreator({ name: 'batch-outputs-plugin', onSetupFormMeta({ mergeEffect }, { outputKey }) { mergeEffect({ [outputKey]: provideBatchOutputsEffect, }); }, onInit(ctx, { outputKey }) { const chainTransformService = ctx.node.getService(ScopeChainTransformService); const batchNodeType = ctx.node.flowNodeType; const transformerId = `${batchNodeType}-outputs`; if (chainTransformService.hasTransformer(transformerId)) { return; } chainTransformService.registerTransformer(transformerId, { transformCovers: (covers, ctx) => { const node = ctx.scope.meta?.node; // Child Node's variable can cover parent if (node?.parent?.flowNodeType === batchNodeType) { return [...covers, getNodeScope(node.parent)]; } return covers; }, transformDeps(scopes, ctx) { const scopeMeta = ctx.scope.meta; if (scopeMeta?.type === FlowNodeScopeType.private) { return scopes; } const node = scopeMeta?.node; // Public of Loop Node depends on child Node if (node?.flowNodeType === batchNodeType) { // Get all child blocks const childBlocks = node.blocks; // public scope of all child blocks return [ getNodePrivateScope(node), ...childBlocks.map((_childBlock) => getNodeScope(_childBlock)), ]; } return scopes; }, }); }, }); ================================================ FILE: packages/materials/form-antd-materials/src/form-plugins/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export { createBatchOutputsFormPlugin } from './batch-outputs-plugin'; ================================================ FILE: packages/materials/form-antd-materials/src/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export * from './components'; export * from './effects'; export * from './utils'; export * from './typings'; export * from './form-plugins'; ================================================ FILE: packages/materials/form-antd-materials/src/typings/flow-value/config.json ================================================ { "name": "flow-value", "depMaterials": [], "depPackages": [] } ================================================ FILE: packages/materials/form-antd-materials/src/typings/flow-value/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export interface IFlowConstantValue { type: 'constant'; content?: string | number | boolean; } export interface IFlowRefValue { type: 'ref'; content?: string[]; } export interface IFlowExpressionValue { type: 'expression'; content?: string; } export interface IFlowTemplateValue { type: 'template'; content?: string; } export type IFlowValue = | IFlowConstantValue | IFlowRefValue | IFlowExpressionValue | IFlowTemplateValue; export type IFlowConstantRefValue = IFlowConstantValue | IFlowRefValue; ================================================ FILE: packages/materials/form-antd-materials/src/typings/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export * from './flow-value'; export * from './json-schema'; ================================================ FILE: packages/materials/form-antd-materials/src/typings/json-schema/config.json ================================================ { "name": "json-schema", "depMaterials": [], "depPackages": [] } ================================================ FILE: packages/materials/form-antd-materials/src/typings/json-schema/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export type JsonSchemaBasicType = | 'boolean' | 'string' | 'integer' | 'number' | 'object' | 'array' | 'map'; export interface IJsonSchema { type?: T; default?: any; title?: string; description?: string; enum?: (string | number)[]; properties?: Record>; additionalProperties?: IJsonSchema; items?: IJsonSchema; required?: string[]; $ref?: string; extra?: { index?: number; // Used in BaseType.isEqualWithJSONSchema, the type comparison will be weak weak?: boolean; // Set the render component formComponent?: string; [key: string]: any; }; } export type IBasicJsonSchema = IJsonSchema; ================================================ FILE: packages/materials/form-antd-materials/src/utils/format-legacy-refs/config.json ================================================ { "name": "format-legacy-ref", "depMaterials": [], "depPackages": [] } ================================================ FILE: packages/materials/form-antd-materials/src/utils/format-legacy-refs/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { isObject } from 'lodash-es'; interface LegacyFlowRefValueSchema { type: 'ref'; content: string; } interface NewFlowRefValueSchema { type: 'ref'; content: string[]; } /** * In flowgram 0.2.0, for introducing Loop variable functionality, * the FlowRefValueSchema type definition is updated: * * interface LegacyFlowRefValueSchema { * type: 'ref'; * content: string; * } * * interface NewFlowRefValueSchema { * type: 'ref'; * content: string[]; * } * * * For making sure backend json will not be changed, we provide format legacy ref utils for updating the formData * * How to use: * * 1. Call formatLegacyRefOnSubmit on the formData before submitting * 2. Call formatLegacyRefOnInit on the formData after submitting * * Example: * import { formatLegacyRefOnSubmit, formatLegacyRefOnInit } from '@flowgram.ai/form-materials'; * formMeta: { * formatOnSubmit: (data) => formatLegacyRefOnSubmit(data), * formatOnInit: (data) => formatLegacyRefOnInit(data), * } */ export function formatLegacyRefOnSubmit(value: any): any { if (isObject(value)) { if (isLegacyFlowRefValueSchema(value)) { return formatLegacyRefToNewRef(value); } return Object.fromEntries( Object.entries(value).map(([key, value]: [string, any]) => [ key, formatLegacyRefOnSubmit(value), ]) ); } if (Array.isArray(value)) { return value.map(formatLegacyRefOnSubmit); } return value; } /** * In flowgram 0.2.0, for introducing Loop variable functionality, * the FlowRefValueSchema type definition is updated: * * interface LegacyFlowRefValueSchema { * type: 'ref'; * content: string; * } * * interface NewFlowRefValueSchema { * type: 'ref'; * content: string[]; * } * * * For making sure backend json will not be changed, we provide format legacy ref utils for updating the formData * * How to use: * * 1. Call formatLegacyRefOnSubmit on the formData before submitting * 2. Call formatLegacyRefOnInit on the formData after submitting * * Example: * import { formatLegacyRefOnSubmit, formatLegacyRefOnInit } from '@flowgram.ai/form-materials'; * * formMeta: { * formatOnSubmit: (data) => formatLegacyRefOnSubmit(data), * formatOnInit: (data) => formatLegacyRefOnInit(data), * } */ export function formatLegacyRefOnInit(value: any): any { if (isObject(value)) { if (isNewFlowRefValueSchema(value)) { return formatNewRefToLegacyRef(value); } return Object.fromEntries( Object.entries(value).map(([key, value]: [string, any]) => [ key, formatLegacyRefOnInit(value), ]) ); } if (Array.isArray(value)) { return value.map(formatLegacyRefOnInit); } return value; } export function isLegacyFlowRefValueSchema(value: any): value is LegacyFlowRefValueSchema { return ( isObject(value) && Object.keys(value).length === 2 && (value as any).type === 'ref' && typeof (value as any).content === 'string' ); } export function isNewFlowRefValueSchema(value: any): value is NewFlowRefValueSchema { return ( isObject(value) && Object.keys(value).length === 2 && (value as any).type === 'ref' && Array.isArray((value as any).content) ); } export function formatLegacyRefToNewRef(value: LegacyFlowRefValueSchema) { const keyPath = value.content.split('.'); if (keyPath[1] === 'outputs') { return { type: 'ref', content: [`${keyPath[0]}.${keyPath[1]}`, ...(keyPath.length > 2 ? keyPath.slice(2) : [])], }; } return { type: 'ref', content: keyPath, }; } export function formatNewRefToLegacyRef(value: NewFlowRefValueSchema) { return { type: 'ref', content: value.content.join('.'), }; } ================================================ FILE: packages/materials/form-antd-materials/src/utils/format-legacy-refs/readme.md ================================================ # Notice In `@flowgram.ai/form-materials@0.2.0`, for introducing loop-related materials, The FlowRefValueSchema type definition is updated: ```typescript interface LegacyFlowRefValueSchema { type: 'ref'; content: string; } interface NewFlowRefValueSchema { type: 'ref'; content: string[]; } ``` For making sure backend json will not be changed in your application, we provide `format-legacy-ref` utils for upgrading How to use: 1. Call formatLegacyRefOnSubmit on the formData before submitting 2. Call formatLegacyRefOnInit on the formData after submitting Example: ```typescript import { formatLegacyRefOnSubmit, formatLegacyRefOnInit } from '@flowgram.ai/form-materials'; formMeta: { formatOnSubmit: (data) => formatLegacyRefOnSubmit(data), formatOnInit: (data) => formatLegacyRefOnInit(data), } ``` ================================================ FILE: packages/materials/form-antd-materials/src/utils/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export * from './format-legacy-refs'; export * from './svg-icon'; export * from './json-schema'; ================================================ FILE: packages/materials/form-antd-materials/src/utils/json-schema/config.json ================================================ { "name": "json-schema", "depMaterials": [ "typings/json-schema" ], "depPackages": [ "lodash-es" ] } ================================================ FILE: packages/materials/form-antd-materials/src/utils/json-schema/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { get } from 'lodash-es'; import { ASTFactory, ASTKind, ASTMatch, ASTNode, ASTNodeJSON, BaseType } from '@flowgram.ai/editor'; import { IJsonSchema } from '../../typings/json-schema'; export namespace JsonSchemaUtils { /** * Converts a JSON schema to an Abstract Syntax Tree (AST) representation. * This function recursively processes the JSON schema and creates corresponding AST nodes. * * For more information on JSON Schema, refer to the official documentation: * https://json-schema.org/ * * @param jsonSchema - The JSON schema to convert. * @returns An AST node representing the JSON schema, or undefined if the schema type is not recognized. */ export function schemaToAST(jsonSchema: IJsonSchema): ASTNodeJSON | undefined { const { type, extra } = jsonSchema || {}; const { weak = false } = extra || {}; if (!type) { return undefined; } switch (type) { case 'object': if (weak) { return { kind: ASTKind.Object, weak: true }; } return ASTFactory.createObject({ properties: Object.entries(jsonSchema.properties || {}) /** * Sorts the properties of a JSON schema based on the 'extra.index' field. * If the 'extra.index' field is not present, the property will be treated as having an index of 0. */ .sort((a, b) => (get(a?.[1], 'extra.index') || 0) - (get(b?.[1], 'extra.index') || 0)) .map(([key, _property]) => ({ key, type: schemaToAST(_property), meta: { title: _property.title, description: _property.description, }, })), }); case 'array': if (weak) { return { kind: ASTKind.Array, weak: true }; } return ASTFactory.createArray({ items: schemaToAST(jsonSchema.items!), }); case 'map': if (weak) { return { kind: ASTKind.Map, weak: true }; } return ASTFactory.createMap({ valueType: schemaToAST(jsonSchema.additionalProperties!), }); case 'string': return ASTFactory.createString(); case 'number': return ASTFactory.createNumber(); case 'boolean': return ASTFactory.createBoolean(); case 'integer': return ASTFactory.createInteger(); default: // If the type is not recognized, return CustomType return ASTFactory.createCustomType({ typeName: type }); } } /** * Convert AST To JSON Schema * @param typeAST * @returns */ export function astToSchema( typeAST: ASTNode, options?: { drilldown?: boolean } ): IJsonSchema | undefined { const { drilldown = true } = options || {}; if (ASTMatch.isString(typeAST)) { return { type: 'string', }; } if (ASTMatch.isBoolean(typeAST)) { return { type: 'boolean', }; } if (ASTMatch.isNumber(typeAST)) { return { type: 'number', }; } if (ASTMatch.isInteger(typeAST)) { return { type: 'integer', }; } if (ASTMatch.isObject(typeAST)) { return { type: 'object', properties: drilldown ? Object.fromEntries( typeAST.properties.map((property) => { const schema = astToSchema(property.type); if (property.meta?.title && schema) { schema.title = property.meta.title; } if (property.meta?.description && schema) { schema.description = property.meta.description; } return [property.key, schema!]; }) ) : {}, }; } if (ASTMatch.isArray(typeAST)) { return { type: 'array', items: drilldown ? astToSchema(typeAST.items) : undefined, }; } if (ASTMatch.isMap(typeAST)) { return { type: 'map', items: drilldown ? astToSchema(typeAST.valueType) : undefined, }; } if (ASTMatch.isCustomType(typeAST)) { return { type: typeAST.typeName, }; } return undefined; } /** * Check if the AST type is match the JSON Schema * @param typeAST * @param schema * @returns */ export function isASTMatchSchema( typeAST: BaseType, schema: IJsonSchema | IJsonSchema[] ): boolean { if (Array.isArray(schema)) { return typeAST.isTypeEqual( ASTFactory.createUnion({ types: schema.map((_schema) => schemaToAST(_schema)!).filter(Boolean), }) ); } return typeAST.isTypeEqual(schemaToAST(schema)); } } ================================================ FILE: packages/materials/form-antd-materials/src/utils/svg-icon/index.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React from 'react'; export function SvgIcon(props: { size?: 'inherit' | 'extra-small' | 'small' | 'default' | 'large' | 'extra-large'; svg: React.ReactNode; }) { return {props.svg}; } ================================================ FILE: packages/materials/form-antd-materials/tsconfig.json ================================================ { "extends": "@flowgram.ai/ts-config/tsconfig.flow.path.json", "compilerOptions": { "jsx": "react" }, "include": ["./src", "./bin/**/*.ts"], "exclude": ["node_modules"] } ================================================ FILE: packages/materials/form-antd-materials/vitest.config.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ const path = require('path'); import { defineConfig } from 'vitest/config'; export default defineConfig({ build: { commonjsOptions: { transformMixedEsModules: true, }, }, test: { globals: true, mockReset: false, environment: 'jsdom', setupFiles: [path.resolve(__dirname, './vitest.setup.ts')], include: ['**/?(*.){test,spec}.?(c|m)[jt]s?(x)'], exclude: [ '**/__mocks__**', '**/node_modules/**', '**/dist/**', '**/lib/**', // lib 编译结果忽略掉 '**/cypress/**', '**/.{idea,git,cache,output,temp}/**', '**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*', ], }, }); ================================================ FILE: packages/materials/form-antd-materials/vitest.setup.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import 'reflect-metadata'; ================================================ FILE: packages/materials/form-materials/bin/run.sh ================================================ #!/bin/sh # Copyright (c) 2025 Bytedance Ltd. and/or its affiliates # SPDX-License-Identifier: MIT echo "⚠️ 'npx @flowgram.ai/form-materials' is deprecated." echo "👉 Please use 'npx @flowgram.ai/cli@latest materials' to sync materials" npx @flowgram.ai/cli@latest materials "$@" ================================================ FILE: packages/materials/form-materials/eslint.config.js ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ const { defineFlatConfig } = require('@flowgram.ai/eslint-config'); module.exports = defineFlatConfig({ preset: 'web', packageRoot: __dirname, rules: { 'no-console': 'off', 'react/no-deprecated': 'off', '@flowgram.ai/e2e-data-testid': 'off', }, }); ================================================ FILE: packages/materials/form-materials/package.json ================================================ { "name": "@flowgram.ai/form-materials", "version": "0.1.8", "homepage": "https://flowgram.ai/", "repository": "https://github.com/bytedance/flowgram.ai", "license": "MIT", "exports": { ".": { "types": "./dist/types/index.d.ts", "import": "./dist/esm/index.mjs", "require": "./dist/cjs/index.js" }, "./components/*": { "types": "./dist/types/components/*/index.d.ts", "import": "./dist/esm/components/*/index.mjs", "require": "./dist/cjs/components/*/index.js" }, "./effects/*": { "types": "./dist/types/effects/*/index.d.ts", "import": "./dist/esm/effects/*/index.mjs", "require": "./dist/cjs/effects/*/index.js" }, "./hooks/*": { "types": "./dist/types/hooks/*/index.d.ts", "import": "./dist/esm/hooks/*/index.mjs", "require": "./dist/cjs/hooks/*/index.js" }, "./shared/*": { "types": "./dist/types/shared/*/index.d.ts", "import": "./dist/esm/shared/*/index.mjs", "require": "./dist/cjs/shared/*/index.js" }, "./form-plugins/*": { "types": "./dist/types/form-plugins/*/index.d.ts", "import": "./dist/esm/form-plugins/*/index.mjs", "require": "./dist/cjs/form-plugins/*/index.js" }, "./plugins/*": { "types": "./dist/types/plugins/*/index.d.ts", "import": "./dist/esm/plugins/*/index.mjs", "require": "./dist/cjs/plugins/*/index.js" }, "./validate/*": { "types": "./dist/types/validate/*/index.d.ts", "import": "./dist/esm/validate/*/index.mjs", "require": "./dist/cjs/validate/*/index.js" } }, "main": "./dist/cjs/index.js", "module": "./dist/esm/index.mjs", "types": "./dist/types/index.d.ts", "sideEffects": false, "bin": { "flowgram-form-materials": "./bin/run.sh" }, "files": [ "dist", "bin", "src" ], "scripts": { "build": "cross-env NODE_ENV=production rslib build", "build:fast": "cross-env NODE_ENV=development rslib build", "build:watch": "npm run build:fast", "name-export": "node scripts/name-export.js", "clean": "rimraf dist", "test": "exit 0", "test:cov": "exit 0", "ts-check": "tsc --noEmit", "watch": "npm run build:fast -- --dts-resolve --watch --ignore-watch dist", "run-bin": "node bin/index.js" }, "dependencies": { "@douyinfe/semi-icons": "^2.80.0", "@douyinfe/semi-ui": "^2.80.0", "@flowgram.ai/editor": "workspace:*", "@flowgram.ai/json-schema": "workspace:*", "@flowgram.ai/coze-editor": "workspace:*", "lodash-es": "^4.17.21", "nanoid": "^5.0.9", "immer": "~10.1.1", "@codemirror/view": "~6.38.0", "@codemirror/state": "~6.5.2", "zod": "^3.24.4" }, "devDependencies": { "@flowgram.ai/eslint-config": "workspace:*", "@flowgram.ai/ts-config": "workspace:*", "@types/lodash-es": "^4.17.12", "@types/node": "^18", "@types/react": "^18", "@types/react-dom": "^18", "@types/inquirer": "^9.0.9", "eslint": "^9.0.0", "react": "^18", "react-dom": "^18", "typescript": "^5.8.3", "vitest": "^3.2.4", "@rslib/core": "~0.12.4", "cross-env": "~7.0.3", "@rsbuild/plugin-react": "^1.1.1", "date-fns": "~4.1.0" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" }, "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" } } ================================================ FILE: packages/materials/form-materials/rslib.config.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import path from 'path'; import { defineConfig } from '@rslib/core'; import { pluginReact } from '@rsbuild/plugin-react'; type RsbuildConfig = Parameters[0]; const commonConfig: Partial = { source: { entry: { index: ['./src/**/*.{ts,tsx,css}'], }, exclude: [], decorators: { version: 'legacy', }, }, bundle: false, dts: { distPath: path.resolve(__dirname, './dist/types'), bundle: false, build: true, }, tools: {}, }; const formats: Partial[] = [ { format: 'esm', output: { distPath: { root: path.resolve(__dirname, './dist/esm'), }, }, }, { dts: false, format: 'cjs', output: { distPath: { root: path.resolve(__dirname, './dist/cjs'), }, }, }, ].map((r) => ({ ...commonConfig, ...r })); export default defineConfig({ lib: formats, output: { target: 'web', cleanDistPath: process.env.NODE_ENV === 'production', }, plugins: [pluginReact({ swcReactOptions: {} })], }); ================================================ FILE: packages/materials/form-materials/scripts/name-export.js ================================================ #!/usr/bin/env node /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ const fs = require('fs'); const path = require('path'); /** * Convert wildcard exports to named exports in index.ts files across multiple folders * This script analyzes each exported file and extracts their named exports */ const folders = ['components', 'hooks', 'plugins', 'shared', 'validate', 'form-plugins', 'effects']; const SRC_DIR = path.join(__dirname, '..', 'src'); /** * Extract all named exports from a file, distinguishing between values and types * @param {string} filePath - Path of the file to analyze * @returns {{values: string[], types: string[]}} - Object containing value exports and type exports */ function extractNamedExports(filePath) { try { const content = fs.readFileSync(filePath, 'utf-8'); const valueExports = []; const typeExports = []; // Collect all type definition names const typeDefinitions = new Set(); const typePatterns = [ /\b(?:type|interface)\s+(\w+)/g, /\bexport\s+(?:type|interface)\s+(\w+)/g, ]; let match; for (const pattern of typePatterns) { while ((match = pattern.exec(content)) !== null) { typeDefinitions.add(match[1]); } } // Match various export patterns const exportPatterns = [ // export const/var/let/function/class/type/interface /\bexport\s+(const|var|let|function|class|type|interface)\s+(\w+)/g, // export { name1, name2 } /\bexport\s*\{([^}]+)\}/g, // export { name as alias } /\bexport\s*\{[^}]*\b(\w+)\s+as\s+(\w+)[^}]*\}/g, // export default function name() /\bexport\s+default\s+(?:function|class)\s+(\w+)/g, // export type { Type1, Type2 } /\bexport\s+type\s*\{([^}]+)\}/g, // export type { Original as Alias } /\bexport\s+type\s*\{[^}]*\b(\w+)\s+as\s+(\w+)[^}]*\}/g, ]; // Handle first pattern: export const/var/let/function/class/type/interface exportPatterns[0].lastIndex = 0; while ((match = exportPatterns[0].exec(content)) !== null) { const [, kind, name] = match; if (kind === 'type' || kind === 'interface' || typeDefinitions.has(name)) { typeExports.push(name); } else { valueExports.push(name); } } // Handle second pattern: export { name1, name2 } exportPatterns[1].lastIndex = 0; while ((match = exportPatterns[1].exec(content)) !== null) { const exportsList = match[1] .split(',') .map((item) => item.trim()) .filter((item) => item && !item.includes(' as ')); for (const name of exportsList) { if (typeDefinitions.has(name)) { typeExports.push(name); } else { valueExports.push(name); } } } // Handle third pattern: export { name as alias } exportPatterns[2].lastIndex = 0; while ((match = exportPatterns[2].exec(content)) !== null) { const [, original, alias] = match; if (typeDefinitions.has(original)) { typeExports.push(alias); } else { valueExports.push(alias); } } // Handle fourth pattern: export default function name() exportPatterns[3].lastIndex = 0; while ((match = exportPatterns[3].exec(content)) !== null) { const name = match[1]; if (typeDefinitions.has(name)) { typeExports.push(name); } else { valueExports.push(name); } } // Handle fifth pattern: export type { Type1, Type2 } exportPatterns[4].lastIndex = 0; while ((match = exportPatterns[4].exec(content)) !== null) { const exportsList = match[1] .split(',') .map((item) => item.trim()) .filter((item) => item && !item.includes(' as ')); for (const name of exportsList) { typeExports.push(name); } } // Handle sixth pattern: export type { Original as Alias } exportPatterns[5].lastIndex = 0; while ((match = exportPatterns[5].exec(content)) !== null) { const [, original, alias] = match; typeExports.push(alias); } // Deduplicate and sort return { values: [...new Set(valueExports)].sort(), types: [...new Set(typeExports)].sort(), }; } catch (error) { console.error(`Failed to read file: ${filePath}`, error.message); return { values: [], types: [] }; } } /** * Process named export conversion for a single folder * @param {string} folderName - Folder name * @param {string} baseDir - Base directory */ function processFolder(folderName, baseDir = SRC_DIR) { const folderPath = path.join(baseDir, folderName); const indexFile = path.join(folderPath, 'index.ts'); console.log(`🔍 Processing folder: ${folderName}`); try { // Check if folder exists if (!fs.existsSync(folderPath) || !fs.statSync(folderPath).isDirectory()) { console.warn(`⚠️ Folder does not exist: ${folderName}`); return; } // Generate new named export content let newContent = ''; // Collect all subdirectory exports const subDirs = fs .readdirSync(folderPath, { withFileTypes: true }) .filter((item) => item.isDirectory() && !item.name.startsWith('.')) .map((item) => item.name); const namedExportsList = []; // Process all subdirectories for (const subDir of subDirs) { const subDirPath = path.join(folderPath, subDir); const subPossiblePaths = [ path.join(subDirPath, 'index.ts'), path.join(subDirPath, 'index.tsx'), path.join(subDirPath, `${subDir}.ts`), path.join(subDirPath, `${subDir}.tsx`), ]; const subFullPath = subPossiblePaths.find(fs.existsSync); if (!subFullPath) continue; const { values: subValues, types: subTypes } = extractNamedExports(subFullPath); if (subValues.length === 0 && subTypes.length === 0) continue; namedExportsList.push({ importPath: `./${subDir}`, values: subValues, types: subTypes }); console.log( `✅ Found exports in ${folderName}/${subDir}:\n (${subValues.length} values and ${subTypes.length} types)` ); } // Generate import statements for (const { importPath, values, types } of namedExportsList) { const imports = []; if (values.length > 0) { imports.push(...values); } if (types.length > 0) { imports.push(...types.map((type) => `type ${type}`)); } if (imports.length > 0) { newContent += `export { ${imports.join(', ')} } from '${importPath}'; `; } } // Write new content fs.writeFileSync(indexFile, newContent); console.log(`✅ Successfully updated ${folderName}/index.ts\n\n`); } catch (error) { console.error(`❌ Failed to process ${folderName}:`, error.message); console.error(error.stack); } } /** * Main function: Process all configured folders */ function convertAllFolders() { console.log('🚀 Starting to process all configured folders...\n'); for (const folder of folders) { processFolder(folder); } console.log('\n🎉 All folders processed successfully!'); processFolder('.'); console.log('\n🎉 Index of form materials is updated!'); } // If this script is run directly if (require.main === module) { convertAllFolders(); } module.exports = { convertAllFolders, extractNamedExports }; ================================================ FILE: packages/materials/form-materials/src/components/assign-row/index.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React from 'react'; import { IconButton } from '@douyinfe/semi-ui'; import { IconMinus } from '@douyinfe/semi-icons'; import { IFlowConstantRefValue } from '@/shared'; import { InjectVariableSelector } from '@/components/variable-selector'; import { InjectDynamicValueInput } from '@/components/dynamic-value-input'; import { BlurInput } from '@/components/blur-input'; import { AssignRowProps } from './types'; export function AssignRow(props: AssignRowProps) { const { value = { operator: 'assign', }, onChange, onDelete, readonly, } = props; return (
{value?.operator === 'assign' ? ( onChange?.({ ...value, left: { type: 'ref', content: v }, }) } /> ) : ( onChange?.({ ...value, left: v, }) } /> )}
onChange?.({ ...value, right: v, }) } />
{onDelete && (
} onClick={() => onDelete?.()} />
)}
); } export { type AssignValueType } from './types'; ================================================ FILE: packages/materials/form-materials/src/components/assign-row/types.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { IFlowRefValue, IFlowValue } from '@/shared'; export type AssignValueType = | { operator: 'assign'; left?: IFlowRefValue; right?: IFlowValue; } | { operator: 'declare'; left?: string; right?: IFlowValue; }; export interface AssignRowProps { value?: AssignValueType; onChange?: (value?: AssignValueType) => void; onDelete?: () => void; readonly?: boolean; } ================================================ FILE: packages/materials/form-materials/src/components/assign-rows/index.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React from 'react'; import { FieldArray } from '@flowgram.ai/editor'; import { Button } from '@douyinfe/semi-ui'; import { IconPlus } from '@douyinfe/semi-icons'; import { AssignRow, AssignValueType } from '@/components/assign-row'; interface AssignRowsProps { name: string; readonly?: boolean; defaultValue?: AssignValueType[]; } export function AssignRows(props: AssignRowsProps) { const { name, readonly, defaultValue } = props; return ( name={name} defaultValue={defaultValue}> {({ field }) => (
{field.map((childField, index) => ( { childField.onChange(value); }} onDelete={() => field.remove(index)} /> ))}
)} ); } ================================================ FILE: packages/materials/form-materials/src/components/batch-outputs/index.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React from 'react'; import { I18n } from '@flowgram.ai/editor'; import { Button, Input } from '@douyinfe/semi-ui'; import { IconDelete, IconPlus } from '@douyinfe/semi-icons'; import { useObjectList } from '@/hooks'; import { InjectVariableSelector } from '@/components/variable-selector'; import { PropsType } from './types'; import './styles.css'; export function BatchOutputs(props: PropsType) { const { readonly, style } = props; const { list, add, updateKey, updateValue, remove } = useObjectList(props); return (
{list.map((item) => (
updateKey(item.id, v)} /> updateValue(item.id, { type: 'ref', content: v })} />
))}
); } ================================================ FILE: packages/materials/form-materials/src/components/batch-outputs/styles.css ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ .gedit-m-batch-outputs-rows { display: flex; flex-direction: column; gap: 10px; margin-bottom: 10px; } .gedit-m-batch-outputs-row { display: flex; align-items: center; gap: 5px; } ================================================ FILE: packages/materials/form-materials/src/components/batch-outputs/types.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { IFlowRefValue } from '@/shared'; export type ValueType = Record; export interface OutputItem { id: number; key?: string; value?: IFlowRefValue; } export interface PropsType { value?: ValueType; onChange: (value?: ValueType) => void; readonly?: boolean; hasError?: boolean; style?: React.CSSProperties; } ================================================ FILE: packages/materials/form-materials/src/components/batch-variable-selector/index.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React from 'react'; import { IJsonSchema } from '@flowgram.ai/json-schema'; import { PrivateScopeProvider } from '@flowgram.ai/editor'; import { VariableSelector, VariableSelectorProps } from '@/components/variable-selector'; const batchVariableSchema: IJsonSchema = { type: 'array', extra: { weak: true }, }; export function BatchVariableSelector(props: VariableSelectorProps) { return ( ); } ================================================ FILE: packages/materials/form-materials/src/components/blur-input/index.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ /* eslint-disable react/prop-types */ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React, { useEffect, useState } from 'react'; import { Input } from '@douyinfe/semi-ui'; type InputProps = React.ComponentPropsWithRef; export function BlurInput(props: InputProps) { const [value, setValue] = useState(''); useEffect(() => { setValue(props.value as string); }, [props.value]); return ( { setValue(value); }} onBlur={(e) => { props.onChange?.(value, e); props.onBlur?.(e); }} /> ); } ================================================ FILE: packages/materials/form-materials/src/components/code-editor/editor-all.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { CodeEditorFactory } from './factory'; import { loadTypescriptLanguage } from './editor-ts'; import { loadSqlLanguage } from './editor-sql'; import { loadShellLanguage } from './editor-shell'; import { loadPythonLanguage } from './editor-python'; import { loadJsonLanguage } from './editor-json'; const languageLoaders: Record Promise> = { json: loadJsonLanguage, python: loadPythonLanguage, sql: loadSqlLanguage, typescript: loadTypescriptLanguage, shell: loadShellLanguage, }; /** * @deprecated CodeEditor will bundle all languages features, use XXXCodeEditor instead for better bundle experience */ export const CodeEditor = CodeEditorFactory( (languageId) => languageLoaders[languageId]?.(languageId), { displayName: 'CodeEditor', fixLanguageId: undefined, } ); ================================================ FILE: packages/materials/form-materials/src/components/code-editor/editor-json.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { languages } from '@flowgram.ai/coze-editor/preset-code'; import { mixLanguages } from '@flowgram.ai/coze-editor'; import { CodeEditorFactory } from './factory'; export const loadJsonLanguage = () => import('@flowgram.ai/coze-editor/language-json').then((module) => { languages.register('json', { // mixLanguages is used to solve the problem that interpolation also uses parentheses, which causes incorrect highlighting language: mixLanguages({ outerLanguage: module.json.language, }), languageService: module.json.languageService, }); }); export const JsonCodeEditor = CodeEditorFactory(loadJsonLanguage, { displayName: 'JsonCodeEditor', fixLanguageId: 'json', }); ================================================ FILE: packages/materials/form-materials/src/components/code-editor/editor-python.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { languages } from '@flowgram.ai/coze-editor/preset-code'; import { CodeEditorFactory } from './factory'; export const loadPythonLanguage = () => import('@flowgram.ai/coze-editor/language-python').then((module) => languages.register('python', module.python) ); export const PythonCodeEditor = CodeEditorFactory(loadPythonLanguage, { displayName: 'PythonCodeEditor', fixLanguageId: 'python', }); ================================================ FILE: packages/materials/form-materials/src/components/code-editor/editor-shell.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { languages } from '@flowgram.ai/coze-editor/preset-code'; import { CodeEditorFactory } from './factory'; export const loadShellLanguage = () => import('@flowgram.ai/coze-editor/language-shell').then((module) => languages.register('shell', module.shell) ); export const ShellCodeEditor = CodeEditorFactory(loadShellLanguage, { displayName: 'ShellCodeEditor', fixLanguageId: 'shell', }); ================================================ FILE: packages/materials/form-materials/src/components/code-editor/editor-sql.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { languages } from '@flowgram.ai/coze-editor/preset-code'; import { mixLanguages } from '@flowgram.ai/coze-editor'; import { CodeEditorFactory } from './factory'; export const loadSqlLanguage = () => import('@flowgram.ai/coze-editor/language-sql').then((module) => { languages.register('sql', { ...module.sql, language: mixLanguages({ outerLanguage: module.sql.language, }), }); }); export const SQLCodeEditor = CodeEditorFactory(loadSqlLanguage, { displayName: 'SQLCodeEditor', fixLanguageId: 'sql', }); ================================================ FILE: packages/materials/form-materials/src/components/code-editor/editor-ts.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { languages } from '@flowgram.ai/coze-editor/preset-code'; import { CodeEditorFactory } from './factory'; export const loadTypescriptLanguage = () => import('@flowgram.ai/coze-editor/language-typescript').then((module) => { languages.register('typescript', module.typescript); // Init TypeScript language service const tsWorker = new Worker( new URL(`@flowgram.ai/coze-editor/language-typescript/worker`, import.meta.url), { type: 'module' } ); module.typescript.languageService.initialize(tsWorker, { compilerOptions: { // eliminate Promise error lib: ['es2015', 'dom'], noImplicitAny: false, }, }); }); export const TypeScriptCodeEditor = CodeEditorFactory(loadTypescriptLanguage, { displayName: 'TypeScriptCodeEditor', fixLanguageId: 'typescript', }); ================================================ FILE: packages/materials/form-materials/src/components/code-editor/editor.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React, { useEffect, useRef } from 'react'; import { ActiveLinePlaceholder, createRenderer, EditorProvider, InferValues, } from '@flowgram.ai/coze-editor/react'; import preset, { type EditorAPI } from '@flowgram.ai/coze-editor/preset-code'; import { EditorView } from '@codemirror/view'; import { getSuffixByLanguageId } from './utils'; import './styles.css'; const OriginCodeEditor = createRenderer(preset, [ EditorView.theme({ '&.cm-focused': { outline: 'none', }, }), ]); // CSS styles are in styles.css type Preset = typeof preset; type Options = Partial>; export interface CodeEditorPropsType extends React.PropsWithChildren<{}> { value?: string; onChange?: (value: string) => void; languageId: 'python' | 'typescript' | 'shell' | 'json' | 'sql'; theme?: 'dark' | 'light'; placeholder?: string; activeLinePlaceholder?: string; readonly?: boolean; options?: Options; mini?: boolean; } export function BaseCodeEditor({ value, onChange, languageId = 'python', theme = 'light', children, placeholder, activeLinePlaceholder, options, readonly, mini, }: CodeEditorPropsType) { const editorRef = useRef(null); const editorValue = String(value || ''); useEffect(() => { // listen to value change if (editorRef.current?.getValue() !== editorValue) { // apply updates on readonly mode const editorView = editorRef.current?.$view; editorView?.dispatch({ changes: { from: 0, to: editorView?.state.doc.length, insert: editorValue, }, }); } }, [editorValue]); return (
{ editorRef.current = editor; }} onChange={(e) => onChange?.(e.value)} > {activeLinePlaceholder && ( {activeLinePlaceholder} )} {children}
); } ================================================ FILE: packages/materials/form-materials/src/components/code-editor/factory.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { useEffect, useMemo, useState } from 'react'; import React from 'react'; import { languages } from '@flowgram.ai/coze-editor/preset-code'; import { Skeleton } from '@douyinfe/semi-ui'; import { lazySuspense } from '@/shared'; import type { CodeEditorPropsType } from './editor'; export const BaseCodeEditor = lazySuspense(() => Promise.all([import('./editor'), import('./theme')]).then(([editorModule]) => ({ default: editorModule.BaseCodeEditor, })) ); interface FactoryParams { displayName: string; fixLanguageId: FixLanguageId extends true ? CodeEditorPropsType['languageId'] : undefined; } export const CodeEditorFactory = ( loadLanguage: (languageId: string) => Promise, { displayName, fixLanguageId }: FactoryParams ): FixLanguageId extends true ? React.FC> : React.FC => { const EditorWithLoad = (props: CodeEditorPropsType) => { const { languageId = fixLanguageId } = props; if (!languageId) { throw new Error('CodeEditorFactory: languageId is required'); } const [loaded, setLoaded] = useState(useMemo(() => !!languages.get(languageId), [languageId])); useEffect(() => { if (!loaded && loadLanguage) { loadLanguage(languageId).then(() => { setLoaded(true); }); } }, [languageId, loaded]); if (!loaded) { return ; } return ; }; EditorWithLoad.displayName = displayName; return EditorWithLoad as FixLanguageId extends true ? React.FC> : React.FC; }; ================================================ FILE: packages/materials/form-materials/src/components/code-editor/index.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export { CodeEditor } from './editor-all'; export { TypeScriptCodeEditor } from './editor-ts'; export { ShellCodeEditor } from './editor-shell'; export { JsonCodeEditor } from './editor-json'; export { SQLCodeEditor } from './editor-sql'; export { PythonCodeEditor } from './editor-python'; export { BaseCodeEditor, type CodeEditorPropsType } from './editor'; ================================================ FILE: packages/materials/form-materials/src/components/code-editor/styles.css ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ .gedit-m-code-editor-container { } .gedit-m-code-editor-container.mini { height: 24px; } ================================================ FILE: packages/materials/form-materials/src/components/code-editor/theme/dark.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { createTheme, tags as t } from '@flowgram.ai/coze-editor/preset-code'; import { type Extension } from '@codemirror/state'; export const colors = { background: '#24292e', foreground: '#d1d5da', selection: '#3392FF44', cursor: '#c8e1ff', dropdownBackground: '#24292e', dropdownBorder: '#1b1f23', activeLine: '#4d566022', matchingBracket: '#888892', keyword: '#9197F1', storage: '#f97583', variable: '#ffab70', variableName: '#D9DCFA', parameter: '#e1e4e8', function: '#FFCA66', string: '#FF9878', constant: '#79b8ff', type: '#79b8ff', class: '#b392f0', number: '#2EC7D9', comment: '#568B2A', heading: '#79b8ff', invalid: '#f97583', regexp: '#9ecbff', propertyName: '#9197F1', separator: '#888892', gutters: '#888892', moduleKeyword: '#CC4FD4', }; export const darkTheme: Extension = createTheme({ variant: 'dark', settings: { background: colors.background, foreground: colors.foreground, caret: colors.cursor, selection: colors.selection, gutterBackground: colors.background, gutterForeground: colors.foreground, gutterBorderColor: 'transparent', gutterBorderWidth: 0, lineHighlight: 'transparent', bracketColors: ['#FBBF24', '#A78BFA', '#7DD3FC'], tooltip: { backgroundColor: '#21262D', color: '#E6EDF3', border: '1px solid #30363D', }, link: { color: '#58A6FF', }, completionItemHover: { backgroundColor: '#21262D', }, completionItemSelected: { backgroundColor: colors.selection, color: colors.foreground, }, completionItemIcon: { color: '#8B949E', }, completionItemLabel: { color: '#E6EDF3', }, completionItemInfo: { color: '#8B949E', }, completionItemDetail: { color: '#6E7681', }, }, styles: [ { tag: t.keyword, color: colors.keyword }, { tag: t.variableName, color: colors.variableName }, { tag: [t.name, t.deleted, t.character, t.macroName], color: colors.variable, }, { tag: [t.propertyName], color: colors.propertyName }, { tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)], color: colors.string, }, { tag: [t.function(t.variableName), t.function(t.propertyName), t.labelName], color: colors.function, }, { tag: [t.moduleKeyword, t.controlKeyword], color: colors.moduleKeyword, }, { tag: [t.color, t.constant(t.name), t.standard(t.name)], color: colors.constant, }, { tag: t.definition(t.name), color: colors.variable }, { tag: [t.className], color: colors.class }, { tag: [t.number, t.changed, t.annotation, t.modifier, t.self, t.namespace], color: colors.number, }, { tag: [t.typeName], color: colors.type, fontStyle: colors.type }, { tag: [t.operatorKeyword], color: colors.keyword }, { tag: [t.url, t.escape, t.regexp, t.link], color: colors.regexp }, { tag: [t.meta, t.comment], color: colors.comment }, { tag: t.strong, fontWeight: 'bold' }, { tag: t.emphasis, fontStyle: 'italic' }, { tag: t.link, textDecoration: 'underline' }, { tag: t.heading, fontWeight: 'bold', color: colors.heading }, { tag: [t.atom, t.bool, t.special(t.variableName)], color: colors.variable }, { tag: t.invalid, color: colors.invalid }, { tag: t.strikethrough, textDecoration: 'line-through' }, { tag: t.separator, color: colors.separator }, ], }); ================================================ FILE: packages/materials/form-materials/src/components/code-editor/theme/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { themes } from '@flowgram.ai/coze-editor/preset-code'; import { lightTheme } from './light'; import { darkTheme } from './dark'; themes.register('dark', darkTheme); themes.register('light', lightTheme); ================================================ FILE: packages/materials/form-materials/src/components/code-editor/theme/light.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { createTheme, tags as t } from '@flowgram.ai/coze-editor/preset-code'; import { type Extension } from '@codemirror/state'; export const colors = { background: '#f4f5f5', foreground: '#444d56', selection: '#0366d625', cursor: '#044289', dropdownBackground: '#fff', dropdownBorder: '#e1e4e8', activeLine: '#c6c6c622', matchingBracket: '#34d05840', keyword: '#d73a49', storage: '#d73a49', variable: '#e36209', parameter: '#24292e', function: '#005cc5', string: '#032f62', constant: '#005cc5', type: '#005cc5', class: '#6f42c1', number: '#005cc5', comment: '#6a737d', heading: '#005cc5', invalid: '#cb2431', regexp: '#032f62', }; export const lightTheme: Extension = createTheme({ variant: 'light', settings: { background: colors.background, foreground: colors.foreground, caret: colors.cursor, selection: colors.selection, gutterBackground: colors.background, gutterForeground: colors.foreground, gutterBorderColor: 'transparent', gutterBorderWidth: 0, lineHighlight: 'transparent', bracketColors: ['#F59E0B', '#8B5CF6', '#06B6D4'], tooltip: { backgroundColor: colors.dropdownBackground, color: colors.foreground, border: 'none', boxShadow: '0 0 1px rgba(0, 0, 0, .3), 0 4px 14px rgba(0, 0, 0, .1)!important', maxWidth: '400px', }, link: { color: '#2563EB', caret: colors.cursor, }, completionItemHover: { backgroundColor: '#F3F4F6', }, completionItemSelected: { backgroundColor: colors.selection, color: colors.foreground, }, completionItemIcon: { color: '#4B5563', }, completionItemLabel: { color: '#1F2937', }, completionItemInfo: { color: '#4B5563', }, completionItemDetail: { color: '#6B7280', }, }, styles: [ { tag: t.keyword, color: colors.keyword }, { tag: [t.name, t.deleted, t.character, t.macroName], color: colors.variable, }, { tag: [t.propertyName], color: colors.function }, { tag: [t.processingInstruction, t.string, t.inserted, t.special(t.string)], color: colors.string, }, { tag: [t.function(t.variableName), t.labelName], color: colors.function }, { tag: [t.color, t.constant(t.name), t.standard(t.name)], color: colors.constant, }, { tag: [t.definition(t.name), t.separator], color: colors.variable }, { tag: [t.className], color: colors.class }, { tag: [t.number, t.changed, t.annotation, t.modifier, t.self, t.namespace], color: colors.number, }, { tag: [t.typeName], color: colors.type, fontStyle: colors.type }, { tag: [t.operator, t.operatorKeyword], color: colors.keyword }, { tag: [t.url, t.escape, t.regexp, t.link], color: colors.regexp }, { tag: [t.meta, t.comment], color: colors.comment }, { tag: t.strong, fontWeight: 'bold' }, { tag: t.emphasis, fontStyle: 'italic' }, { tag: t.link, textDecoration: 'underline' }, { tag: t.heading, fontWeight: 'bold', color: colors.heading }, { tag: [t.atom, t.bool, t.special(t.variableName)], color: colors.variable }, { tag: t.invalid, color: colors.invalid }, { tag: t.strikethrough, textDecoration: 'line-through' }, ], }); ================================================ FILE: packages/materials/form-materials/src/components/code-editor/utils.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export function getSuffixByLanguageId(languageId: string) { if (languageId === 'python') { return '.py'; } if (languageId === 'typescript') { return '.ts'; } if (languageId === 'shell') { return '.sh'; } if (languageId === 'json') { return '.json'; } if (languageId === 'sql') { return '.sql'; } return ''; } ================================================ FILE: packages/materials/form-materials/src/components/code-editor-mini/index.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React from 'react'; import { CodeEditor, type CodeEditorPropsType } from '@/components/code-editor'; /** * @deprecated use mini in CodeEditorPropsType instead */ export function CodeEditorMini(props: CodeEditorPropsType) { return (
); } ================================================ FILE: packages/materials/form-materials/src/components/condition-context/context.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React, { createContext, useContext } from 'react'; import { IConditionRule, ConditionOpConfigs } from './types'; import { defaultConditionOpConfigs } from './op'; interface ContextType { rules?: Record; ops?: ConditionOpConfigs; } export const ConditionContext = createContext({ rules: {}, ops: defaultConditionOpConfigs, }); export const ConditionProvider = (props: React.PropsWithChildren) => { const { rules, ops } = props; return ( {props.children} ); }; export const useConditionContext = () => useContext(ConditionContext); ================================================ FILE: packages/materials/form-materials/src/components/condition-context/hooks/use-condition.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { useEffect, useMemo, useRef } from 'react'; import { IJsonSchema } from '@flowgram.ai/json-schema'; import { I18n } from '@flowgram.ai/editor'; import { useTypeManager } from '@/plugins'; import { IConditionRule, ConditionOpConfigs } from '../types'; import { useConditionContext } from '../context'; interface HooksParams { /** * Left schema of condition */ leftSchema?: IJsonSchema; /** * Operator of condition */ operator?: string; /** * If op is not in opOptionList, clear it */ onClearOp?: () => void; /** * If targetSchema updated, clear it */ onClearRight?: () => void; /** * @deprecated use ConditionProvider instead * custom rule config */ ruleConfig?: { ops?: ConditionOpConfigs; rules?: Record; }; } export function useCondition({ leftSchema, operator, onClearOp, onClearRight, ruleConfig, }: HooksParams) { const typeManager = useTypeManager(); const { rules: contextRules, ops: contextOps } = useConditionContext(); // Merge user rules and context rules const userRules = useMemo( () => ruleConfig?.rules || contextRules || {}, [contextRules, ruleConfig?.rules] ); // Merge user operators and context operators const allOps = useMemo(() => ruleConfig?.ops || contextOps || {}, [contextOps, ruleConfig?.ops]); // Get type configuration const config = useMemo( () => (leftSchema ? typeManager.getTypeBySchema(leftSchema) : undefined), [leftSchema, typeManager] ); // Calculate rule const rule = useMemo(() => { if (!config) { return undefined; } if (userRules[config.type]) { return userRules[config.type]; } if (typeof config.conditionRule === 'function') { return config.conditionRule(leftSchema); } return config.conditionRule; }, [userRules, leftSchema, config]); // Calculate operator option list const opOptionList = useMemo( () => Object.keys(rule || {}) .filter((_op) => allOps[_op]) .map((_op) => ({ ...(allOps?.[_op] || {}), value: _op, label: I18n.t(allOps?.[_op]?.label || _op), })), [rule, allOps] ); // When op not in list, clear it useEffect(() => { if (!operator || !rule) { return; } if (!opOptionList.find((item) => item.value === operator)) { onClearOp?.(); } }, [operator, opOptionList, onClearOp]); // get target schema const targetSchema = useMemo(() => { const targetType: string | IJsonSchema | null = rule?.[operator || ''] || null; if (!targetType) { return undefined; } if (typeof targetType === 'string') { return { type: targetType, extra: { weak: true } }; } return targetType; }, [rule, operator]); const prevTargetSchemaRef = useRef(undefined); // When type of target schema updated, clear it useEffect(() => { if (!prevTargetSchemaRef.current) { prevTargetSchemaRef.current = targetSchema; return; } if (prevTargetSchemaRef.current?.type !== targetSchema?.type) { onClearRight?.(); } prevTargetSchemaRef.current = targetSchema; }, [targetSchema, onClearRight]); // get current operator config const opConfig = useMemo(() => allOps[operator || ''], [operator, allOps]); return { rule, opConfig, opOptionList, targetSchema, }; } ================================================ FILE: packages/materials/form-materials/src/components/condition-context/index.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export { type IConditionRule, type IConditionRuleFactory, type ConditionOpConfigs, type ConditionOpConfig, } from './types'; export { ConditionPresetOp } from './op'; export { ConditionProvider, useConditionContext } from './context'; export { useCondition } from './hooks/use-condition'; ================================================ FILE: packages/materials/form-materials/src/components/condition-context/op.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { ConditionOpConfigs } from './types'; export enum ConditionPresetOp { EQ = 'eq', NEQ = 'neq', GT = 'gt', GTE = 'gte', LT = 'lt', LTE = 'lte', IN = 'in', NIN = 'nin', CONTAINS = 'contains', NOT_CONTAINS = 'not_contains', IS_EMPTY = 'is_empty', IS_NOT_EMPTY = 'is_not_empty', IS_TRUE = 'is_true', IS_FALSE = 'is_false', } export const defaultConditionOpConfigs: ConditionOpConfigs = { [ConditionPresetOp.EQ]: { label: 'Equal', abbreviation: '=', }, [ConditionPresetOp.NEQ]: { label: 'Not Equal', abbreviation: '≠', }, [ConditionPresetOp.GT]: { label: 'Greater Than', abbreviation: '>', }, [ConditionPresetOp.GTE]: { label: 'Greater Than or Equal', abbreviation: '>=', }, [ConditionPresetOp.LT]: { label: 'Less Than', abbreviation: '<', }, [ConditionPresetOp.LTE]: { label: 'Less Than or Equal', abbreviation: '<=', }, [ConditionPresetOp.IN]: { label: 'In', abbreviation: '∈', }, [ConditionPresetOp.NIN]: { label: 'Not In', abbreviation: '∉', }, [ConditionPresetOp.CONTAINS]: { label: 'Contains', abbreviation: '⊇', }, [ConditionPresetOp.NOT_CONTAINS]: { label: 'Not Contains', abbreviation: '⊉', }, [ConditionPresetOp.IS_EMPTY]: { label: 'Is Empty', abbreviation: '=', rightDisplay: 'Empty', }, [ConditionPresetOp.IS_NOT_EMPTY]: { label: 'Is Not Empty', abbreviation: '≠', rightDisplay: 'Empty', }, [ConditionPresetOp.IS_TRUE]: { label: 'Is True', abbreviation: '=', rightDisplay: 'True', }, [ConditionPresetOp.IS_FALSE]: { label: 'Is False', abbreviation: '=', rightDisplay: 'False', }, }; ================================================ FILE: packages/materials/form-materials/src/components/condition-context/types.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { type IJsonSchema } from '@flowgram.ai/json-schema'; export interface ConditionOpConfig { label: string; abbreviation: string; // When right is not a value, display this text rightDisplay?: string; } export type OpKey = string; export type ConditionOpConfigs = Record; export type IConditionRule = Record; export type IConditionRuleFactory = ( schema?: IJsonSchema ) => Record; ================================================ FILE: packages/materials/form-materials/src/components/condition-row/index.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React, { useMemo } from 'react'; import { JsonSchemaUtils } from '@flowgram.ai/json-schema'; import { I18n, useScopeAvailable } from '@flowgram.ai/editor'; import { Button, Input, Select } from '@douyinfe/semi-ui'; import { IconChevronDownStroked } from '@douyinfe/semi-icons'; import { InjectVariableSelector } from '@/components/variable-selector'; import { InjectDynamicValueInput } from '@/components/dynamic-value-input'; import { IConditionRule, ConditionOpConfigs, useCondition } from '@/components/condition-context'; import { ConditionRowValueType } from './types'; import './styles.css'; interface PropTypes { value?: ConditionRowValueType; onChange: (value?: ConditionRowValueType) => void; style?: React.CSSProperties; readonly?: boolean; /** * @deprecated use ConditionContext instead to pass ruleConfig to multiple */ ruleConfig?: { ops?: ConditionOpConfigs; rules?: Record; }; } export function ConditionRow({ style, value, onChange, readonly, ruleConfig }: PropTypes) { const { left, operator, right } = value || {}; const available = useScopeAvailable(); const variable = useMemo(() => { if (!left) return undefined; return available.getByKeyPath(left.content); }, [available, left]); const leftSchema = useMemo(() => { if (!variable) return undefined; return JsonSchemaUtils.astToSchema(variable.type, { drilldown: false }); }, [variable?.type?.hash]); const { rule, opConfig, opOptionList, targetSchema } = useCondition({ leftSchema, operator, ruleConfig, onClearOp() { onChange({ ...value, operator: undefined, }); }, onClearRight() { onChange({ ...value, right: undefined, }); }, }); const renderOpSelect = () => ( )}
); } export { type ConditionRowValueType }; ================================================ FILE: packages/materials/form-materials/src/components/condition-row/styles.css ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ .gedit-m-condition-row-container { display: flex; align-items: center; gap: 4px; } .gedit-m-condition-row-operator { } .gedit-m-condition-row-left { width: 100%; } .gedit-m-condition-row-right { width: 100%; } .gedit-m-condition-row-values { flex-grow: 1; display: flex; flex-direction: column; align-items: center; gap: 4px; overflow: hidden; } ================================================ FILE: packages/materials/form-materials/src/components/condition-row/types.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { IFlowConstantRefValue, IFlowRefValue } from '@/shared'; export interface ConditionRowValueType { left?: IFlowRefValue; operator?: string; right?: IFlowConstantRefValue; } ================================================ FILE: packages/materials/form-materials/src/components/constant-input/index.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ /* eslint-disable react/prop-types */ import React, { useMemo } from 'react'; import { Input } from '@douyinfe/semi-ui'; import { useTypeManager } from '@/plugins'; import { PropsType, Strategy as ConstantInputStrategy } from './types'; export { type ConstantInputStrategy }; export function ConstantInput(props: PropsType) { const { value, onChange, schema, strategies, fallbackRenderer, readonly, ...rest } = props; const typeManager = useTypeManager(); const Renderer = useMemo(() => { const strategy = (strategies || []).find((_strategy) => _strategy.hit(schema)); if (!strategy) { return typeManager.getTypeBySchema(schema)?.ConstantRenderer; } return strategy?.Renderer; }, [strategies, schema]); if (!Renderer) { if (fallbackRenderer) { return React.createElement(fallbackRenderer, { value, onChange, readonly, ...rest, }); } return ; } return ; } ================================================ FILE: packages/materials/form-materials/src/components/constant-input/types.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { IJsonSchema } from '@flowgram.ai/json-schema'; import { ConstantRendererProps } from '@/plugins'; export interface Strategy { hit: (schema: IJsonSchema) => boolean; Renderer: React.FC>; } export interface PropsType extends ConstantRendererProps { schema: IJsonSchema; strategies?: Strategy[]; fallbackRenderer?: React.FC; [key: string]: any; } ================================================ FILE: packages/materials/form-materials/src/components/coze-editor-extensions/extensions/inputs-tree.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React, { useMemo, useEffect, useState } from 'react'; import { isPlainObject, last } from 'lodash-es'; import { type ArrayType, ASTMatch, type BaseType, type BaseVariableField, useCurrentScope, } from '@flowgram.ai/editor'; import { Mention, MentionOpenChangeEvent, getCurrentMentionReplaceRange, useEditor, PositionMirror, } from '@flowgram.ai/coze-editor/react'; import { EditorAPI } from '@flowgram.ai/coze-editor/preset-prompt'; import { type TreeNodeData } from '@douyinfe/semi-ui/lib/es/tree'; import { Tree, Popover } from '@douyinfe/semi-ui'; import { IInputsValues } from '@/shared/flow-value/types'; import { FlowValueUtils } from '@/shared'; type VariableField = BaseVariableField<{ icon?: string | JSX.Element; title?: string }>; export function InputsPicker({ inputsValues, onSelect, }: { inputsValues: IInputsValues; onSelect: (v: string) => void; }) { const scope = useCurrentScope(); const getArrayDrilldown = (type: ArrayType, depth = 1): { type: BaseType; depth: number } => { if (ASTMatch.isArray(type.items)) { return getArrayDrilldown(type.items, depth + 1); } return { type: type.items, depth: depth }; }; const renderVariable = (variable: VariableField, keyPath: string[]): TreeNodeData => { let type = variable?.type; let children: TreeNodeData[] | undefined; if (ASTMatch.isObject(type)) { children = (type.properties || []) .map((_property) => renderVariable(_property as VariableField, [...keyPath, _property.key])) .filter(Boolean) as TreeNodeData[]; } if (ASTMatch.isArray(type)) { const drilldown = getArrayDrilldown(type); if (ASTMatch.isObject(drilldown.type)) { children = (drilldown.type.properties || []) .map((_property) => renderVariable(_property as VariableField, [ ...keyPath, ...new Array(drilldown.depth).fill('[0]'), _property.key, ]) ) .filter(Boolean) as TreeNodeData[]; } } const key = keyPath .map((_key, idx) => (_key === '[0]' || idx === 0 ? _key : `.${_key}`)) .join(''); return { key: key, label: last(keyPath), value: key, children, }; }; const getTreeData = (value: any, keyPath: string[]): TreeNodeData | undefined => { const currKey = keyPath.join('.'); if (FlowValueUtils.isFlowValue(value)) { if (FlowValueUtils.isRef(value)) { const variable = scope?.available?.getByKeyPath(value.content || []); if (variable) { return renderVariable(variable, keyPath); } } return { key: currKey, value: currKey, label: last(keyPath), }; } if (isPlainObject(value)) { return { key: currKey, value: currKey, label: last(keyPath), children: Object.entries(value) .map(([key, value]) => getTreeData(value, [...keyPath, key])!) .filter(Boolean), }; } }; const treeData: TreeNodeData[] = useMemo( () => Object.entries(inputsValues) .map(([key, value]) => getTreeData(value, [key])!) .filter(Boolean), [] ); return onSelect(v)} />; } const DEFAULT_TRIGGER_CHARACTERS = ['{', '{}', '@']; export function InputsTree({ inputsValues, triggerCharacters = DEFAULT_TRIGGER_CHARACTERS, }: { inputsValues: IInputsValues; triggerCharacters?: string[]; }) { const [posKey, setPosKey] = useState(''); const [visible, setVisible] = useState(false); const [position, setPosition] = useState(-1); const editor = useEditor(); function insert(variablePath: string) { const range = getCurrentMentionReplaceRange(editor.$view.state); if (!range) { return; } /** * When user input {{xxxx}}, {{{xxx}}}(more brackets if possible), replace all brackets with {{xxxx}} */ let { from, to } = range; while (editor.$view.state.doc.sliceString(from - 1, from) === '{') { from--; } while (editor.$view.state.doc.sliceString(to, to + 1) === '}') { to++; } editor.replaceText({ ...range, text: '{{' + variablePath + '}}', }); setVisible(false); } function handleOpenChange(e: MentionOpenChangeEvent) { setPosition(e.state.selection.main.head); setVisible(e.value); } useEffect(() => { if (!editor) { return; } }, [editor, visible]); return ( <> { insert(v); }} />
} > {/* PositionMirror allows the Popover to appear at the specified cursor position */} setPosKey(String(Math.random()))} /> ); } ================================================ FILE: packages/materials/form-materials/src/components/coze-editor-extensions/extensions/variable-tag.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React, { useLayoutEffect } from 'react'; import { isEqual, last } from 'lodash-es'; import { BaseVariableField, Disposable, DisposableCollection, Scope, useCurrentScope, } from '@flowgram.ai/editor'; import { useInjector } from '@flowgram.ai/coze-editor/react'; import { Popover, Tag } from '@douyinfe/semi-ui'; import { IconIssueStroked } from '@douyinfe/semi-icons'; import { Decoration, DecorationSet, EditorView, MatchDecorator, ViewPlugin, WidgetType, } from '@codemirror/view'; import { IPolyfillRoot, polyfillCreateRoot } from '@/shared'; import '../styles.css'; class VariableTagWidget extends WidgetType { keyPath?: string[]; toDispose = new DisposableCollection(); scope: Scope; root: IPolyfillRoot; constructor({ keyPath, scope }: { keyPath?: string[]; scope: Scope }) { super(); this.keyPath = keyPath; this.scope = scope; } renderIcon = (icon: string | JSX.Element) => { if (typeof icon === 'string') { return ; } return icon; }; renderVariable(v?: BaseVariableField) { if (!v) { this.root.render( Unknown ); return; } const rootField = last(v.parentFields) || v; const isRoot = v === rootField; const rootTitle = ( {rootField.meta?.title ? `${rootField.meta.title} ${isRoot ? '' : '-'} ` : ''} ); const rootIcon = this.renderIcon(rootField?.meta.icon); this.root.render( {rootIcon} {rootTitle} {v?.keyPath.slice(1).join('.')}
} > {rootIcon} {rootTitle} {!isRoot && {v?.key}} ); } toDOM(view: EditorView): HTMLElement { const dom = document.createElement('span'); this.root = polyfillCreateRoot(dom); this.toDispose.push( Disposable.create(() => { this.root.unmount(); }) ); const refresh = () => { this.renderVariable(this.scope.available.getByKeyPath(this.keyPath)); }; this.toDispose.push( this.scope.available.trackByKeyPath(this.keyPath, refresh, { triggerOnInit: false }) ); if (this.keyPath?.[0]) { this.toDispose.push( // listen to root title changed this.scope.available.trackByKeyPath<{ title?: string }>([this.keyPath[0]], refresh, { selector: (curr) => ({ ...curr?.meta }), triggerOnInit: false, }) ); } refresh(); return dom; } eq(other: VariableTagWidget) { return isEqual(this.keyPath, other.keyPath); } ignoreEvent(): boolean { return false; } destroy(dom: HTMLElement): void { this.toDispose.dispose(); } } export function VariableTagInject() { const injector = useInjector(); const scope = useCurrentScope({ strict: true }); // 基于 {{var}} 的正则进行匹配,匹配后进行自定义渲染 useLayoutEffect(() => { const atMatcher = new MatchDecorator({ regexp: /\{\{([^\}\{]+)\}\}/g, decoration: (match) => Decoration.replace({ widget: new VariableTagWidget({ keyPath: match[1]?.split('.') ?? [], scope, }), }), }); return injector.inject([ ViewPlugin.fromClass( class { decorations: DecorationSet; constructor(private view: EditorView) { this.decorations = atMatcher.createDeco(view); } update() { this.decorations = atMatcher.createDeco(this.view); } }, { decorations: (p) => p.decorations, provide(p) { return EditorView.atomicRanges.of( (view) => view.plugin(p)?.decorations ?? Decoration.none ); }, } ), ]); }, [injector]); return null; } ================================================ FILE: packages/materials/form-materials/src/components/coze-editor-extensions/extensions/variable-tree.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React, { useCallback, useEffect, useState } from 'react'; import { debounce } from 'lodash-es'; import { Mention, MentionOpenChangeEvent, getCurrentMentionReplaceRange, useEditor, PositionMirror, } from '@flowgram.ai/coze-editor/react'; import { EditorAPI } from '@flowgram.ai/coze-editor/preset-prompt'; import { Popover, Tree } from '@douyinfe/semi-ui'; import { useVariableTree } from '@/components/variable-selector'; const DEFAULT_TRIGGER_CHARACTER = ['{', '{}', '@']; export function VariableTree({ triggerCharacters = DEFAULT_TRIGGER_CHARACTER, }: { triggerCharacters?: string[]; }) { const [posKey, setPosKey] = useState(''); const [visible, setVisible] = useState(false); const [position, setPosition] = useState(-1); const editor = useEditor(); function insert(variablePath: string) { const range = getCurrentMentionReplaceRange(editor.$view.state); if (!range) { return; } /** * When user input {{xxxx}}, {{{xxx}}}(more brackets if possible), replace all brackets with {{xxxx}} */ let { from, to } = range; while (editor.$view.state.doc.sliceString(from - 1, from) === '{') { from--; } while (editor.$view.state.doc.sliceString(to, to + 1) === '}') { to++; } editor.replaceText({ from, to, text: '{{' + variablePath + '}}', }); setVisible(false); } function handleOpenChange(e: MentionOpenChangeEvent) { setPosition(e.state.selection.main.head); setVisible(e.value); } useEffect(() => { if (!editor) { return; } }, [editor, visible]); const treeData = useVariableTree({}); const debounceUpdatePosKey = useCallback( debounce(() => setPosKey(String(Math.random())), 100), [] ); return ( <> { // When Expand, an animation is triggered, so we need to update the position by debounce debounceUpdatePosKey(); }} onSelect={(v) => { insert(v); }} />
} > {/* PositionMirror allows the Popover to appear at the specified cursor position */} { // Update immediately to avoid the popover position lagging behind the cursor setPosKey(String(Math.random())); }} /> ); } ================================================ FILE: packages/materials/form-materials/src/components/coze-editor-extensions/index.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { lazy } from 'react'; import { createInjectMaterial } from '@/shared'; export const EditorVariableTree = createInjectMaterial( lazy(() => import('./extensions/variable-tree').then((module) => ({ default: module.VariableTree })) ), { renderKey: 'EditorVariableTree', } ); export const EditorVariableTagInject = createInjectMaterial( lazy(() => import('./extensions/variable-tag').then((module) => ({ default: module.VariableTagInject })) ), { renderKey: 'EditorVariableTagInject', } ); export const EditorInputsTree = createInjectMaterial( lazy(() => import('./extensions/inputs-tree').then((module) => ({ default: module.InputsTree }))), { renderKey: 'EditorInputsTree', } ); ================================================ FILE: packages/materials/form-materials/src/components/coze-editor-extensions/styles.css ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ .gedit-m-coze-editor-root-title { margin-right: 4px; min-width: 20px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--semi-color-text-2); } .gedit-m-coze-editor-var-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .gedit-m-coze-editor-tag { display: inline-flex; align-items: center; justify-content: flex-start; max-width: 300px; & .semi-tag-content-center { justify-content: flex-start; } &.semi-tag { margin: 0 5px; } } .gedit-m-coze-editor-popover-content { padding: 10px; display: inline-flex; align-items: center; justify-content: flex-start; } ================================================ FILE: packages/materials/form-materials/src/components/db-condition-row/index.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React, { useMemo } from 'react'; import { I18n } from '@flowgram.ai/editor'; import { Button, Icon, Input, Select } from '@douyinfe/semi-ui'; import { IconChevronDownStroked } from '@douyinfe/semi-icons'; import { useTypeManager } from '@/plugins'; import { InjectDynamicValueInput } from '@/components/dynamic-value-input'; import { useCondition, type ConditionOpConfigs, type IConditionRule, } from '@/components/condition-context'; import { DBConditionOptionType, DBConditionRowValueType } from './types'; import './styles.css'; interface PropTypes { value?: DBConditionRowValueType; onChange: (value?: DBConditionRowValueType) => void; style?: React.CSSProperties; options?: DBConditionOptionType[]; readonly?: boolean; /** * @deprecated use ConditionContext instead to pass ruleConfig to multiple */ ruleConfig?: { ops?: ConditionOpConfigs; rules?: Record; }; } export function DBConditionRow({ style, value, onChange, readonly, options, ruleConfig, }: PropTypes) { const { left, operator, right } = value || {}; const typeManager = useTypeManager(); const leftSchema = useMemo( () => options?.find((item) => item.value === left)?.schema, [left, options] ); const { opConfig, rule, opOptionList, targetSchema } = useCondition({ leftSchema, operator, ruleConfig, onClearOp() { onChange({ ...value, operator: undefined, }); }, onClearRight() { onChange({ ...value, right: undefined, }); }, }); const renderDBOptionSelect = () => ( { onChange({ ...value, operator: v as string, }); }} triggerRender={({ value }) => ( )} /> ); return (
{renderOpSelect()}
{renderDBOptionSelect()}
{targetSchema ? ( onChange({ ...value, right: v })} /> ) : ( )}
); } export { type DBConditionRowValueType, type DBConditionOptionType }; ================================================ FILE: packages/materials/form-materials/src/components/db-condition-row/styles.css ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ .gedit-m-db-condition-row-container { display: flex; align-items: center; gap: 4px; } .gedit-m-db-condition-row-operator { } .gedit-m-db-condition-row-left { width: 100%; } .gedit-m-db-condition-row-right { width: 100%; } .gedit-m-db-condition-row-values { flex-grow: 1; display: flex; flex-direction: column; align-items: center; gap: 4px; } .gedit-m-db-condition-row-option-label { display: flex; align-items: center; gap: 10px; } .gedit-m-db-condition-row-select { & .semi-select-selection { margin-left: 5px; } } ================================================ FILE: packages/materials/form-materials/src/components/db-condition-row/types.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { IJsonSchema } from '@flowgram.ai/json-schema'; import { IFlowConstantRefValue } from '@/shared'; export interface DBConditionRowValueType { left?: string; operator?: string; right?: IFlowConstantRefValue; } export interface DBConditionOptionType { label: string | JSX.Element; value: string; schema: IJsonSchema; } ================================================ FILE: packages/materials/form-materials/src/components/display-flow-value/index.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React, { useMemo } from 'react'; import { JsonSchemaTypeManager, JsonSchemaUtils } from '@flowgram.ai/json-schema'; import { useScopeAvailable } from '@flowgram.ai/editor'; import { IFlowValue } from '@/shared'; import { FlowValueUtils } from '@/shared'; import { DisplaySchemaTag } from '@/components/display-schema-tag'; interface PropsType { value?: IFlowValue; title?: JSX.Element | string; showIconInTree?: boolean; typeManager?: JsonSchemaTypeManager; } export function DisplayFlowValue({ value, title, showIconInTree }: PropsType) { const available = useScopeAvailable(); const variable = value?.type === 'ref' ? available.getByKeyPath(value?.content) : undefined; const schema = useMemo(() => { if (value?.type === 'ref') { return JsonSchemaUtils.astToSchema(variable?.type); } if (value?.type === 'template') { return { type: 'string' }; } if (value?.type === 'constant') { return FlowValueUtils.inferConstantJsonSchema(value); } return { type: 'unknown' }; }, [value, variable?.hash]); return ( ); } ================================================ FILE: packages/materials/form-materials/src/components/display-inputs-values/index.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React, { useMemo } from 'react'; import { isPlainObject } from 'lodash-es'; import { useScopeAvailable } from '@flowgram.ai/editor'; import { IInputsValues } from '@/shared/flow-value'; import { FlowValueUtils } from '@/shared'; import { DisplayFlowValue } from '@/components/display-flow-value'; import './styles.css'; import { DisplaySchemaTag } from '../display-schema-tag'; interface PropsType { value?: IInputsValues; showIconInTree?: boolean; } export function DisplayInputsValues({ value, showIconInTree }: PropsType) { const childEntries = Object.entries(value || {}); return (
{childEntries.map(([key, value]) => { if (FlowValueUtils.isFlowValue(value)) { return ( ); } if (isPlainObject(value)) { return ( ); } return null; })}
); } export function DisplayInputsValueAllInTag({ value, title, showIconInTree, }: PropsType & { title: string; }) { const available = useScopeAvailable(); const schema = useMemo( () => FlowValueUtils.inferJsonSchema(value, available.scope), [available.version, value] ); return ; } ================================================ FILE: packages/materials/form-materials/src/components/display-inputs-values/styles.css ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ .gedit-m-display-inputs-wrapper { display: flex; gap: 5px; flex-wrap: wrap; } ================================================ FILE: packages/materials/form-materials/src/components/display-outputs/index.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React, { useLayoutEffect } from 'react'; import { IJsonSchema, JsonSchemaTypeManager, JsonSchemaUtils } from '@flowgram.ai/json-schema'; import { useCurrentScope, useRefresh } from '@flowgram.ai/editor'; import { DisplaySchemaTag } from '@/components/display-schema-tag'; import './styles.css'; interface PropsType { value?: IJsonSchema; showIconInTree?: boolean; displayFromScope?: boolean; typeManager?: JsonSchemaTypeManager; style?: React.CSSProperties; } export function DisplayOutputs({ value, showIconInTree, displayFromScope, style }: PropsType) { const scope = useCurrentScope(); const refresh = useRefresh(); useLayoutEffect(() => { if (!displayFromScope || !scope) { return () => null; } const disposable = scope.output.onListOrAnyVarChange(() => { refresh(); }); return () => { disposable.dispose(); }; }, [displayFromScope]); const properties: IJsonSchema['properties'] = displayFromScope ? (scope?.output.variables || []).reduce((acm, curr) => { acm = { ...acm, ...(JsonSchemaUtils.astToSchema(curr.type)?.properties || {}), }; return acm; }, {}) : value?.properties || {}; const childEntries = Object.entries(properties || {}); return (
{childEntries.map(([key, schema]) => ( ))}
); } ================================================ FILE: packages/materials/form-materials/src/components/display-outputs/styles.css ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ .gedit-m-display-outputs-wrapper { display: flex; gap: 5px; flex-wrap: wrap; } ================================================ FILE: packages/materials/form-materials/src/components/display-schema-tag/index.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React from 'react'; import { IJsonSchema } from '@flowgram.ai/json-schema'; import { Popover, Tag } from '@douyinfe/semi-ui'; import { useTypeManager } from '@/plugins'; import { DisplaySchemaTree } from '@/components/display-schema-tree'; import './styles.css'; interface PropsType { title?: JSX.Element | string; value?: IJsonSchema; showIconInTree?: boolean; warning?: boolean; } export function DisplaySchemaTag({ value = {}, showIconInTree, title, warning }: PropsType) { const typeManager = useTypeManager(); const icon = typeManager?.getDisplayIcon(value) || typeManager.getDisplayIcon({ type: 'unknown' }); return (
} > {icon && React.cloneElement(icon, { className: 'tag-icon', })} {title && {title}} ); } ================================================ FILE: packages/materials/form-materials/src/components/display-schema-tag/styles.css ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ .gedit-m-display-schema-tag-popover-content { padding: 10px; } .gedit-m-display-schema-tag-tag { padding: 4px; & .tag-icon { width: 12px; height: 12px; } } .gedit-m-display-schema-tag-title { display: inline-block; margin-left: 4px; margin-top: -1px; overflow: hidden; text-overflow: ellipsis; } ================================================ FILE: packages/materials/form-materials/src/components/display-schema-tree/index.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React from 'react'; import { type IJsonSchema, type JsonSchemaTypeManager, useTypeManager, } from '@flowgram.ai/json-schema'; import './styles.css'; interface PropsType { value?: IJsonSchema; parentKey?: string; depth?: number; drilldown?: boolean; showIcon?: boolean; typeManager?: JsonSchemaTypeManager; } export function DisplaySchemaTree(props: Omit) { return ; } function SchemaTree(props: PropsType) { const { value: schema = {}, drilldown = true, depth = 0, showIcon = true, parentKey = '', } = props || {}; const typeManager = useTypeManager() as JsonSchemaTypeManager; const config = typeManager.getTypeBySchema(schema); const title = typeManager.getComplexText(schema); const icon = typeManager?.getDisplayIcon(schema); let properties: IJsonSchema['properties'] = drilldown && config ? config.getTypeSchemaProperties(schema) : {}; const childEntries = Object.entries(properties || {}); return (
{depth !== 0 &&
} {showIcon && icon && React.cloneElement(icon, { className: 'tree-icon', })}
{parentKey ? ( <> {`${parentKey} (`} {title} {')'} ) : ( title )}
{childEntries?.length ? (
{childEntries.map(([key, value]) => ( ))}
) : null}
); } ================================================ FILE: packages/materials/form-materials/src/components/display-schema-tree/styles.css ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ .gedit-m-display-schema-tree-row { display: flex; align-items: center; & .tree-icon { margin-right: 8px; width: 14px; height: 14px; } height: 27px; white-space: nowrap; } .gedit-m-display-schema-tree-horizontal-line { position: relative; &::before, &::after { content: ""; position: absolute; background-color: var(--semi-color-text-3); } &::after { top: 0px; right: 6px; width: 15px; height: 1px; } } .gedit-m-display-schema-tree-title { /* overflow: hidden; text-overflow: ellipsis; */ } .gedit-m-display-schema-tree-level { padding-left: 30px; position: relative; /* &::before { content: ''; position: absolute; background-color: var(--semi-color-text-3); top: 0px; bottom: 0px; left: -22px; width: 1px; } */ } .gedit-m-display-schema-tree-item { position: relative; &::before { content: ""; position: absolute; background-color: var(--semi-color-text-3); } &:not(:last-child)::before { width: 1px; top: 0; bottom: 0; left: -22px; } &:last-child::before { width: 1px; top: 0; height: 14px; left: -22px; } &.depth-0::before { width: 0px !important; } } ================================================ FILE: packages/materials/form-materials/src/components/dynamic-value-input/hooks.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { useEffect, useMemo, useRef, useState } from 'react'; import { IJsonSchema } from '@flowgram.ai/json-schema'; import { useScopeAvailable } from '@flowgram.ai/editor'; import { IFlowConstantRefValue } from '@/shared'; export function useRefVariable(value?: IFlowConstantRefValue) { const available = useScopeAvailable(); const refVariable = useMemo(() => { if (value?.type === 'ref') { return available.getByKeyPath(value.content); } }, [value, available]); return refVariable; } export function useSelectSchema( schemaFromProps?: IJsonSchema, constantProps?: { schema?: IJsonSchema; }, value?: IFlowConstantRefValue ) { let defaultSelectSchema = schemaFromProps || constantProps?.schema || { type: 'string' }; if (value?.type === 'constant') { defaultSelectSchema = value?.schema || defaultSelectSchema; } const changeVersion = useRef(0); const effectVersion = useRef(0); const [selectSchema, setSelectSchema] = useState(defaultSelectSchema); useEffect(() => { effectVersion.current += 1; if (changeVersion.current === effectVersion.current) { return; } effectVersion.current = changeVersion.current; if (value?.type === 'constant' && value?.schema) { setSelectSchema(value?.schema); return; } }, [value]); const setSelectSchemaWithVersionUpdate = (schema: IJsonSchema) => { setSelectSchema(schema); changeVersion.current += 1; }; return [selectSchema, setSelectSchemaWithVersionUpdate] as const; } export function useIncludeSchema(schemaFromProps?: IJsonSchema) { const includeSchema = useMemo(() => { if (!schemaFromProps) { return; } if (schemaFromProps?.type === 'number') { return [schemaFromProps, { type: 'integer' }]; } return { ...schemaFromProps, extra: { weak: true, ...schemaFromProps?.extra } }; }, [schemaFromProps]); return includeSchema; } ================================================ FILE: packages/materials/form-materials/src/components/dynamic-value-input/index.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React from 'react'; import { JsonSchemaUtils, IJsonSchema, useTypeManager, type JsonSchemaTypeManager, } from '@flowgram.ai/json-schema'; import { IconButton } from '@douyinfe/semi-ui'; import { IconSetting } from '@douyinfe/semi-icons'; import { IFlowConstantRefValue, IFlowConstantValue } from '@/shared'; import { createInjectMaterial } from '@/shared'; import { InjectVariableSelector } from '@/components/variable-selector'; import { TypeSelector } from '@/components/type-selector'; import { ConstantInput, ConstantInputStrategy } from '@/components/constant-input'; import './styles.css'; import { useIncludeSchema, useRefVariable, useSelectSchema } from './hooks'; interface PropsType { value?: IFlowConstantRefValue; onChange: (value?: IFlowConstantRefValue) => void; readonly?: boolean; hasError?: boolean; style?: React.CSSProperties; schema?: IJsonSchema; constantProps?: { strategies?: ConstantInputStrategy[]; schema?: IJsonSchema; // set schema of constant input only [key: string]: any; }; } const DEFAULT_VALUE: IFlowConstantValue = { type: 'constant', content: '', schema: { type: 'string' }, }; export function DynamicValueInput({ value, onChange, readonly, style, schema: schemaFromProps, constantProps, }: PropsType) { const refVariable = useRefVariable(value); const [selectSchema, setSelectSchema] = useSelectSchema(schemaFromProps, constantProps, value); const includeSchema = useIncludeSchema(schemaFromProps); const typeManager = useTypeManager() as JsonSchemaTypeManager; const renderTypeSelector = () => { if (schemaFromProps) { return ; } if (value?.type === 'ref') { const schema = refVariable?.type ? JsonSchemaUtils.astToSchema(refVariable?.type) : undefined; return ; } return ( { setSelectSchema(_v || { type: 'string' }); const schema = _v || { type: 'string' }; let content = typeManager.getDefaultValue(schema); if (_v?.type === 'object') { content = '{}'; } if (_v?.type === 'array') { content = '[]'; } onChange({ type: 'constant', content, schema, }); }} readonly={readonly} /> ); }; const renderMain = () => { if (value?.type === 'ref') { // Display Variable Or Delete return ( onChange(_v ? { type: 'ref', content: _v } : DEFAULT_VALUE)} includeSchema={includeSchema} readonly={readonly} /> ); } const constantSchema = schemaFromProps || selectSchema || { type: 'string' }; return ( onChange({ type: 'constant', content: _v, schema: constantSchema })} schema={constantSchema || { type: 'string' }} readonly={readonly} fallbackRenderer={() => ( onChange(_v ? { type: 'ref', content: _v } : DEFAULT_VALUE)} includeSchema={includeSchema} readonly={readonly} /> )} {...constantProps} strategies={[...(constantProps?.strategies || [])]} /> ); }; const renderTrigger = () => ( onChange({ type: 'ref', content: _v })} includeSchema={includeSchema} readonly={readonly} triggerRender={() => ( } /> )} /> ); return (
{renderTypeSelector()}
{renderMain()}
{renderTrigger()}
); } DynamicValueInput.renderKey = 'dynamic-value-input-render-key'; export const InjectDynamicValueInput = createInjectMaterial(DynamicValueInput); ================================================ FILE: packages/materials/form-materials/src/components/dynamic-value-input/styles.css ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ .gedit-m-dynamic-value-input-container { display: flex; align-items: center; border-radius: 4px; border: 1px solid var(--semi-color-border); line-height: normal; overflow: hidden; background-color: var(--semi-color-fill-0); } .gedit-m-dynamic-value-input-main { flex-grow: 1; overflow: hidden; min-width: 0; border-left: 1px solid var(--semi-color-border); border-right: 1px solid var(--semi-color-border); & .semi-tree-select, & .semi-input-number, & .semi-select { width: 100%; border: none; border-radius: 0; } & .semi-input-wrapper { border: none; border-radius: 0; } & .semi-input-textarea-wrapper { border: none; border-radius: 0; } & .semi-input-textarea { padding: 2px 6px; border: none; border-radius: 0; word-break: break-all; } } .gedit-m-dynamic-value-input-type { & .semi-button { border-radius: 0; } } .gedit-m-dynamic-value-input-trigger { & .semi-button { border-radius: 0; } } ================================================ FILE: packages/materials/form-materials/src/components/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export { AssignRow, type AssignValueType } from './assign-row'; export { AssignRows } from './assign-rows'; export { BatchOutputs } from './batch-outputs'; export { BatchVariableSelector } from './batch-variable-selector'; export { BlurInput } from './blur-input'; export { BaseCodeEditor, CodeEditor, JsonCodeEditor, PythonCodeEditor, SQLCodeEditor, ShellCodeEditor, TypeScriptCodeEditor, type CodeEditorPropsType, } from './code-editor'; export { CodeEditorMini } from './code-editor-mini'; export { ConditionPresetOp, ConditionProvider, type ConditionOpConfig, type ConditionOpConfigs, type IConditionRule, type IConditionRuleFactory, useCondition, useConditionContext, } from './condition-context'; export { ConditionRow, type ConditionRowValueType } from './condition-row'; export { ConstantInput, type ConstantInputStrategy } from './constant-input'; export { EditorInputsTree, EditorVariableTagInject, EditorVariableTree, } from './coze-editor-extensions'; export { DBConditionRow, type DBConditionOptionType, type DBConditionRowValueType, } from './db-condition-row'; export { DisplayFlowValue } from './display-flow-value'; export { DisplayInputsValueAllInTag, DisplayInputsValues } from './display-inputs-values'; export { DisplayOutputs } from './display-outputs'; export { DisplaySchemaTag } from './display-schema-tag'; export { DisplaySchemaTree } from './display-schema-tree'; export { DynamicValueInput, InjectDynamicValueInput } from './dynamic-value-input'; export { InputsValues } from './inputs-values'; export { InputsValuesTree } from './inputs-values-tree'; export { JsonEditorWithVariables, type JsonEditorWithVariablesProps, } from './json-editor-with-variables'; export { JsonSchemaCreator, type JsonSchemaCreatorProps } from './json-schema-creator'; export { JsonSchemaEditor } from './json-schema-editor'; export { PromptEditor, type PromptEditorPropsType } from './prompt-editor'; export { PromptEditorWithInputs, type PromptEditorWithInputsProps, } from './prompt-editor-with-inputs'; export { PromptEditorWithVariables, type PromptEditorWithVariablesProps, } from './prompt-editor-with-variables'; export { SQLEditorWithVariables, type SQLEditorWithVariablesProps, } from './sql-editor-with-variables'; export { InjectTypeSelector, TypeSelector, getTypeSelectValue, parseTypeSelectValue, type TypeSelectorProps, } from './type-selector'; export { InjectVariableSelector, VariableSelector, VariableSelectorProvider, useVariableTree, type VariableSelectorProps, } from './variable-selector'; ================================================ FILE: packages/materials/form-materials/src/components/inputs-values/index.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React from 'react'; import { I18n } from '@flowgram.ai/editor'; import { Button, IconButton } from '@douyinfe/semi-ui'; import { IconDelete, IconPlus } from '@douyinfe/semi-icons'; import { IFlowConstantRefValue, IFlowValue } from '@/shared'; import { useObjectList } from '@/hooks'; import { InjectDynamicValueInput } from '@/components/dynamic-value-input'; import { BlurInput } from '@/components/blur-input'; import { PropsType } from './types'; import './styles.css'; export function InputsValues({ value, onChange, style, readonly, constantProps, schema, hasError, }: PropsType) { const { list, updateKey, updateValue, remove, add } = useObjectList({ value, onChange, sortIndexKey: 'extra.index', }); return (
{list.map((item) => (
updateKey(item.id, v)} placeholder={I18n.t('Input Key')} /> updateValue(item.id, v)} schema={schema} hasError={hasError} constantProps={{ ...constantProps, strategies: [...(constantProps?.strategies || [])], }} /> } size="small" onClick={() => remove(item.id)} />
))}
); } ================================================ FILE: packages/materials/form-materials/src/components/inputs-values/styles.css ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ .gedit-m-inputs-values-rows { display: flex; flex-direction: column; gap: 10px; margin-bottom: 10px; } .gedit-m-inputs-values-row { display: flex; align-items: flex-start; gap: 5px; } ================================================ FILE: packages/materials/form-materials/src/components/inputs-values/types.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { IJsonSchema } from '@flowgram.ai/json-schema'; import { IFlowValue } from '@/shared'; import { ConstantInputStrategy } from '@/components/constant-input'; export interface PropsType { value?: Record; onChange: (value?: Record) => void; readonly?: boolean; hasError?: boolean; schema?: IJsonSchema; style?: React.CSSProperties; constantProps?: { strategies?: ConstantInputStrategy[]; [key: string]: any; }; } ================================================ FILE: packages/materials/form-materials/src/components/inputs-values-tree/hooks/use-child-list.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { useMemo } from 'react'; import { isPlainObject } from 'lodash-es'; import { FlowValueUtils } from '@/shared'; import { useObjectList } from '@/hooks'; interface ListItem { id: string; key?: string; value?: any; } export function useChildList( value?: any, onChange?: (value: any) => void ): { canAddField: boolean; hasChildren: boolean; list: ListItem[]; add: (defaultValue?: any) => void; updateKey: (id: string, key: string) => void; updateValue: (id: string, value: any) => void; remove: (id: string) => void; } { const canAddField = useMemo(() => { if (!isPlainObject(value)) { return false; } if (FlowValueUtils.isFlowValue(value)) { // Constant Object Value Can Add child fields return FlowValueUtils.isConstant(value) && value?.schema?.type === 'object'; } return true; }, [value]); const objectListValue = useMemo(() => { if (isPlainObject(value)) { if (FlowValueUtils.isFlowValue(value)) { return undefined; } return value; } return undefined; }, [value]); const { list, add, updateKey, updateValue, remove } = useObjectList({ value: objectListValue, onChange: (value) => { onChange?.(value); }, sortIndexKey: (value) => (FlowValueUtils.isFlowValue(value) ? 'extra.index' : ''), }); const hasChildren = useMemo( () => canAddField && (list.length > 0 || Object.keys(objectListValue || {}).length > 0), [canAddField, list.length, Object.keys(objectListValue || {}).length] ); return { canAddField, hasChildren, list, add, updateKey, updateValue, remove, }; } ================================================ FILE: packages/materials/form-materials/src/components/inputs-values-tree/icon.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React from 'react'; import Icon from '@douyinfe/semi-icons'; const iconAddChildrenSvg = ( ); export const IconAddChildren = () => ; ================================================ FILE: packages/materials/form-materials/src/components/inputs-values-tree/index.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React from 'react'; import { I18n } from '@flowgram.ai/editor'; import { Button } from '@douyinfe/semi-ui'; import { IconPlus } from '@douyinfe/semi-icons'; import { FlowValueUtils, IFlowValue, IInputsValues } from '@/shared'; import { useObjectList } from '@/hooks'; import { PropsType } from './types'; import './styles.css'; import { InputValueRow } from './row'; export function InputsValuesTree(props: PropsType) { const { value, onChange, readonly, hasError, constantProps } = props; const { list, updateKey, updateValue, remove, add } = useObjectList< IInputsValues | IFlowValue | undefined >({ value, onChange: (v) => onChange?.(v as IInputsValues), sortIndexKey: (value) => (FlowValueUtils.isFlowValue(value) ? 'extra.index' : ''), }); return (
{list.map((item) => ( updateKey(item.id, key)} onUpdateValue={(value) => updateValue(item.id, value)} onRemove={() => remove(item.id)} readonly={readonly} hasError={hasError} constantProps={constantProps} /> ))}
); } ================================================ FILE: packages/materials/form-materials/src/components/inputs-values-tree/row.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React, { useMemo, useState } from 'react'; import { I18n } from '@flowgram.ai/editor'; import { IconButton, Input } from '@douyinfe/semi-ui'; import { IconChevronDown, IconChevronRight, IconDelete } from '@douyinfe/semi-icons'; import { IFlowConstantValue } from '@/shared'; import { ConstantInputStrategy } from '@/components/constant-input'; import { PropsType } from './types'; import './styles.css'; import { useChildList } from './hooks/use-child-list'; import { InjectDynamicValueInput } from '../dynamic-value-input'; import { BlurInput } from '../blur-input'; import { IconAddChildren } from './icon'; const AddObjectChildStrategy: ConstantInputStrategy = { hit: (schema) => schema.type === 'object', Renderer: () => ( ), }; export function InputValueRow( props: { keyName?: string; value?: any; onUpdateKey: (key: string) => void; onUpdateValue: (value: any) => void; onRemove?: () => void; $isLast?: boolean; $level?: number; } & Pick ) { const { keyName, value, $level = 0, onUpdateKey, onUpdateValue, $isLast, onRemove, constantProps, hasError, readonly, } = props; const [collapse, setCollapse] = useState(false); const { canAddField, hasChildren, list, add, updateKey, updateValue, remove } = useChildList( value, onUpdateValue ); const strategies = useMemo( () => [...(hasChildren ? [AddObjectChildStrategy] : []), ...(constantProps?.strategies || [])], [hasChildren, constantProps?.strategies] ); const flowDisplayValue = useMemo( () => hasChildren ? ({ type: 'constant', schema: { type: 'object' }, } as IFlowConstantValue) : value, [hasChildren, value] ); return ( <>
0 ? 'show-line' : ''} ${ $isLast ? 'is-last' : '' } ${hasChildren ? 'show-collapse' : ''}`} > {hasChildren && (
setCollapse((_collapse) => !_collapse)} > {collapse ? : }
)}
onUpdateKey?.(v)} placeholder={I18n.t('Input Key')} /> onUpdateValue(v)} hasError={hasError} constantProps={{ ...constantProps, strategies, }} />
{canAddField && ( } onClick={() => { add({ type: 'constant', content: '', schema: { type: 'string' }, }); setCollapse(true); }} /> )} } size="small" onClick={() => onRemove?.()} />
{hasChildren && (
{list.map((_item, index) => ( { updateValue(_item.id, _v); }} onUpdateKey={(k) => { updateKey(_item.id, k); }} onRemove={() => { remove(_item.id); }} $isLast={index === list.length - 1} /> ))}
)}
); } ================================================ FILE: packages/materials/form-materials/src/components/inputs-values-tree/styles.css ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ .gedit-m-inputs-values-tree-container { } .gedit-m-inputs-values-tree-row { display: flex; align-items: flex-start; gap: 5px; } .gedit-m-inputs-values-tree-collapse-trigger { cursor: pointer; margin-right: 5px; } .gedit-m-inputs-values-tree-tree-items { display: grid; grid-template-columns: auto 1fr; } .gedit-m-inputs-values-tree-tree-items.shrink { padding-left: 3px; margin-top: 10px; } .gedit-m-inputs-values-tree-tree-item-left { grid-column: 1; position: relative; width: 16px; } .gedit-m-inputs-values-tree-tree-item-left.show-line::before { /* 竖线 */ content: ""; height: var(--line-height, 100%); position: absolute; left: -14px; top: -16px; width: 1px; background: #d9d9d9; display: block; } .gedit-m-inputs-values-tree-tree-item-left.show-line::after { /* 横线 */ content: ""; position: absolute; left: -14px; /* 横线起点和竖线对齐 */ top: 8px; /* 跟随你的行高调整 */ width: var(--line-width, 30px); /* 横线长度 */ height: 1px; background: #d9d9d9; display: block; } .gedit-m-inputs-values-tree-tree-item-left.show-line.is-last::before { height: 24px; } .gedit-m-inputs-values-tree-tree-item-left.show-line.show-collapse::after { width: 12px; } .gedit-m-inputs-values-tree-tree-item-right { grid-column: 2; margin-bottom: 10px; &:last-child { margin-bottom: 0px; } } .gedit-m-inputs-values-tree-tree-item-main { display: flex; flex-direction: column; gap: 10px; position: relative; } .gedit-m-inputs-values-tree-collapsible { display: none; } .gedit-m-inputs-values-tree-collapsible.collapse { display: block; } .gedit-m-inputs-values-tree-actions { white-space: nowrap; } ================================================ FILE: packages/materials/form-materials/src/components/inputs-values-tree/types.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { IJsonSchema } from '@flowgram.ai/json-schema'; import { IInputsValues } from '@/shared'; import { ConstantInputStrategy } from '@/components/constant-input'; export interface PropsType { value?: IInputsValues; onChange: (value?: IInputsValues) => void; readonly?: boolean; hasError?: boolean; schema?: IJsonSchema; style?: React.CSSProperties; constantProps?: { strategies?: ConstantInputStrategy[]; [key: string]: any; }; } ================================================ FILE: packages/materials/form-materials/src/components/json-editor-with-variables/editor.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React from 'react'; import { I18n } from '@flowgram.ai/editor'; import { transformerCreator } from '@flowgram.ai/coze-editor/preset-code'; import { Text } from '@flowgram.ai/coze-editor/language-json'; import { EditorVariableTree, EditorVariableTagInject } from '@/components/coze-editor-extensions'; import { JsonCodeEditor, type CodeEditorPropsType } from '@/components/code-editor'; const TRIGGER_CHARACTERS = ['@']; type Match = { match: string; range: [number, number] }; function findAllMatches(inputString: string, regex: RegExp): Match[] { const globalRegex = new RegExp( regex, regex.flags.includes('g') ? regex.flags : regex.flags + 'g' ); let match; const matches: Match[] = []; while ((match = globalRegex.exec(inputString)) !== null) { if (match.index === globalRegex.lastIndex) { globalRegex.lastIndex++; } matches.push({ match: match[0], range: [match.index, match.index + match[0].length], }); } return matches; } const transformer = transformerCreator((text: Text) => { const originalSource = text.toString(); const matches = findAllMatches(originalSource, /\{\{([^\}]*)\}\}/g); if (matches.length > 0) { matches.forEach(({ range }) => { text.replaceRange(range[0], range[1], 'null'); }); } return text; }); export interface JsonEditorWithVariablesProps extends Omit {} export function JsonEditorWithVariables(props: JsonEditorWithVariablesProps) { return ( ); } ================================================ FILE: packages/materials/form-materials/src/components/json-editor-with-variables/index.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { lazySuspense } from '@/shared'; export const JsonEditorWithVariables = lazySuspense(() => import('./editor').then((module) => ({ default: module.JsonEditorWithVariables })) ); export type { JsonEditorWithVariablesProps } from './editor'; ================================================ FILE: packages/materials/form-materials/src/components/json-schema-creator/index.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export { JsonSchemaCreator } from './json-schema-creator'; export type { JsonSchemaCreatorProps } from './json-schema-creator'; ================================================ FILE: packages/materials/form-materials/src/components/json-schema-creator/json-input-modal.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React, { useState } from 'react'; import type { IJsonSchema } from '@flowgram.ai/json-schema'; import { I18n } from '@flowgram.ai/editor'; import { Modal, Typography } from '@douyinfe/semi-ui'; import { jsonToSchema } from './utils/json-to-schema'; import { JsonCodeEditor } from '../code-editor'; const { Text } = Typography; interface JsonInputModalProps { visible: boolean; onClose: () => void; onConfirm: (schema: IJsonSchema) => void; } export function JsonInputModal({ visible, onClose, onConfirm }: JsonInputModalProps) { const [jsonInput, setJsonInput] = useState(''); const [error, setError] = useState(''); const handleConfirm = () => { try { const schema = jsonToSchema(jsonInput); onConfirm(schema); setJsonInput(''); setError(''); } catch (err) { setError((err as Error).message); } }; return (
{I18n.t('Paste JSON data')}:
setJsonInput(value || '')} />
{error && (
{error}
)}
); } ================================================ FILE: packages/materials/form-materials/src/components/json-schema-creator/json-schema-creator.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React, { useState } from 'react'; import type { IJsonSchema } from '@flowgram.ai/json-schema'; import { I18n } from '@flowgram.ai/editor'; import { Button } from '@douyinfe/semi-ui'; import { JsonInputModal } from './json-input-modal'; export interface JsonSchemaCreatorProps { /** 生成 schema 后的回调 */ onSchemaCreate?: (schema: IJsonSchema) => void; } export function JsonSchemaCreator({ onSchemaCreate }: JsonSchemaCreatorProps) { const [visible, setVisible] = useState(false); const handleCreate = (schema: IJsonSchema) => { onSchemaCreate?.(schema); setVisible(false); }; return ( <> setVisible(false)} onConfirm={handleCreate} /> ); } ================================================ FILE: packages/materials/form-materials/src/components/json-schema-creator/utils/json-to-schema.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import type { IJsonSchema } from '@flowgram.ai/json-schema'; export function jsonToSchema(jsonString: string): IJsonSchema { // 1. 解析 JSON const data = JSON.parse(jsonString); // 会自动抛出语法错误 // 2. 生成 schema return generateSchema(data); } function generateSchema(value: any): IJsonSchema { // null if (value === null) { return { type: 'string' }; } // array if (Array.isArray(value)) { const schema: IJsonSchema = { type: 'array' }; if (value.length > 0) { schema.items = generateSchema(value[0]); } return schema; } // object if (typeof value === 'object') { const schema: IJsonSchema = { type: 'object', properties: {}, required: [], }; for (const [key, val] of Object.entries(value)) { schema.properties![key] = generateSchema(val); schema.required!.push(key); } return schema; } // primitive types const type = typeof value; return { type: type as any }; } ================================================ FILE: packages/materials/form-materials/src/components/json-schema-editor/default-value.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React from 'react'; import { IJsonSchema } from '@flowgram.ai/json-schema'; import { I18n } from '@flowgram.ai/editor'; import { ConstantInput } from '@/components/constant-input'; /** * Renders the corresponding default value input component based on different data types. * @param props - Component properties, including value, type, placeholder, onChange. * @returns Returns the input component of the corresponding type or null. */ export function DefaultValue(props: { value: any; schema?: IJsonSchema; placeholder?: string; onChange: (value: any) => void; }) { const { value, schema, onChange, placeholder } = props; return (
onChange(_v)} schema={schema || { type: 'string' }} placeholder={placeholder ?? I18n.t('Default value if parameter is not provided')} enableMultiLineStr />
); } ================================================ FILE: packages/materials/form-materials/src/components/json-schema-editor/hooks.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { useEffect, useRef, useState } from 'react'; import { difference, omit } from 'lodash-es'; import { produce } from 'immer'; import { IJsonSchema, type JsonSchemaTypeManager, useTypeManager } from '@flowgram.ai/json-schema'; import { PropertyValueType } from './types'; let _id = 0; function genId() { return _id++; } export function usePropertiesEdit( value?: PropertyValueType, onChange?: (value: PropertyValueType) => void ) { const typeManager = useTypeManager() as JsonSchemaTypeManager; // Get drilldown properties (array.items.items.properties...) const drilldownSchema = typeManager.getPropertiesParent(value || {}); const canAddField = typeManager.canAddField(value || {}); const [propertyList, setPropertyList] = useState([]); const latestPropertyListRef = useRef(propertyList); const effectVersion = useRef(0); const changeVersion = useRef(0); useEffect(() => { effectVersion.current = effectVersion.current + 1; if (effectVersion.current === changeVersion.current) { return; } effectVersion.current = changeVersion.current; // If the value is changed, update the property list const _list = latestPropertyListRef.current; const newNames = Object.entries(drilldownSchema?.properties || {}) .sort(([, a], [, b]) => (a.extra?.index ?? 0) - (b.extra?.index ?? 0)) .map(([key]) => key); const oldNames = _list.map((item) => item.name).filter(Boolean) as string[]; const addNames = difference(newNames, oldNames); const next = _list .filter((item) => !item.name || newNames.includes(item.name)) .map((item) => ({ key: item.key, name: item.name, isPropertyRequired: drilldownSchema?.required?.includes(item.name || '') || false, ...(drilldownSchema?.properties?.[item.name || ''] || item || {}), })) .concat( addNames.map((_name) => ({ key: genId(), name: _name, isPropertyRequired: drilldownSchema?.required?.includes(_name) || false, ...(drilldownSchema?.properties?.[_name] || {}), })) ); latestPropertyListRef.current = next; setPropertyList(next); }, [drilldownSchema]); const updatePropertyList = (updater: (list: PropertyValueType[]) => PropertyValueType[]) => { changeVersion.current = changeVersion.current + 1; const next = updater(latestPropertyListRef.current); latestPropertyListRef.current = next; setPropertyList(next); // onChange to parent const nextProperties: Record = {}; const nextRequired: string[] = []; for (const _property of next) { if (!_property.name) { continue; } nextProperties[_property.name] = omit(_property, ['key', 'name', 'isPropertyRequired']); if (_property.isPropertyRequired) { nextRequired.push(_property.name); } } onChange?.( produce(value || {}, (draft) => { const propertiesParent = typeManager.getPropertiesParent(draft); if (propertiesParent) { propertiesParent.properties = nextProperties; propertiesParent.required = nextRequired; return; } }) ); }; const onAddProperty = () => { const _list = latestPropertyListRef.current; const next = [ ..._list, { key: genId(), name: '', type: 'string', extra: { index: _list.length + 1 } }, ]; latestPropertyListRef.current = next; setPropertyList(next); }; const onRemoveProperty = (key: number) => { updatePropertyList((_list) => _list.filter((_property) => _property.key !== key)); }; const onEditProperty = (key: number, nextValue: PropertyValueType) => { updatePropertyList((_list) => _list.map((_property) => (_property.key === key ? nextValue : _property)) ); }; useEffect(() => { if (!canAddField) { latestPropertyListRef.current = []; setPropertyList([]); } }, [canAddField]); return { propertyList, canAddField, onAddProperty, onRemoveProperty, onEditProperty, }; } ================================================ FILE: packages/materials/form-materials/src/components/json-schema-editor/icon.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React from 'react'; import Icon from '@douyinfe/semi-icons'; const iconAddChildrenSvg = ( ); export const IconAddChildren = () => ; ================================================ FILE: packages/materials/form-materials/src/components/json-schema-editor/index.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React, { useMemo, useState } from 'react'; import { IJsonSchema } from '@flowgram.ai/json-schema'; import { I18n } from '@flowgram.ai/editor'; import { Button, Checkbox, IconButton } from '@douyinfe/semi-ui'; import { IconExpand, IconShrink, IconPlus, IconChevronDown, IconChevronRight, IconMinus, } from '@douyinfe/semi-icons'; import { InjectTypeSelector } from '@/components/type-selector'; import { BlurInput } from '@/components/blur-input'; import { ConfigType, PropertyValueType } from './types'; import { IconAddChildren } from './icon'; import { usePropertiesEdit } from './hooks'; import { DefaultValue } from './default-value'; import './styles.css'; const DEFAULT = { type: 'object' }; export function JsonSchemaEditor(props: { value?: IJsonSchema; onChange?: (value: IJsonSchema) => void; config?: ConfigType; className?: string; readonly?: boolean; }) { const { value = DEFAULT, config = {}, onChange: onChangeProps, readonly } = props; const { propertyList, onAddProperty, onRemoveProperty, onEditProperty } = usePropertiesEdit( value, onChangeProps ); return (
{propertyList.map((_property) => ( { onEditProperty(_property.key!, _v); }} onRemove={() => { onRemoveProperty(_property.key!); }} /> ))}
); } function PropertyEdit(props: { value?: PropertyValueType; config?: ConfigType; onChange?: (value: PropertyValueType) => void; onRemove?: () => void; readonly?: boolean; $isLast?: boolean; $level?: number; // 添加层级属性 }) { const { value, config, readonly, $level = 0, onChange: onChangeProps, onRemove, $isLast } = props; const [expand, setExpand] = useState(false); const [collapse, setCollapse] = useState(false); const { name, type, items, default: defaultValue, description, isPropertyRequired } = value || {}; const typeSelectorValue = useMemo(() => ({ type, items }), [type, items]); const { propertyList, canAddField, onAddProperty, onRemoveProperty, onEditProperty } = usePropertiesEdit(value, onChangeProps); const onChange = (key: string, _value: any) => { onChangeProps?.({ ...(value || {}), [key]: _value, }); }; const showCollapse = canAddField && propertyList.length > 0; return ( <>
0 ? 'show-line' : ''} ${ $isLast ? 'is-last' : '' } ${showCollapse ? 'show-collapse' : ''}`} > {showCollapse && (
setCollapse((_collapse) => !_collapse)} > {collapse ? : }
)}
onChange('name', value)} />
{ onChangeProps?.({ ...(value || {}), ..._value, }); }} />
onChange('isPropertyRequired', e.target.checked)} />
: } onClick={() => { setExpand((_expand) => !_expand); }} /> {canAddField && ( } onClick={() => { onAddProperty(); setCollapse(true); }} /> )} } onClick={onRemove} />
{expand && (
{config?.descTitle ?? I18n.t('Description')}
onChange('description', value)} placeholder={ config?.descPlaceholder ?? I18n.t('Help LLM to understand the property') } /> {$level === 0 && ( <>
{config?.defaultValueTitle ?? I18n.t('Default Value')}
onChange('default', value)} />
)}
)}
{showCollapse && (
{propertyList.map((_property, index) => ( { onEditProperty(_property.key!, _v); }} onRemove={() => { onRemoveProperty(_property.key!); }} $isLast={index === propertyList.length - 1} /> ))}
)}
); } ================================================ FILE: packages/materials/form-materials/src/components/json-schema-editor/styles.css ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ .gedit-m-json-schema-editor-container { /* & .semi-input { background-color: #fff; border-radius: 6px; height: 24px; } */ } .gedit-m-json-schema-editor-row { display: flex; align-items: center; gap: 6px; } .gedit-m-json-schema-editor-collapse-trigger { cursor: pointer; margin-right: 5px; } .gedit-m-json-schema-editor-expand-detail { display: flex; flex-direction: column; } .gedit-m-json-schema-editor-label { font-size: 12px; color: #999; font-weight: 400; margin-bottom: 2px; } .gedit-m-json-schema-editor-tree-items { display: grid; grid-template-columns: auto 1fr; } .gedit-m-json-schema-editor-tree-items.shrink { padding-left: 3px; margin-top: 10px; } .gedit-m-json-schema-editor-tree-item-left { grid-column: 1; position: relative; width: 16px; } .gedit-m-json-schema-editor-tree-item-left.show-line::before { /* 竖线 */ content: ""; height: var(--line-height, 100%); position: absolute; left: -14px; top: -16px; width: 1px; background: #d9d9d9; display: block; } .gedit-m-json-schema-editor-tree-item-left.show-line::after { /* 横线 */ content: ""; position: absolute; left: -14px; /* 横线起点和竖线对齐 */ top: 8px; /* 跟随你的行高调整 */ width: var(--line-width, 30px); /* 横线长度 */ height: 1px; background: #d9d9d9; display: block; } .gedit-m-json-schema-editor-tree-item-left.show-line.is-last::before { height: 24px; } .gedit-m-json-schema-editor-tree-item-left.show-line.show-collapse::after { width: 12px; } .gedit-m-json-schema-editor-tree-item-right { grid-column: 2; margin-bottom: 10px; &:last-child { margin-bottom: 0px; } } .gedit-m-json-schema-editor-tree-item-main { display: flex; flex-direction: column; gap: 10px; position: relative; } .gedit-m-json-schema-editor-collapsible { display: none; } .gedit-m-json-schema-editor-collapsible.collapse { display: block; } .gedit-m-json-schema-editor-name { flex-grow: 1; } .gedit-m-json-schema-editor-type { } .gedit-m-json-schema-editor-required { } .gedit-m-json-schema-editor-actions { white-space: nowrap; } .gedit-m-json-schema-editor-default-value-wrapper { margin: 0; } .gedit-m-json-schema-editor-constant-input-wrapper { flex-grow: 1; & .semi-tree-select, & .semi-input-number, & .semi-select { width: 100%; } } ================================================ FILE: packages/materials/form-materials/src/components/json-schema-editor/types.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { IJsonSchema } from '@flowgram.ai/json-schema'; export interface PropertyValueType extends IJsonSchema { name?: string; key?: number; isPropertyRequired?: boolean; } export type PropertiesValueType = Pick; export type JsonSchemaProperties = IJsonSchema['properties']; export interface ConfigType { placeholder?: string; descTitle?: string; descPlaceholder?: string; defaultValueTitle?: string; defaultValuePlaceholder?: string; addButtonText?: string; } ================================================ FILE: packages/materials/form-materials/src/components/prompt-editor/editor.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React, { useEffect, useRef } from 'react'; import { Renderer, EditorProvider, ActiveLinePlaceholder, InferValues, } from '@flowgram.ai/coze-editor/react'; import preset, { EditorAPI } from '@flowgram.ai/coze-editor/preset-prompt'; import { PropsType } from './types'; import MarkdownHighlight from './extensions/markdown'; import LanguageSupport from './extensions/language-support'; import JinjaHighlight from './extensions/jinja'; import './styles.css'; type Preset = typeof preset; type Options = Partial>; export interface PromptEditorPropsType extends PropsType { options?: Options; } export function PromptEditor(props: PromptEditorPropsType) { const { value, onChange, readonly, placeholder, activeLinePlaceholder, style, hasError, children, disableMarkdownHighlight, options, } = props || {}; const editorRef = useRef(null); const editorValue = String(value?.content || ''); useEffect(() => { // listen to value change if (editorRef.current?.getValue() !== editorValue) { // apply updates on readonly mode const editorView = editorRef.current?.$view; editorView?.dispatch({ changes: { from: 0, to: editorView?.state.doc.length, insert: editorValue, }, }); } }, [editorValue]); return (
{ editorRef.current = editor; }} plugins={preset} defaultValue={editorValue} options={{ readOnly: readonly, editable: !readonly, placeholder, ...options, }} onChange={(e) => { onChange({ type: 'template', content: e.value }); }} /> {activeLinePlaceholder && ( {activeLinePlaceholder} )} {!disableMarkdownHighlight && } {children}
); } ================================================ FILE: packages/materials/form-materials/src/components/prompt-editor/extensions/jinja.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { useLayoutEffect } from 'react'; import { useInjector } from '@flowgram.ai/coze-editor/react'; import { astDecorator } from '@flowgram.ai/coze-editor'; import { EditorView } from '@codemirror/view'; function JinjaHighlight() { const injector = useInjector(); useLayoutEffect( () => injector.inject([ astDecorator.whole.of((cursor) => { if (cursor.name === 'JinjaStatementStart' || cursor.name === 'JinjaStatementEnd') { return { type: 'className', className: 'jinja-statement-bracket', }; } if (cursor.name === 'JinjaComment') { return { type: 'className', className: 'jinja-comment', }; } if (cursor.name === 'JinjaExpression') { return { type: 'className', className: 'jinja-expression', }; } }), EditorView.theme({ '.jinja-statement-bracket': { color: '#D1009D', }, '.jinja-comment': { color: '#0607094D', }, '.jinja-expression': { color: '#4E40E5', }, }), ]), [injector] ); return null; } export default JinjaHighlight; ================================================ FILE: packages/materials/form-materials/src/components/prompt-editor/extensions/language-support.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { useLayoutEffect } from 'react'; import { useInjector } from '@flowgram.ai/coze-editor/react'; import { languageSupport } from '@flowgram.ai/coze-editor/preset-prompt'; function LanguageSupport() { const injector = useInjector(); useLayoutEffect(() => injector.inject([languageSupport]), [injector]); return null; } export default LanguageSupport; ================================================ FILE: packages/materials/form-materials/src/components/prompt-editor/extensions/markdown.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { useLayoutEffect } from 'react'; import { useInjector } from '@flowgram.ai/coze-editor/react'; import { astDecorator } from '@flowgram.ai/coze-editor'; import { EditorView } from '@codemirror/view'; function MarkdownHighlight() { const injector = useInjector(); useLayoutEffect( () => injector.inject([ astDecorator.whole.of((cursor) => { // # heading if (cursor.name.startsWith('ATXHeading')) { return { type: 'className', className: 'heading', }; } // *italic* if (cursor.name === 'Emphasis') { return { type: 'className', className: 'emphasis', }; } // **bold** if (cursor.name === 'StrongEmphasis') { return { type: 'className', className: 'strong-emphasis', }; } // - // 1. // > if (cursor.name === 'ListMark' || cursor.name === 'QuoteMark') { return { type: 'className', className: 'mark', }; } }), EditorView.theme({ '.heading': { color: '#00818C', fontWeight: 'bold', }, '.emphasis': { fontStyle: 'italic', }, '.strong-emphasis': { fontWeight: 'bold', }, '.mark': { color: '#4E40E5', }, }), ]), [injector] ); return null; } export default MarkdownHighlight; ================================================ FILE: packages/materials/form-materials/src/components/prompt-editor/index.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { lazySuspense } from '@/shared'; export const PromptEditor = lazySuspense(() => import('./editor').then((module) => ({ default: module.PromptEditor })) ); export type { PromptEditorPropsType } from './editor'; ================================================ FILE: packages/materials/form-materials/src/components/prompt-editor/styles.css ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ .gedit-m-prompt-editor-container { background-color: var(--semi-color-fill-0); padding-left: 10px; padding-right: 6px; } .gedit-m-prompt-editor-container.has-error { border: 1px solid var(--semi-color-danger-6); } ================================================ FILE: packages/materials/form-materials/src/components/prompt-editor/types.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React from 'react'; import { IFlowTemplateValue } from '@/shared'; export type PropsType = React.PropsWithChildren<{ value?: IFlowTemplateValue; onChange: (value?: IFlowTemplateValue) => void; readonly?: boolean; hasError?: boolean; placeholder?: string; activeLinePlaceholder?: string; disableMarkdownHighlight?: boolean; style?: React.CSSProperties; }>; ================================================ FILE: packages/materials/form-materials/src/components/prompt-editor-with-inputs/index.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React from 'react'; import type { IInputsValues } from '@/shared/flow-value'; import { PromptEditor, PromptEditorPropsType } from '@/components/prompt-editor'; import { EditorInputsTree } from '@/components/coze-editor-extensions'; export interface PromptEditorWithInputsProps extends PromptEditorPropsType { inputsValues: IInputsValues; } export function PromptEditorWithInputs({ inputsValues, ...restProps }: PromptEditorWithInputsProps) { return ( ); } ================================================ FILE: packages/materials/form-materials/src/components/prompt-editor-with-variables/index.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React from 'react'; import { PromptEditor, PromptEditorPropsType } from '@/components/prompt-editor'; import { EditorVariableTree, EditorVariableTagInject } from '@/components/coze-editor-extensions'; export interface PromptEditorWithVariablesProps extends PromptEditorPropsType {} export function PromptEditorWithVariables(props: PromptEditorWithVariablesProps) { return ( ); } ================================================ FILE: packages/materials/form-materials/src/components/sql-editor-with-variables/editor.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React from 'react'; import { I18n } from '@flowgram.ai/editor'; import { EditorVariableTree, EditorVariableTagInject } from '@/components/coze-editor-extensions'; import { SQLCodeEditor, type CodeEditorPropsType } from '@/components/code-editor'; export interface SQLEditorWithVariablesProps extends Omit {} export function SQLEditorWithVariables(props: SQLEditorWithVariablesProps) { return ( ); } ================================================ FILE: packages/materials/form-materials/src/components/sql-editor-with-variables/index.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { lazySuspense } from '@/shared'; export const SQLEditorWithVariables = lazySuspense(() => import('./editor').then((module) => ({ default: module.SQLEditorWithVariables })) ); export type { SQLEditorWithVariablesProps } from './editor'; ================================================ FILE: packages/materials/form-materials/src/components/type-selector/index.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React, { useMemo } from 'react'; import { IJsonSchema, useTypeManager, JsonSchemaTypeManager } from '@flowgram.ai/json-schema'; import { Cascader, Icon, IconButton } from '@douyinfe/semi-ui'; import { createInjectMaterial } from '@/shared/inject-material'; export interface TypeSelectorProps { value?: Partial; onChange?: (value?: Partial) => void; readonly?: boolean; /** * @deprecated use readonly instead */ disabled?: boolean; style?: React.CSSProperties; } const labelStyle: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: 5 }; export const getTypeSelectValue = (value?: Partial): string[] | undefined => { if (value?.type === 'array' && value?.items) { return [value.type, ...(getTypeSelectValue(value.items) || [])]; } return value?.type ? [value.type] : undefined; }; export const parseTypeSelectValue = (value?: string[]): Partial | undefined => { const [type, ...subTypes] = value || []; if (type === 'array') { return { type: 'array', items: parseTypeSelectValue(subTypes) }; } return { type }; }; export function TypeSelector(props: TypeSelectorProps) { const { value, onChange, readonly, disabled, style } = props; const selectValue = useMemo(() => getTypeSelectValue(value), [value]); const typeManager = useTypeManager() as JsonSchemaTypeManager; const icon = typeManager.getDisplayIcon(value || {}); const options = useMemo( () => typeManager.getTypeRegistriesWithParentType().map((_type) => { const isArray = _type.type === 'array'; return { label: (
{typeManager.getTypeBySchema(_type)?.label || _type.type}
), value: _type.type, children: isArray ? typeManager.getTypeRegistriesWithParentType('array').map((_type) => ({ label: (
{typeManager.getTypeBySchema(_type)?.label || _type.type}
), value: _type.type, })) : [], }; }), [] ); const isDisabled = readonly || disabled; return ( ( )} treeData={options} value={selectValue} leafOnly={true} onChange={(value) => { onChange?.(parseTypeSelectValue(value as string[])); }} /> ); } TypeSelector.renderKey = 'type-selector-render-key'; export const InjectTypeSelector = createInjectMaterial(TypeSelector); ================================================ FILE: packages/materials/form-materials/src/components/variable-selector/context.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React, { createContext, useContext, useMemo } from 'react'; import { IJsonSchema } from '@flowgram.ai/json-schema'; import { BaseVariableField } from '@flowgram.ai/editor'; type VariableField = BaseVariableField<{ icon?: string | JSX.Element; title?: string; disabled?: boolean; }>; export const VariableSelectorContext = createContext<{ includeSchema?: IJsonSchema | IJsonSchema[]; excludeSchema?: IJsonSchema | IJsonSchema[]; skipVariable?: (variable: VariableField) => boolean; }>({}); export const useVariableSelectorContext = () => useContext(VariableSelectorContext); export const VariableSelectorProvider = ({ children, skipVariable, includeSchema, excludeSchema, }: { skipVariable?: (variable?: BaseVariableField) => boolean; includeSchema?: IJsonSchema | IJsonSchema[]; excludeSchema?: IJsonSchema | IJsonSchema[]; children: React.ReactNode; }) => { const context = useMemo( () => ({ skipVariable, includeSchema, excludeSchema, }), [skipVariable, includeSchema, excludeSchema] ); return ( {children} ); }; ================================================ FILE: packages/materials/form-materials/src/components/variable-selector/index.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React, { useMemo } from 'react'; import { IJsonSchema } from '@flowgram.ai/json-schema'; import { I18n } from '@flowgram.ai/editor'; import { type TriggerRenderProps } from '@douyinfe/semi-ui/lib/es/treeSelect'; import { type TreeNodeData } from '@douyinfe/semi-ui/lib/es/tree'; import { Popover, Tag, TreeSelect } from '@douyinfe/semi-ui'; import { IconChevronDownStroked, IconIssueStroked } from '@douyinfe/semi-icons'; import { createInjectMaterial } from '@/shared'; import { useVariableTree } from './use-variable-tree'; import { useVariableSelectorContext } from './context'; import './styles.css'; export interface VariableSelectorProps { value?: string[]; config?: { placeholder?: string; notFoundContent?: string; }; onChange: (value?: string[]) => void; includeSchema?: IJsonSchema | IJsonSchema[]; excludeSchema?: IJsonSchema | IJsonSchema[]; readonly?: boolean; hasError?: boolean; style?: React.CSSProperties; triggerRender?: (props: TriggerRenderProps) => React.ReactNode; } export { useVariableTree }; export const VariableSelector = ({ value, config = {}, onChange, style, readonly = false, includeSchema, excludeSchema, hasError, triggerRender, }: VariableSelectorProps) => { const { skipVariable } = useVariableSelectorContext(); const treeData = useVariableTree({ includeSchema, excludeSchema, skipVariable, }); const treeValue = useMemo(() => { if (typeof value === 'string') { console.warn( 'The Value of VariableSelector is a string, it should be an ARRAY. \n', 'Please check the value of VariableSelector \n' ); return value; } return value?.join('.'); }, [value]); const renderIcon = (icon: string | JSX.Element) => { if (typeof icon === 'string') { return ; } return icon; }; return ( <> { onChange((_config as TreeNodeData).keyPath as string[]); }} renderSelectedItem={(_option: TreeNodeData) => { if (!_option?.keyPath) { return ( } color="amber" closable={!readonly} onClose={() => onChange(undefined)} > {config?.notFoundContent ?? 'Undefined'} ); } const rootIcon = renderIcon(_option.rootMeta?.icon || _option?.icon); const rootTitle = (
{_option.rootMeta?.title ? `${_option.rootMeta?.title} ${_option.isRoot ? '' : '-'} ` : null}
); return (
{rootIcon} {rootTitle}
{_option.keyPath.slice(1).join('.')}
} > onChange(undefined)} > {rootTitle} {!_option.isRoot && (
{_option.label}
)}
); }} showClear={false} arrowIcon={} triggerRender={triggerRender} placeholder={config?.placeholder ?? I18n.t('Select Variable')} /> ); }; VariableSelector.renderKey = 'variable-selector-render-key'; export const InjectVariableSelector = createInjectMaterial(VariableSelector); export { VariableSelectorProvider } from './context'; ================================================ FILE: packages/materials/form-materials/src/components/variable-selector/styles.css ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ .gedit-m-variable-selector-root-title { margin-right: 4px; min-width: 20px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--semi-color-text-2); } .gedit-m-variable-selector-var-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; &.in-selector { min-width: 50%; } } .gedit-m-variable-selector-tag { width: 100%; display: flex; align-items: center; justify-content: flex-start; margin: 0; height: 22px; .semi-tag-content-center { justify-content: flex-start; } } .gedit-m-variable-selector-tree-select { outline: none; &.error { outline: 1px solid red; } .semi-tree-select-selection { padding: 0px; height: 22px; } .semi-tree-select-selection-content { width: 100%; } .semi-tree-select-selection-placeholder { padding-left: 10px; } } .gedit-m-variable-selector-tag-pop { padding: 10px; display: inline-flex; align-items: center; justify-content: flex-start; white-space: nowrap; } .gedit-m-variable-selector-dropdown { max-height: 300px; overflow: auto; } ================================================ FILE: packages/materials/form-materials/src/components/variable-selector/use-variable-tree.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React, { useCallback } from 'react'; import { IJsonSchema, JsonSchemaTypeManager, JsonSchemaUtils, useTypeManager, } from '@flowgram.ai/json-schema'; import { ASTMatch, BaseVariableField, useAvailableVariables } from '@flowgram.ai/editor'; import { TreeNodeData } from '@douyinfe/semi-ui/lib/es/tree'; import { Icon } from '@douyinfe/semi-ui'; import { useVariableSelectorContext } from './context'; type VariableField = BaseVariableField<{ icon?: string | JSX.Element; title?: string; disabled?: boolean; }>; export function useVariableTree(params: { includeSchema?: IJsonSchema | IJsonSchema[]; excludeSchema?: IJsonSchema | IJsonSchema[]; skipVariable?: (variable: VariableField) => boolean; }): TreeNodeData[] { const context = useVariableSelectorContext(); const { includeSchema = context.includeSchema, excludeSchema = context.excludeSchema, skipVariable = context.skipVariable, } = params; const typeManager = useTypeManager() as JsonSchemaTypeManager; const variables = useAvailableVariables(); const getVariableTypeIcon = useCallback((variable: VariableField) => { if (variable.meta?.icon) { if (typeof variable.meta.icon === 'string') { return ; } return variable.meta.icon; } const schema = JsonSchemaUtils.astToSchema(variable.type, { drilldownObject: false }); return ; }, []); const renderVariable = ( variable: VariableField, parentFields: VariableField[] = [] ): TreeNodeData | null => { let type = variable?.type; if (!type) { return null; } let children: TreeNodeData[] | undefined; if (ASTMatch.isObject(type)) { children = (type.properties || []) .map((_property) => renderVariable(_property as VariableField, [...parentFields, variable])) .filter(Boolean) as TreeNodeData[]; } const keyPath = [...parentFields.map((_field) => _field.key), variable.key]; const key = keyPath.join('.'); const isSchemaInclude = includeSchema ? JsonSchemaUtils.isASTMatchSchema(type, includeSchema) : true; const isSchemaExclude = excludeSchema ? JsonSchemaUtils.isASTMatchSchema(type, excludeSchema) : false; const isCustomSkip = skipVariable ? skipVariable(variable) : false; // disabled in meta when created const isMetaDisabled = variable.meta?.disabled; const isSchemaMatch = isSchemaInclude && !isSchemaExclude && !isCustomSkip && !isMetaDisabled; // If not match, and no children, return null if (!isSchemaMatch && !children?.length) { return null; } return { key: key, label: variable.meta?.title || variable.key, value: key, keyPath, icon: getVariableTypeIcon(variable), children, disabled: !isSchemaMatch, rootMeta: parentFields[0]?.meta || variable.meta, isRoot: !parentFields?.length, }; }; return [...variables.slice(0).reverse()] .map((_variable) => renderVariable(_variable as VariableField)) .filter(Boolean) as TreeNodeData[]; } ================================================ FILE: packages/materials/form-materials/src/effects/auto-rename-ref/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { DataEvent, Effect, EffectOptions, VariableFieldKeyRenameService, } from '@flowgram.ai/editor'; import { IFlowRefValue, IFlowTemplateValue } from '@/shared'; import { FlowValueUtils } from '@/shared'; /** * Auto rename ref when form item's key is renamed * * Example: * * formMeta: { * effects: { * "inputsValues": autoRenameRefEffect, * } * } */ export const autoRenameRefEffect: EffectOptions[] = [ { event: DataEvent.onValueInit, effect: ((params) => { const { context, form, name } = params; const renameService = context.node.getService(VariableFieldKeyRenameService); const disposable = renameService.onRename(({ before, after }) => { const beforeKeyPath = [ ...before.parentFields.map((_field) => _field.key).reverse(), before.key, ]; const afterKeyPath = [ ...after.parentFields.map((_field) => _field.key).reverse(), after.key, ]; // traverse rename refs inside form item 'name' traverseRef(name, form.getValueIn(name), (_drilldownName, _v) => { if (_v.type === 'ref') { // ref auto rename if (isKeyPathMatch(_v.content, beforeKeyPath)) { _v.content = [...afterKeyPath, ...(_v.content || [])?.slice(beforeKeyPath.length)]; form.setValueIn(_drilldownName, _v); } } else if (_v.type === 'template') { // template auto rename const templateKeyPaths = FlowValueUtils.getTemplateKeyPaths(_v); let hasMatch = false; templateKeyPaths.forEach((_keyPath) => { if (isKeyPathMatch(_keyPath, beforeKeyPath)) { hasMatch = true; const nextKeyPath = [ ...afterKeyPath, ...(_keyPath || [])?.slice(beforeKeyPath.length), ]; _v.content = _v.content?.replace( `{{${_keyPath.join('.')}}`, `{{${nextKeyPath.join('.')}}` ); } }); if (hasMatch) { form.setValueIn(_drilldownName, { ..._v }); } } }); }); return () => { disposable.dispose(); }; }) as Effect, }, ]; /** * If ref value's keyPath is the under as targetKeyPath * @param value * @param targetKeyPath * @returns */ function isKeyPathMatch(keyPath: string[] = [], targetKeyPath: string[]) { return targetKeyPath.every((_key, index) => _key === keyPath[index]); } /** * Traverse value to find ref * @param value * @param options * @returns */ function traverseRef( name: string, value: any, cb: (name: string, _v: IFlowRefValue | IFlowTemplateValue) => void ) { for (const { value: _v, path } of FlowValueUtils.traverse(value, { includeTypes: ['ref', 'template'], path: name, })) { cb(path, _v as IFlowRefValue | IFlowTemplateValue); } } ================================================ FILE: packages/materials/form-materials/src/effects/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export { autoRenameRefEffect } from './auto-rename-ref'; export { listenRefSchemaChange } from './listen-ref-schema-change'; export { listenRefValueChange } from './listen-ref-value-change'; export { provideBatchInputEffect } from './provide-batch-input'; export { provideJsonSchemaOutputs } from './provide-json-schema-outputs'; export { syncVariableTitle } from './sync-variable-title'; export { validateWhenVariableSync } from './validate-when-variable-sync'; ================================================ FILE: packages/materials/form-materials/src/effects/listen-ref-schema-change/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { IJsonSchema, JsonSchemaUtils } from '@flowgram.ai/json-schema'; import { BaseType, DataEvent, Effect, EffectFuncProps, EffectOptions, getNodeScope, } from '@flowgram.ai/editor'; import { IFlowRefValue } from '@/shared'; /** * Example: * const formMeta = { * effect: { * 'inputsValues.*': listenRefSchemaChange(({ name, schema, form }) => { * form.setValueIn(`${name}.schema`, schema); * }) * } * } * @param cb * @returns */ export const listenRefSchemaChange = ( cb: (props: EffectFuncProps & { schema?: IJsonSchema }) => void ): EffectOptions[] => [ { event: DataEvent.onValueInitOrChange, effect: ((params) => { const { context, value } = params; if (value?.type !== 'ref') { return () => null; } const disposable = getNodeScope(context.node).available.trackByKeyPath( value?.content || [], (_type) => { cb({ ...params, schema: JsonSchemaUtils.astToSchema(_type) }); }, { selector: (_v) => _v?.type, } ); return () => { disposable.dispose(); }; }) as Effect, }, ]; ================================================ FILE: packages/materials/form-materials/src/effects/listen-ref-value-change/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { BaseVariableField, DataEvent, Effect, EffectFuncProps, EffectOptions, getNodeScope, } from '@flowgram.ai/editor'; import { IFlowRefValue } from '@/shared'; /** * Example: * const formMeta = { * effect: { * 'inputsValues.*': listenRefValueChange(({ name, variable, form }) => { * const schema = JsonSchemaUtils.astToSchema(variable?.type); * form.setValueIn(`${name}.schema`, schema); * }) * } * } * @param cb * @returns */ export const listenRefValueChange = ( cb: (props: EffectFuncProps & { variable?: BaseVariableField }) => void ): EffectOptions[] => [ { event: DataEvent.onValueInitOrChange, effect: ((params) => { const { context, value } = params; if (value?.type !== 'ref') { return () => null; } const disposable = getNodeScope(context.node).available.trackByKeyPath( value?.content || [], (v) => { cb({ ...params, variable: v }); } ); return () => { disposable.dispose(); }; }) as Effect, }, ]; ================================================ FILE: packages/materials/form-materials/src/effects/provide-batch-input/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { ASTFactory, EffectOptions, FlowNodeRegistry, createEffectFromVariableProvider, } from '@flowgram.ai/editor'; import { IFlowRefValue } from '@/shared'; export const provideBatchInputEffect: EffectOptions[] = createEffectFromVariableProvider({ private: true, parse: (value: IFlowRefValue, ctx) => [ ASTFactory.createVariableDeclaration({ key: `${ctx.node.id}_locals`, meta: { title: ctx.node.form?.getValueIn('title'), icon: ctx.node.getNodeRegistry().info?.icon, }, type: ASTFactory.createObject({ properties: [ ASTFactory.createProperty({ key: 'item', initializer: ASTFactory.createEnumerateExpression({ enumerateFor: ASTFactory.createKeyPathExpression({ keyPath: value.content || [], }), }), }), ASTFactory.createProperty({ key: 'index', type: ASTFactory.createNumber(), }), ], }), }), ], }); ================================================ FILE: packages/materials/form-materials/src/effects/provide-json-schema-outputs/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { JsonSchemaUtils, IJsonSchema } from '@flowgram.ai/json-schema'; import { ASTFactory, EffectOptions, FlowNodeRegistry, createEffectFromVariableProvider, } from '@flowgram.ai/editor'; export const provideJsonSchemaOutputs: EffectOptions[] = createEffectFromVariableProvider({ parse: (value: IJsonSchema, ctx) => [ ASTFactory.createVariableDeclaration({ key: `${ctx.node.id}`, meta: { title: ctx.node.form?.getValueIn('title') || ctx.node.id, icon: ctx.node.getNodeRegistry().info?.icon, }, type: JsonSchemaUtils.schemaToAST(value), }), ], }); ================================================ FILE: packages/materials/form-materials/src/effects/sync-variable-title/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { DataEvent, Effect, EffectOptions, FlowNodeRegistry, FlowNodeVariableData, } from '@flowgram.ai/editor'; export const syncVariableTitle: EffectOptions[] = [ { event: DataEvent.onValueChange, effect: (({ value, context }) => { context.node.getData(FlowNodeVariableData).allScopes.forEach((_scope) => { _scope.output.variables.forEach((_var) => { _var.updateMeta({ ...(_var.meta || {}), title: value || context.node.id, icon: context.node.getNodeRegistry().info?.icon, }); }); }); }) as Effect, }, ]; ================================================ FILE: packages/materials/form-materials/src/effects/validate-when-variable-sync/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { DataEvent, Effect, EffectOptions, getNodeScope, getNodePrivateScope, } from '@flowgram.ai/editor'; export const validateWhenVariableSync = ({ scope, }: { scope?: 'private' | 'public'; } = {}): EffectOptions[] => [ { event: DataEvent.onValueInit, effect: (({ context, form, name }) => { const nodeScope = scope === 'private' ? getNodePrivateScope(context.node) : getNodeScope(context.node); const disposable = nodeScope.available.onListOrAnyVarChange(() => { const errorKeys = Object.entries(form.state.errors || {}) .filter(([_, errors]) => errors?.length > 0) .filter(([key]) => key.startsWith(name) || name.startsWith(key)) .map(([key]) => key); if (errorKeys.length > 0) { form.validate(); } }); return () => disposable.dispose(); }) as Effect, }, ]; ================================================ FILE: packages/materials/form-materials/src/form-plugins/batch-outputs-plugin/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { set } from 'lodash-es'; import { JsonSchemaUtils } from '@flowgram.ai/json-schema'; import { ASTFactory, createEffectFromVariableProvider, defineFormPluginCreator, FlowNodeRegistry, getNodePrivateScope, getNodeScope, ScopeChainTransformService, type EffectOptions, type FormPluginCreator, FlowNodeScopeType, } from '@flowgram.ai/editor'; import { IFlowRefValue } from '@/shared'; export const provideBatchOutputsEffect: EffectOptions[] = createEffectFromVariableProvider({ parse: (value: Record, ctx) => [ ASTFactory.createVariableDeclaration({ key: `${ctx.node.id}`, meta: { title: ctx.node.form?.getValueIn('title'), icon: ctx.node.getNodeRegistry().info?.icon, }, type: ASTFactory.createObject({ properties: Object.entries(value).map(([_key, value]) => ASTFactory.createProperty({ key: _key, initializer: ASTFactory.createWrapArrayExpression({ wrapFor: ASTFactory.createKeyPathExpression({ keyPath: value?.content || [], }), }), }) ), }), }), ], }); /** * Free Layout only right now */ export const createBatchOutputsFormPlugin: FormPluginCreator<{ outputKey: string; /** * if set, infer json schema to inferTargetKey when submit */ inferTargetKey?: string; }> = defineFormPluginCreator({ name: 'batch-outputs-plugin', onSetupFormMeta({ mergeEffect, addFormatOnSubmit }, { outputKey, inferTargetKey }) { mergeEffect({ [outputKey]: provideBatchOutputsEffect, }); if (inferTargetKey) { addFormatOnSubmit((formData, ctx) => { const outputVariable = getNodeScope(ctx.node).output.variables?.[0]; if (outputVariable?.type) { set(formData, inferTargetKey, JsonSchemaUtils.astToSchema(outputVariable?.type)); } return formData; }); } }, onInit(ctx, { outputKey }) { const chainTransformService = ctx.node.getService(ScopeChainTransformService); const batchNodeType = ctx.node.flowNodeType; const transformerId = `${batchNodeType}-outputs`; if (chainTransformService.hasTransformer(transformerId)) { return; } chainTransformService.registerTransformer(transformerId, { transformCovers: (covers, ctx) => { const node = ctx.scope.meta?.node; // Child Node's variable can cover parent if (node?.parent?.flowNodeType === batchNodeType) { return [...covers, getNodeScope(node.parent)]; } return covers; }, transformDeps(scopes, ctx) { const scopeMeta = ctx.scope.meta; if (scopeMeta?.type === FlowNodeScopeType.private) { return scopes; } const node = scopeMeta?.node; // Public of Loop Node depends on child Node if (node?.flowNodeType === batchNodeType) { // Get all child blocks const childBlocks = node.blocks; // public scope of all child blocks return [ getNodePrivateScope(node), ...childBlocks.map((_childBlock) => getNodeScope(_childBlock)), ]; } return scopes; }, }); }, }); ================================================ FILE: packages/materials/form-materials/src/form-plugins/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export { createBatchOutputsFormPlugin, provideBatchOutputsEffect } from './batch-outputs-plugin'; export { createInferAssignPlugin } from './infer-assign-plugin'; export { createInferInputsPlugin } from './infer-inputs-plugin'; ================================================ FILE: packages/materials/form-materials/src/form-plugins/infer-assign-plugin/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { set, uniqBy } from 'lodash-es'; import { JsonSchemaUtils } from '@flowgram.ai/json-schema'; import { ASTFactory, createEffectFromVariableProvider, defineFormPluginCreator, FlowNodeRegistry, getNodeScope, } from '@flowgram.ai/editor'; import { IFlowRefValue, IFlowValue } from '@/shared'; type AssignValueType = | { operator: 'assign'; left?: IFlowRefValue; right?: IFlowValue; } | { operator: 'declare'; left?: string; right?: IFlowValue; }; interface InputConfig { assignKey: string; outputKey: string; } export const createInferAssignPlugin = defineFormPluginCreator({ onSetupFormMeta({ addFormatOnSubmit, mergeEffect }, { assignKey, outputKey }) { if (!assignKey || !outputKey) { return; } mergeEffect({ [assignKey]: createEffectFromVariableProvider({ parse: (value: AssignValueType[], ctx) => { const declareRows = uniqBy( value.filter((_v) => _v.operator === 'declare' && _v.left && _v.right), 'left' ); return [ ASTFactory.createVariableDeclaration({ key: `${ctx.node.id}`, meta: { title: ctx.node.form?.getValueIn('title'), icon: ctx.node.getNodeRegistry().info?.icon, }, type: ASTFactory.createObject({ properties: declareRows.map((_v) => ASTFactory.createProperty({ key: _v.left as string, type: _v.right?.type === 'constant' ? JsonSchemaUtils.schemaToAST(_v.right?.schema || {}) : undefined, initializer: _v.right?.type === 'ref' ? ASTFactory.createKeyPathExpression({ keyPath: _v.right?.content || [], }) : {}, }) ), }), }), ]; }, }), }); addFormatOnSubmit((formData, ctx) => { set( formData, outputKey, JsonSchemaUtils.astToSchema(getNodeScope(ctx.node).output.variables?.[0]?.type) ); return formData; }); }, }); ================================================ FILE: packages/materials/form-materials/src/form-plugins/infer-inputs-plugin/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { get, omit, set } from 'lodash-es'; import { Immer } from 'immer'; import { defineFormPluginCreator, getNodePrivateScope, getNodeScope } from '@flowgram.ai/editor'; import { FlowValueUtils } from '@/shared'; const { produce } = new Immer({ autoFreeze: false }); interface InputConfig { sourceKey: string; targetKey: string; scope?: 'private' | 'public'; /** * For backend runtime, constant schema is redundant, so we can choose to ignore it */ ignoreConstantSchema?: boolean; } export const createInferInputsPlugin = defineFormPluginCreator({ onSetupFormMeta( { addFormatOnSubmit, addFormatOnInit }, { sourceKey, targetKey, scope, ignoreConstantSchema } ) { if (!sourceKey || !targetKey) { return; } addFormatOnSubmit((formData, ctx) => produce(formData, (draft: any) => { const sourceData = get(formData, sourceKey); set( draft, targetKey, FlowValueUtils.inferJsonSchema( sourceData, scope === 'private' ? getNodePrivateScope(ctx.node) : getNodeScope(ctx.node) ) ); if (ignoreConstantSchema) { for (const { value, path } of FlowValueUtils.traverse(sourceData, { includeTypes: ['constant'], })) { if (FlowValueUtils.isConstant(value) && value?.schema) { set(formData, `${sourceKey}.${path}`, omit(value, ['schema'])); } } } }) ); if (ignoreConstantSchema) { // Revert Schema in frontend addFormatOnInit((formData, ctx) => { const targetSchema = get(formData, targetKey); if (!targetSchema) { return formData; } // For backend data, it's not necessary to use immer for (const { value, pathArr } of FlowValueUtils.traverse(get(formData, sourceKey), { includeTypes: ['constant'], })) { if (FlowValueUtils.isConstant(value) && !value?.schema) { const schemaPath = pathArr.map((_item) => `properties.${_item}`).join('.'); const schema = get(targetSchema, schemaPath); if (schema) { set(value, 'schema', schema); } } } return formData; }); } }, }); ================================================ FILE: packages/materials/form-materials/src/hooks/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export { useObjectList } from './use-object-list'; ================================================ FILE: packages/materials/form-materials/src/hooks/use-object-list/index.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { useEffect, useRef, useState } from 'react'; import { nanoid } from 'nanoid'; import { difference, get, isObject, set } from 'lodash-es'; function genId() { return nanoid(); } interface ListItem { id: string; key?: string; value?: ValueType; } type ObjectType = Record; export function useObjectList({ value, onChange, sortIndexKey, }: { value?: ObjectType; onChange: (value?: ObjectType) => void; sortIndexKey?: string | ((item: ValueType | undefined) => string); }) { const [list, setList] = useState[]>([]); const effectVersion = useRef(0); const changeVersion = useRef(0); const getSortIndex = (value?: ValueType) => { if (typeof sortIndexKey === 'function') { return get(value, sortIndexKey(value)) || 0; } return get(value, sortIndexKey || '') || 0; }; useEffect(() => { effectVersion.current = effectVersion.current + 1; if (effectVersion.current === changeVersion.current) { return; } effectVersion.current = changeVersion.current; setList((_prevList) => { const newKeys = Object.entries(value || {}) .sort((a, b) => getSortIndex(a[1]) - getSortIndex(b[1])) .map(([key]) => key); const oldKeys = _prevList.map((item) => item.key).filter(Boolean) as string[]; const addKeys = difference(newKeys, oldKeys); return _prevList .filter((item) => !item.key || newKeys.includes(item.key)) .map((item) => ({ id: item.id, key: item.key, value: item.key ? value?.[item.key!] : item.value, })) .concat( addKeys.map((_key) => ({ id: genId(), key: _key, value: value?.[_key], })) ); }); }, [value]); const add = (defaultValue?: ValueType) => { setList((prevList) => [ ...prevList, { id: genId(), value: defaultValue, }, ]); }; const updateValue = (itemId: string, value: ValueType) => { changeVersion.current = changeVersion.current + 1; setList((prevList) => { const nextList = prevList.map((_item) => { if (_item.id === itemId) { return { ..._item, value, }; } return _item; }); onChange( Object.fromEntries( nextList .filter((item) => item.key) .map((item) => [item.key!, item.value]) .map((_res, idx) => { const indexKey = typeof sortIndexKey === 'function' ? sortIndexKey(_res[1] as ValueType | undefined) : sortIndexKey; if (isObject(_res[1]) && indexKey) { set(_res[1], indexKey, idx); } return _res; }) ) ); return nextList; }); }; const updateKey = (itemId: string, key: string) => { changeVersion.current = changeVersion.current + 1; setList((prevList) => { const nextList = prevList.map((_item) => { if (_item.id === itemId) { return { ..._item, key, }; } return _item; }); onChange( Object.fromEntries( nextList.filter((item) => item.key).map((item) => [item.key!, item.value]) ) ); return nextList; }); }; const remove = (itemId: string) => { changeVersion.current = changeVersion.current + 1; setList((prevList) => { const nextList = prevList.filter((_item) => _item.id !== itemId); onChange( Object.fromEntries( nextList.filter((item) => item.key).map((item) => [item.key!, item.value]) ) ); return nextList; }); }; return { list, add, updateKey, updateValue, remove }; } ================================================ FILE: packages/materials/form-materials/src/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export { AssignRow, AssignRows, BaseCodeEditor, BatchOutputs, BatchVariableSelector, BlurInput, CodeEditor, CodeEditorMini, ConditionPresetOp, ConditionProvider, ConditionRow, ConstantInput, DBConditionRow, DisplayFlowValue, DisplayInputsValueAllInTag, DisplayInputsValues, DisplayOutputs, DisplaySchemaTag, DisplaySchemaTree, DynamicValueInput, EditorInputsTree, EditorVariableTagInject, EditorVariableTree, InjectDynamicValueInput, InjectTypeSelector, InjectVariableSelector, InputsValues, InputsValuesTree, JsonCodeEditor, JsonEditorWithVariables, JsonSchemaCreator, JsonSchemaEditor, PromptEditor, PromptEditorWithInputs, PromptEditorWithVariables, PythonCodeEditor, SQLCodeEditor, SQLEditorWithVariables, ShellCodeEditor, TypeScriptCodeEditor, TypeSelector, VariableSelector, VariableSelectorProvider, getTypeSelectValue, parseTypeSelectValue, type AssignValueType, type CodeEditorPropsType, type ConditionOpConfig, type ConditionOpConfigs, type ConditionRowValueType, type ConstantInputStrategy, type DBConditionOptionType, type DBConditionRowValueType, type IConditionRule, type IConditionRuleFactory, type JsonEditorWithVariablesProps, type JsonSchemaCreatorProps, type PromptEditorPropsType, type PromptEditorWithInputsProps, type PromptEditorWithVariablesProps, type SQLEditorWithVariablesProps, type TypeSelectorProps, type VariableSelectorProps, useCondition, useConditionContext, useVariableTree, } from './components'; export { autoRenameRefEffect, listenRefSchemaChange, listenRefValueChange, provideBatchInputEffect, provideJsonSchemaOutputs, syncVariableTitle, validateWhenVariableSync, } from './effects'; export { createBatchOutputsFormPlugin, createInferAssignPlugin, createInferInputsPlugin, provideBatchOutputsEffect, } from './form-plugins'; export { useObjectList } from './hooks'; export { JsonSchemaTypePresetProvider, JsonSchemaUtils, createDisableDeclarationPlugin, createTypePresetPlugin, type ConstantRendererProps, type IJsonSchema, type JsonSchemaBasicType, type JsonSchemaTypeRegistry, useTypeManager, } from './plugins'; export { FlowValueUtils, createInjectMaterial, formatLegacyRefOnInit, formatLegacyRefOnSubmit, formatLegacyRefToNewRef, formatNewRefToLegacyRef, isLegacyFlowRefValueSchema, isNewFlowRefValueSchema, lazySuspense, polyfillCreateRoot, type FlowValueType, type IFlowConstantRefValue, type IFlowConstantValue, type IFlowExpressionValue, type IFlowRefValue, type IFlowTemplateValue, type IFlowValue, type IFlowValueExtra, type IInputsValues, type IPolyfillRoot, unstableSetCreateRoot, withSuspense, } from './shared'; export { validateFlowValue } from './validate'; ================================================ FILE: packages/materials/form-materials/src/plugins/disable-declaration-plugin/create-disable-declaration-plugin.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { ASTMatch, definePluginCreator, type GlobalEventActionType, VariableEngine, } from '@flowgram.ai/editor'; export const createDisableDeclarationPlugin = definePluginCreator({ onInit(ctx) { const variableEngine = ctx.get(VariableEngine); const handleEvent = (action: GlobalEventActionType) => { if (ASTMatch.isVariableDeclaration(action.ast)) { if (!action.ast.meta?.disabled) { action.ast.updateMeta({ ...(action.ast.meta || {}), disabled: true, }); } } }; variableEngine.onGlobalEvent('NewAST', handleEvent); variableEngine.onGlobalEvent('UpdateAST', handleEvent); }, }); ================================================ FILE: packages/materials/form-materials/src/plugins/disable-declaration-plugin/index.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export { createDisableDeclarationPlugin } from './create-disable-declaration-plugin'; ================================================ FILE: packages/materials/form-materials/src/plugins/index.ts ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ export { createDisableDeclarationPlugin } from './disable-declaration-plugin'; export { JsonSchemaTypePresetProvider, JsonSchemaUtils, createTypePresetPlugin, type ConstantRendererProps, type IJsonSchema, type JsonSchemaBasicType, type JsonSchemaTypeRegistry, useTypeManager, } from './json-schema-preset'; ================================================ FILE: packages/materials/form-materials/src/plugins/json-schema-preset/create-type-preset-plugin.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { BaseTypeManager, jsonSchemaContainerModule, JsonSchemaTypeManager, } from '@flowgram.ai/json-schema'; import { definePluginCreator, type PluginCreator } from '@flowgram.ai/editor'; import { JsonSchemaTypeRegistry } from './types'; import { initRegistries, jsonSchemaTypePreset } from './type-definition'; initRegistries(); type TypePresetRegistry = Partial & Pick; interface TypePresetPluginOptions { types?: TypePresetRegistry[]; unregisterTypes?: string[]; } export const createTypePresetPlugin: PluginCreator = definePluginCreator({ onInit(ctx, opts) { const typeManager = ctx.get(BaseTypeManager) as JsonSchemaTypeManager; jsonSchemaTypePreset.forEach((_type) => typeManager.register(_type)); opts.types?.forEach((_type) => typeManager.register(_type)); opts.unregisterTypes?.forEach((_type) => typeManager.unregister(_type)); }, containerModules: [jsonSchemaContainerModule], }); ================================================ FILE: packages/materials/form-materials/src/plugins/json-schema-preset/index.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import { type JsonSchemaBasicType, JsonSchemaUtils, type IJsonSchema, } from '@flowgram.ai/json-schema'; import { type ConstantRendererProps, type JsonSchemaTypeRegistry } from './types'; import { useTypeManager, JsonSchemaTypePresetProvider } from './react'; import { createTypePresetPlugin } from './create-type-preset-plugin'; export { createTypePresetPlugin, useTypeManager, JsonSchemaTypePresetProvider, JsonSchemaUtils, type IJsonSchema, type JsonSchemaTypeRegistry, type ConstantRendererProps, type JsonSchemaBasicType, }; ================================================ FILE: packages/materials/form-materials/src/plugins/json-schema-preset/react.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ import React from 'react'; import { type IJsonSchema, useTypeManager as useOriginTypeManager, TypePresetProvider as OriginTypePresetProvider, JsonSchemaTypeManager, } from '@flowgram.ai/json-schema'; import { type JsonSchemaTypeRegistry } from './types'; import { initRegistries, jsonSchemaTypePreset } from './type-definition'; // If you want to use new type Manager, init registries initRegistries(); export const useTypeManager = () => useOriginTypeManager() as JsonSchemaTypeManager; export const JsonSchemaTypePresetProvider = ({ types = [], children, }: React.PropsWithChildren<{ types: JsonSchemaTypeRegistry[] }>) => ( {children} ); ================================================ FILE: packages/materials/form-materials/src/plugins/json-schema-preset/type-definition/array.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ /* eslint-disable react/prop-types */ import React from 'react'; import { I18n } from '@flowgram.ai/editor'; import { ConditionPresetOp } from '@/components/condition-context/op'; import { JsonCodeEditor } from '@/components/code-editor'; import { type JsonSchemaTypeRegistry } from '../types'; export const arrayRegistry: Partial = { type: 'array', ConstantRenderer: (props) => ( props.onChange?.(v)} placeholder={I18n.t('Please Input Array')} readonly={props.readonly} /> ), conditionRule: { [ConditionPresetOp.IS_EMPTY]: null, [ConditionPresetOp.IS_NOT_EMPTY]: null, [ConditionPresetOp.CONTAINS]: { type: 'array', extra: { weak: true } }, [ConditionPresetOp.NOT_CONTAINS]: { type: 'array', extra: { weak: true } }, [ConditionPresetOp.EQ]: { type: 'array', extra: { weak: true } }, [ConditionPresetOp.NEQ]: { type: 'array', extra: { weak: true } }, }, }; ================================================ FILE: packages/materials/form-materials/src/plugins/json-schema-preset/type-definition/boolean.tsx ================================================ /** * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates * SPDX-License-Identifier: MIT */ /* eslint-disable react/prop-types */ import React from 'react'; import { I18n } from '@flowgram.ai/editor'; import { Select } from '@douyinfe/semi-ui'; import { ConditionPresetOp } from '@/components/condition-context/op'; import { type JsonSchemaTypeRegistry } from '../types'; export const booleanRegistry: Partial = { type: 'boolean', ConstantRenderer: (props) => { const { value, onChange, ...rest } = props; return (