From f8608b9e0515cc71420072171cecb83065829359 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 13 Jan 2026 13:55:13 +0000 Subject: [PATCH 1/5] Initial plan From e666c274a02aef8372fe1757b3701b6184013b55 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 13 Jan 2026 14:03:21 +0000 Subject: [PATCH 2/5] Add moveNode functionality and canvas drag-and-drop for reordering components Co-authored-by: huangyiirene <7665279+huangyiirene@users.noreply.github.com> --- packages/designer/src/components/Canvas.tsx | 102 +++++++++++++++--- .../designer/src/context/DesignerContext.tsx | 54 +++++++++- 2 files changed, 140 insertions(+), 16 deletions(-) diff --git a/packages/designer/src/components/Canvas.tsx b/packages/designer/src/components/Canvas.tsx index 78990d20b..1b9f57d7b 100644 --- a/packages/designer/src/components/Canvas.tsx +++ b/packages/designer/src/components/Canvas.tsx @@ -16,7 +16,10 @@ export const Canvas: React.FC = ({ className }) => { hoveredNodeId, setHoveredNodeId, draggingType, + draggingNodeId, + setDraggingNodeId, addNode, + moveNode, } = useDesigner(); const [scale, setScale] = useState(1); @@ -35,15 +38,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 +61,81 @@ 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) { + 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(() => { + 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 + const elements = document.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 +144,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 +181,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; } `; diff --git a/packages/designer/src/context/DesignerContext.tsx b/packages/designer/src/context/DesignerContext.tsx index 83bf81bc9..6f4d7ac50 100644 --- a/packages/designer/src/context/DesignerContext.tsx +++ b/packages/designer/src/context/DesignerContext.tsx @@ -10,9 +10,12 @@ export interface DesignerContextValue { setHoveredNodeId: React.Dispatch>; 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,10 @@ export const DesignerProvider: React.FC = ({ setSelectedNodeId(null); }, []); + 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} From 904b644748fc64dc3bb1b4d73e953eefce0c558b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 13 Jan 2026 14:07:33 +0000 Subject: [PATCH 3/5] Add comprehensive tests for drag-and-drop functionality Co-authored-by: huangyiirene <7665279+huangyiirene@users.noreply.github.com> --- .../src/__tests__/drag-and-drop.test.tsx | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 packages/designer/src/__tests__/drag-and-drop.test.tsx 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'); + }); +}); From 882db86da1496c5d3ebee440be24820da3bda756 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 13 Jan 2026 14:09:53 +0000 Subject: [PATCH 4/5] Address code review feedback: add JSDoc, scope queries to ref, add TODO comment Co-authored-by: huangyiirene <7665279+huangyiirene@users.noreply.github.com> --- packages/designer/src/components/Canvas.tsx | 10 ++++++++-- packages/designer/src/context/DesignerContext.tsx | 12 ++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/designer/src/components/Canvas.tsx b/packages/designer/src/components/Canvas.tsx index 1b9f57d7b..ab3919aa0 100644 --- a/packages/designer/src/components/Canvas.tsx +++ b/packages/designer/src/components/Canvas.tsx @@ -23,6 +23,7 @@ export const Canvas: React.FC = ({ className }) => { } = useDesigner(); const [scale, setScale] = useState(1); + const canvasRef = React.useRef(null); const handleClick = (e: React.MouseEvent) => { // Find closest element with data-obj-id @@ -72,6 +73,8 @@ export const Canvas: React.FC = ({ className }) => { 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); @@ -95,6 +98,8 @@ export const Canvas: React.FC = ({ className }) => { // 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')) { @@ -117,8 +122,8 @@ export const Canvas: React.FC = ({ className }) => { setDraggingNodeId(null); }; - // Add draggable attribute and event listeners to all elements with data-obj-id - const elements = document.querySelectorAll('[data-obj-id]'); + // 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) { @@ -207,6 +212,7 @@ export const Canvas: React.FC = ({ className }) => {
= ({ 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)); }, []); From e2aa9b8d147f7f1a3644800f85bb5149ec4d2a8e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 13 Jan 2026 14:11:59 +0000 Subject: [PATCH 5/5] Update documentation to reflect completed drag-and-drop features Co-authored-by: huangyiirene <7665279+huangyiirene@users.noreply.github.com> --- packages/designer/IMPLEMENTATION.zh-CN.md | 34 +++++++++++++---------- packages/designer/README.md | 5 ++-- 2 files changed, 22 insertions(+), 17 deletions(-) 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