diff --git a/CLAUDE.md b/CLAUDE.md index 7a348f72..eaa95b5b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 +
{ + 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 | null>(null); + +const clearHoverTimeout = useCallback(() => { + if (hoverTimeoutRef.current) { + clearTimeout(hoverTimeoutRef.current); + hoverTimeoutRef.current = null; + } +}, []); + +// 组件卸载时清理 +useEffect(() => { + return () => clearHoverTimeout(); +}, [clearHoverTimeout]); + +
{ + 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 命名规范 diff --git a/apps/web/public/version.json b/apps/web/public/version.json index f60c3a15..0beee595 100644 --- a/apps/web/public/version.json +++ b/apps/web/public/version.json @@ -1,5 +1,5 @@ { "version": "0.5.0", - "buildTime": "2026-01-15T02:10:18.679Z", + "buildTime": "2026-01-15T02:37:59.666Z", "gitCommit": "unknown" } \ No newline at end of file diff --git a/packages/drawnix/src/components/auto-complete-shape-picker/auto-complete-shape-picker.tsx b/packages/drawnix/src/components/auto-complete-shape-picker/auto-complete-shape-picker.tsx index 2efd3872..2890d973 100644 --- a/packages/drawnix/src/components/auto-complete-shape-picker/auto-complete-shape-picker.tsx +++ b/packages/drawnix/src/components/auto-complete-shape-picker/auto-complete-shape-picker.tsx @@ -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 = ( const content = (
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 = ({ withToolFocus, // 工具焦点管理 - 双击编辑 withWorkZone, // 工作区元素 - 在画布上显示工作流进度 withArrowLineAutoCompleteExtend, // 自动完成形状选择 - hover 中点时选择下一个节点形状 + withDefaultFill, // 默认填充 - 让新创建的图形有白色填充,方便双击编辑 withTracking, ]; @@ -725,28 +730,24 @@ const DrawnixContent: React.FC = ({ 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); diff --git a/packages/drawnix/src/hooks/useAutoCompleteShapePicker.ts b/packages/drawnix/src/hooks/useAutoCompleteShapePicker.ts index c7b02571..3c20c95f 100644 --- a/packages/drawnix/src/hooks/useAutoCompleteShapePicker.ts +++ b/packages/drawnix/src/hooks/useAutoCompleteShapePicker.ts @@ -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 内部已经会重置状态) diff --git a/packages/drawnix/src/plugins/with-arrow-line-auto-complete-extend.ts b/packages/drawnix/src/plugins/with-arrow-line-auto-complete-extend.ts index c348e11b..c8aafa2b 100644 --- a/packages/drawnix/src/plugins/with-arrow-line-auto-complete-extend.ts +++ b/packages/drawnix/src/plugins/with-arrow-line-auto-complete-extend.ts @@ -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 | 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; }; diff --git a/packages/drawnix/src/plugins/with-default-fill.ts b/packages/drawnix/src/plugins/with-default-fill.ts new file mode 100644 index 00000000..5c24a416 --- /dev/null +++ b/packages/drawnix/src/plugins/with-default-fill.ts @@ -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(); + + 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; +};