diff --git a/packages/designer/IMPLEMENTATION.zh-CN.md b/packages/designer/IMPLEMENTATION.zh-CN.md index c91f44f3c..d67ca4a28 100644 --- a/packages/designer/IMPLEMENTATION.zh-CN.md +++ b/packages/designer/IMPLEMENTATION.zh-CN.md @@ -160,27 +160,29 @@ function CustomDesigner() { 以下功能在路线图中,但尚未实现: -1. **拖放功能**: 从组件面板拖放组件到画布 (当前是点击添加) -2. **撤销/重做**: 历史记录和撤销功能 -3. **Schema 验证**: 验证 schema 的正确性 -4. **组件树视图**: 显示组件的树形结构 -5. **键盘快捷键**: 快捷键支持 -6. **组件搜索**: 在组件面板中搜索 -7. **复制粘贴**: 复制和粘贴组件 -8. **导出为代码**: 将 schema 导出为 React 代码 +1. **撤销/重做**: 历史记录和撤销功能 +2. **Schema 验证**: 验证 schema 的正确性 +3. **组件树视图**: 显示组件的树形结构 +4. **键盘快捷键**: 快捷键支持 +5. **组件搜索**: 在组件面板中搜索 +6. **复制粘贴**: 复制和粘贴组件 +7. **导出为代码**: 将 schema 导出为 React 代码 + +## 已实现功能 + +1. **✅ 拖放功能**: 从组件面板拖放组件到画布 +2. **✅ 画布内拖动**: 在画布中拖动组件以重新排序 ## 已知问题 -1. **构建错误**: renderer 和 ui 包中存在 TypeScript 错误 (这些是已存在的问题,不是由本 PR 引入) -2. **需要修复配置**: TypeScript 配置需要调整以正确编译 +1. **拖动位置**: 当前拖动组件到容器时,总是插入到开头位置。未来版本将支持根据鼠标位置计算插入位置。 ## 下一步 -1. 修复构建配置问题 -2. 实现拖放功能 (使用 react-dnd 或类似库) -3. 添加撤销/重做功能 -4. 实现 schema 验证 -5. 创建更多示例和教程 +1. 优化拖动插入位置计算 (根据鼠标位置而不是总是插入到开头) +2. 添加撤销/重做功能 +3. 实现 schema 验证 +4. 创建更多示例和教程 ## 总结 @@ -189,6 +191,8 @@ function CustomDesigner() { - ✅ 组件面板和属性编辑 - ✅ JSON 导入导出 - ✅ 实时预览 +- ✅ 拖放功能 (从面板到画布) +- ✅ 画布内拖动重排序 - ✅ 示例应用 - ✅ 文档 diff --git a/packages/designer/README.md b/packages/designer/README.md index 4b8c1ba2d..bcb7e3c32 100644 --- a/packages/designer/README.md +++ b/packages/designer/README.md @@ -5,6 +5,7 @@ A drag-and-drop visual editor to generate Object UI schemas. ## Features - **Visual Schema Editor**: Edit Object UI schemas visually with a live preview +- **Drag-and-Drop**: Drag components from the palette to the canvas and reorder them within the canvas - **Component Palette**: Browse and add components from a categorized list - **Property Editor**: Configure component properties with a dynamic form - **JSON Import/Export**: Import and export schemas as JSON @@ -207,8 +208,8 @@ module.exports = { ## Features Roadmap -- [ ] Drag and drop components from palette -- [ ] Drag to reorder components in canvas +- [x] Drag and drop components from palette +- [x] Drag to reorder components in canvas - [ ] Undo/redo functionality - [ ] Schema validation - [ ] Component tree view diff --git a/packages/designer/src/__tests__/drag-and-drop.test.tsx b/packages/designer/src/__tests__/drag-and-drop.test.tsx new file mode 100644 index 000000000..2796c181d --- /dev/null +++ b/packages/designer/src/__tests__/drag-and-drop.test.tsx @@ -0,0 +1,134 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { render, screen, act } from '@testing-library/react'; +import { DesignerProvider, useDesigner } from '../context/DesignerContext'; +import type { SchemaNode } from '@object-ui/protocol'; +import React from 'react'; +import userEvent from '@testing-library/user-event'; + +// Test component to access designer context +const TestComponent = () => { + const { + schema, + draggingNodeId, + setDraggingNodeId, + moveNode, + addNode + } = useDesigner(); + + return ( +
+
{JSON.stringify(schema)}
+
{draggingNodeId || 'null'}
+ + + + +
+ ); +}; + +describe('Drag and Drop Functionality', () => { + const initialSchema: SchemaNode = { + type: 'div', + id: 'root', + body: [ + { type: 'card', id: 'card-1' }, + { type: 'card', id: 'card-2' } + ] + }; + + it('should initialize draggingNodeId as null', () => { + render( + + + + ); + + expect(screen.getByTestId('dragging-node-id').textContent).toBe('null'); + }); + + it('should set and clear draggingNodeId', async () => { + const user = userEvent.setup(); + + render( + + + + ); + + const setButton = screen.getByText('Set Dragging'); + const clearButton = screen.getByText('Clear Dragging'); + + // Set dragging node + await user.click(setButton); + expect(screen.getByTestId('dragging-node-id').textContent).toBe('test-node'); + + // Clear dragging node + await user.click(clearButton); + expect(screen.getByTestId('dragging-node-id').textContent).toBe('null'); + }); + + it('should move nodes within the schema', async () => { + const user = userEvent.setup(); + + render( + + + + ); + + const moveButton = screen.getByText('Move Node'); + + // Get initial schema + const initialSchemaText = screen.getByTestId('schema').textContent; + const initial = JSON.parse(initialSchemaText || '{}'); + expect(initial.body[0].id).toBe('card-1'); + expect(initial.body[1].id).toBe('card-2'); + + // Move first node to position 1 + await user.click(moveButton); + + // Get updated schema + const updatedSchemaText = screen.getByTestId('schema').textContent; + const updated = JSON.parse(updatedSchemaText || '{}'); + + // After moving card-1 to index 1, card-2 should be at index 0 + expect(updated.body[0].id).toBe('card-2'); + expect(updated.body[1].id).toBe('card-1'); + }); + + it('should add nodes to the schema', async () => { + const user = userEvent.setup(); + + render( + + + + ); + + const addButton = screen.getByText('Add Node'); + + // Get initial schema + const initialSchemaText = screen.getByTestId('schema').textContent; + const initial = JSON.parse(initialSchemaText || '{}'); + expect(initial.body.length).toBe(2); + + // Add a new node + await user.click(addButton); + + // Get updated schema + const updatedSchemaText = screen.getByTestId('schema').textContent; + const updated = JSON.parse(updatedSchemaText || '{}'); + + // Should have 3 nodes now + expect(updated.body.length).toBe(3); + expect(updated.body[2].type).toBe('text'); + }); +}); diff --git a/packages/designer/src/components/Canvas.tsx b/packages/designer/src/components/Canvas.tsx index 78990d20b..ab3919aa0 100644 --- a/packages/designer/src/components/Canvas.tsx +++ b/packages/designer/src/components/Canvas.tsx @@ -16,10 +16,14 @@ export const Canvas: React.FC = ({ className }) => { hoveredNodeId, setHoveredNodeId, draggingType, + draggingNodeId, + setDraggingNodeId, addNode, + moveNode, } = useDesigner(); const [scale, setScale] = useState(1); + const canvasRef = React.useRef(null); const handleClick = (e: React.MouseEvent) => { // Find closest element with data-obj-id @@ -35,15 +39,18 @@ export const Canvas: React.FC = ({ className }) => { }; const handleDragOver = (e: React.DragEvent) => { - if (!draggingType) return; + if (!draggingType && !draggingNodeId) return; e.preventDefault(); const target = (e.target as Element).closest('[data-obj-id]'); if (target) { e.stopPropagation(); const id = target.getAttribute('data-obj-id'); - setHoveredNodeId(id); - e.dataTransfer.dropEffect = 'copy'; + // Don't allow dropping on the node being dragged + if (id !== draggingNodeId) { + setHoveredNodeId(id); + e.dataTransfer.dropEffect = draggingNodeId ? 'move' : 'copy'; + } } else { setHoveredNodeId(null); } @@ -55,27 +62,85 @@ export const Canvas: React.FC = ({ className }) => { const handleDrop = (e: React.DragEvent) => { e.preventDefault(); - if (!draggingType) return; - + const target = (e.target as Element).closest('[data-obj-id]'); const targetId = target?.getAttribute('data-obj-id'); if (targetId) { e.stopPropagation(); - const config = ComponentRegistry.getConfig(draggingType); - if (config) { - const newNode = { - type: draggingType, - ...(config.defaultProps || {}), - body: config.defaultChildren || undefined - }; - addNode(targetId, newNode); - } + + // Handle moving existing component + if (draggingNodeId) { + // Don't allow dropping on itself + if (draggingNodeId !== targetId) { + // TODO: Calculate proper insertion index based on drop position + // For now, always insert at the beginning (index 0) + moveNode(draggingNodeId, targetId, 0); + } + setDraggingNodeId(null); + } + // Handle adding new component from palette + else if (draggingType) { + const config = ComponentRegistry.getConfig(draggingType); + if (config) { + const newNode = { + type: draggingType, + ...(config.defaultProps || {}), + body: config.defaultChildren || undefined + }; + addNode(targetId, newNode); + } + } } setHoveredNodeId(null); }; + // Make components in canvas draggable + React.useEffect(() => { + if (!canvasRef.current) return; + + const handleDragStart = (e: DragEvent) => { + const target = (e.target as Element).closest('[data-obj-id]'); + if (target && target.getAttribute('data-obj-id')) { + const nodeId = target.getAttribute('data-obj-id'); + // Don't allow dragging the root node + if (nodeId === schema.id) { + e.preventDefault(); + return; + } + setDraggingNodeId(nodeId); + e.stopPropagation(); + if (e.dataTransfer) { + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', nodeId || ''); + } + } + }; + + const handleDragEnd = () => { + setDraggingNodeId(null); + }; + + // Add draggable attribute and event listeners to all elements with data-obj-id within canvas + const elements = canvasRef.current.querySelectorAll('[data-obj-id]'); + elements.forEach(el => { + // Don't make root draggable + if (el.getAttribute('data-obj-id') !== schema.id) { + el.setAttribute('draggable', 'true'); + el.addEventListener('dragstart', handleDragStart as EventListener); + el.addEventListener('dragend', handleDragEnd as EventListener); + } + }); + + return () => { + elements.forEach(el => { + el.removeEventListener('dragstart', handleDragStart as EventListener); + el.removeEventListener('dragend', handleDragEnd as EventListener); + }); + }; + }, [schema, setDraggingNodeId]); + // Inject styles for selection/hover using dynamic CSS // Using a more refined outline style const highlightStyles = ` @@ -84,6 +149,14 @@ export const Canvas: React.FC = ({ className }) => { transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); } + [data-obj-id]:not([data-obj-id="${schema.id}"]) { + cursor: grab; + } + + [data-obj-id]:not([data-obj-id="${schema.id}"]):active { + cursor: grabbing; + } + [data-obj-id="${selectedNodeId}"] { outline: 2px solid #3b82f6 !important; outline-offset: -1px; @@ -113,7 +186,11 @@ export const Canvas: React.FC = ({ className }) => { outline: 2px dashed #60a5fa !important; outline-offset: -2px; background-color: rgba(59, 130, 246, 0.05); - cursor: copy; + cursor: ${draggingNodeId ? 'move' : 'copy'}; + } + + [data-obj-id="${draggingNodeId}"] { + opacity: 0.5; } `; @@ -135,6 +212,7 @@ export const Canvas: React.FC = ({ className }) => {
>; draggingType: string | null; setDraggingType: React.Dispatch>; + draggingNodeId: string | null; + setDraggingNodeId: React.Dispatch>; addNode: (parentId: string | null, node: SchemaNode, index?: number) => void; updateNode: (id: string, updates: Partial) => void; removeNode: (id: string) => void; + moveNode: (nodeId: string, targetParentId: string | null, targetIndex: number) => void; } const DesignerContext = createContext(undefined); @@ -119,6 +122,47 @@ const removeNodeById = (node: SchemaNode, id: string): SchemaNode | null => { return node; }; +// Find Node by ID and return it +const findNodeById = (node: SchemaNode, id: string): SchemaNode | null => { + if (node.id === id) return node; + + if (Array.isArray(node.body)) { + for (const child of node.body) { + const found = findNodeById(child, id); + if (found) return found; + } + } else if (node.body && typeof node.body === 'object') { + return findNodeById(node.body as SchemaNode, id); + } + + return null; +}; + +// Move Node - removes from current location and adds to new location +const moveNodeInTree = ( + root: SchemaNode, + nodeId: string, + targetParentId: string | null, + targetIndex: number +): SchemaNode => { + // First, find and extract the node + const nodeToMove = findNodeById(root, nodeId); + if (!nodeToMove) return root; + + // Don't allow moving a node into itself or its descendants + if (targetParentId === nodeId || findNodeById(nodeToMove, targetParentId || '')) { + return root; + } + + // Remove the node from its current location + const treeWithoutNode = removeNodeById(root, nodeId); + if (!treeWithoutNode) return root; + + // Add it to the new location + const finalTargetId = targetParentId || treeWithoutNode.id; + return addNodeToParent(treeWithoutNode, finalTargetId, nodeToMove, targetIndex); +}; + interface DesignerProviderProps { children?: React.ReactNode; @@ -144,6 +188,7 @@ export const DesignerProvider: React.FC = ({ const [selectedNodeId, setSelectedNodeId] = useState(null); const [hoveredNodeId, setHoveredNodeId] = useState(null); const [draggingType, setDraggingType] = useState(null); + const [draggingNodeId, setDraggingNodeId] = useState(null); // Notify parent on change const isFirstRender = useRef(true); @@ -187,6 +232,22 @@ export const DesignerProvider: React.FC = ({ setSelectedNodeId(null); }, []); + /** + * Move a node to a different location in the schema tree. + * + * @param nodeId - ID of the node to move + * @param targetParentId - ID of the target parent container (or null for root) + * @param targetIndex - Index position within the target parent's children + * + * @remarks + * - Prevents moving a node into itself or its descendants + * - Removes the node from its current location before adding to new location + * - If the node is not found, the schema remains unchanged + */ + const moveNode = useCallback((nodeId: string, targetParentId: string | null, targetIndex: number) => { + setSchemaState(prev => moveNodeInTree(prev, nodeId, targetParentId, targetIndex)); + }, []); + return ( = ({ setHoveredNodeId, draggingType, setDraggingType, + draggingNodeId, + setDraggingNodeId, addNode, updateNode, - removeNode + removeNode, + moveNode }}> {children}