优化形状

This commit is contained in:
liquid committed 2026-01-15 09:13:27 +08:00
1 parent cb2fcaca68
commit ceff394bef
7 files changed
+606 -1

No files matched your search

+1 -1
View File
@@ -1,5 +1,5 @@
{
"version": "0.5.0",
"buildTime": "2026-01-14T23:53:07.802Z",
"buildTime": "2026-01-15T00:54:24.365Z",
"gitCommit": "unknown"
}
@@ -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);
}
}
@@ -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<AutoCompleteShapePickerProps> = ({
visible,
position,
currentShape,
onSelectShape,
onClose,
container,
}) => {
const pickerRef = useRef<HTMLDivElement>(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 = (
<div
ref={pickerRef}
className="auto-complete-shape-picker"
style={{
left: adjustedPosition.x,
top: adjustedPosition.y,
}}
>
<Island padding={1}>
<Stack.Col gap={1}>
<Stack.Row gap={1}>
{SHAPES.map((shape, index) => (
<ToolButton
key={index}
className={classNames('shape-button', {
'is-current': currentShape === shape.pointer,
})}
type="icon"
size="small"
visible={true}
icon={shape.icon}
title={shape.title}
aria-label={shape.title}
onPointerDown={({ event }) => {
event.stopPropagation();
handleShapeSelect(shape.pointer);
}}
/>
))}
</Stack.Row>
</Stack.Col>
</Island>
</div>
);
// 如果提供了容器,使用 Portal
if (container) {
return createPortal(content, container);
}
return content;
};
export default AutoCompleteShapePicker;
@@ -0,0 +1,2 @@
export { AutoCompleteShapePicker } from './auto-complete-shape-picker';
export type { AutoCompleteShapePickerProps, ShapeOption } from './auto-complete-shape-picker';
+20
View File
@@ -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<DrawnixProps> = ({
withToolResize, // 工具缩放功能 - 拖拽缩放手柄
withToolFocus, // 工具焦点管理 - 双击编辑
withWorkZone, // 工作区元素 - 在画布上显示工作流进度
withArrowLineAutoCompleteExtend, // 自动完成形状选择 - hover 中点时选择下一个节点形状
withTracking,
];
@@ -708,6 +712,13 @@ const DrawnixContent: React.FC<DrawnixContentProps> = ({
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<DrawnixContentProps> = ({
visible={quickToolbarVisible}
onClose={() => setQuickToolbarVisible(false)}
/>
{/* Auto Complete Shape Picker - 自动完成形状选择器 */}
<AutoCompleteShapePicker
visible={autoCompleteState.visible}
position={autoCompleteState.position}
currentShape={autoCompleteState.currentShape || undefined}
onSelectShape={selectAutoCompleteShape}
onClose={closeAutoCompletePicker}
container={containerRef.current}
/>
{/* AI Input Bar - 底部 AI 输入框 */}
<AIInputBar isDataReady={isDataReady} />
{/* Version Update Prompt - 顶部右上角升级提示 */}
@@ -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<AutoCompleteShapeState>(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,
};
}
@@ -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<PlaitBoard, AutoCompleteShapeState>();
const BOARD_TO_STATE_CALLBACK = new WeakMap<PlaitBoard, (state: AutoCompleteShapeState) => 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<typeof setTimeout> | 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;
};