Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 19 additions & 15 deletions packages/designer/IMPLEMENTATION.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 创建更多示例和教程

## 总结

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

Expand Down
5 changes: 3 additions & 2 deletions packages/designer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
134 changes: 134 additions & 0 deletions packages/designer/src/__tests__/drag-and-drop.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<div data-testid="schema">{JSON.stringify(schema)}</div>
<div data-testid="dragging-node-id">{draggingNodeId || 'null'}</div>
<button onClick={() => setDraggingNodeId('test-node')}>Set Dragging</button>
<button onClick={() => setDraggingNodeId(null)}>Clear Dragging</button>
<button onClick={() => {
const newNode: SchemaNode = { type: 'text', id: 'new-text' };
addNode(schema.id, newNode);
}}>Add Node</button>
<button onClick={() => {
if (schema.body && Array.isArray(schema.body) && schema.body.length > 0) {
moveNode(schema.body[0].id!, schema.id, 1);
}
}}>Move Node</button>
</div>
);
};

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(
<DesignerProvider initialSchema={initialSchema}>
<TestComponent />
</DesignerProvider>
);

expect(screen.getByTestId('dragging-node-id').textContent).toBe('null');
});

it('should set and clear draggingNodeId', async () => {
const user = userEvent.setup();

render(
<DesignerProvider initialSchema={initialSchema}>
<TestComponent />
</DesignerProvider>
);

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(
<DesignerProvider initialSchema={initialSchema}>
<TestComponent />
</DesignerProvider>
);

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(
<DesignerProvider initialSchema={initialSchema}>
<TestComponent />
</DesignerProvider>
);

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');
});
});
108 changes: 93 additions & 15 deletions packages/designer/src/components/Canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,14 @@ export const Canvas: React.FC<CanvasProps> = ({ className }) => {
hoveredNodeId,
setHoveredNodeId,
draggingType,
draggingNodeId,
setDraggingNodeId,
addNode,
moveNode,
} = useDesigner();

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

const handleClick = (e: React.MouseEvent) => {
// Find closest element with data-obj-id
Expand All @@ -35,15 +39,18 @@ export const Canvas: React.FC<CanvasProps> = ({ 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);
}
Expand All @@ -55,27 +62,85 @@ export const Canvas: React.FC<CanvasProps> = ({ 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 || '');
Copy link

Copilot AI Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fallback to empty string when nodeId is null/undefined is unnecessary since nodeId has already been null-checked at line 108. The || '' can be removed for cleaner code.

Suggested change
e.dataTransfer.setData('text/plain', nodeId || '');
e.dataTransfer.setData('text/plain', nodeId);

Copilot uses AI. Check for mistakes.
}
}
};

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]);
Comment on lines +100 to +142
Copy link

Copilot AI Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The useEffect hook re-runs whenever the schema changes, which could be frequent during editing. This causes all drag event listeners to be removed and re-attached on every schema update, which is inefficient. Consider using a MutationObserver to dynamically handle newly added elements, or add schema.id to the dependency array instead of the entire schema object to reduce unnecessary re-renders.

Copilot uses AI. Check for mistakes.

// Inject styles for selection/hover using dynamic CSS
// Using a more refined outline style
const highlightStyles = `
Expand All @@ -84,6 +149,14 @@ export const Canvas: React.FC<CanvasProps> = ({ 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;
Expand Down Expand Up @@ -113,7 +186,11 @@ export const Canvas: React.FC<CanvasProps> = ({ 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;
}
`;

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

<div
ref={canvasRef}
className="flex-1 overflow-auto p-12 relative flex justify-center"
onClick={handleClick}
onDragOver={handleDragOver}
Expand Down
Loading