chore: import upstream snapshot with attribution
CI / build (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:09:51 +08:00
commit 0232b4e2bb
3528 changed files with 291404 additions and 0 deletions
@@ -0,0 +1,11 @@
/**
* 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,
});
@@ -0,0 +1,60 @@
{
"name": "@flowgram.ai/free-hover-plugin",
"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/free-layout-core": "workspace:*",
"@flowgram.ai/renderer": "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/"
}
}
@@ -0,0 +1,14 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { definePluginCreator } from '@flowgram.ai/core';
import { HoverLayer } from './hover-layer';
export const createFreeHoverPlugin = definePluginCreator({
onInit(ctx): void {
ctx.playground.registerLayer(HoverLayer);
},
});
@@ -0,0 +1,323 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
/* eslint-disable complexity */
import { inject, injectable } from 'inversify';
import { type IPoint } from '@flowgram.ai/utils';
import { SelectorBoxConfigEntity } from '@flowgram.ai/renderer';
import {
WorkflowDocument,
WorkflowDragService,
WorkflowHoverService,
WorkflowLineEntity,
WorkflowLinesManager,
WorkflowNodeEntity,
WorkflowSelectService,
} from '@flowgram.ai/free-layout-core';
import { WorkflowPortEntity } from '@flowgram.ai/free-layout-core';
import { FlowNodeBaseType, FlowNodeTransformData } from '@flowgram.ai/document';
import {
EditorState,
EditorStateConfigEntity,
Layer,
PlaygroundConfigEntity,
observeEntities,
observeEntity,
observeEntityDatas,
type LayerOptions,
} from '@flowgram.ai/core';
import { getSelectionBounds } from './selection-utils';
const PORT_BG_CLASS_NAME = 'workflow-port-bg';
export interface HoverLayerOptions extends LayerOptions {
canHovered?: (e: MouseEvent, service: WorkflowHoverService) => boolean;
}
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace HoverLayerOptions {
export const DEFAULT: HoverLayerOptions = {
canHovered: () => true,
};
}
const LINE_CLASS_NAME = '.gedit-flow-activity-line';
const NODE_CLASS_NAME = '.gedit-flow-activity-node';
@injectable()
export class HoverLayer extends Layer<HoverLayerOptions> {
static type = 'HoverLayer';
@inject(WorkflowDocument) document: WorkflowDocument;
@inject(WorkflowSelectService) selectionService: WorkflowSelectService;
@inject(WorkflowDragService) dragService: WorkflowDragService;
@inject(WorkflowHoverService) hoverService: WorkflowHoverService;
@inject(WorkflowLinesManager)
linesManager: WorkflowLinesManager;
@observeEntity(EditorStateConfigEntity)
protected editorStateConfig: EditorStateConfigEntity;
@observeEntity(SelectorBoxConfigEntity)
protected selectorBoxConfigEntity: SelectorBoxConfigEntity;
@inject(PlaygroundConfigEntity) configEntity: PlaygroundConfigEntity;
/**
* 监听节点 transform
*/
@observeEntityDatas(WorkflowNodeEntity, FlowNodeTransformData)
protected readonly nodeTransforms: FlowNodeTransformData[];
/**
* 按选中排序
* @private
*/
protected nodeTransformsWithSort: FlowNodeTransformData[] = [];
autorun(): void {
const { activatedNode } = this.selectionService;
this.nodeTransformsWithSort = this.nodeTransforms
.filter((n) => n.entity.id !== 'root' && n.entity.flowNodeType !== FlowNodeBaseType.GROUP)
.reverse() // 后创建的排在前面
.sort((n1) => (n1.entity === activatedNode ? -1 : 0));
}
/**
* 监听线条
*/
@observeEntities(WorkflowLineEntity)
protected readonly lines: WorkflowLineEntity[];
/**
* 是否正在调整线条
* @protected
*/
get isDrawing(): boolean {
return this.linesManager.isDrawing;
}
onReady(): void {
this.options = {
...HoverLayerOptions.DEFAULT,
...this.options,
};
this.toDispose.pushAll([
// 监听主动触发的 hover 事件
this.hoverService.onUpdateHoverPosition((hoverPosition) => {
const { position, target } = hoverPosition;
const canvasPosition = this.config.getPosFromMouseEvent({
clientX: position.x,
clientY: position.y,
});
this.updateHoveredState(canvasPosition, target);
}),
// 监听画布鼠标移动事件
this.listenPlaygroundEvent('mousemove', (e: MouseEvent) => {
this.hoverService.hoveredPos = this.config.getPosFromMouseEvent(e);
if (!this.isEnabled()) {
return;
}
if (!this.options.canHovered!(e, this.hoverService)) {
return;
}
const mousePos = this.config.getPosFromMouseEvent(e);
// 更新 hover 状态
this.updateHoveredState(mousePos, e?.target as HTMLElement);
}),
this.selectionService.onSelectionChanged(() => this.autorun()),
// 控制触控
this.listenPlaygroundEvent('touchstart', (e: MouseEvent): boolean | undefined => {
if (!this.isEnabled() || this.isDrawing) {
return undefined;
}
return this.handleDragLine(e);
}),
// 控制选中逻辑
this.listenPlaygroundEvent('mousedown', (e: MouseEvent): boolean | undefined => {
if (!this.isEnabled() || this.isDrawing) {
return undefined;
}
const { hoveredNode } = this.hoverService;
const lineDrag = this.handleDragLine(e);
if (lineDrag) {
return true;
}
const mousePos = this.config.getPosFromMouseEvent(e);
const selectionBounds = getSelectionBounds(
this.selectionService.selection,
// 这里只考虑多选模式,单选模式已经下沉到 use-node-render 中
true
);
if (selectionBounds.width > 0 && selectionBounds.contains(mousePos.x, mousePos.y)) {
/**
* 拖拽选择框
*/
this.dragService.startDragSelectedNodes(e)?.then((dragSuccess) => {
if (!dragSuccess) {
// 拖拽没有成功触发了点击
if (hoveredNode && hoveredNode instanceof WorkflowNodeEntity) {
// 追加选择
if (e.shiftKey) {
this.selectionService.toggleSelect(hoveredNode);
} else {
this.selectionService.selectNode(hoveredNode);
}
} else {
this.selectionService.clear();
}
}
});
// 这里会组织触发 selector box
return true;
} else {
if (!hoveredNode) {
this.selectionService.clear();
}
}
return undefined;
}),
]);
}
/**
* 更新 hoverd
* @param mousePos
*/
updateHoveredState(mousePos: IPoint, target?: HTMLElement): void {
const { hoverService } = this;
const nodeTransforms = this.nodeTransformsWithSort;
const outputPortHovered = this.linesManager.getPortFromMousePos(mousePos, 'output');
const inputPortHovered = this.linesManager.getPortFromMousePos(mousePos, 'input');
// 在两个端口叠加情况,优先使用 outputPort
const portHovered = outputPortHovered || inputPortHovered;
const lineDomNodes = this.playgroundNode.querySelectorAll(LINE_CLASS_NAME);
const checkTargetFromLine = [...lineDomNodes].some((lineDom) =>
lineDom.contains(target as HTMLElement)
);
if (portHovered) {
if (this.document.options.twoWayConnection) {
hoverService.updateHoveredKey(portHovered.id);
} else {
// 默认 只有 output 点位可以 hover
if (portHovered.portType === 'output') {
hoverService.updateHoveredKey(portHovered.id);
} else if (checkTargetFromLine || target?.className?.includes?.(PORT_BG_CLASS_NAME)) {
// 输入点采用获取最接近的线条
const lineHovered = this.linesManager.getCloseInLineFromMousePos(mousePos);
if (lineHovered) {
this.updateHoveredKey(lineHovered.id);
}
}
}
return;
}
// Drawing 情况,不能选中节点和线条
if (this.isDrawing) {
return;
}
const nodeHovered = nodeTransforms.find((trans: FlowNodeTransformData) =>
trans.bounds.contains(mousePos.x, mousePos.y)
)?.entity as WorkflowNodeEntity;
// 判断当前鼠标位置所在元素是否在节点内部
const nodeDomNodes = this.playgroundNode.querySelectorAll(NODE_CLASS_NAME);
const checkTargetFromNode = [...nodeDomNodes].some((nodeDom) =>
nodeDom.contains(target as HTMLElement)
);
if (nodeHovered || checkTargetFromNode) {
if (nodeHovered?.id) {
this.updateHoveredKey(nodeHovered.id);
}
}
// 获取最接近的线条
// 线条会相交需要获取最接近点位的线条,不能删除的线条不能被选中
const lineHovered = checkTargetFromLine
? this.linesManager.getCloseInLineFromMousePos(mousePos)
: undefined;
if (nodeHovered && lineHovered) {
const nodeStackIndex = nodeHovered.renderData.stackIndex;
const lineStackIndex = lineHovered.stackIndex;
if (nodeStackIndex > lineStackIndex) {
return this.updateHoveredKey(nodeHovered.id);
} else {
return this.updateHoveredKey(lineHovered.id);
}
}
// 判断节点是否 hover
if (nodeHovered) {
return this.updateHoveredKey(nodeHovered.id);
}
// 判断线条是否 hover
if (lineHovered) {
return this.updateHoveredKey(lineHovered.id);
}
// 上述逻辑都未命中 则清空 hovered
hoverService.clearHovered();
const currentState = this.editorStateConfig.getCurrentState();
const isMouseFriendly = currentState === EditorState.STATE_MOUSE_FRIENDLY_SELECT;
// 鼠标优先,并且不是按住 shift 键,更新为小手
if (isMouseFriendly && !this.editorStateConfig.isPressingShift) {
this.configEntity.updateCursor('grab');
}
}
updateHoveredKey(key: string): void {
// 鼠标优先交互模式,如果是 hover,需要将鼠标的小手去掉,还原鼠标原有样式
this.configEntity.updateCursor('default');
this.hoverService.updateHoveredKey(key);
}
/**
* 判断是否能够 hover
* @returns 是否能 hover
*/
isEnabled(): boolean {
const currentState = this.editorStateConfig.getCurrentState();
// 选择框情况禁止 hover
return (
// 鼠标友好模式下,也需要支持 hover 效果,不然线条选择不到
// Coze 中没有使用该插件,需要在 workflow/render 包相应位置改动
(currentState === EditorState.STATE_SELECT ||
currentState === EditorState.STATE_MOUSE_FRIENDLY_SELECT) &&
!this.selectorBoxConfigEntity.isStart &&
!this.dragService.isDragging
);
}
private handleDragLine(e: MouseEvent): boolean | undefined {
const { someHovered } = this.hoverService;
// 重置线条
if (someHovered && someHovered instanceof WorkflowLineEntity) {
this.dragService.resetLine(someHovered, e);
return true;
}
if (
someHovered &&
someHovered instanceof WorkflowPortEntity &&
!someHovered.disabled &&
e.button !== 1
) {
e.stopPropagation();
e.preventDefault();
this.dragService.startDrawingLine(someHovered, e);
return true;
}
}
}
@@ -0,0 +1,6 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export * from './create-free-hover-plugin';
@@ -0,0 +1,25 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { Rectangle } from '@flowgram.ai/utils';
import { WorkflowNodeEntity } from '@flowgram.ai/free-layout-core';
import { FlowNodeTransformData } from '@flowgram.ai/document';
import { type Entity } from '@flowgram.ai/core';
const BOUNDS_PADDING = 2;
export function getSelectionBounds(
selection: Entity[],
ignoreOneSelect: boolean = true // 忽略单选
): Rectangle {
const selectedNodes = selection.filter((node) => node instanceof WorkflowNodeEntity);
// 选中单个的时候不显示
return selectedNodes.length > (ignoreOneSelect ? 1 : 0)
? Rectangle.enlarge(selectedNodes.map((n) => n.getData(FlowNodeTransformData)!.bounds)).pad(
BOUNDS_PADDING
)
: Rectangle.EMPTY;
}
@@ -0,0 +1,7 @@
{
"extends": "@flowgram.ai/ts-config/tsconfig.flow.path.json",
"compilerOptions": {
},
"include": ["./src"],
"exclude": ["node_modules"]
}
@@ -0,0 +1,31 @@
/**
* 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.*',
],
},
});
@@ -0,0 +1,6 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import 'reflect-metadata';