This commit is contained in:
@@ -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';
|
||||
Reference in New Issue
Block a user