表单设计器
This commit is contained in:
parent
e63ca34702
commit
03d4d21424
@ -31,10 +31,13 @@ import {
|
|||||||
DndContext,
|
DndContext,
|
||||||
DragEndEvent,
|
DragEndEvent,
|
||||||
DragStartEvent,
|
DragStartEvent,
|
||||||
|
DragOverEvent,
|
||||||
DragOverlay,
|
DragOverlay,
|
||||||
PointerSensor,
|
PointerSensor,
|
||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
|
pointerWithin,
|
||||||
|
rectIntersection,
|
||||||
closestCenter
|
closestCenter
|
||||||
} from '@dnd-kit/core';
|
} from '@dnd-kit/core';
|
||||||
import { arrayMove } from '@dnd-kit/sortable';
|
import { arrayMove } from '@dnd-kit/sortable';
|
||||||
@ -120,6 +123,9 @@ const FormDesigner: React.FC<FormDesignerProps> = ({
|
|||||||
// 正在拖拽的元素ID
|
// 正在拖拽的元素ID
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 拖拽悬停的目标ID(用于显示插入指示器)
|
||||||
|
const [overId, setOverId] = useState<string | null>(null);
|
||||||
|
|
||||||
// 拖拽传感器 - 添加激活约束,减少误触和卡顿
|
// 拖拽传感器 - 添加激活约束,减少误触和卡顿
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, {
|
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
|
// 生成唯一 ID
|
||||||
const generateId = () => `field_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
const generateId = () => `field_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
|
||||||
// 处理拖拽开始
|
// 处理拖拽开始
|
||||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||||
setActiveId(event.active.id.toString());
|
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);
|
setActiveId(null);
|
||||||
|
setOverId(null);
|
||||||
|
|
||||||
if (!over) return;
|
if (!over) return;
|
||||||
|
|
||||||
@ -207,8 +251,8 @@ const FormDesigner: React.FC<FormDesignerProps> = ({
|
|||||||
setFields((prev) => {
|
setFields((prev) => {
|
||||||
const newFields = [...prev];
|
const newFields = [...prev];
|
||||||
|
|
||||||
// 如果拖到画布空白处,添加到末尾
|
// 如果拖到画布空白处或底部区域,添加到末尾
|
||||||
if (over.id === 'canvas') {
|
if (over.id === 'canvas' || over.id === 'canvas-bottom' || overData?.isBottom) {
|
||||||
newFields.push(newField);
|
newFields.push(newField);
|
||||||
} else {
|
} else {
|
||||||
// 如果拖到某个字段上,插入到该字段之后
|
// 如果拖到某个字段上,插入到该字段之后
|
||||||
@ -633,8 +677,9 @@ const FormDesigner: React.FC<FormDesignerProps> = ({
|
|||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
collisionDetection={closestCenter}
|
collisionDetection={customCollisionDetection}
|
||||||
>
|
>
|
||||||
<div className="form-designer-body">
|
<div className="form-designer-body">
|
||||||
{/* 左侧组件面板 */}
|
{/* 左侧组件面板 */}
|
||||||
@ -653,6 +698,8 @@ const FormDesigner: React.FC<FormDesignerProps> = ({
|
|||||||
onPasteToGrid={handlePasteToGrid}
|
onPasteToGrid={handlePasteToGrid}
|
||||||
labelAlign={formConfig.labelAlign}
|
labelAlign={formConfig.labelAlign}
|
||||||
hasClipboard={!!clipboard}
|
hasClipboard={!!clipboard}
|
||||||
|
activeId={activeId}
|
||||||
|
overId={overId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 右侧属性面板 */}
|
{/* 右侧属性面板 */}
|
||||||
|
|||||||
@ -24,6 +24,8 @@ interface DesignCanvasProps {
|
|||||||
onDeleteField: (fieldId: string) => void;
|
onDeleteField: (fieldId: string) => void;
|
||||||
labelAlign?: 'left' | 'right' | 'top';
|
labelAlign?: 'left' | 'right' | 'top';
|
||||||
hasClipboard?: boolean;
|
hasClipboard?: boolean;
|
||||||
|
activeId?: string | null; // 正在拖拽的元素ID
|
||||||
|
overId?: string | null; // 拖拽悬停的目标ID
|
||||||
}
|
}
|
||||||
|
|
||||||
const DesignCanvas: React.FC<DesignCanvasProps> = ({
|
const DesignCanvas: React.FC<DesignCanvasProps> = ({
|
||||||
@ -36,6 +38,8 @@ const DesignCanvas: React.FC<DesignCanvasProps> = ({
|
|||||||
onDeleteField,
|
onDeleteField,
|
||||||
labelAlign = 'right',
|
labelAlign = 'right',
|
||||||
hasClipboard = false,
|
hasClipboard = false,
|
||||||
|
activeId,
|
||||||
|
overId,
|
||||||
}) => {
|
}) => {
|
||||||
const { setNodeRef } = useDroppable({
|
const { setNodeRef } = useDroppable({
|
||||||
id: 'canvas',
|
id: 'canvas',
|
||||||
@ -91,22 +95,29 @@ const DesignCanvas: React.FC<DesignCanvasProps> = ({
|
|||||||
>
|
>
|
||||||
<div className="form-designer-field-list">
|
<div className="form-designer-field-list">
|
||||||
{fields.map((field) => (
|
{fields.map((field) => (
|
||||||
<FieldItem
|
<React.Fragment key={field.id}>
|
||||||
key={field.id}
|
{/* 插入指示器 - 显示在拖拽悬停的字段上方 */}
|
||||||
field={field}
|
{activeId && overId === field.id && activeId !== field.id && (
|
||||||
isSelected={field.id === selectedFieldId}
|
<DropIndicator text="释放以插入到此处" />
|
||||||
onSelect={() => onSelectField(field.id)}
|
)}
|
||||||
onCopy={() => onCopyField(field.id)}
|
|
||||||
onDelete={() => onDeleteField(field.id)}
|
<FieldItem
|
||||||
selectedFieldId={selectedFieldId}
|
field={field}
|
||||||
onSelectField={onSelectField}
|
isSelected={field.id === selectedFieldId}
|
||||||
onCopyField={onCopyField}
|
onSelect={() => onSelectField(field.id)}
|
||||||
onPasteToGrid={onPasteToGrid}
|
onCopy={() => onCopyField(field.id)}
|
||||||
onDeleteField={onDeleteField}
|
onDelete={() => onDeleteField(field.id)}
|
||||||
labelAlign={labelAlign}
|
selectedFieldId={selectedFieldId}
|
||||||
hasClipboard={hasClipboard}
|
onSelectField={onSelectField}
|
||||||
/>
|
onCopyField={onCopyField}
|
||||||
|
onPasteToGrid={onPasteToGrid}
|
||||||
|
onDeleteField={onDeleteField}
|
||||||
|
labelAlign={labelAlign}
|
||||||
|
hasClipboard={hasClipboard}
|
||||||
|
/>
|
||||||
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
|
<CanvasBottomDropZone />
|
||||||
</div>
|
</div>
|
||||||
</SortableContext>
|
</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;
|
export default DesignCanvas;
|
||||||
|
|
||||||
|
|||||||
@ -141,7 +141,7 @@ const GridColumn: React.FC<GridColumnProps> = ({
|
|||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={{
|
style={{
|
||||||
minHeight: 100,
|
minHeight: fields.length === 0 ? 80 : 'auto',
|
||||||
padding: 12,
|
padding: 12,
|
||||||
border: `2px dashed ${isOver ? '#1890ff' : '#d9d9d9'}`,
|
border: `2px dashed ${isOver ? '#1890ff' : '#d9d9d9'}`,
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
|
|||||||
@ -133,7 +133,7 @@
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 20px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 2px solid #e8e8e8;
|
border: 2px solid #e8e8e8;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@ -141,6 +141,12 @@
|
|||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 栅格布局字段项特殊样式 - 更大的间距 */
|
||||||
|
.form-designer-field-item:has(.ant-row) {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.form-designer-field-item:hover {
|
.form-designer-field-item:hover {
|
||||||
border-color: #1890ff;
|
border-color: #1890ff;
|
||||||
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.1);
|
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.1);
|
||||||
@ -263,3 +269,15 @@
|
|||||||
height: 24px !important;
|
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