Skip to content

Commit 91b2183

Browse files
authored
Merge pull request #45 from objectstack-ai/copilot/improve-designer-features
2 parents 858004f + 3679594 commit 91b2183

File tree

9 files changed

+653
-54
lines changed

9 files changed

+653
-54
lines changed

packages/designer/README.md

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ A professional drag-and-drop visual editor to generate Object UI schemas with ad
1212
- **Component Search**: Quickly find components with the built-in search functionality
1313
- **JSON Import/Export**: Import and export schemas as JSON files or clipboard
1414
- **Undo/Redo**: Full history management with keyboard shortcuts (works with resize!)
15-
- **Copy/Paste**: Duplicate components easily with Ctrl+C/V
15+
- **Copy/Cut/Paste**: 🆕 Duplicate and move components easily with Ctrl+C/X/V
16+
- **Duplicate**: 🆕 Quickly duplicate components with Ctrl+D
17+
- **Component Tree**: 🆕 Hierarchical tree view with expand/collapse all functionality
18+
- **Move Up/Down**: 🆕 Reorder components within their parent container
1619

1720
### Visual Design
1821
- **Premium Gradient Effects**: 🆕 Smooth gradient borders and backgrounds for enhanced visual feedback
@@ -29,9 +32,15 @@ A professional drag-and-drop visual editor to generate Object UI schemas with ad
2932
- `Ctrl+Z` / `Cmd+Z`: Undo (including resize operations)
3033
- `Ctrl+Y` / `Cmd+Y` / `Cmd+Shift+Z`: Redo (including resize operations)
3134
- `Ctrl+C` / `Cmd+C`: Copy component
35+
- `Ctrl+X` / `Cmd+X`: Cut component 🆕
3236
- `Ctrl+V` / `Cmd+V`: Paste component
37+
- `Ctrl+D` / `Cmd+D`: Duplicate component 🆕
38+
- `Ctrl+↑` / `Cmd+↑`: Move component up in tree 🆕
39+
- `Ctrl+↓` / `Cmd+↓`: Move component down in tree 🆕
3340
- `Delete` / `Backspace`: Delete component
3441
- `Click`: Select component (shows resize handles if resizable)
42+
- **Keyboard Shortcuts Help**: 🆕 Click the keyboard icon in toolbar to view all shortcuts
43+
- **Context Menu**: 🆕 Right-click components for quick actions (Copy, Cut, Paste, Duplicate, Move Up/Down, Delete)
3544
- **Resize Handles**: 🆕 8-directional handles appear on selected container components
3645
- **Visual Constraints**: 🆕 Min/max width and height constraints prevent invalid sizing
3746
- **Tooltips**: Contextual help throughout the interface
@@ -146,10 +155,16 @@ The designer supports the following keyboard shortcuts for efficient workflow:
146155
| `Ctrl+Y` / `Cmd+Y` | Redo | Redo the last undone change |
147156
| `Cmd+Shift+Z` | Redo (Mac) | Alternative redo on macOS |
148157
| `Ctrl+C` / `Cmd+C` | Copy | Copy the selected component |
158+
| `Ctrl+X` / `Cmd+X` | Cut | Cut the selected component 🆕 |
149159
| `Ctrl+V` / `Cmd+V` | Paste | Paste the copied component |
160+
| `Ctrl+D` / `Cmd+D` | Duplicate | Duplicate the selected component 🆕 |
161+
| `Ctrl+↑` / `Cmd+↑` | Move Up | Move component up in parent container 🆕 |
162+
| `Ctrl+↓` / `Cmd+↓` | Move Down | Move component down in parent container 🆕 |
150163
| `Delete` / `Backspace` | Delete | Delete the selected component |
151164

152-
**Note**: Copy, paste, and delete shortcuts only work when not editing text in input fields.
165+
**Note**: Copy, cut, paste, duplicate, and delete shortcuts only work when not editing text in input fields.
166+
167+
**Tip**: Click the keyboard icon (⌨️) in the toolbar to view the interactive keyboard shortcuts reference anytime!
153168

154169
## Components
155170

