diff --git a/apps/web/public/version.json b/apps/web/public/version.json index dc27eb7d..c04c2bf3 100644 --- a/apps/web/public/version.json +++ b/apps/web/public/version.json @@ -1,5 +1,5 @@ { "version": "0.5.0", - "buildTime": "2026-01-14T23:53:07.802Z", + "buildTime": "2026-01-15T00:54:24.365Z", "gitCommit": "unknown" } \ No newline at end of file diff --git a/packages/drawnix/src/components/auto-complete-shape-picker/auto-complete-shape-picker.scss b/packages/drawnix/src/components/auto-complete-shape-picker/auto-complete-shape-picker.scss new file mode 100644 index 00000000..1230e64a --- /dev/null +++ b/packages/drawnix/src/components/auto-complete-shape-picker/auto-complete-shape-picker.scss @@ -0,0 +1,31 @@ +@import '../../styles/z-index'; + +.auto-complete-shape-picker { + position: fixed; + z-index: $z-popover; + animation: fadeIn 0.15s ease-out; + + .shape-button { + transition: all 0.15s ease; + + &.is-current { + background: var(--color-primary-light, #e3f2fd); + border-color: var(--color-primary, #5A4FCF); + } + + &:hover { + transform: scale(1.05); + } + } +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} 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 new file mode 100644 index 00000000..2efd3872 --- /dev/null +++ b/packages/drawnix/src/components/auto-complete-shape-picker/auto-complete-shape-picker.tsx @@ -0,0 +1,157 @@ +import React, { useCallback, useEffect, useRef } from 'react'; +import { createPortal } from 'react-dom'; +import classNames from 'classnames'; +import { BasicShapes, DrawPointerType, FlowchartSymbols } from '@plait/draw'; +import { Island } from '../island'; +import Stack from '../stack'; +import { ToolButton } from '../tool-button'; +import { + RectangleIcon, + EllipseIcon, + TriangleIcon, + DiamondIcon, + ParallelogramIcon, + RoundRectangleIcon, + TerminalIcon, +} from '../icons'; +import './auto-complete-shape-picker.scss'; + +export interface ShapeOption { + icon: React.ReactNode; + title: string; + pointer: DrawPointerType; +} + +const SHAPES: ShapeOption[] = [ + { icon: RectangleIcon, title: 'Rectangle', pointer: BasicShapes.rectangle }, + { icon: EllipseIcon, title: 'Ellipse', pointer: BasicShapes.ellipse }, + { icon: TriangleIcon, title: 'Triangle', pointer: BasicShapes.triangle }, + { icon: TerminalIcon, title: 'Terminal', pointer: FlowchartSymbols.terminal }, + { icon: DiamondIcon, title: 'Diamond', pointer: BasicShapes.diamond }, + { icon: ParallelogramIcon, title: 'Parallelogram', pointer: BasicShapes.parallelogram }, + { icon: RoundRectangleIcon, title: 'RoundRectangle', pointer: BasicShapes.roundRectangle }, +]; + +export interface AutoCompleteShapePickerProps { + /** 是否显示 */ + visible: boolean; + /** 位置 */ + position: { x: number; y: number }; + /** 当前源元素的形状(用于高亮默认选项) */ + currentShape?: DrawPointerType; + /** 选择形状的回调 */ + onSelectShape: (shape: DrawPointerType) => void; + /** 关闭回调 */ + onClose: () => void; + /** 容器元素 */ + container?: HTMLElement | null; +} + +export const AutoCompleteShapePicker: React.FC = ({ + visible, + position, + currentShape, + onSelectShape, + onClose, + container, +}) => { + const pickerRef = useRef(null); + + // 点击外部关闭 + useEffect(() => { + if (!visible) return; + + const handleClickOutside = (event: MouseEvent) => { + if (pickerRef.current && !pickerRef.current.contains(event.target as Node)) { + onClose(); + } + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose(); + } + }; + + // 延迟添加监听器,避免立即触发 + const timer = setTimeout(() => { + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleKeyDown); + }, 50); + + return () => { + clearTimeout(timer); + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [visible, onClose]); + + const handleShapeSelect = useCallback((shape: DrawPointerType) => { + onSelectShape(shape); + onClose(); + }, [onSelectShape, onClose]); + + if (!visible) return null; + + // 计算位置,确保不超出视口且值有效 + const adjustedPosition = { + x: isNaN(position.x) ? 0 : position.x, + y: isNaN(position.y) ? 0 : position.y, + }; + const pickerWidth = 200; + const pickerHeight = 80; + + if (typeof window !== 'undefined') { + if (adjustedPosition.x + pickerWidth > window.innerWidth) { + adjustedPosition.x = window.innerWidth - pickerWidth - 10; + } + if (adjustedPosition.y + pickerHeight > window.innerHeight) { + adjustedPosition.y = position.y - pickerHeight - 20; + } + } + + const content = ( +
+ + + + {SHAPES.map((shape, index) => ( + { + event.stopPropagation(); + handleShapeSelect(shape.pointer); + }} + /> + ))} + + + +
+ ); + + // 如果提供了容器,使用 Portal + if (container) { + return createPortal(content, container); + } + + return content; +}; + +export default AutoCompleteShapePicker; diff --git a/packages/drawnix/src/components/auto-complete-shape-picker/index.ts b/packages/drawnix/src/components/auto-complete-shape-picker/index.ts new file mode 100644 index 00000000..f921b327 --- /dev/null +++ b/packages/drawnix/src/components/auto-complete-shape-picker/index.ts @@ -0,0 +1,2 @@ +export { AutoCompleteShapePicker } from './auto-complete-shape-picker'; +export type { AutoCompleteShapePickerProps, ShapeOption } from './auto-complete-shape-picker'; diff --git a/packages/drawnix/src/drawnix.tsx b/packages/drawnix/src/drawnix.tsx index 9f57d4c1..99b34dd4 100644 --- a/packages/drawnix/src/drawnix.tsx +++ b/packages/drawnix/src/drawnix.tsx @@ -64,6 +64,9 @@ import { QuickCreationToolbar } from './components/toolbar/quick-creation-toolba import { CacheQuotaProvider } from './components/cache-quota-provider/CacheQuotaProvider'; import { RecentColorsProvider } from './components/unified-color-picker'; 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'; 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 }))); @@ -519,6 +522,7 @@ export const Drawnix: React.FC = ({ withToolResize, // 工具缩放功能 - 拖拽缩放手柄 withToolFocus, // 工具焦点管理 - 双击编辑 withWorkZone, // 工作区元素 - 在画布上显示工作流进度 + withArrowLineAutoCompleteExtend, // 自动完成形状选择 - hover 中点时选择下一个节点形状 withTracking, ]; @@ -708,6 +712,13 @@ const DrawnixContent: React.FC = ({ const [quickToolbarVisible, setQuickToolbarVisible] = useState(false); const [quickToolbarPosition, setQuickToolbarPosition] = useState<[number, number] | null>(null); + // 自动完成形状选择器状态 + const { + state: autoCompleteState, + selectShape: selectAutoCompleteShape, + closePicker: closeAutoCompletePicker, + } = useAutoCompleteShapePicker(board); + // 监听双击空白区域事件 useEffect(() => { if (!board) return; @@ -836,6 +847,15 @@ const DrawnixContent: React.FC = ({ visible={quickToolbarVisible} onClose={() => setQuickToolbarVisible(false)} /> + {/* Auto Complete Shape Picker - 自动完成形状选择器 */} + {/* AI Input Bar - 底部 AI 输入框 */} {/* Version Update Prompt - 顶部右上角升级提示 */} diff --git a/packages/drawnix/src/hooks/useAutoCompleteShapePicker.ts b/packages/drawnix/src/hooks/useAutoCompleteShapePicker.ts new file mode 100644 index 00000000..c7b02571 --- /dev/null +++ b/packages/drawnix/src/hooks/useAutoCompleteShapePicker.ts @@ -0,0 +1,79 @@ +import { useState, useEffect, useCallback } from 'react'; +import { PlaitBoard } from '@plait/core'; +import { DrawPointerType, PlaitDrawElement } from '@plait/draw'; +import { + AutoCompleteShapeState, + getAutoCompleteState, + registerAutoCompleteStateCallback, + setAutoCompleteState, + resetAutoCompleteState, + createAutoCompleteElements, +} from '../plugins/with-arrow-line-auto-complete-extend'; + +const initialState: AutoCompleteShapeState = { + visible: false, + position: { x: 0, y: 0 }, + sourceElement: null, + currentShape: null, + hitIndex: -1, + hitPoint: null, +}; + +/** + * 连接插件状态和 React 组件的 Hook + */ +export function useAutoCompleteShapePicker(board: PlaitBoard | null) { + const [state, setState] = useState(initialState); + + // 注册状态回调 + useEffect(() => { + if (!board) return; + + // 初始化状态 + setState(getAutoCompleteState(board)); + + // 注册回调 + const unregister = registerAutoCompleteStateCallback(board, (newState) => { + setState(newState); + }); + + return () => { + unregister(); + }; + }, [board]); + + // 选择形状并创建元素 + const selectShape = useCallback((shape: DrawPointerType) => { + if (!board) return; + + const currentState = getAutoCompleteState(board); + + if (currentState.sourceElement && + currentState.hitPoint && + currentState.hitIndex >= 0) { + // 创建并插入连接线和新形状 + createAutoCompleteElements( + board, + currentState.sourceElement as PlaitDrawElement, + currentState.hitIndex, + currentState.hitPoint, + shape + ); + } + + // 关闭选择器(createAutoCompleteElements 内部已经会重置状态) + setAutoCompleteState(board, { ...initialState }); + }, [board]); + + // 关闭选择器 + const closePicker = useCallback(() => { + if (!board) return; + resetAutoCompleteState(board); + }, [board]); + + return { + state, + selectShape, + closePicker, + }; +} 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 new file mode 100644 index 00000000..511a8c9a --- /dev/null +++ b/packages/drawnix/src/plugins/with-arrow-line-auto-complete-extend.ts @@ -0,0 +1,316 @@ +import { + PlaitBoard, + PlaitElement, + rotateAntiPointsByElement, + toActivePoint, + Transforms, + Direction, + isHorizontalDirection, + rotatePointsByElement, +} from '@plait/core'; +import { + BasicShapes, + createDefaultGeometry, + DrawPointerType, + getAutoCompletePoints, + getHitIndexOfAutoCompletePoint, + getSelectedDrawElements, + PlaitDrawElement, + insertElement, + createArrowLineElement, + ArrowLineShape, + ArrowLineMarkerType, + getHitConnection, + getConnectionPoint, +} from '@plait/draw'; +import { getDirectionByIndex } from '@plait/common'; + +/** + * 自动完成形状选择状态 + */ +export interface AutoCompleteShapeState { + /** 是否显示形状选择器 */ + visible: boolean; + /** 选择器位置(屏幕坐标) */ + position: { x: number; y: number }; + /** 当前源元素 */ + sourceElement: PlaitElement | null; + /** 当前源元素的形状 */ + currentShape: DrawPointerType | null; + /** hover 的连接点索引 */ + hitIndex: number; + /** hover 的连接点坐标 (viewBox 坐标) */ + hitPoint: [number, number] | null; +} + +const initialState: AutoCompleteShapeState = { + visible: false, + position: { x: 0, y: 0 }, + sourceElement: null, + currentShape: null, + hitIndex: -1, + hitPoint: null, +}; + +// WeakMap 存储每个 board 的状态 +const BOARD_TO_AUTO_COMPLETE_STATE = new WeakMap(); +const BOARD_TO_STATE_CALLBACK = new WeakMap void>(); + +/** + * 获取 board 的自动完成状态 + */ +export function getAutoCompleteState(board: PlaitBoard): AutoCompleteShapeState { + return BOARD_TO_AUTO_COMPLETE_STATE.get(board) || initialState; +} + +/** + * 设置 board 的自动完成状态 + */ +export function setAutoCompleteState(board: PlaitBoard, state: AutoCompleteShapeState): void { + BOARD_TO_AUTO_COMPLETE_STATE.set(board, state); + const callback = BOARD_TO_STATE_CALLBACK.get(board); + if (callback) { + callback(state); + } +} + +/** + * 注册状态变化回调 + */ +export function registerAutoCompleteStateCallback( + board: PlaitBoard, + callback: (state: AutoCompleteShapeState) => void +): () => void { + BOARD_TO_STATE_CALLBACK.set(board, callback); + return () => { + BOARD_TO_STATE_CALLBACK.delete(board); + }; +} + +/** + * 重置自动完成状态 + */ +export function resetAutoCompleteState(board: PlaitBoard): void { + setAutoCompleteState(board, initialState); +} + +// 新形状与源元素的默认距离 +const AUTO_COMPLETE_DISTANCE = 100; +// 新形状的默认大小 +const DEFAULT_SHAPE_SIZE = 100; + +/** + * 创建并插入连接线和新形状 + */ +export function createAutoCompleteElements( + board: PlaitBoard, + sourceElement: PlaitDrawElement, + hitIndex: number, + hitPoint: [number, number], + targetShape: DrawPointerType +): void { + // 获取方向(0: 右, 1: 下, 2: 左, 3: 上) + const direction = getDirectionByIndex(hitIndex); + + // 计算新形状的位置 + let offsetX = 0; + let offsetY = 0; + + switch (direction) { + case Direction.right: + offsetX = AUTO_COMPLETE_DISTANCE; + break; + case Direction.bottom: + offsetY = AUTO_COMPLETE_DISTANCE; + break; + case Direction.left: + offsetX = -AUTO_COMPLETE_DISTANCE - DEFAULT_SHAPE_SIZE; + break; + case Direction.top: + offsetY = -AUTO_COMPLETE_DISTANCE - DEFAULT_SHAPE_SIZE; + break; + } + + // 计算新形状的点(左上角和右下角) + const newShapePoints: [number, number][] = [ + [hitPoint[0] + offsetX, hitPoint[1] + offsetY - DEFAULT_SHAPE_SIZE / 2], + [hitPoint[0] + offsetX + DEFAULT_SHAPE_SIZE, hitPoint[1] + offsetY + DEFAULT_SHAPE_SIZE / 2], + ]; + + // 根据方向调整位置 + if (isHorizontalDirection(direction)) { + newShapePoints[0][1] = hitPoint[1] - DEFAULT_SHAPE_SIZE / 2; + newShapePoints[1][1] = hitPoint[1] + DEFAULT_SHAPE_SIZE / 2; + } else { + newShapePoints[0][0] = hitPoint[0] - DEFAULT_SHAPE_SIZE / 2; + newShapePoints[1][0] = hitPoint[0] + DEFAULT_SHAPE_SIZE / 2; + } + + // 创建新形状 + const newShapeElement = createDefaultGeometry(board, newShapePoints, targetShape); + + // 复制源元素的样式 + const typedSource = sourceElement as PlaitDrawElement & { + angle?: number; + fill?: string; + strokeColor?: string; + strokeStyle?: string; + strokeWidth?: number; + groupId?: string; + }; + + if (typedSource.angle !== undefined) newShapeElement.angle = typedSource.angle; + if (typedSource.fill !== undefined) newShapeElement.fill = typedSource.fill; + if (typedSource.strokeColor !== undefined) newShapeElement.strokeColor = typedSource.strokeColor; + if (typedSource.strokeStyle !== undefined) newShapeElement.strokeStyle = typedSource.strokeStyle; + if (typedSource.strokeWidth !== undefined) newShapeElement.strokeWidth = typedSource.strokeWidth; + + // 计算连接点 + const sourceConnectionPoint = rotatePointsByElement(hitPoint, sourceElement) || hitPoint; + const sourceConnection = getHitConnection(board, sourceConnectionPoint, sourceElement); + + // 计算目标连接点(新形状面向源的那个点) + let targetHitIndex: number; + switch (direction) { + case Direction.right: + targetHitIndex = 2; // 左边 + break; + case Direction.bottom: + targetHitIndex = 3; // 上边 + break; + case Direction.left: + targetHitIndex = 0; // 右边 + break; + case Direction.top: + targetHitIndex = 1; // 下边 + break; + default: + targetHitIndex = 2; + } + + const targetConnectionPoints = getAutoCompletePoints(board, newShapeElement, false); + const targetPoint = targetConnectionPoints[targetHitIndex] || targetConnectionPoints[0]; + const targetConnection = getHitConnection(board, targetPoint, newShapeElement); + + // 创建箭头线 + const arrowLineElement = createArrowLineElement( + ArrowLineShape.elbow, + [sourceConnectionPoint, targetPoint], + { + marker: ArrowLineMarkerType.none, + connection: sourceConnection, + boundId: sourceElement.id, + }, + { + marker: ArrowLineMarkerType.arrow, + connection: targetConnection, + boundId: newShapeElement.id, + }, + [], + {} + ); + + // 插入元素 + Transforms.insertNode(board, arrowLineElement, [board.children.length]); + insertElement(board, newShapeElement); + + // 重置状态 + resetAutoCompleteState(board); +} + +/** + * 扩展 @plait/draw 的自动完成功能 + * + * 功能: + * 1. 在 hover 到连接点时显示形状选择器 + * 2. 允许用户选择下一个节点的形状类型 + * 3. 默认使用同类形状 + */ +export const withArrowLineAutoCompleteExtend = (board: PlaitBoard) => { + const { pointerMove, pointerLeave } = board; + + let hoverTimeout: ReturnType | null = null; + let lastHitIndex = -1; + + board.pointerMove = (event: PointerEvent) => { + // 清除之前的延迟 + if (hoverTimeout) { + clearTimeout(hoverTimeout); + hoverTimeout = null; + } + + const selectedElements = getSelectedDrawElements(board); + const originElement = selectedElements.length === 1 && selectedElements[0]; + const currentState = getAutoCompleteState(board); + + if (!originElement || + !PlaitDrawElement.isShapeElement(originElement) || + PlaitDrawElement.isText(originElement)) { + if (currentState.visible) { + setAutoCompleteState(board, { ...initialState }); + } + lastHitIndex = -1; + pointerMove(event); + return; + } + + const activePoint = toActivePoint(board, event.x, event.y); + const points = getAutoCompletePoints(board, originElement, true); + const rotatedPoint = rotateAntiPointsByElement(board, activePoint, originElement, true) || activePoint; + const hitIndex = getHitIndexOfAutoCompletePoint(rotatedPoint, points); + const hitPoint = points[hitIndex]; + + if (hitPoint) { + // 只有当 hitIndex 变化或选择器未显示时才更新 + if (hitIndex !== lastHitIndex || !currentState.visible) { + lastHitIndex = hitIndex; + + // 保存当前鼠标位置用于显示选择器 + const mouseX = event.clientX; + const mouseY = event.clientY; + + // 延迟显示选择器 + hoverTimeout = setTimeout(() => { + // 获取元素的形状类型 + const shape = PlaitDrawElement.isImage(originElement) + ? BasicShapes.rectangle + : (originElement as { shape?: DrawPointerType }).shape || BasicShapes.rectangle; + + setAutoCompleteState(board, { + visible: true, + // 选择器宽度约 280px,水平居中显示在鼠标正下方 + position: { x: mouseX - 140, y: mouseY + 20 }, + sourceElement: originElement, + currentShape: shape, + hitIndex, + hitPoint, + }); + }, 200); // 200ms 延迟,避免快速移动时频繁触发 + } + } else { + lastHitIndex = -1; + if (currentState.visible) { + // 延迟隐藏,给用户时间移动到选择器 + hoverTimeout = setTimeout(() => { + const state = getAutoCompleteState(board); + if (state.visible) { + setAutoCompleteState(board, { ...initialState }); + } + }, 300); + } + } + + pointerMove(event); + }; + + board.pointerLeave = (event: PointerEvent) => { + if (hoverTimeout) { + clearTimeout(hoverTimeout); + hoverTimeout = null; + } + // 不立即关闭,让用户有机会移动到选择器 + pointerLeave(event); + }; + + return board; +};