-
+
+
+
+
+
+
Start Building Your UI
+
Drag components from the left panel to begin designing.
+
+
+ →
+ Drag & Drop: Add components from the palette
+
+
+ →
+ Click to Select: Edit properties in the right panel
+
+
+ →
+ Keyboard Shortcuts: Ctrl+Z/Y for undo/redo
+
-
Start Building
-
Drag components from the left sidebar to start creating your UI.
)}
diff --git a/packages/designer/src/components/ComponentPalette.tsx b/packages/designer/src/components/ComponentPalette.tsx
index 2f2f9c80c..533432e34 100644
--- a/packages/designer/src/components/ComponentPalette.tsx
+++ b/packages/designer/src/components/ComponentPalette.tsx
@@ -14,7 +14,21 @@ import {
MousePointer2,
Box,
Grid,
- AlignJustify
+ AlignJustify,
+ PanelLeft,
+ FileText,
+ Circle,
+ User,
+ MessageSquare,
+ Bell,
+ Zap,
+ BarChart3,
+ Menu,
+ ChevronRight,
+ Layers,
+ Columns3,
+ Minus,
+ X
} from 'lucide-react';
import { cn } from '@object-ui/components';
import { ScrollArea } from '@object-ui/components';
@@ -26,20 +40,50 @@ interface ComponentPaletteProps {
// Map component types to Lucide icons
const getIconForType = (type: string) => {
switch (type) {
+ // Layout
case 'div':
case 'container': return Box;
case 'card': return CreditCard;
- case 'text':
- case 'span': return Type;
- case 'image': return Image;
+ case 'grid': return Grid;
+ case 'stack': return AlignJustify;
+ case 'separator': return Minus;
+
+ // Form
case 'button': return MousePointer2;
case 'input': return Type;
+ case 'textarea': return FileText;
case 'checkbox': return CheckSquare;
case 'switch': return ToggleLeft;
case 'select': return List;
+ case 'label': return Type;
+
+ // Data Display
+ case 'text':
+ case 'span': return Type;
+ case 'image': return Image;
+ case 'badge': return Circle;
+ case 'avatar': return User;
case 'table': return Table;
- case 'grid': return Grid;
- case 'stack': return AlignJustify;
+
+ // Feedback
+ case 'alert': return Bell;
+ case 'progress': return BarChart3;
+ case 'skeleton': return Layers;
+ case 'toast': return MessageSquare;
+
+ // Overlay
+ case 'dialog':
+ case 'drawer':
+ case 'popover':
+ case 'tooltip':
+ case 'sheet': return PanelLeft;
+
+ // Navigation
+ case 'tabs': return Columns3;
+ case 'breadcrumb': return ChevronRight;
+ case 'pagination': return Menu;
+ case 'menubar': return Menu;
+
default: return Square;
}
};
@@ -57,6 +101,7 @@ const CATEGORIES = {
export const ComponentPalette: React.FC
= ({ className }) => {
const { setDraggingType } = useDesigner();
const allConfigs = ComponentRegistry.getAllConfigs();
+ const [searchQuery, setSearchQuery] = React.useState('');
const handleDragStart = (e: React.DragEvent, type: string) => {
e.dataTransfer.setData('componentType', type);
@@ -103,6 +148,18 @@ export const ComponentPalette: React.FC = ({ className })
);
};
+ // Filter components by search query
+ const filterBySearch = (types: string[]) => {
+ if (!searchQuery.trim()) return types;
+
+ const query = searchQuery.toLowerCase();
+ return types.filter(type => {
+ const config = ComponentRegistry.getConfig(type);
+ return type.toLowerCase().includes(query) ||
+ config?.label?.toLowerCase().includes(query);
+ });
+ };
+
// Filter available components based on category
const getComponentscategory = (categoryComponents: string[]) => {
return categoryComponents.filter(type => ComponentRegistry.getConfig(type));
@@ -110,15 +167,35 @@ export const ComponentPalette: React.FC = ({ className })
return (
-
-
Components
-
Drag to add to canvas
+
+
+
Components
+
Drag to add to canvas
+
+
+ setSearchQuery(e.target.value)}
+ className="w-full h-8 px-3 text-xs border rounded-md bg-gray-50 focus:bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
+ />
+ {searchQuery && (
+
+ )}
+
{Object.entries(CATEGORIES).map(([category, types]) => {
- const availableTypes = getComponentscategory(types);
+ const availableTypes = filterBySearch(getComponentscategory(types));
if (availableTypes.length === 0) return null;
return (
@@ -134,7 +211,7 @@ export const ComponentPalette: React.FC
= ({ className })
{/* Fallback for uncategorized */}
{(() => {
const categorized = new Set(Object.values(CATEGORIES).flat());
- const uncategorized = Object.keys(allConfigs).filter(t => !categorized.has(t));
+ const uncategorized = filterBySearch(Object.keys(allConfigs).filter(t => !categorized.has(t)));
if (uncategorized.length === 0) return null;
diff --git a/packages/designer/src/components/Designer.tsx b/packages/designer/src/components/Designer.tsx
index 384d5ec11..e8dbf55b8 100644
--- a/packages/designer/src/components/Designer.tsx
+++ b/packages/designer/src/components/Designer.tsx
@@ -1,9 +1,10 @@
-import React from 'react';
+import React, { useEffect } from 'react';
import { DesignerProvider } from '../context/DesignerContext';
import { ComponentPalette } from './ComponentPalette';
import { Canvas } from './Canvas';
import { PropertyPanel } from './PropertyPanel';
import { Toolbar } from './Toolbar';
+import { useDesigner } from '../context/DesignerContext';
import type { SchemaNode } from '@object-ui/core';
interface DesignerProps {
@@ -11,29 +12,81 @@ interface DesignerProps {
onSchemaChange?: (schema: SchemaNode) => void;
}
-export const Designer: React.FC = ({ initialSchema, onSchemaChange }) => {
+const DesignerContent: React.FC = () => {
+ const { undo, redo, copyNode, pasteNode, removeNode, selectedNodeId, canUndo, canRedo } = useDesigner();
+
+ // Keyboard shortcuts
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ // Check if we're in an editable element
+ const target = e.target as HTMLElement;
+ const isEditing =
+ target.tagName === 'INPUT' ||
+ target.tagName === 'TEXTAREA' ||
+ target.tagName === 'SELECT' ||
+ target.isContentEditable;
+
+ // Undo: Ctrl+Z / Cmd+Z
+ if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey && canUndo) {
+ e.preventDefault();
+ undo();
+ }
+ // Redo: Ctrl+Y / Cmd+Shift+Z
+ else if (((e.ctrlKey || e.metaKey) && e.key === 'y') || ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'z')) {
+ if (canRedo) {
+ e.preventDefault();
+ redo();
+ }
+ }
+ // Copy: Ctrl+C / Cmd+C (only when not editing)
+ else if ((e.ctrlKey || e.metaKey) && e.key === 'c' && !isEditing && selectedNodeId) {
+ e.preventDefault();
+ copyNode(selectedNodeId);
+ }
+ // Paste: Ctrl+V / Cmd+V (only when not editing)
+ else if ((e.ctrlKey || e.metaKey) && e.key === 'v' && !isEditing) {
+ e.preventDefault();
+ pasteNode(selectedNodeId);
+ }
+ // Delete: Delete / Backspace (only when not editing)
+ else if ((e.key === 'Delete' || e.key === 'Backspace') && !isEditing && selectedNodeId) {
+ e.preventDefault();
+ removeNode(selectedNodeId);
+ }
+ };
+
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, [undo, redo, copyNode, pasteNode, removeNode, selectedNodeId, canUndo, canRedo]);
+
return (
-
-
-
+
+
+
+
+ {/* Left Sidebar */}
+
+
+
-
- {/* Left Sidebar */}
-
-
-
-
- {/* Main Canvas Area */}
-
-
-
-
- {/* Right Sidebar */}
-
+ {/* Main Canvas Area */}
+
+
+
+
+ {/* Right Sidebar */}
+
+
+ );
+};
+
+export const Designer: React.FC
= ({ initialSchema, onSchemaChange }) => {
+ return (
+
+
);
};
diff --git a/packages/designer/src/components/PropertyPanel.tsx b/packages/designer/src/components/PropertyPanel.tsx
index 4ee707a80..9f62bf237 100644
--- a/packages/designer/src/components/PropertyPanel.tsx
+++ b/packages/designer/src/components/PropertyPanel.tsx
@@ -14,7 +14,7 @@ import {
} from "@object-ui/components"
import { Textarea } from '@object-ui/components';
import { ScrollArea } from '@object-ui/components';
-import { Settings2, Trash2, Layers, Type } from 'lucide-react';
+import { Settings2, Trash2, Layers, Copy, ClipboardPaste } from 'lucide-react';
import { cn } from '@object-ui/components';
import type { ComponentInput } from '@object-ui/core';
@@ -23,7 +23,7 @@ interface PropertyPanelProps {
}
export const PropertyPanel: React.FC = ({ className }) => {
- const { schema, selectedNodeId, updateNode, removeNode } = useDesigner();
+ const { schema, selectedNodeId, updateNode, removeNode, copyNode, pasteNode, canPaste } = useDesigner();
// Recursive finder
const findNode = (node: any, id: string): any => {
@@ -155,22 +155,43 @@ export const PropertyPanel: React.FC = ({ className }) => {
return (
-
+
{config?.label || selectedNode.type}
{selectedNode.type}
ID: {selectedNode.id}
-
+
+
+
+
+
diff --git a/packages/designer/src/components/Toolbar.tsx b/packages/designer/src/components/Toolbar.tsx
index e7a75a4c7..e8a006bbf 100644
--- a/packages/designer/src/components/Toolbar.tsx
+++ b/packages/designer/src/components/Toolbar.tsx
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useState, useRef } from 'react';
import { Button } from '@object-ui/components';
import {
Monitor,
@@ -7,52 +7,318 @@ import {
Undo,
Redo,
Play,
- Share2,
+ Share2,
+ Download,
+ Upload,
+ Copy,
+ FileJson,
} from 'lucide-react';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@object-ui/components";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@object-ui/components";
+import { Textarea } from '@object-ui/components';
+import { useDesigner } from '../context/DesignerContext';
+import { cn } from '@object-ui/components';
export const Toolbar: React.FC = () => {
+ const { schema, setSchema, undo, redo, canUndo, canRedo, viewportMode, setViewportMode } = useDesigner();
+ const [showExportDialog, setShowExportDialog] = useState(false);
+ const [showImportDialog, setShowImportDialog] = useState(false);
+ const [importJson, setImportJson] = useState('');
+ const [importError, setImportError] = useState('');
+ const fileInputRef = useRef(null);
+
+ const handleExport = () => {
+ const json = JSON.stringify(schema, null, 2);
+ const blob = new Blob([json], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = 'schema.json';
+ a.click();
+ URL.revokeObjectURL(url);
+ };
+
+ const handleCopyJson = async () => {
+ try {
+ const json = JSON.stringify(schema, null, 2);
+ await navigator.clipboard.writeText(json);
+ // Could add a toast notification here for success
+ } catch (error) {
+ console.error('Failed to copy to clipboard:', error);
+ // Fallback: could show an error message or use a different copy method
+ }
+ };
+
+ const handleImport = () => {
+ try {
+ setImportError('');
+ const parsed = JSON.parse(importJson);
+ setSchema(parsed);
+ setShowImportDialog(false);
+ setImportJson('');
+ } catch (error) {
+ setImportError(error instanceof Error ? error.message : 'Invalid JSON');
+ }
+ };
+
+ const handleFileImport = (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+
+ const reader = new FileReader();
+ reader.onload = (event) => {
+ try {
+ const json = event.target?.result as string;
+ const parsed = JSON.parse(json);
+ setSchema(parsed);
+ } catch (error) {
+ setImportError(error instanceof Error ? error.message : 'Failed to import JSON file');
+ setShowImportDialog(true);
+ }
+ };
+ reader.readAsText(file);
+ };
+
return (
-
-
-
- O
+
+
+
+
+ O
+
+
Object UI Designer
+
Beta
-
Object UI
-
Beta
-
-
-
-
-
+ {/* Center Controls */}
+
+ {/* Viewport Toggle */}
+
+
+
+
+
+ Desktop View (1024px)
+
+
+
+
+
+ Tablet View (768px)
+
+
+
+
+
+ Mobile View (375px)
+
+
+ {/* Right Actions */}
-
-
+
);
};
diff --git a/packages/designer/src/context/DesignerContext.tsx b/packages/designer/src/context/DesignerContext.tsx
index 41e90c34e..38bc19515 100644
--- a/packages/designer/src/context/DesignerContext.tsx
+++ b/packages/designer/src/context/DesignerContext.tsx
@@ -1,6 +1,8 @@
import React, { createContext, useContext, useState, useCallback, useRef, useEffect } from 'react';
import type { SchemaNode } from '@object-ui/core';
+export type ViewportMode = 'desktop' | 'tablet' | 'mobile';
+
export interface DesignerContextValue {
schema: SchemaNode;
setSchema: (schema: SchemaNode) => void;
@@ -12,10 +14,19 @@ export interface DesignerContextValue {
setDraggingType: React.Dispatch
>;
draggingNodeId: string | null;
setDraggingNodeId: React.Dispatch>;
+ viewportMode: ViewportMode;
+ setViewportMode: 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;
+ copyNode: (id: string) => void;
+ pasteNode: (parentId: string | null) => void;
+ canPaste: boolean;
+ undo: () => void;
+ redo: () => void;
+ canUndo: boolean;
+ canRedo: boolean;
}
const DesignerContext = createContext(undefined);
@@ -189,6 +200,14 @@ export const DesignerProvider: React.FC = ({
const [hoveredNodeId, setHoveredNodeId] = useState(null);
const [draggingType, setDraggingType] = useState(null);
const [draggingNodeId, setDraggingNodeId] = useState(null);
+ const [viewportMode, setViewportMode] = useState('desktop');
+
+ // Undo/Redo state
+ const [history, setHistory] = useState([ensureNodeIds(initialSchema || defaultSchema)]);
+ const [historyIndex, setHistoryIndex] = useState(0);
+
+ // Copy/Paste state
+ const [clipboard, setClipboard] = useState(null);
// Notify parent on change
const isFirstRender = useRef(true);
@@ -200,53 +219,108 @@ export const DesignerProvider: React.FC = ({
onSchemaChange?.(schema);
}, [schema, onSchemaChange]);
+ // Add to history when schema changes (debounced)
+ const addToHistory = useCallback((newSchema: SchemaNode) => {
+ setHistory(prev => {
+ // Remove any history after current index
+ const newHistory = prev.slice(0, historyIndex + 1);
+ // Add new state
+ newHistory.push(newSchema);
+ // Limit history to 50 items
+ if (newHistory.length > 50) {
+ newHistory.shift();
+ // Index is now one less since we removed from start
+ setHistoryIndex(newHistory.length - 1);
+ } else {
+ setHistoryIndex(newHistory.length - 1);
+ }
+ return newHistory;
+ });
+ }, [historyIndex]);
+
const setSchema = useCallback((newSchema: SchemaNode) => {
- setSchemaState(ensureNodeIds(newSchema));
- }, []);
+ const withIds = ensureNodeIds(newSchema);
+ setSchemaState(withIds);
+ addToHistory(withIds);
+ }, [addToHistory]);
const addNode = useCallback((parentId: string | null, node: SchemaNode, index?: number) => {
const nodeWithId = ensureNodeIds(node);
setSchemaState(prev => {
- // If parentId is null, we assume adding to root if root exists?
- // Or if root is container.
- // For simplicty, try to add to root if parentId matches root.id or is null
const targetId = parentId || prev.id;
- return addNodeToParent(prev, targetId, nodeWithId, index);
+ const newSchema = addNodeToParent(prev, targetId, nodeWithId, index);
+ addToHistory(newSchema);
+ return newSchema;
});
// Select the new node
setTimeout(() => setSelectedNodeId(nodeWithId.id || null), 50);
- }, []);
+ }, [addToHistory]);
const updateNode = useCallback((id: string, updates: Partial) => {
- setSchemaState(prev => updateNodeById(prev, id, updates));
- }, []);
+ setSchemaState(prev => {
+ const newSchema = updateNodeById(prev, id, updates);
+ addToHistory(newSchema);
+ return newSchema;
+ });
+ }, [addToHistory]);
const removeNode = useCallback((id: string) => {
setSchemaState(prev => {
const res = removeNodeById(prev, id);
- return res || prev; // If root removed, fallback to prev (prevent empty)? Or allow clearing?
- // Usually we don't allow removing root.
+ const newSchema = res || prev;
+ addToHistory(newSchema);
+ return newSchema;
});
setSelectedNodeId(null);
- }, []);
+ }, [addToHistory]);
- /**
- * 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));
- }, []);
+ setSchemaState(prev => {
+ const newSchema = moveNodeInTree(prev, nodeId, targetParentId, targetIndex);
+ addToHistory(newSchema);
+ return newSchema;
+ });
+ }, [addToHistory]);
+
+ // Undo/Redo functions
+ const undo = useCallback(() => {
+ if (historyIndex > 0) {
+ const newIndex = historyIndex - 1;
+ setHistoryIndex(newIndex);
+ setSchemaState(history[newIndex]);
+ }
+ }, [historyIndex, history]);
+
+ const redo = useCallback(() => {
+ if (historyIndex < history.length - 1) {
+ const newIndex = historyIndex + 1;
+ setHistoryIndex(newIndex);
+ setSchemaState(history[newIndex]);
+ }
+ }, [historyIndex, history]);
+
+ const canUndo = historyIndex > 0;
+ const canRedo = historyIndex < history.length - 1;
+
+ // Copy/Paste functions
+ const copyNode = useCallback((id: string) => {
+ const node = findNodeById(schema, id);
+ if (node) {
+ // Create a deep copy without the ID so it gets a new one when pasted
+ const { id: originalId, ...nodeWithoutId } = node;
+ setClipboard(nodeWithoutId as SchemaNode);
+ }
+ }, [schema]);
+
+ const pasteNode = useCallback((parentId: string | null) => {
+ if (clipboard) {
+ addNode(parentId, clipboard);
+ }
+ }, [clipboard, addNode]);
+
+ const canPaste = clipboard !== null;
return (
= ({
setDraggingType,
draggingNodeId,
setDraggingNodeId,
+ viewportMode,
+ setViewportMode,
addNode,
updateNode,
removeNode,
- moveNode
+ moveNode,
+ copyNode,
+ pasteNode,
+ canPaste,
+ undo,
+ redo,
+ canUndo,
+ canRedo
}}>
{children}
diff --git a/packages/react/src/SchemaRenderer.tsx b/packages/react/src/SchemaRenderer.tsx
index c66f7d3b0..af02b22db 100644
--- a/packages/react/src/SchemaRenderer.tsx
+++ b/packages/react/src/SchemaRenderer.tsx
@@ -17,5 +17,5 @@ export const SchemaRenderer: React.FC<{ schema: SchemaNode }> = ({ schema }) =>
);
}
- return ;
+ return ;
};