Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
145 changes: 145 additions & 0 deletions packages/designer/src/__tests__/keyboard-shortcuts.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
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.

Unused import screen.

Suggested change
import { render, screen, fireEvent } from '@testing-library/react';
import { render, fireEvent } from '@testing-library/react';

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.

Removed in commit 3679594.

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>
</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);
});
Comment on lines +89 to +162
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 duplicate test only verifies that a node was added, but doesn't verify that the duplicated node has the correct content or is positioned correctly as a sibling. Consider adding assertions to check that the duplicated node has the same type/content as the original and is placed at the expected index (immediately after the original).

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.

Enhanced in commit 3679594. Added assertions to verify the duplicated node has the same type and content as the original, and confirmed it's positioned at the expected index (immediately after the original).


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');
});
});
143 changes: 106 additions & 37 deletions packages/designer/src/components/ComponentTree.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useCallback, useMemo, useState } from 'react';
import React, { useCallback, useMemo, useState, createContext, useContext } from 'react';
import { useDesigner } from '../context/DesignerContext';
import { ScrollArea } from '@object-ui/components';
import { Button } from '@object-ui/components';
Expand Down Expand Up @@ -29,10 +29,31 @@ interface TreeNodeProps {
onSelect: (id: string) => void;
}

// Context for controlling expansion state globally
interface TreeExpansionContextValue {
expandAll: boolean;
collapseAll: boolean;
}

const TreeExpansionContext = createContext<TreeExpansionContextValue>({
expandAll: false,
collapseAll: false
});

const TreeNode: React.FC<TreeNodeProps> = React.memo(({ node, level, isSelected, selectedNodeId, onSelect }) => {
const expansionContext = useContext(TreeExpansionContext);
const [isExpanded, setIsExpanded] = useState(true);
const [isVisible, setIsVisible] = useState(true);

// Effect to handle expand/collapse all from parent
React.useEffect(() => {
if (expansionContext.expandAll) {
setIsExpanded(true);
} else if (expansionContext.collapseAll) {
setIsExpanded(false);
}
}, [expansionContext.expandAll, expansionContext.collapseAll]);
Comment on lines +49 to +55
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 expansion effect will run on every node in the tree whenever the context changes, which could be expensive for large trees. Additionally, the effect doesn't reset the triggers after applying them, which means the context values remain true indefinitely. Consider using a counter-based approach with a reset mechanism, or using an event-based system instead of continuous context values.

Copilot uses AI. Check for mistakes.

const hasChildren = useMemo(() => {
if (!node.body) return false;
if (Array.isArray(node.body)) return node.body.length > 0;
Expand Down Expand Up @@ -156,50 +177,98 @@ const TreeNode: React.FC<TreeNodeProps> = React.memo(({ node, level, isSelected,
TreeNode.displayName = 'TreeNode';

export const ComponentTree: React.FC<ComponentTreeProps> = React.memo(({ className }) => {
const { schema, selectedNodeId, setSelectedNodeId } = useDesigner();
const { schema, selectedNodeId, setSelectedNodeId, moveNodeUp, moveNodeDown } = useDesigner();
const [expandTrigger, setExpandTrigger] = useState(0);
const [collapseTrigger, setCollapseTrigger] = useState(0);

const handleSelect = useCallback((id: string) => {
setSelectedNodeId(id);
}, [setSelectedNodeId]);

return (
<div className={cn("flex flex-col h-full bg-white", className)}>
<ScrollArea className="flex-1">
<div className="p-2">
{schema && (
<TreeNode
node={schema}
level={0}
isSelected={selectedNodeId === schema.id}
selectedNodeId={selectedNodeId}
onSelect={handleSelect}
/>
)}
</div>
</ScrollArea>
const handleExpandAll = useCallback(() => {
setExpandTrigger(prev => prev + 1);
}, []);

const handleCollapseAll = useCallback(() => {
setCollapseTrigger(prev => prev + 1);
}, []);

// Keyboard navigation for tree
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Only handle keyboard events when tree is focused and not in an input
const target = e.target as HTMLElement;
const isEditing =
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' ||
target.isContentEditable;

{/* Tree Actions */}
<div className="px-4 py-2 border-t shrink-0 bg-gray-50">
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
className="h-7 text-xs flex-1"
title="Expand all nodes"
>
Expand All
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs flex-1"
title="Collapse all nodes"
>
Collapse All
</Button>
if (isEditing || !selectedNodeId) return;

// Arrow Up: Move component up in tree (reorder)
if (e.key === 'ArrowUp' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
moveNodeUp(selectedNodeId);
}
// Arrow Down: Move component down in tree (reorder)
else if (e.key === 'ArrowDown' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
moveNodeDown(selectedNodeId);
}
};

window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [selectedNodeId, moveNodeUp, moveNodeDown]);

const expansionContextValue = useMemo(() => ({
expandAll: expandTrigger > 0,
collapseAll: collapseTrigger > 0
}), [expandTrigger, collapseTrigger]);
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 expansion context logic has a bug. When expandTrigger is incremented multiple times (e.g., from 1 to 2), both expandAll and collapseAll could be true simultaneously if both triggers are > 0. This could cause nodes to attempt both expanding and collapsing. Consider using a discriminated union approach or reset the opposite trigger when one is activated.

Suggested change
const expansionContextValue = useMemo(() => ({
expandAll: expandTrigger > 0,
collapseAll: collapseTrigger > 0
}), [expandTrigger, collapseTrigger]);
const expansionContextValue = useMemo(() => {
if (expandTrigger > collapseTrigger) {
return { expandAll: true, collapseAll: false };
}
if (collapseTrigger > expandTrigger) {
return { expandAll: false, collapseAll: true };
}
// Initial or neutral state: no global expand/collapse action
return { expandAll: false, collapseAll: false };
}, [expandTrigger, collapseTrigger]);

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.

Fixed in commit 3679594. Changed to discriminated union approach that compares trigger values to determine which action is active, preventing simultaneous expand/collapse states.


return (
<TreeExpansionContext.Provider value={expansionContextValue}>
<div className={cn("flex flex-col h-full bg-white", className)}>
<ScrollArea className="flex-1">
<div className="p-2">
{schema && (
<TreeNode
node={schema}
level={0}
isSelected={selectedNodeId === schema.id}
selectedNodeId={selectedNodeId}
onSelect={handleSelect}
/>
)}
</div>
</ScrollArea>

{/* Tree Actions */}
<div className="px-4 py-2 border-t shrink-0 bg-gray-50">
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
className="h-7 text-xs flex-1"
title="Expand all nodes"
onClick={handleExpandAll}
>
Expand All
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs flex-1"
title="Collapse all nodes"
onClick={handleCollapseAll}
>
Collapse All
</Button>
</div>
</div>
</div>
</div>
</TreeExpansionContext.Provider>
);
});

Expand Down
Loading
Loading