@@ -269,7 +284,13 @@ module.exports = {
269284
- [x] **Resizable component indicators** 🆕
270285
- [x] Undo/redo functionality with history (works with resize)
271286
- [x] Copy/paste components
272-
- [x] Keyboard shortcuts (Ctrl+Z/Y, Ctrl+C/V, Delete)
287+
- [x] **Cut functionality (Ctrl+X/Cmd+X)** 🆕
288+
- [x] **Duplicate functionality (Ctrl+D/Cmd+D)** 🆕
289+
- [x] **Component tree with expand/collapse all** 🆕
290+
- [x] **Context menu with move up/down** 🆕
291+
- [x] **Keyboard navigation (Ctrl+↑/↓ for reordering)** 🆕
292+
- [x] **Keyboard shortcuts help dialog** 🆕
293+
- [x] Keyboard shortcuts (Ctrl+Z/Y, Ctrl+C/X/V/D, Delete)
273294
- [x] Component search in palette
274295
- [x] JSON import/export with file and clipboard support
275296
- [x] Responsive viewport modes (Desktop/Tablet/Mobile)
@@ -282,8 +303,10 @@ module.exports = {
282303
- [ ] Keyboard resize controls (Shift+Arrow keys)
283304
- [ ] Resize preview overlay showing dimensions
284305
- [ ] Schema validation with error indicators
285-
- [ ] Component tree view for better navigation
306+
- [ ] Component visibility toggle (hide/show components)
286307
- [ ] Copy/duplicate entire schema branches
308+
- [ ] Multi-select components with Shift+Click
309+
- [ ] Component grouping and ungrouping
287310
- [ ] Custom component templates library
288311
- [ ] Export to React/TypeScript code
289312
- [ ] Collaborative editing features
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
import { describe, it, expect, beforeEach } from 'vitest';
2+
import { render, fireEvent } from '@testing-library/react';
3+
import { DesignerProvider, useDesigner } from '../context/DesignerContext';
4+
import { SchemaNode } from '@object-ui/core';
5+
import React from 'react';
6+
7+
// Test component to access designer context
8+
const TestComponent = () => {
9+
const {
10+
schema,
11+
selectedNodeId,
12+
setSelectedNodeId,
13+
copyNode,
14+
cutNode,
15+
duplicateNode,
16+
pasteNode,
17+
moveNodeUp,
18+
moveNodeDown,
19+
canPaste
20+
} = useDesigner();
21+
22+
return (
23+
<div>
24+
<div data-testid="schema">{JSON.stringify(schema)}</div>
25+
<div data-testid="selected">{selectedNodeId || 'none'}</div>
26+
<div data-testid="can-paste">{canPaste ? 'yes' : 'no'}</div>
27+
<button onClick={() => setSelectedNodeId('child-1')} data-testid="select-child-1">Select Child 1</button>
28+
<button onClick={() => copyNode('child-1')} data-testid="copy">Copy</button>
29+
<button onClick={() => cutNode('child-1')} data-testid="cut">Cut</button>
30+
<button onClick={() => duplicateNode('child-1')} data-testid="duplicate">Duplicate</button>
31+
<button onClick={() => pasteNode('root')} data-testid="paste">Paste</button>
32+
<button onClick={() => moveNodeUp('child-2')} data-testid="move-up">Move Up</button>
33+
<button onClick={() => moveNodeDown('child-1')} data-testid="move-down">Move Down</button>
34+
<button onClick={() => moveNodeUp('child-1')} data-testid="move-up-first">Move Up First</button>
35+
<button onClick={() => moveNodeDown('child-3')} data-testid="move-down-last">Move Down Last</button>
36+
<button onClick={() => cutNode('nonexistent-id')} data-testid="cut-nonexistent">Cut Nonexistent</button>
37+
</div>
38+
);
39+
};
40+
41+
describe('Keyboard Shortcuts and Navigation', () => {
42+
const initialSchema: SchemaNode = {
43+
type: 'div',
44+
id: 'root',
45+
body: [
46+
{ type: 'text', id: 'child-1', content: 'First' },
47+
{ type: 'text', id: 'child-2', content: 'Second' },
48+
{ type: 'text', id: 'child-3', content: 'Third' }
49+
]
50+
};
51+
52+
beforeEach(() => {
53+
// Reset any global state if needed
54+
});
55+
56+
it('should copy a node', () => {
57+
const { getByTestId } = render(
58+
<DesignerProvider initialSchema={initialSchema}>
59+
<TestComponent />
60+
</DesignerProvider>
61+
);
62+
63+
expect(getByTestId('can-paste').textContent).toBe('no');
64+
65+
fireEvent.click(getByTestId('copy'));
66+
67+
expect(getByTestId('can-paste').textContent).toBe('yes');
68+
});
69+
70+
it('should cut a node and allow paste', () => {
71+
const { getByTestId } = render(
72+
<DesignerProvider initialSchema={initialSchema}>
73+
<TestComponent />
74+
</DesignerProvider>
75+
);
76+
77+
expect(getByTestId('can-paste').textContent).toBe('no');
78+
79+
fireEvent.click(getByTestId('cut'));
80+
81+
// Should be able to paste after cut
82+
expect(getByTestId('can-paste').textContent).toBe('yes');
83+
84+
// The schema should have the node removed
85+
const schema = JSON.parse(getByTestId('schema').textContent || '{}');
86+
expect(schema.body).toHaveLength(2); // One node was cut
87+
});
88+
89+
it('should duplicate a node', () => {
90+
const { getByTestId } = render(
91+
<DesignerProvider initialSchema={initialSchema}>
92+
<TestComponent />
93+
</DesignerProvider>
94+
);
95+
96+
const initialBody = JSON.parse(getByTestId('schema').textContent || '{}').body;
97+
const initialLength = initialBody.length;
98+
99+
fireEvent.click(getByTestId('duplicate'));
100+
101+
// Schema should have an extra node
102+
const schema = JSON.parse(getByTestId('schema').textContent || '{}');
103+
expect(schema.body.length).toBe(initialLength + 1);
104+
105+
// The duplicated node should have the same type as the original
106+
expect(schema.body[1].type).toBe('text');
107+
expect(schema.body[1].content).toBe('First');
108+
109+
// The duplicated node should be positioned right after the original (at index 1)
110+
expect(schema.body[1].id).not.toBe('child-1'); // Should have a new ID
111+
});
112+
113+
it('should handle moving a node up when already at top', () => {
114+
const { getByTestId } = render(
115+
<DesignerProvider initialSchema={initialSchema}>
116+
<TestComponent />
117+
</DesignerProvider>
118+
);
119+
120+
const schemaBeforeMove = JSON.parse(getByTestId('schema').textContent || '{}');
121+
122+
// Try to move the first node up (should do nothing)
123+
fireEvent.click(getByTestId('move-up-first'));
124+
125+
const schemaAfterMove = JSON.parse(getByTestId('schema').textContent || '{}');
126+
// Schema should remain unchanged
127+
expect(schemaAfterMove.body[0].id).toBe(schemaBeforeMove.body[0].id);
128+
});
129+
130+
it('should handle moving a node down when already at bottom', () => {
131+
const { getByTestId } = render(
132+
<DesignerProvider initialSchema={initialSchema}>
133+
<TestComponent />
134+
</DesignerProvider>
135+
);
136+
137+
const schemaBeforeMove = JSON.parse(getByTestId('schema').textContent || '{}');
138+
139+
// Try to move the last node down (should do nothing)
140+
fireEvent.click(getByTestId('move-down-last'));
141+
142+
const schemaAfterMove = JSON.parse(getByTestId('schema').textContent || '{}');
143+
// Schema should remain unchanged
144+
expect(schemaAfterMove.body[2].id).toBe(schemaBeforeMove.body[2].id);
145+
});
146+
147+
it('should handle cutting a non-existent node gracefully', () => {
148+
const { getByTestId } = render(
149+
<DesignerProvider initialSchema={initialSchema}>
150+
<TestComponent />
151+
</DesignerProvider>
152+
);
153+
154+
const initialLength = JSON.parse(getByTestId('schema').textContent || '{}').body.length;
155+
156+
// Try to cut a node that doesn't exist
157+
fireEvent.click(getByTestId('cut-nonexistent'));
158+
159+
const schema = JSON.parse(getByTestId('schema').textContent || '{}');
160+
// Schema should remain unchanged
161+
expect(schema.body.length).toBe(initialLength);
162+
});
163+
164+
it('should paste a copied node', () => {
165+
const { getByTestId } = render(
166+
<DesignerProvider initialSchema={initialSchema}>
167+
<TestComponent />
168+
</DesignerProvider>
169+
);
170+
171+
fireEvent.click(getByTestId('copy'));
172+
fireEvent.click(getByTestId('paste'));
173+
174+
// Schema should have an extra node
175+
const schema = JSON.parse(getByTestId('schema').textContent || '{}');
176+
expect(schema.body.length).toBeGreaterThan(3);
177+
});
178+
179+
it('should move a node up', () => {
180+
const { getByTestId } = render(
181+
<DesignerProvider initialSchema={initialSchema}>
182+
<TestComponent />
183+
</DesignerProvider>
184+
);
185+
186+
fireEvent.click(getByTestId('move-up'));
187+
188+
const schema = JSON.parse(getByTestId('schema').textContent || '{}');
189+
// child-2 should now be at index 0 after moving up
190+
expect(schema.body[0].id).toBe('child-2');
191+
});
192+
193+
it('should move a node down', () => {
194+
const { getByTestId } = render(
195+
<DesignerProvider initialSchema={initialSchema}>
196+
<TestComponent />
197+
</DesignerProvider>
198+
);
199+
200+
fireEvent.click(getByTestId('move-down'));
201+
202+
const schema = JSON.parse(getByTestId('schema').textContent || '{}');
203+
// child-1 should now be at index 1 after moving down
204+
expect(schema.body[1].id).toBe('child-1');
205+
});
206+
});

0 commit comments

Comments
 (0)