Skip to content

Commit 4722fec

Browse files
committed
2 parents 68011f5 + adc057c commit 4722fec

5 files changed

Lines changed: 314 additions & 33 deletions

File tree

packages/designer/IMPLEMENTATION.zh-CN.md

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -160,27 +160,29 @@ function CustomDesigner() {
160160

161161
以下功能在路线图中,但尚未实现:
162162

163-
1. **拖放功能**: 从组件面板拖放组件到画布 (当前是点击添加)
164-
2. **撤销/重做**: 历史记录和撤销功能
165-
3. **Schema 验证**: 验证 schema 的正确性
166-
4. **组件树视图**: 显示组件的树形结构
167-
5. **键盘快捷键**: 快捷键支持
168-
6. **组件搜索**: 在组件面板中搜索
169-
7. **复制粘贴**: 复制和粘贴组件
170-
8. **导出为代码**: 将 schema 导出为 React 代码
163+
1. **撤销/重做**: 历史记录和撤销功能
164+
2. **Schema 验证**: 验证 schema 的正确性
165+
3. **组件树视图**: 显示组件的树形结构
166+
4. **键盘快捷键**: 快捷键支持
167+
5. **组件搜索**: 在组件面板中搜索
168+
6. **复制粘贴**: 复制和粘贴组件
169+
7. **导出为代码**: 将 schema 导出为 React 代码
170+
171+
## 已实现功能
172+
173+
1. **✅ 拖放功能**: 从组件面板拖放组件到画布
174+
2. **✅ 画布内拖动**: 在画布中拖动组件以重新排序
171175

172176
## 已知问题
173177

174-
1. **构建错误**: renderer 和 ui 包中存在 TypeScript 错误 (这些是已存在的问题,不是由本 PR 引入)
175-
2. **需要修复配置**: TypeScript 配置需要调整以正确编译
178+
1. **拖动位置**: 当前拖动组件到容器时,总是插入到开头位置。未来版本将支持根据鼠标位置计算插入位置。
176179

177180
## 下一步
178181

179-
1. 修复构建配置问题
180-
2. 实现拖放功能 (使用 react-dnd 或类似库)
181-
3. 添加撤销/重做功能
182-
4. 实现 schema 验证
183-
5. 创建更多示例和教程
182+
1. 优化拖动插入位置计算 (根据鼠标位置而不是总是插入到开头)
183+
2. 添加撤销/重做功能
184+
3. 实现 schema 验证
185+
4. 创建更多示例和教程
184186

185187
## 总结
186188

@@ -189,6 +191,8 @@ function CustomDesigner() {
189191
- ✅ 组件面板和属性编辑
190192
- ✅ JSON 导入导出
191193
- ✅ 实时预览
194+
- ✅ 拖放功能 (从面板到画布)
195+
- ✅ 画布内拖动重排序
192196
- ✅ 示例应用
193197
- ✅ 文档
194198

packages/designer/README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ A drag-and-drop visual editor to generate Object UI schemas.
55
## Features
66

77
- **Visual Schema Editor**: Edit Object UI schemas visually with a live preview
8+
- **Drag-and-Drop**: Drag components from the palette to the canvas and reorder them within the canvas
89
- **Component Palette**: Browse and add components from a categorized list
910
- **Property Editor**: Configure component properties with a dynamic form
1011
- **JSON Import/Export**: Import and export schemas as JSON
@@ -207,8 +208,8 @@ module.exports = {
207208

208209
## Features Roadmap
209210

210-
- [ ] Drag and drop components from palette
211-
- [ ] Drag to reorder components in canvas
211+
- [x] Drag and drop components from palette
212+
- [x] Drag to reorder components in canvas
212213
- [ ] Undo/redo functionality
213214
- [ ] Schema validation
214215
- [ ] Component tree view
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { describe, it, expect, beforeEach } from 'vitest';
2+
import { render, screen, act } from '@testing-library/react';
3+
import { DesignerProvider, useDesigner } from '../context/DesignerContext';
4+
import type { SchemaNode } from '@object-ui/protocol';
5+
import React from 'react';
6+
import userEvent from '@testing-library/user-event';
7+
8+
// Test component to access designer context
9+
const TestComponent = () => {
10+
const {
11+
schema,
12+
draggingNodeId,
13+
setDraggingNodeId,
14+
moveNode,
15+
addNode
16+
} = useDesigner();
17+
18+
return (
19+
<div>
20+
<div data-testid="schema">{JSON.stringify(schema)}</div>
21+
<div data-testid="dragging-node-id">{draggingNodeId || 'null'}</div>
22+
<button onClick={() => setDraggingNodeId('test-node')}>Set Dragging</button>
23+
<button onClick={() => setDraggingNodeId(null)}>Clear Dragging</button>
24+
<button onClick={() => {
25+
const newNode: SchemaNode = { type: 'text', id: 'new-text' };
26+
addNode(schema.id, newNode);
27+
}}>Add Node</button>
28+
<button onClick={() => {
29+
if (schema.body && Array.isArray(schema.body) && schema.body.length > 0) {
30+
moveNode(schema.body[0].id!, schema.id, 1);
31+
}
32+
}}>Move Node</button>
33+
</div>
34+
);
35+
};
36+
37+
describe('Drag and Drop Functionality', () => {
38+
const initialSchema: SchemaNode = {
39+
type: 'div',
40+
id: 'root',
41+
body: [
42+
{ type: 'card', id: 'card-1' },
43+
{ type: 'card', id: 'card-2' }
44+
]
45+
};
46+
47+
it('should initialize draggingNodeId as null', () => {
48+
render(
49+
<DesignerProvider initialSchema={initialSchema}>
50+
<TestComponent />
51+
</DesignerProvider>
52+
);
53+
54+
expect(screen.getByTestId('dragging-node-id').textContent).toBe('null');
55+
});
56+
57+
it('should set and clear draggingNodeId', async () => {
58+
const user = userEvent.setup();
59+
60+
render(
61+
<DesignerProvider initialSchema={initialSchema}>
62+
<TestComponent />
63+
</DesignerProvider>
64+
);
65+
66+
const setButton = screen.getByText('Set Dragging');
67+
const clearButton = screen.getByText('Clear Dragging');
68+
69+
// Set dragging node
70+
await user.click(setButton);
71+
expect(screen.getByTestId('dragging-node-id').textContent).toBe('test-node');
72+
73+
// Clear dragging node
74+
await user.click(clearButton);
75+
expect(screen.getByTestId('dragging-node-id').textContent).toBe('null');
76+
});
77+
78+
it('should move nodes within the schema', async () => {
79+
const user = userEvent.setup();
80+
81+
render(
82+
<DesignerProvider initialSchema={initialSchema}>
83+
<TestComponent />
84+
</DesignerProvider>
85+
);
86+
87+
const moveButton = screen.getByText('Move Node');
88+
89+
// Get initial schema
90+
const initialSchemaText = screen.getByTestId('schema').textContent;
91+
const initial = JSON.parse(initialSchemaText || '{}');
92+
expect(initial.body[0].id).toBe('card-1');
93+
expect(initial.body[1].id).toBe('card-2');
94+
95+
// Move first node to position 1
96+
await user.click(moveButton);
97+
98+
// Get updated schema
99+
const updatedSchemaText = screen.getByTestId('schema').textContent;
100+
const updated = JSON.parse(updatedSchemaText || '{}');
101+
102+
// After moving card-1 to index 1, card-2 should be at index 0
103+
expect(updated.body[0].id).toBe('card-2');
104+
expect(updated.body[1].id).toBe('card-1');
105+
});
106+
107+
it('should add nodes to the schema', async () => {
108+
const user = userEvent.setup();
109+
110+
render(
111+
<DesignerProvider initialSchema={initialSchema}>
112+
<TestComponent />
113+
</DesignerProvider>
114+
);
115+
116+
const addButton = screen.getByText('Add Node');
117+
118+
// Get initial schema
119+
const initialSchemaText = screen.getByTestId('schema').textContent;
120+
const initial = JSON.parse(initialSchemaText || '{}');
121+
expect(initial.body.length).toBe(2);
122+
123+
// Add a new node
124+
await user.click(addButton);
125+
126+
// Get updated schema
127+
const updatedSchemaText = screen.getByTestId('schema').textContent;
128+
const updated = JSON.parse(updatedSchemaText || '{}');
129+
130+
// Should have 3 nodes now
131+
expect(updated.body.length).toBe(3);
132+
expect(updated.body[2].type).toBe('text');
133+
});
134+
});

packages/designer/src/components/Canvas.tsx

Lines changed: 93 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,14 @@ export const Canvas: React.FC<CanvasProps> = ({ className }) => {
1616
hoveredNodeId,
1717
setHoveredNodeId,
1818
draggingType,
19+
draggingNodeId,
20+
setDraggingNodeId,
1921
addNode,
22+
moveNode,
2023
} = useDesigner();
2124

2225
const [scale, setScale] = useState(1);
26+
const canvasRef = React.useRef<HTMLDivElement>(null);
2327

2428
const handleClick = (e: React.MouseEvent) => {
2529
// Find closest element with data-obj-id
@@ -35,15 +39,18 @@ export const Canvas: React.FC<CanvasProps> = ({ className }) => {
3539
};
3640

3741
const handleDragOver = (e: React.DragEvent) => {
38-
if (!draggingType) return;
42+
if (!draggingType && !draggingNodeId) return;
3943
e.preventDefault();
4044

4145
const target = (e.target as Element).closest('[data-obj-id]');
4246
if (target) {
4347
e.stopPropagation();
4448
const id = target.getAttribute('data-obj-id');
45-
setHoveredNodeId(id);
46-
e.dataTransfer.dropEffect = 'copy';
49+
// Don't allow dropping on the node being dragged
50+
if (id !== draggingNodeId) {
51+
setHoveredNodeId(id);
52+
e.dataTransfer.dropEffect = draggingNodeId ? 'move' : 'copy';
53+
}
4754
} else {
4855
setHoveredNodeId(null);
4956
}
@@ -55,27 +62,85 @@ export const Canvas: React.FC<CanvasProps> = ({ className }) => {
5562

5663
const handleDrop = (e: React.DragEvent) => {
5764
e.preventDefault();
58-
if (!draggingType) return;
59-
65+
6066
const target = (e.target as Element).closest('[data-obj-id]');
6167
const targetId = target?.getAttribute('data-obj-id');
6268

6369
if (targetId) {
6470
e.stopPropagation();
65-
const config = ComponentRegistry.getConfig(draggingType);
66-
if (config) {
67-
const newNode = {
68-
type: draggingType,
69-
...(config.defaultProps || {}),
70-
body: config.defaultChildren || undefined
71-
};
72-
addNode(targetId, newNode);
73-
}
71+
72+
// Handle moving existing component
73+
if (draggingNodeId) {
74+
// Don't allow dropping on itself
75+
if (draggingNodeId !== targetId) {
76+
// TODO: Calculate proper insertion index based on drop position
77+
// For now, always insert at the beginning (index 0)
78+
moveNode(draggingNodeId, targetId, 0);
79+
}
80+
setDraggingNodeId(null);
81+
}
82+
// Handle adding new component from palette
83+
else if (draggingType) {
84+
const config = ComponentRegistry.getConfig(draggingType);
85+
if (config) {
86+
const newNode = {
87+
type: draggingType,
88+
...(config.defaultProps || {}),
89+
body: config.defaultChildren || undefined
90+
};
91+
addNode(targetId, newNode);
92+
}
93+
}
7494
}
7595

7696
setHoveredNodeId(null);
7797
};
7898

99+
// Make components in canvas draggable
100+
React.useEffect(() => {
101+
if (!canvasRef.current) return;
102+
103+
const handleDragStart = (e: DragEvent) => {
104+
const target = (e.target as Element).closest('[data-obj-id]');
105+
if (target && target.getAttribute('data-obj-id')) {
106+
const nodeId = target.getAttribute('data-obj-id');
107+
// Don't allow dragging the root node
108+
if (nodeId === schema.id) {
109+
e.preventDefault();
110+
return;
111+
}
112+
setDraggingNodeId(nodeId);
113+
e.stopPropagation();
114+
if (e.dataTransfer) {
115+
e.dataTransfer.effectAllowed = 'move';
116+
e.dataTransfer.setData('text/plain', nodeId || '');
117+
}
118+
}
119+
};
120+
121+
const handleDragEnd = () => {
122+
setDraggingNodeId(null);
123+
};
124+
125+
// Add draggable attribute and event listeners to all elements with data-obj-id within canvas
126+
const elements = canvasRef.current.querySelectorAll('[data-obj-id]');
127+
elements.forEach(el => {
128+
// Don't make root draggable
129+
if (el.getAttribute('data-obj-id') !== schema.id) {
130+
el.setAttribute('draggable', 'true');
131+
el.addEventListener('dragstart', handleDragStart as EventListener);
132+
el.addEventListener('dragend', handleDragEnd as EventListener);
133+
}
134+
});
135+
136+
return () => {
137+
elements.forEach(el => {
138+
el.removeEventListener('dragstart', handleDragStart as EventListener);
139+
el.removeEventListener('dragend', handleDragEnd as EventListener);
140+
});
141+
};
142+
}, [schema, setDraggingNodeId]);
143+
79144
// Inject styles for selection/hover using dynamic CSS
80145
// Using a more refined outline style
81146
const highlightStyles = `
@@ -84,6 +149,14 @@ export const Canvas: React.FC<CanvasProps> = ({ className }) => {
84149
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
85150
}
86151
152+
[data-obj-id]:not([data-obj-id="${schema.id}"]) {
153+
cursor: grab;
154+
}
155+
156+
[data-obj-id]:not([data-obj-id="${schema.id}"]):active {
157+
cursor: grabbing;
158+
}
159+
87160
[data-obj-id="${selectedNodeId}"] {
88161
outline: 2px solid #3b82f6 !important;
89162
outline-offset: -1px;
@@ -113,7 +186,11 @@ export const Canvas: React.FC<CanvasProps> = ({ className }) => {
113186
outline: 2px dashed #60a5fa !important;
114187
outline-offset: -2px;
115188
background-color: rgba(59, 130, 246, 0.05);
116-
cursor: copy;
189+
cursor: ${draggingNodeId ? 'move' : 'copy'};
190+
}
191+
192+
[data-obj-id="${draggingNodeId}"] {
193+
opacity: 0.5;
117194
}
118195
`;
119196

@@ -135,6 +212,7 @@ export const Canvas: React.FC<CanvasProps> = ({ className }) => {
135212
</div>
136213

137214
<div
215+
ref={canvasRef}
138216
className="flex-1 overflow-auto p-12 relative flex justify-center"
139217
onClick={handleClick}
140218
onDragOver={handleDragOver}

0 commit comments

Comments
 (0)