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
31 changes: 27 additions & 4 deletions packages/designer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ A professional drag-and-drop visual editor to generate Object UI schemas with ad
- **Component Search**: Quickly find components with the built-in search functionality
- **JSON Import/Export**: Import and export schemas as JSON files or clipboard
- **Undo/Redo**: Full history management with keyboard shortcuts (works with resize!)
- **Copy/Paste**: Duplicate components easily with Ctrl+C/V
- **Copy/Cut/Paste**: 🆕 Duplicate and move components easily with Ctrl+C/X/V
- **Duplicate**: 🆕 Quickly duplicate components with Ctrl+D
- **Component Tree**: 🆕 Hierarchical tree view with expand/collapse all functionality
- **Move Up/Down**: 🆕 Reorder components within their parent container

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

**Note**: Copy, paste, and delete shortcuts only work when not editing text in input fields.
**Note**: Copy, cut, paste, duplicate, and delete shortcuts only work when not editing text in input fields.

**Tip**: Click the keyboard icon (⌨️) in the toolbar to view the interactive keyboard shortcuts reference anytime!

## Components

Expand Down Expand Up @@ -269,7 +284,13 @@ module.exports = {
- [x] **Resizable component indicators** 🆕
- [x] Undo/redo functionality with history (works with resize)
- [x] Copy/paste components
- [x] Keyboard shortcuts (Ctrl+Z/Y, Ctrl+C/V, Delete)
- [x] **Cut functionality (Ctrl+X/Cmd+X)** 🆕
- [x] **Duplicate functionality (Ctrl+D/Cmd+D)** 🆕
- [x] **Component tree with expand/collapse all** 🆕
- [x] **Context menu with move up/down** 🆕
- [x] **Keyboard navigation (Ctrl+↑/↓ for reordering)** 🆕
- [x] **Keyboard shortcuts help dialog** 🆕
- [x] Keyboard shortcuts (Ctrl+Z/Y, Ctrl+C/X/V/D, Delete)
- [x] Component search in palette
- [x] JSON import/export with file and clipboard support
- [x] Responsive viewport modes (Desktop/Tablet/Mobile)
Expand All @@ -282,8 +303,10 @@ module.exports = {
- [ ] Keyboard resize controls (Shift+Arrow keys)
- [ ] Resize preview overlay showing dimensions
- [ ] Schema validation with error indicators
- [ ] Component tree view for better navigation
- [ ] Component visibility toggle (hide/show components)
- [ ] Copy/duplicate entire schema branches
- [ ] Multi-select components with Shift+Click
- [ ] Component grouping and ungrouping
- [ ] Custom component templates library
- [ ] Export to React/TypeScript code
- [ ] Collaborative editing features
Expand Down
206 changes: 206 additions & 0 deletions packages/designer/src/__tests__/keyboard-shortcuts.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { render, fireEvent } from '@testing-library/react';
import { DesignerProvider, useDesigner } from '../context/DesignerContext';
import { SchemaNode } from '@object-ui/core';
import React from 'react';

// Test component to access designer context
const TestComponent = () => {
const {
schema,
selectedNodeId,
setSelectedNodeId,
copyNode,
cutNode,
duplicateNode,
pasteNode,
moveNodeUp,
moveNodeDown,
canPaste
} = useDesigner();

return (
<div>
<div data-testid="schema">{JSON.stringify(schema)}</div>
<div data-testid="selected">{selectedNodeId || 'none'}</div>
<div data-testid="can-paste">{canPaste ? 'yes' : 'no'}</div>
<button onClick={() => setSelectedNodeId('child-1')} data-testid="select-child-1">Select Child 1</button>
<button onClick={() => copyNode('child-1')} data-testid="copy">Copy</button>
<button onClick={() => cutNode('child-1')} data-testid="cut">Cut</button>
<button onClick={() => duplicateNode('child-1')} data-testid="duplicate">Duplicate</button>
<button onClick={() => pasteNode('root')} data-testid="paste">Paste</button>
<button onClick={() => moveNodeUp('child-2')} data-testid="move-up">Move Up</button>
<button onClick={() => moveNodeDown('child-1')} data-testid="move-down">Move Down</button>
<button onClick={() => moveNodeUp('child-1')} data-testid="move-up-first">Move Up First</button>
<button onClick={() => moveNodeDown('child-3')} data-testid="move-down-last">Move Down Last</button>
<button onClick={() => cutNode('nonexistent-id')} data-testid="cut-nonexistent">Cut Nonexistent</button>
</div>
);
};

describe('Keyboard Shortcuts and Navigation', () => {
const initialSchema: SchemaNode = {
type: 'div',
id: 'root',
body: [
{ type: 'text', id: 'child-1', content: 'First' },
{ type: 'text', id: 'child-2', content: 'Second' },
{ type: 'text', id: 'child-3', content: 'Third' }
]
};

beforeEach(() => {
// Reset any global state if needed
});

it('should copy a node', () => {
const { getByTestId } = render(
<DesignerProvider initialSchema={initialSchema}>
<TestComponent />
</DesignerProvider>
);

expect(getByTestId('can-paste').textContent).toBe('no');

fireEvent.click(getByTestId('copy'));

expect(getByTestId('can-paste').textContent).toBe('yes');
});

it('should cut a node and allow paste', () => {
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

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

The test file is missing test coverage for edge cases such as attempting to move a node up when it's already at the top position, moving down when at the bottom, or cutting/duplicating a node that doesn't exist. These boundary conditions should be tested to ensure the functions handle them gracefully.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added in commit 3679594. Implemented 3 new edge case tests: moving up when already at top, moving down when already at bottom, and cutting a non-existent node. All tests pass successfully.

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

expect(getByTestId('can-paste').textContent).toBe('no');

fireEvent.click(getByTestId('cut'));

// Should be able to paste after cut
expect(getByTestId('can-paste').textContent).toBe('yes');

// The schema should have the node removed
const schema = JSON.parse(getByTestId('schema').textContent || '{}');
expect(schema.body).toHaveLength(2); // One node was cut
});

it('should duplicate a node', () => {
const { getByTestId } = render(
<DesignerProvider initialSchema={initialSchema}>
<TestComponent />
</DesignerProvider>
);

const initialBody = JSON.parse(getByTestId('schema').textContent || '{}').body;
const initialLength = initialBody.length;

fireEvent.click(getByTestId('duplicate'));

// Schema should have an extra node
const schema = JSON.parse(getByTestId('schema').textContent || '{}');
expect(schema.body.length).toBe(initialLength + 1);

// The duplicated node should have the same type as the original
expect(schema.body[1].type).toBe('text');
expect(schema.body[1].content).toBe('First');

// The duplicated node should be positioned right after the original (at index 1)
expect(schema.body[1].id).not.toBe('child-1'); // Should have a new ID
});

it('should handle moving a node up when already at top', () => {
const { getByTestId } = render(
<DesignerProvider initialSchema={initialSchema}>
<TestComponent />
</DesignerProvider>
);

const schemaBeforeMove = JSON.parse(getByTestId('schema').textContent || '{}');

// Try to move the first node up (should do nothing)
fireEvent.click(getByTestId('move-up-first'));

const schemaAfterMove = JSON.parse(getByTestId('schema').textContent || '{}');
// Schema should remain unchanged
expect(schemaAfterMove.body[0].id).toBe(schemaBeforeMove.body[0].id);
});

it('should handle moving a node down when already at bottom', () => {
const { getByTestId } = render(
<DesignerProvider initialSchema={initialSchema}>
<TestComponent />
</DesignerProvider>
);

const schemaBeforeMove = JSON.parse(getByTestId('schema').textContent || '{}');

// Try to move the last node down (should do nothing)
fireEvent.click(getByTestId('move-down-last'));

const schemaAfterMove = JSON.parse(getByTestId('schema').textContent || '{}');
// Schema should remain unchanged
expect(schemaAfterMove.body[2].id).toBe(schemaBeforeMove.body[2].id);
});

it('should handle cutting a non-existent node gracefully', () => {
const { getByTestId } = render(
<DesignerProvider initialSchema={initialSchema}>
<TestComponent />
</DesignerProvider>
);

const initialLength = JSON.parse(getByTestId('schema').textContent || '{}').body.length;

// Try to cut a node that doesn't exist
fireEvent.click(getByTestId('cut-nonexistent'));

const schema = JSON.parse(getByTestId('schema').textContent || '{}');
// Schema should remain unchanged
expect(schema.body.length).toBe(initialLength);
});

it('should paste a copied node', () => {
const { getByTestId } = render(
<DesignerProvider initialSchema={initialSchema}>
<TestComponent />
</DesignerProvider>
);

fireEvent.click(getByTestId('copy'));
fireEvent.click(getByTestId('paste'));

// Schema should have an extra node
const schema = JSON.parse(getByTestId('schema').textContent || '{}');
expect(schema.body.length).toBeGreaterThan(3);
});

it('should move a node up', () => {
const { getByTestId } = render(
<DesignerProvider initialSchema={initialSchema}>
<TestComponent />
</DesignerProvider>
);

fireEvent.click(getByTestId('move-up'));

const schema = JSON.parse(getByTestId('schema').textContent || '{}');
// child-2 should now be at index 0 after moving up
expect(schema.body[0].id).toBe('child-2');
});

it('should move a node down', () => {
const { getByTestId } = render(
<DesignerProvider initialSchema={initialSchema}>
<TestComponent />
</DesignerProvider>
);

fireEvent.click(getByTestId('move-down'));

const schema = JSON.parse(getByTestId('schema').textContent || '{}');
// child-1 should now be at index 1 after moving down
expect(schema.body[1].id).toBe('child-1');
});
});
Loading