docs(claude): 添加新规则 - Hover 延迟操作计时器清理

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
liquidliangandClaude Opus 4.5 committed 2026-01-15 11:28:10 +08:00
1 parent 82f9d7aab5
commit ebe979fe58
7 files changed
+398 -79

No files matched your search

+42 -39
View File
@@ -1121,56 +1121,59 @@ createShape(board, points, shapeType);
- 事件处理器使用 `useCallback` 包装
- Hook 顺序:状态 hooks → 副作用 hooks → 事件处理器 → 渲染逻辑
### 新增 UI 组件需同步更新全局事件排除列表
#### Hover 延迟操作需要正确的计时器清理
**场景**: 添加新的浮层/覆盖层 UI 组件(如工具栏、导航、面板等)时,需要将其排除在全局事件处理之外
**场景**: 实现 hover 延迟展开/显示等交互效果时(如工具栏 Popover 延迟展开)
❌ **错误示例**:
```typescript
// drawnix.tsx 中的双击事件处理
const handleDoubleClick = (event: MouseEvent) => {
const target = event.target as HTMLElement;
const isInsideOverlay = target.closest('.chat-drawer') ||
target.closest('.minimap') ||
target.closest('.ai-input-bar');
// ❌ 忘记添加新组件 .view-navigation
if (isInsideOverlay) return;
// 双击空白区域触发 quick-creation-toolbar
setQuickToolbarVisible(true);
};
// 错误:没有清理计时器,可能导致内存泄漏和意外行为
const [open, setOpen] = useState(false);
// 结果:双击 view-navigation 会意外触发 quick-creation-toolbar
<div
onPointerEnter={() => {
setTimeout(() => setOpen(true), 300); // 计时器没有被追踪
}}
>
```
✅ **正确示例**:
```typescript
// 添加新组件后,同步更新排除列表
const handleDoubleClick = (event: MouseEvent) => {
const target = event.target as HTMLElement;
const isInsideOverlay = target.closest('.chat-drawer') ||
target.closest('.minimap') ||
target.closest('.ai-input-bar') ||
target.closest('.view-navigation'); // ✅ 新增组件
if (isInsideOverlay) return;
setQuickToolbarVisible(true);
};
// 正确:使用 ref 追踪计时器,在离开和卸载时清理
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearHoverTimeout = useCallback(() => {
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
hoverTimeoutRef.current = null;
}
}, []);
// 组件卸载时清理
useEffect(() => {
return () => clearHoverTimeout();
}, [clearHoverTimeout]);
<div
onPointerEnter={() => {
clearHoverTimeout(); // 先清除之前的计时器
hoverTimeoutRef.current = setTimeout(() => setOpen(true), 300);
}}
onPointerLeave={() => {
clearHoverTimeout(); // 离开时取消延迟操作
}}
onPointerDown={() => {
clearHoverTimeout(); // 点击时立即响应,取消延迟
setOpen(true);
}}
>
```
**需要排除的组件类型**:
- 浮层面板:`.chat-drawer`, `.project-drawer`, `.toolbox-drawer`
- 工具栏:`.view-navigation`, `.unified-toolbar`, `.ai-input-bar`
- 弹窗:`.t-dialog`, `.settings-dialog`
- 其他 UI:`.minimap`, `.task-queue-panel`
**检查位置**: `drawnix.tsx` 中的 `handleDoubleClick` 函数(约第 715-730 行)
**原因**: 全局事件处理(如双击显示快捷工具栏)会捕获整个容器内的事件。如果不排除新添加的 UI 组件,用户在这些组件上的操作会触发意外的全局行为(如在缩放按钮上双击却弹出了创建工具栏)。
**关键点**:
- 使用 `useRef` 存储计时器 ID(不用 state,避免不必要的重渲染)
- `onPointerLeave` 清除计时器(用户离开后取消待执行的操作)
- `onPointerDown` 清除计时器(点击时立即响应,不等待延迟)
- `useEffect` 清理函数确保组件卸载时清除计时器
### CSS/SCSS 规范
- 使用 BEM 命名规范
+1 -1
View File
@@ -1,5 +1,5 @@
{
"version": "0.5.0",
"buildTime": "2026-01-15T02:10:18.679Z",
"buildTime": "2026-01-15T02:37:59.666Z",
"gitCommit": "unknown"
}
@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import classNames from 'classnames';
import { ATTACHED_ELEMENT_CLASS_NAME } from '@plait/core';
import { BasicShapes, DrawPointerType, FlowchartSymbols } from '@plait/draw';
import { Island } from '../island';
import Stack from '../stack';
@@ -113,7 +114,7 @@ export const AutoCompleteShapePicker: React.FC<AutoCompleteShapePickerProps> = (
const content = (
<div
ref={pickerRef}
className="auto-complete-shape-picker"
className={classNames('auto-complete-shape-picker', ATTACHED_ELEMENT_CLASS_NAME)}
style={{
left: adjustedPosition.x,
top: adjustedPosition.y,
+22 -21
View File
@@ -10,6 +10,9 @@ import {
ThemeColorMode,
Viewport,
getSelectedElements,
getHitElementByPoint,
toHostPoint,
toViewBoxPoint,
} from '@plait/core';
import React, { useState, useRef, useEffect, useMemo, useCallback, lazy, Suspense } from 'react';
import { withGroup } from '@plait/common';
@@ -67,6 +70,7 @@ import { usePencilCursor } from './hooks/usePencilCursor';
import { withArrowLineAutoCompleteExtend } from './plugins/with-arrow-line-auto-complete-extend';
import { AutoCompleteShapePicker } from './components/auto-complete-shape-picker';
import { useAutoCompleteShapePicker } from './hooks/useAutoCompleteShapePicker';
import { withDefaultFill } from './plugins/with-default-fill';
const TTDDialog = lazy(() => import('./components/ttd-dialog/ttd-dialog').then(module => ({ default: module.TTDDialog })));
const SettingsDialog = lazy(() => import('./components/settings-dialog/settings-dialog').then(module => ({ default: module.SettingsDialog })));
@@ -523,6 +527,7 @@ export const Drawnix: React.FC<DrawnixProps> = ({
withToolFocus, // 工具焦点管理 - 双击编辑
withWorkZone, // 工作区元素 - 在画布上显示工作流进度
withArrowLineAutoCompleteExtend, // 自动完成形状选择 - hover 中点时选择下一个节点形状
withDefaultFill, // 默认填充 - 让新创建的图形有白色填充,方便双击编辑
withTracking,
];
@@ -725,28 +730,24 @@ const DrawnixContent: React.FC<DrawnixContentProps> = ({
const handleDoubleClick = (event: MouseEvent) => {
const target = event.target as HTMLElement;
// 检查是否双击在浮层组件内部(ChatDrawer、抽屉面板、弹窗等)
// 这些组件不应该触发画布的快捷工具栏
const isInsideOverlay = target.closest('.chat-drawer') ||
target.closest('.project-drawer') ||
target.closest('.toolbox-drawer') ||
target.closest('.task-queue-panel') ||
target.closest('.t-dialog') ||
target.closest('.settings-dialog') ||
target.closest('.minimap') ||
target.closest('.ai-input-bar') ||
target.closest('.view-navigation');
if (isInsideOverlay) {
return; // 不处理浮层内的双击事件
}
// 检查是否双击在空白区域(没有选中任何元素)
const selectedElements = getSelectedElements(board);
if (selectedElements.length === 0) {
// 获取双击位置(屏幕坐标)
// 只处理画布区域内的双击(正向判断,避免维护浮层组件列表)
const isInsideCanvas = target.closest('.board-host-svg') ||
target.closest('.plait-board-container');
if (!isInsideCanvas) {
return;
}
// 检查双击位置是否命中了画布上的元素
// 使用 getHitElementByPoint 直接检测,而不是依赖 selectedElements
// 因为双击事件触发时,元素可能还没被选中
const viewBoxPoint = toViewBoxPoint(board, toHostPoint(board, event.clientX, event.clientY));
const hitElement = getHitElementByPoint(board, viewBoxPoint);
// 只有双击空白区域时才显示快速创建工具栏
// 双击图形元素应该由 Plait 框架处理(进入文本编辑模式)
if (!hitElement) {
const position: [number, number] = [event.clientX, event.clientY];
setQuickToolbarPosition(position);
setQuickToolbarVisible(true);
@@ -8,6 +8,7 @@ import {
setAutoCompleteState,
resetAutoCompleteState,
createAutoCompleteElements,
createShapeForArrowLine,
} from '../plugins/with-arrow-line-auto-complete-extend';
const initialState: AutoCompleteShapeState = {
@@ -17,6 +18,7 @@ const initialState: AutoCompleteShapeState = {
currentShape: null,
hitIndex: -1,
hitPoint: null,
mode: 'hover',
};
/**
@@ -48,17 +50,29 @@ export function useAutoCompleteShapePicker(board: PlaitBoard | null) {
const currentState = getAutoCompleteState(board);
if (currentState.sourceElement &&
currentState.hitPoint &&
currentState.hitIndex >= 0) {
// 创建并插入连接线和新形状
createAutoCompleteElements(
board,
currentState.sourceElement as PlaitDrawElement,
currentState.hitIndex,
currentState.hitPoint,
shape
);
if (currentState.mode === 'arrowLine') {
// 箭头线模式:为已存在的箭头线创建形状
if (currentState.arrowLineElement && currentState.arrowLineEndPoint) {
createShapeForArrowLine(
board,
currentState.arrowLineElement,
currentState.arrowLineEndPoint,
shape
);
}
} else {
// hover 模式:创建并插入连接线和新形状
if (currentState.sourceElement &&
currentState.hitPoint &&
currentState.hitIndex >= 0) {
createAutoCompleteElements(
board,
currentState.sourceElement as PlaitDrawElement,
currentState.hitIndex,
currentState.hitPoint,
shape
);
}
}
// 关闭选择器(createAutoCompleteElements 内部已经会重置状态)
@@ -1,12 +1,17 @@
import {
PlaitBoard,
PlaitElement,
PlaitPointerType,
BoardTransforms,
rotateAntiPointsByElement,
toActivePoint,
Transforms,
Direction,
rotatePointsByElement,
RectangleClient,
clearSelectedElement,
addSelectedElement,
Point,
} from '@plait/core';
import {
BasicShapes,
@@ -41,6 +46,12 @@ export interface AutoCompleteShapeState {
hitIndex: number;
/** hover 的连接点坐标 (viewBox 坐标,用于创建元素) */
hitPoint: [number, number] | null;
/** 模式:hover 表示 hover 连接点触发,arrowLine 表示箭头线创建完成触发 */
mode: 'hover' | 'arrowLine';
/** 箭头线模式下的箭头线元素 */
arrowLineElement?: PlaitElement | null;
/** 箭头线模式下的终点位置 */
arrowLineEndPoint?: Point | null;
}
const initialState: AutoCompleteShapeState = {
@@ -50,6 +61,9 @@ const initialState: AutoCompleteShapeState = {
currentShape: null,
hitIndex: -1,
hitPoint: null,
mode: 'hover',
arrowLineElement: null,
arrowLineEndPoint: null,
};
// WeakMap 存储每个 board 的状态
@@ -163,6 +177,9 @@ export function createAutoCompleteElements(
// 创建新形状
const newShapeElement = createDefaultGeometry(board, newShapePoints, targetShape as GeometryShapes);
// 设置默认白色填充(让图形中间可点击,方便双击编辑文字)
newShapeElement.fill = '#ffffff';
// 复制源元素的样式(仅对 ShapeElement)
if (PlaitDrawElement.isShapeElement(sourceElement) && !PlaitDrawElement.isText(sourceElement)) {
const typedSource = sourceElement as PlaitDrawElement & {
@@ -174,7 +191,10 @@ export function createAutoCompleteElements(
};
if (typedSource.angle !== undefined) newShapeElement.angle = typedSource.angle;
if (typedSource.fill !== undefined) newShapeElement.fill = typedSource.fill;
// 复制源元素的填充色(如果有)
if (typedSource.fill && typedSource.fill !== 'none') {
newShapeElement.fill = typedSource.fill;
}
if (typedSource.strokeColor !== undefined) newShapeElement.strokeColor = typedSource.strokeColor;
if (typedSource.strokeStyle !== undefined) {
(newShapeElement as typeof typedSource).strokeStyle = typedSource.strokeStyle;
@@ -237,9 +257,131 @@ export function createAutoCompleteElements(
// 插入元素
Transforms.insertNode(board, arrowLineElement, [board.children.length]);
insertElement(board, newShapeElement);
// 重置状态
resetAutoCompleteState(board);
// 延迟选中新插入的形状元素
const newElementId = newShapeElement.id;
setTimeout(() => {
// 从 board 中查找实际插入的元素(因为插入后引用可能变化)
const insertedElement = board.children.find(el => el.id === newElementId);
if (insertedElement) {
clearSelectedElement(board);
addSelectedElement(board, insertedElement);
// 切换到选择模式,触发 UI 更新
BoardTransforms.updatePointerType(board, PlaitPointerType.selection);
}
}, 500);
}
/**
* 为已存在的箭头线创建连接的形状
* 用于箭头线拖拽完成后,在终点位置创建形状
*/
export function createShapeForArrowLine(
board: PlaitBoard,
arrowLineElement: PlaitElement,
endPoint: Point,
targetShape: DrawPointerType
): void {
// 默认形状尺寸(当找不到源元素时使用)
let shapeWidth = 100;
let shapeHeight = 100;
// 尝试从箭头线的源元素获取尺寸
const arrowLine = arrowLineElement as PlaitElement & {
source?: { boundId?: string };
target?: { boundId?: string };
points?: Point[];
};
const sourceBoundId = arrowLine.source?.boundId;
if (sourceBoundId) {
// 找到源元素
const sourceElement = board.children.find(el => el.id === sourceBoundId) as PlaitDrawElement | undefined;
if (sourceElement && PlaitDrawElement.isShapeElement(sourceElement)) {
// 使用源元素的尺寸
const sourceRect = RectangleClient.getRectangleByPoints(sourceElement.points);
shapeWidth = sourceRect.width;
shapeHeight = sourceRect.height;
}
}
// 计算新形状的位置(以终点为中心)
const newShapePoints: [[number, number], [number, number]] = [
[endPoint[0] - shapeWidth / 2, endPoint[1] - shapeHeight / 2],
[endPoint[0] + shapeWidth / 2, endPoint[1] + shapeHeight / 2],
];
// 创建新形状
const newShapeElement = createDefaultGeometry(board, newShapePoints, targetShape as GeometryShapes);
// 设置默认白色填充
newShapeElement.fill = '#ffffff';
// 计算连接点
const newShapeRect = RectangleClient.getRectangleByPoints(newShapeElement.points);
const targetEdgeCenterPoints = RectangleClient.getEdgeCenterPoints(newShapeRect);
// 根据箭头线的方向确定连接点
const arrowPoints = arrowLine.points || [];
if (arrowPoints.length >= 2) {
const startPoint = arrowPoints[0];
const lineEndPoint = arrowPoints[arrowPoints.length - 1];
// 计算箭头线的方向
const dx = lineEndPoint[0] - startPoint[0];
const dy = lineEndPoint[1] - startPoint[1];
// 根据方向选择最近的连接点
let targetHitIndex: number;
if (Math.abs(dx) > Math.abs(dy)) {
// 水平方向为主
targetHitIndex = dx > 0 ? 3 : 1; // 从右边来连左边,从左边来连右边
} else {
// 垂直方向为主
targetHitIndex = dy > 0 ? 0 : 2; // 从下边来连上边,从上边来连下边
}
const connectionPoint = targetEdgeCenterPoints[targetHitIndex];
const targetConnection = getHitConnection(board, connectionPoint, newShapeElement);
// 更新箭头线的终点连接
const arrowLineIndex = board.children.findIndex(el => el.id === arrowLineElement.id);
if (arrowLineIndex !== -1) {
// 更新箭头线的 target 属性
Transforms.setNode(
board,
{
target: {
marker: ArrowLineMarkerType.arrow,
connection: targetConnection,
boundId: newShapeElement.id,
},
points: [startPoint, connectionPoint],
},
[arrowLineIndex]
);
}
}
// 插入新形状
insertElement(board, newShapeElement);
// 重置状态
resetAutoCompleteState(board);
// 延迟选中新插入的形状元素
const newElementId = newShapeElement.id;
setTimeout(() => {
const insertedElement = board.children.find(el => el.id === newElementId);
if (insertedElement) {
clearSelectedElement(board);
addSelectedElement(board, insertedElement);
}
}, 100);
}
/**
@@ -249,14 +391,23 @@ export function createAutoCompleteElements(
* 1. 在 hover 到连接点时显示形状选择器
* 2. 允许用户选择下一个节点的形状类型
* 3. 默认使用同类形状
* 4. 在箭头线拖拽完成后,如果终点未连接形状,显示形状选择器
*/
export const withArrowLineAutoCompleteExtend = (board: PlaitBoard) => {
const { pointerMove, pointerLeave } = board;
const { pointerMove, pointerLeave, globalPointerUp } = board;
let hoverTimeout: ReturnType<typeof setTimeout> | null = null;
let lastHitIndex = -1;
// 记录上一次 children 的长度,用于检测新插入的箭头线
let lastChildrenLength = board.children.length;
// 记录鼠标位置
let lastMousePosition = { x: 0, y: 0 };
board.pointerMove = (event: PointerEvent) => {
// 记录鼠标位置
lastMousePosition = { x: event.clientX, y: event.clientY };
// 清除之前的延迟
if (hoverTimeout) {
clearTimeout(hoverTimeout);
@@ -270,7 +421,7 @@ export const withArrowLineAutoCompleteExtend = (board: PlaitBoard) => {
if (!originElement ||
!PlaitDrawElement.isShapeElement(originElement) ||
PlaitDrawElement.isText(originElement)) {
if (currentState.visible) {
if (currentState.visible && currentState.mode === 'hover') {
setAutoCompleteState(board, { ...initialState });
}
lastHitIndex = -1;
@@ -308,16 +459,17 @@ export const withArrowLineAutoCompleteExtend = (board: PlaitBoard) => {
currentShape: shape,
hitIndex,
hitPoint,
mode: 'hover',
});
}, 200); // 200ms 延迟,避免快速移动时频繁触发
}, 50); // 50ms 延迟,避免快速移动时频繁触发
}
} else {
lastHitIndex = -1;
if (currentState.visible) {
if (currentState.visible && currentState.mode === 'hover') {
// 延迟隐藏,给用户时间移动到选择器
hoverTimeout = setTimeout(() => {
const state = getAutoCompleteState(board);
if (state.visible) {
if (state.visible && state.mode === 'hover') {
setAutoCompleteState(board, { ...initialState });
}
}, 300);
@@ -335,6 +487,55 @@ export const withArrowLineAutoCompleteExtend = (board: PlaitBoard) => {
// 不立即关闭,让用户有机会移动到选择器
pointerLeave(event);
};
board.globalPointerUp = (event: PointerEvent) => {
// 先调用原始的 globalPointerUp
globalPointerUp(event);
// 检查是否有新插入的箭头线
const currentChildrenLength = board.children.length;
if (currentChildrenLength > lastChildrenLength) {
// 获取新插入的元素(最后一个)
const newElement = board.children[currentChildrenLength - 1];
// 检查是否是箭头线
if (PlaitDrawElement.isArrowLine(newElement)) {
const arrowLine = newElement as PlaitElement & {
target?: { boundId?: string };
points?: Point[];
};
// 检查箭头线的终点是否连接到了形状
const targetBoundId = arrowLine.target?.boundId;
if (!targetBoundId) {
// 终点未连接形状,显示形状选择器
const arrowPoints = arrowLine.points || [];
if (arrowPoints.length >= 2) {
const endPoint = arrowPoints[arrowPoints.length - 1];
// 使用延迟确保 UI 状态已更新
setTimeout(() => {
setAutoCompleteState(board, {
visible: true,
position: { x: lastMousePosition.x - 140, y: lastMousePosition.y + 20 },
sourceElement: null,
currentShape: BasicShapes.rectangle,
hitIndex: -1,
hitPoint: null,
mode: 'arrowLine',
arrowLineElement: newElement,
arrowLineEndPoint: endPoint,
});
}, 50);
}
}
}
}
// 更新记录的长度
lastChildrenLength = board.children.length;
};
return board;
};
@@ -0,0 +1,99 @@
/**
* With Default Fill Plugin
*
* 为新创建的几何形状设置默认白色填充
* 这样可以让图形中间区域可点击,方便双击进入文本编辑
*/
import { PlaitBoard, PlaitElement, Transforms, Path } from '@plait/core';
import { PlaitDrawElement, BasicShapes } from '@plait/draw';
// 默认填充颜色(白色)
const DEFAULT_GEOMETRY_FILL = '#ffffff';
// 需要设置默认填充的形状类型
const SHAPES_NEED_FILL: string[] = [
BasicShapes.rectangle,
BasicShapes.ellipse,
BasicShapes.triangle,
BasicShapes.diamond,
BasicShapes.parallelogram,
BasicShapes.roundRectangle,
];
/**
* 检查元素是否需要设置默认填充
*/
function shouldSetDefaultFill(element: PlaitElement): boolean {
// 只处理几何形状
if (!PlaitDrawElement.isGeometry(element)) {
return false;
}
// 排除文本元素
if (PlaitDrawElement.isText(element)) {
return false;
}
// 排除图片元素
if (PlaitDrawElement.isImage(element)) {
return false;
}
// 检查形状类型
const typedElement = element as PlaitElement & { shape?: string; fill?: string };
// 只处理基本形状
if (!typedElement.shape || !SHAPES_NEED_FILL.includes(typedElement.shape)) {
return false;
}
// 检查是否已经设置了填充(不是 none 或 undefined)
if (typedElement.fill && typedElement.fill !== 'none') {
return false;
}
return true;
}
/**
* 插件:为新创建的几何形状设置默认填充
* 使用 afterChange 在变更后检查并设置填充
*/
export const withDefaultFill = (board: PlaitBoard): PlaitBoard => {
const { afterChange } = board;
// 记录已处理过的元素 ID,避免重复处理
const processedElements = new Set<string>();
board.afterChange = () => {
// 先调用原始的 afterChange
afterChange();
// 收集需要更新的元素
const updates: { path: Path; element: PlaitElement }[] = [];
board.children.forEach((element, index) => {
if (shouldSetDefaultFill(element) && !processedElements.has(element.id)) {
updates.push({ path: [index], element });
processedElements.add(element.id);
}
});
// 在 afterChange 完成后,使用 setTimeout 延迟更新
// 避免在 afterChange 中直接修改导致的问题
if (updates.length > 0) {
setTimeout(() => {
updates.forEach(({ path }) => {
// 检查元素是否仍然存在且需要更新
const currentElement = board.children[path[0]];
if (currentElement && shouldSetDefaultFill(currentElement)) {
Transforms.setNode(board, { fill: DEFAULT_GEOMETRY_FILL }, path);
}
});
}, 0);
}
};
return board;
};