Skip to content

Commit 72566fc

Browse files
committed
feat: 增加鼠标拖拽晃动连线上的节点自动将其摘除的特性 (参考了 https://github.com/orgs/graphif/discussions/682 ) 默认关闭,需要在设置中开启
1 parent 7f44496 commit 72566fc

12 files changed

Lines changed: 236 additions & 7 deletions

File tree

.trae/skills/create-setting-item/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export const settingsIcons = {
6464
- `app/src/locales/zh_TW.yml` - 繁体中文
6565
- `app/src/locales/en.yml` - 英文
6666
- `app/src/locales/zh_TWC.yml` - 接地气繁体中文
67+
- `app/src/locales/id.yml` - 印度尼西亚语
6768

6869
**翻译结构:**
6970

app/src/core/service/Settings.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ export const settingsSchema = z.object({
147147
.union([z.literal("auto"), z.literal("manual"), z.literal("autoByLength")])
148148
.default("autoByLength"),
149149
allowAddCycleEdge: z.boolean().default(false),
150+
enableDragNodeShakeDetachFromEdge: z.boolean().default(false),
150151
autoLayoutWhenTreeGenerate: z.boolean().default(true),
151152
treeGenerateInheritParentColor: z.boolean().default(false),
152153
textNodeAutoFormatTreeWhenExitEdit: z.boolean().default(false),

app/src/core/service/SettingsIcons.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ import {
7575
SunMoon,
7676
SquareMousePointer,
7777
Minus,
78+
Unlink,
7879
} from "lucide-react";
7980

8081
export const settingsIcons = {
@@ -127,6 +128,7 @@ export const settingsIcons = {
127128
textNodeBigContentThresholdWhenPaste: ArrowDownNarrowWide,
128129
textNodePasteSizeAdjustMode: Scaling,
129130
allowAddCycleEdge: RotateCw,
131+
enableDragNodeShakeDetachFromEdge: Unlink,
130132
enableDragEdgeRotateStructure: SplinePointer,
131133
enableCtrlWheelRotateStructure: RefreshCcw,
132134
autoLayoutWhenTreeGenerate: ListTree,

app/src/core/service/controlService/controller/concrete/ControllerEntityClickSelectAndMove.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { ControllerClass } from "@/core/service/controlService/controller/ControllerClass";
2+
import { ShakeDetector } from "@/core/service/controlService/controller/utils/ShakeDetector";
3+
import { ConnectNodeSmartTools } from "@/core/service/dataManageService/connectNodeSmartTools";
24
import { RectangleNoteEffect } from "@/core/service/feedbackService/effectEngine/concrete/RectangleNoteEffect";
35
import { RectangleRenderEffect } from "@/core/service/feedbackService/effectEngine/concrete/RectangleRenderEffect";
46
import { Settings } from "@/core/service/Settings";
@@ -15,6 +17,7 @@ import { Rectangle } from "@graphif/shapes";
1517
export class ControllerEntityClickSelectAndMoveClass extends ControllerClass {
1618
private isMovingEntity = false;
1719
private mouseDownViewLocation = Vector.getZero();
20+
private shakeDetector = new ShakeDetector();
1821

1922
public mousedown: (event: MouseEvent) => void = (event: MouseEvent) => {
2023
if (event.button !== 0) {
@@ -89,6 +92,7 @@ export class ControllerEntityClickSelectAndMoveClass extends ControllerClass {
8992
// 单击选中
9093
if (clickedStageObject !== null) {
9194
this.isMovingEntity = true;
95+
this.shakeDetector.reset(); // 开始拖拽时重置摇晃检测器
9296

9397
if (
9498
this.project.controller.pressingKeySet.has("shift") &&
@@ -188,6 +192,20 @@ export class ControllerEntityClickSelectAndMoveClass extends ControllerClass {
188192
this.project.autoAlign.preAlignAllSelected();
189193
}
190194

195+
// 检测摇晃动作 - 只在开启设置且选中单个节点时检测
196+
if (Settings.enableDragNodeShakeDetachFromEdge && !this.shakeDetector.hasTriggered()) {
197+
const selectedEntities = this.project.stageManager.getSelectedEntities();
198+
if (selectedEntities.length === 1) {
199+
// 使用窗口坐标(屏幕像素)进行摇晃检测,与世界坐标缩放无关
200+
const viewLocation = new Vector(event.clientX, event.clientY);
201+
const isShaking = this.shakeDetector.addSample(viewLocation, Date.now());
202+
if (isShaking) {
203+
// 检测到摇晃,触发节点脱离(不向上平移)
204+
ConnectNodeSmartTools.removeNodeFromTree(this.project, false);
205+
}
206+
}
207+
}
208+
191209
this.lastMoveLocation = worldLocation.clone();
192210
}
193211
};
@@ -218,6 +236,7 @@ export class ControllerEntityClickSelectAndMoveClass extends ControllerClass {
218236
}
219237

220238
this.isMovingEntity = false;
239+
this.shakeDetector.reset();
221240
};
222241

223242
public mouseMoveOutWindowForcedShutdown(_outsideLocation: Vector): void {
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import { Vector } from "@graphif/data-structures";
2+
3+
/**
4+
* 拖拽摇晃检测器
5+
* 用于检测用户在拖拽节点时是否进行快速来回摇晃动作(扁平形状,非圆形转动)
6+
* 如果检测到摇晃,可以触发特定操作(如将节点从连线结构中脱离)
7+
*/
8+
export class ShakeDetector {
9+
// 配置参数 - 使用窗口坐标(屏幕像素),与世界坐标缩放无关
10+
private readonly sampleInterval = 50; // 采样间隔(ms)
11+
private readonly maxSamples = 10; // 最大采样点数
12+
private readonly directionChangeThreshold = 3; // 方向改变次数阈值
13+
private readonly minShakeDistance = 100; // 最小摇晃距离(窗口坐标,像素)
14+
private readonly timeWindow = 500; // 时间窗口(ms)
15+
private readonly minMoveThreshold = 8; // 忽略微小移动的阈值(窗口坐标,像素)
16+
private readonly minSpeedThreshold = 15; // 最小速度阈值,过滤掉过慢的移动(像素/采样间隔)
17+
private readonly minAspectRatio = 2.0; // 最小长宽比,确保摇晃是扁平的(来回)而非圆形
18+
19+
private samples: { location: Vector; time: number }[] = [];
20+
private lastSampleTime = 0;
21+
private triggered = false;
22+
23+
/**
24+
* 重置检测器状态
25+
*/
26+
reset(): void {
27+
this.samples = [];
28+
this.lastSampleTime = 0;
29+
this.triggered = false;
30+
}
31+
32+
/**
33+
* 添加一个新的位置样本
34+
* @param location 当前位置(窗口坐标/屏幕像素)
35+
* @param currentTime 当前时间戳
36+
* @returns 是否检测到摇晃
37+
*/
38+
addSample(location: Vector, currentTime: number): boolean {
39+
if (this.triggered) {
40+
return false; // 已经触发过,不再检测
41+
}
42+
43+
// 控制采样频率
44+
if (currentTime - this.lastSampleTime < this.sampleInterval) {
45+
return false;
46+
}
47+
this.lastSampleTime = currentTime;
48+
49+
// 添加新样本
50+
this.samples.push({ location: location.clone(), time: currentTime });
51+
52+
// 清理过期样本
53+
const cutoffTime = currentTime - this.timeWindow;
54+
while (this.samples.length > 0 && this.samples[0].time < cutoffTime) {
55+
this.samples.shift();
56+
}
57+
58+
// 限制样本数量
59+
if (this.samples.length > this.maxSamples) {
60+
this.samples.shift();
61+
}
62+
63+
// 检测摇晃
64+
return this.checkShake();
65+
}
66+
67+
/**
68+
* 检测是否发生摇晃
69+
* 算法:检测在一定时间窗口内,快速来回移动(扁平形状)
70+
*/
71+
private checkShake(): boolean {
72+
if (this.samples.length < 4) {
73+
return false;
74+
}
75+
76+
// 计算包围盒和总距离
77+
let minX = Infinity,
78+
maxX = -Infinity;
79+
let minY = Infinity,
80+
maxY = -Infinity;
81+
let totalDistance = 0;
82+
let directionChanges = 0;
83+
let lastDirectionX = 0;
84+
let lastDirectionY = 0;
85+
let fastMoveCount = 0;
86+
87+
for (let i = 1; i < this.samples.length; i++) {
88+
const prev = this.samples[i - 1];
89+
const curr = this.samples[i];
90+
const diff = curr.location.subtract(prev.location);
91+
const distance = diff.magnitude();
92+
93+
// 更新包围盒
94+
minX = Math.min(minX, prev.location.x, curr.location.x);
95+
maxX = Math.max(maxX, prev.location.x, curr.location.x);
96+
minY = Math.min(minY, prev.location.y, curr.location.y);
97+
maxY = Math.max(maxY, prev.location.y, curr.location.y);
98+
99+
// 忽略微小移动
100+
if (distance < this.minMoveThreshold) {
101+
continue;
102+
}
103+
104+
totalDistance += distance;
105+
106+
// 统计快速移动次数(用于判断是否为快速摇晃)
107+
if (distance >= this.minSpeedThreshold) {
108+
fastMoveCount++;
109+
}
110+
111+
// 计算当前方向(简化为一维方向)
112+
const currentDirectionX = diff.x > 0 ? 1 : diff.x < 0 ? -1 : 0;
113+
const currentDirectionY = diff.y > 0 ? 1 : diff.y < 0 ? -1 : 0;
114+
115+
// 检测X方向改变
116+
if (lastDirectionX !== 0 && currentDirectionX !== 0 && lastDirectionX !== currentDirectionX) {
117+
directionChanges++;
118+
}
119+
120+
// 检测Y方向改变
121+
if (lastDirectionY !== 0 && currentDirectionY !== 0 && lastDirectionY !== currentDirectionY) {
122+
directionChanges++;
123+
}
124+
125+
if (currentDirectionX !== 0) {
126+
lastDirectionX = currentDirectionX;
127+
}
128+
if (currentDirectionY !== 0) {
129+
lastDirectionY = currentDirectionY;
130+
}
131+
}
132+
133+
// 需要足够多的快速移动(确保是快速摇晃而非慢速拖动)
134+
if (fastMoveCount < 3) {
135+
return false;
136+
}
137+
138+
// 计算长宽比,确保是扁平形状(来回摇晃)而非圆形
139+
const width = maxX - minX;
140+
const height = maxY - minY;
141+
142+
// 如果移动范围太小,不认为是有效摇晃
143+
if (width < 30 && height < 30) {
144+
return false;
145+
}
146+
147+
const aspectRatioX = height > 0 ? width / height : Infinity;
148+
const aspectRatioY = width > 0 ? height / width : Infinity;
149+
const isFlatShape = aspectRatioX >= this.minAspectRatio || aspectRatioY >= this.minAspectRatio;
150+
151+
// 判断是否为有效摇晃:
152+
// 1. 方向改变次数足够(来回次数)
153+
// 2. 总距离足够
154+
// 3. 是扁平形状(来回摇晃而非转圈)
155+
if (directionChanges >= this.directionChangeThreshold && totalDistance >= this.minShakeDistance && isFlatShape) {
156+
this.triggered = true;
157+
return true;
158+
}
159+
160+
return false;
161+
}
162+
163+
/**
164+
* 标记为已触发,防止重复触发
165+
*/
166+
markTriggered(): void {
167+
this.triggered = true;
168+
}
169+
170+
/**
171+
* 检查是否已经触发过
172+
*/
173+
hasTriggered(): boolean {
174+
return this.triggered;
175+
}
176+
}

app/src/core/service/dataManageService/connectNodeSmartTools.tsx

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -155,8 +155,9 @@ export namespace ConnectNodeSmartTools {
155155
/**
156156
* 将选中的节点从树中移除,并重新连接其前后节点
157157
* @param project
158+
* @param moveUp 是否在摘除后向上平移节点,默认为 true(快捷键触发时)
158159
*/
159-
export function removeNodeFromTree(project: Project) {
160+
export function removeNodeFromTree(project: Project, moveUp: boolean = true) {
160161
const selectedEntities = project.stageManager
161162
.getSelectedEntities()
162163
.filter((node) => node instanceof ConnectableEntity);
@@ -207,12 +208,15 @@ export namespace ConnectNodeSmartTools {
207208
});
208209
});
209210

210-
// 将选中的节点从连线中跳出来,向上移动,移动距离等于节点高度
211-
const rectangle = selectedNode.collisionBox.getRectangle();
212-
const originalLocation = rectangle.location.clone();
213-
// 计算新位置:原位置向上移动节点高度,使新位置的底部边缘对齐原位置的顶部边缘
214-
const newLocation = new Vector(originalLocation.x, originalLocation.y - rectangle.size.y);
215-
selectedNode.moveTo(newLocation);
211+
// 根据参数决定是否向上平移节点
212+
if (moveUp) {
213+
// 将选中的节点从连线中跳出来,向上移动,移动距离等于节点高度
214+
const rectangle = selectedNode.collisionBox.getRectangle();
215+
const originalLocation = rectangle.location.clone();
216+
// 计算新位置:原位置向上移动节点高度,使新位置的底部边缘对齐原位置的顶部边缘
217+
const newLocation = new Vector(originalLocation.x, originalLocation.y - rectangle.size.y);
218+
selectedNode.moveTo(newLocation);
219+
}
216220
project.historyManager.recordStep();
217221
}
218222
}

app/src/locales/en.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,11 @@ settings:
412412
description: |
413413
When enabled, nodes can have self-loops, i.e., connections to themselves, which is useful for state machine diagrams.
414414
This is disabled by default because it is not commonly used and can be easily triggered by mistake.
415+
enableDragNodeShakeDetachFromEdge:
416+
title: Enable Drag Node Shake to Detach from Edge
417+
description: |
418+
When enabled, quickly shaking the mouse while dragging a single node will automatically detach it from the edge structure.
419+
Disabled by default to prevent accidental triggers.
415420
enableDragEdgeRotateStructure:
416421
title: Enable Drag Edge to Rotate Structure
417422
description: |

app/src/locales/id.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,11 @@ settings:
543543
description: |
544544
Saat diaktifkan, dapat menambahkan loop pada node, yaitu node terhubung dengan dirinya sendiri, digunakan untuk menggambar mesin status
545545
Default mati, karena jarang digunakan, mudah memicu secara tidak sengaja
546+
enableDragNodeShakeDetachFromEdge:
547+
title: Izinkan Lepas Node dari Garis dengan Goyangan
548+
description: |
549+
Saat diaktifkan, menggoyangkan mouse dengan cepat saat menyeret satu node akan secara otomatis melepaskannya dari struktur garis
550+
Default mati untuk mencegah pemicu yang tidak disengaja
546551
enableDragEdgeRotateStructure:
547552
title: Izinkan Rotasi Struktur dengan Menyeret Garis
548553
description: |

app/src/locales/zh_CN.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,11 @@ settings:
553553
description: |
554554
开启后,节点之间可以添加自环,即节点与自身相连,用于状态机绘制
555555
默认关闭,因为不常用,容易误触发
556+
enableDragNodeShakeDetachFromEdge:
557+
title: 允许拖拽摇晃从连线中脱离节点
558+
description: |
559+
开启后,拖拽单个节点时快速摇晃鼠标,可自动将节点从连线结构中脱离
560+
默认关闭,防止误触发
556561
enableDragEdgeRotateStructure:
557562
title: 允许拖拽连线旋转结构
558563
description: |

app/src/locales/zh_TW.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,11 @@ settings:
553553
description: |
554554
開啟後,節點之間可以添加自環,即節點與自身相連,用於狀態機繪製
555555
默認關閉,因為不常用,容易誤觸發
556+
enableDragNodeShakeDetachFromEdge:
557+
title: 允許拖拽搖晃從連線中脫離節點
558+
description: |
559+
開啟後,拖拽單個節點時快速搖晃鼠標,可自動將節點從連線結構中脫離
560+
默認關閉,防止誤觸發
556561
enableDragEdgeRotateStructure:
557562
title: 允許拖拽連線旋轉結構
558563
description: |

0 commit comments

Comments
 (0)