表单设计器
This commit is contained in:
parent
e63ca34702
commit
03d4d21424
@ -31,10 +31,13 @@ import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragStartEvent,
|
||||
DragOverEvent,
|
||||
DragOverlay,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
pointerWithin,
|
||||
rectIntersection,
|
||||
closestCenter
|
||||
} from '@dnd-kit/core';
|
||||
import { arrayMove } from '@dnd-kit/sortable';
|
||||
@ -120,6 +123,9 @@ const FormDesigner: React.FC<FormDesignerProps> = ({
|
||||
// 正在拖拽的元素ID
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
|
||||
// 拖拽悬停的目标ID(用于显示插入指示器)
|
||||
const [overId, setOverId] = useState<string | null>(null);
|
||||
|
||||
// 拖拽传感器 - 添加激活约束,减少误触和卡顿
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
@ -129,12 +135,49 @@ const FormDesigner: React.FC<FormDesignerProps> = ({
|
||||
})
|
||||
);
|
||||
|
||||
// 自定义碰撞检测策略:优先使用指针检测,避免栅格内部区域干扰排序
|
||||
const customCollisionDetection = useCallback((args: any) => {
|
||||
// 首先尝试使用 pointerWithin - 只有指针真正进入区域时才触发
|
||||
const pointerCollisions = pointerWithin(args);
|
||||
|
||||
if (pointerCollisions.length > 0) {
|
||||
// 过滤掉栅格列(grid-xxx-col-xxx),优先选择画布或字段
|
||||
const nonGridCollisions = pointerCollisions.filter((collision: any) => {
|
||||
const id = collision.id.toString();
|
||||
return !id.startsWith('grid-') || id === 'canvas' || id === 'canvas-bottom';
|
||||
});
|
||||
|
||||
if (nonGridCollisions.length > 0) {
|
||||
return nonGridCollisions;
|
||||
}
|
||||
|
||||
// 如果只有栅格列,说明确实是要拖入栅格
|
||||
return pointerCollisions;
|
||||
}
|
||||
|
||||
// 如果 pointerWithin 没有结果,使用 rectIntersection 作为后备
|
||||
const rectCollisions = rectIntersection(args);
|
||||
if (rectCollisions.length > 0) {
|
||||
return rectCollisions;
|
||||
}
|
||||
|
||||
// 最后使用 closestCenter 作为兜底
|
||||
return closestCenter(args);
|
||||
}, []);
|
||||
|
||||
// 生成唯一 ID
|
||||
const generateId = () => `field_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
// 处理拖拽开始
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
setActiveId(event.active.id.toString());
|
||||
setOverId(null);
|
||||
}, []);
|
||||
|
||||
// 处理拖拽悬停
|
||||
const handleDragOver = useCallback((event: DragOverEvent) => {
|
||||
const { over } = event;
|
||||
setOverId(over ? over.id.toString() : null);
|
||||
}, []);
|
||||
|
||||
// 处理拖拽结束
|
||||
@ -143,6 +186,7 @@ const FormDesigner: React.FC<FormDesignerProps> = ({
|
||||
|
||||
// 清除拖拽状态
|
||||
setActiveId(null);
|
||||
setOverId(null);
|
||||
|
||||
if (!over) return;
|
||||
|
||||
@ -207,8 +251,8 @@ const FormDesigner: React.FC<FormDesignerProps> = ({
|
||||
setFields((prev) => {
|
||||
const newFields = [...prev];
|
||||
|
||||
// 如果拖到画布空白处,添加到末尾
|
||||
if (over.id === 'canvas') {
|
||||
// 如果拖到画布空白处或底部区域,添加到末尾
|
||||
if (over.id === 'canvas' || over.id === 'canvas-bottom' || overData?.isBottom) {
|
||||
newFields.push(newField);
|
||||
} else {
|
||||
// 如果拖到某个字段上,插入到该字段之后
|
||||
@ -633,8 +677,9 @@ const FormDesigner: React.FC<FormDesignerProps> = ({
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
collisionDetection={closestCenter}
|
||||
collisionDetection={customCollisionDetection}
|
||||
>
|
||||
<div className="form-designer-body">
|
||||
{/* 左侧组件面板 */}
|
||||
@ -653,6 +698,8 @@ const FormDesigner: React.FC<FormDesignerProps> = ({
|
||||
onPasteToGrid={handlePasteToGrid}
|
||||
labelAlign={formConfig.labelAlign}
|
||||
hasClipboard={!!clipboard}
|
||||
activeId={activeId}
|
||||
overId={overId}
|
||||
/>
|
||||
|
||||
{/* 右侧属性面板 */}
|
||||
|
||||
@ -24,6 +24,8 @@ interface DesignCanvasProps {
|
||||
onDeleteField: (fieldId: string) => void;
|
||||
labelAlign?: 'left' | 'right' | 'top';
|
||||
hasClipboard?: boolean;
|
||||
activeId?: string | null; // 正在拖拽的元素ID
|
||||
overId?: string | null; // 拖拽悬停的目标ID
|
||||
}
|
||||
|
||||
const DesignCanvas: React.FC<DesignCanvasProps> = ({
|
||||
@ -36,6 +38,8 @@ const DesignCanvas: React.FC<DesignCanvasProps> = ({
|
||||
onDeleteField,
|
||||
labelAlign = 'right',
|
||||
hasClipboard = false,
|
||||
activeId,
|
||||
overId,
|
||||
}) => {
|
||||
const { setNodeRef } = useDroppable({
|
||||
id: 'canvas',
|
||||
@ -91,22 +95,29 @@ const DesignCanvas: React.FC<DesignCanvasProps> = ({
|
||||
>
|
||||
<div className="form-designer-field-list">
|
||||
{fields.map((field) => (
|
||||
<FieldItem
|
||||
key={field.id}
|
||||
field={field}
|
||||
isSelected={field.id === selectedFieldId}
|
||||
onSelect={() => onSelectField(field.id)}
|
||||
onCopy={() => onCopyField(field.id)}
|
||||
onDelete={() => onDeleteField(field.id)}
|
||||
selectedFieldId={selectedFieldId}
|
||||
onSelectField={onSelectField}
|
||||
onCopyField={onCopyField}
|
||||
onPasteToGrid={onPasteToGrid}
|
||||
onDeleteField={onDeleteField}
|
||||
labelAlign={labelAlign}
|
||||
hasClipboard={hasClipboard}
|
||||
/>
|
||||
<React.Fragment key={field.id}>
|
||||
{/* 插入指示器 - 显示在拖拽悬停的字段上方 */}
|
||||
{activeId && overId === field.id && activeId !== field.id && (
|
||||
<DropIndicator text="释放以插入到此处" />
|
||||
)}
|
||||
|
||||
<FieldItem
|
||||
field={field}
|
||||
isSelected={field.id === selectedFieldId}
|
||||
onSelect={() => onSelectField(field.id)}
|
||||
onCopy={() => onCopyField(field.id)}
|
||||
onDelete={() => onDeleteField(field.id)}
|
||||
selectedFieldId={selectedFieldId}
|
||||
onSelectField={onSelectField}
|
||||
onCopyField={onCopyField}
|
||||
onPasteToGrid={onPasteToGrid}
|
||||
onDeleteField={onDeleteField}
|
||||
labelAlign={labelAlign}
|
||||
hasClipboard={hasClipboard}
|
||||
/>
|
||||
</React.Fragment>
|
||||
))}
|
||||
<CanvasBottomDropZone />
|
||||
</div>
|
||||
</SortableContext>
|
||||
)}
|
||||
@ -115,5 +126,66 @@ const DesignCanvas: React.FC<DesignCanvasProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
// 插入指示器组件
|
||||
interface DropIndicatorProps {
|
||||
text: string;
|
||||
}
|
||||
|
||||
const DropIndicator: React.FC<DropIndicatorProps> = ({ text }) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: 40,
|
||||
margin: '8px 0',
|
||||
border: '2px dashed #1890ff',
|
||||
borderRadius: 6,
|
||||
background: 'linear-gradient(90deg, #f0f5ff 0%, #e6f4ff 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#1890ff',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
transition: 'all 0.2s ease',
|
||||
animation: 'dropIndicatorPulse 1.5s ease-in-out infinite',
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 画布底部拖放区域组件
|
||||
const CanvasBottomDropZone: React.FC = () => {
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: 'canvas-bottom',
|
||||
data: {
|
||||
accept: 'field',
|
||||
isBottom: true, // 标识这是底部区域
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={{
|
||||
minHeight: 60,
|
||||
marginTop: 16,
|
||||
border: `2px dashed ${isOver ? '#1890ff' : 'transparent'}`,
|
||||
borderRadius: 6,
|
||||
background: isOver ? '#f0f5ff' : 'transparent',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: isOver ? '#1890ff' : '#d9d9d9',
|
||||
fontSize: 12,
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
>
|
||||
{isOver ? '释放以添加到底部' : '拖拽到此处添加到底部'}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DesignCanvas;
|
||||
|
||||
|
||||
@ -141,7 +141,7 @@ const GridColumn: React.FC<GridColumnProps> = ({
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={{
|
||||
minHeight: 100,
|
||||
minHeight: fields.length === 0 ? 80 : 'auto',
|
||||
padding: 12,
|
||||
border: `2px dashed ${isOver ? '#1890ff' : '#d9d9d9'}`,
|
||||
borderRadius: 6,
|
||||
|
||||
@ -133,7 +133,7 @@
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
margin-bottom: 20px;
|
||||
background: #fff;
|
||||
border: 2px solid #e8e8e8;
|
||||
border-radius: 8px;
|
||||
@ -141,6 +141,12 @@
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* 栅格布局字段项特殊样式 - 更大的间距 */
|
||||
.form-designer-field-item:has(.ant-row) {
|
||||
margin-bottom: 24px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.form-designer-field-item:hover {
|
||||
border-color: #1890ff;
|
||||
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.1);
|
||||
@ -263,3 +269,15 @@
|
||||
height: 24px !important;
|
||||
}
|
||||
|
||||
/* 插入指示器脉冲动画 */
|
||||
@keyframes dropIndicatorPulse {
|
||||
0%, 100% {
|
||||
opacity: 0.8;
|
||||
box-shadow: 0 0 0 0 rgba(24, 144, 255, 0.4);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
box-shadow: 0 0 0 4px rgba(24, 144, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user