From 09140161397cba87fda945bfbe9f532085632e4c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 13 Jan 2026 11:23:24 +0000 Subject: [PATCH 1/9] Initial plan From d6cd72af68b35d385b2176c35178c13acdaa1b99 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 13 Jan 2026 11:31:45 +0000 Subject: [PATCH 2/9] Add designer package implementation with core components Co-authored-by: huangyiirene <7665279+huangyiirene@users.noreply.github.com> --- examples/designer-demo/index.html | 13 + examples/designer-demo/package.json | 37 +++ examples/designer-demo/postcss.config.js | 9 + examples/designer-demo/src/App.tsx | 38 +++ examples/designer-demo/src/main.tsx | 10 + examples/designer-demo/tailwind.config.js | 14 + examples/designer-demo/tsconfig.json | 24 ++ examples/designer-demo/vite.config.ts | 15 ++ packages/designer/package.json | 6 + packages/designer/src/components/Canvas.tsx | 78 ++++++ .../src/components/ComponentPalette.tsx | 95 +++++++ packages/designer/src/components/Designer.tsx | 39 +++ .../designer/src/components/PropertyPanel.tsx | 172 ++++++++++++ packages/designer/src/components/Toolbar.tsx | 104 ++++++++ .../designer/src/context/DesignerContext.tsx | 249 ++++++++++++++++++ packages/designer/src/index.ts | 15 +- packages/designer/tsconfig.json | 25 +- pnpm-lock.yaml | 117 +++++++- 18 files changed, 1055 insertions(+), 5 deletions(-) create mode 100644 examples/designer-demo/index.html create mode 100644 examples/designer-demo/package.json create mode 100644 examples/designer-demo/postcss.config.js create mode 100644 examples/designer-demo/src/App.tsx create mode 100644 examples/designer-demo/src/main.tsx create mode 100644 examples/designer-demo/tailwind.config.js create mode 100644 examples/designer-demo/tsconfig.json create mode 100644 examples/designer-demo/vite.config.ts create mode 100644 packages/designer/src/components/Canvas.tsx create mode 100644 packages/designer/src/components/ComponentPalette.tsx create mode 100644 packages/designer/src/components/Designer.tsx create mode 100644 packages/designer/src/components/PropertyPanel.tsx create mode 100644 packages/designer/src/components/Toolbar.tsx create mode 100644 packages/designer/src/context/DesignerContext.tsx diff --git a/examples/designer-demo/index.html b/examples/designer-demo/index.html new file mode 100644 index 000000000..4a3b71adb --- /dev/null +++ b/examples/designer-demo/index.html @@ -0,0 +1,13 @@ + + + + + + + Object UI Designer Demo + + +
+ + + diff --git a/examples/designer-demo/package.json b/examples/designer-demo/package.json new file mode 100644 index 000000000..0a3a2c19d --- /dev/null +++ b/examples/designer-demo/package.json @@ -0,0 +1,37 @@ +{ + "name": "@examples/designer-demo", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@object-ui/designer": "workspace:*", + "@object-ui/protocol": "workspace:*", + "@object-ui/renderer": "workspace:*", + "@object-ui/ui": "workspace:*", + "react": "^19.2.3", + "react-dom": "^19.2.3" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/node": "^24.10.1", + "@types/react": "^19.2.3", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "autoprefixer": "^10.4.23", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.19", + "typescript": "~5.9.3", + "typescript-eslint": "^8.46.4", + "vite": "^7.2.4" + } +} diff --git a/examples/designer-demo/postcss.config.js b/examples/designer-demo/postcss.config.js new file mode 100644 index 000000000..d55cfdd81 --- /dev/null +++ b/examples/designer-demo/postcss.config.js @@ -0,0 +1,9 @@ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + "../../packages/ui/src/**/*.{js,ts,jsx,tsx}", + "../../packages/renderer/src/**/*.{js,ts,jsx,tsx}", + "../../packages/designer/src/**/*.{js,ts,jsx,tsx}", + ], +} diff --git a/examples/designer-demo/src/App.tsx b/examples/designer-demo/src/App.tsx new file mode 100644 index 000000000..96f90faef --- /dev/null +++ b/examples/designer-demo/src/App.tsx @@ -0,0 +1,38 @@ +import { Designer } from '@object-ui/designer'; +import { useState } from 'react'; +import type { SchemaNode } from '@object-ui/protocol'; + +const initialSchema: SchemaNode = { + type: 'div', + className: 'p-8', + body: [ + { + type: 'card', + title: 'Welcome to Object UI Designer', + body: [ + { + type: 'text', + content: 'Start by adding components from the left panel or edit this card.' + } + ] + } + ] +}; + +function App() { + const [schema, setSchema] = useState(initialSchema); + + const handleSchemaChange = (newSchema: SchemaNode) => { + setSchema(newSchema); + console.log('Schema updated:', newSchema); + }; + + return ( + + ); +} + +export default App; diff --git a/examples/designer-demo/src/main.tsx b/examples/designer-demo/src/main.tsx new file mode 100644 index 000000000..5d1034180 --- /dev/null +++ b/examples/designer-demo/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import App from './App.tsx'; +import '@object-ui/ui/index.css'; + +createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/examples/designer-demo/tailwind.config.js b/examples/designer-demo/tailwind.config.js new file mode 100644 index 000000000..4c1605e8c --- /dev/null +++ b/examples/designer-demo/tailwind.config.js @@ -0,0 +1,14 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + "../../packages/ui/src/**/*.{js,ts,jsx,tsx}", + "../../packages/renderer/src/**/*.{js,ts,jsx,tsx}", + "../../packages/designer/src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: {}, + }, + plugins: [], +} diff --git a/examples/designer-demo/tsconfig.json b/examples/designer-demo/tsconfig.json new file mode 100644 index 000000000..f0a235055 --- /dev/null +++ b/examples/designer-demo/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/examples/designer-demo/vite.config.ts b/examples/designer-demo/vite.config.ts new file mode 100644 index 000000000..3ce9b7023 --- /dev/null +++ b/examples/designer-demo/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import path from 'path'; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@object-ui/protocol': path.resolve(__dirname, '../../packages/protocol/src'), + '@object-ui/renderer': path.resolve(__dirname, '../../packages/renderer/src'), + '@object-ui/ui': path.resolve(__dirname, '../../packages/ui/src'), + '@object-ui/designer': path.resolve(__dirname, '../../packages/designer/src'), + }, + }, +}); diff --git a/packages/designer/package.json b/packages/designer/package.json index 04051b326..759a0fe78 100644 --- a/packages/designer/package.json +++ b/packages/designer/package.json @@ -6,6 +6,7 @@ "types": "dist/index.d.ts", "scripts": { "build": "tsc", + "dev": "tsc --watch", "test": "echo \"Error: no test specified\" && exit 1" }, "peerDependencies": { @@ -16,5 +17,10 @@ "@object-ui/protocol": "workspace:*", "@object-ui/renderer": "workspace:*", "@object-ui/ui": "workspace:*" + }, + "devDependencies": { + "@types/react": "^19.2.3", + "@types/react-dom": "^19.2.3", + "typescript": "^5.0.0" } } diff --git a/packages/designer/src/components/Canvas.tsx b/packages/designer/src/components/Canvas.tsx new file mode 100644 index 000000000..221167470 --- /dev/null +++ b/packages/designer/src/components/Canvas.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { SchemaRenderer } from '@object-ui/renderer'; +import { useDesigner } from '../context/DesignerContext'; +import type { SchemaNode } from '@object-ui/protocol'; + +interface CanvasProps { + className?: string; +} + +export const Canvas: React.FC = ({ className }) => { + const { schema, selectedNodeId, setSelectedNodeId, hoveredNodeId, setHoveredNodeId } = useDesigner(); + + const handleNodeClick = (e: React.MouseEvent, nodeId: string) => { + e.stopPropagation(); + setSelectedNodeId(nodeId); + }; + + const handleNodeHover = (nodeId: string | null) => { + setHoveredNodeId(nodeId); + }; + + return ( +
+
+
+ +
+
+
+ ); +}; + +interface DesignerSchemaRendererProps { + schema: SchemaNode; + selectedNodeId: string | null; + hoveredNodeId: string | null; + onNodeClick: (e: React.MouseEvent, nodeId: string) => void; + onNodeHover: (nodeId: string | null) => void; +} + +const DesignerSchemaRenderer: React.FC = ({ + schema, + selectedNodeId, + hoveredNodeId, + onNodeClick, + onNodeHover +}) => { + const isSelected = schema.id === selectedNodeId; + const isHovered = schema.id === hoveredNodeId; + + return ( +
schema.id && onNodeClick(e, schema.id)} + onMouseEnter={() => schema.id && onNodeHover(schema.id)} + onMouseLeave={() => onNodeHover(null)} + className="relative" + style={{ + outline: isSelected ? '2px solid #3b82f6' : isHovered ? '2px dashed #93c5fd' : 'none', + outlineOffset: '2px' + }} + > + {/* Selection label */} + {isSelected && schema.id && ( +
+ {schema.type} ({schema.id}) +
+ )} + + +
+ ); +}; diff --git a/packages/designer/src/components/ComponentPalette.tsx b/packages/designer/src/components/ComponentPalette.tsx new file mode 100644 index 000000000..2a561de25 --- /dev/null +++ b/packages/designer/src/components/ComponentPalette.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import { ComponentRegistry } from '@object-ui/renderer'; +import type { SchemaNode } from '@object-ui/protocol'; +import { useDesigner } from '../context/DesignerContext'; + +interface ComponentPaletteProps { + className?: string; +} + +export const ComponentPalette: React.FC = ({ className }) => { + const { addNode, selectedNodeId } = useDesigner(); + const allConfigs = ComponentRegistry.getAllConfigs(); + + const handleComponentClick = (type: string) => { + const config = ComponentRegistry.getConfig(type); + if (!config) return; + + const newNode: SchemaNode = { + type, + ...(config.defaultProps || {}), + body: config.defaultChildren || undefined + }; + + // Add to selected node if it exists, otherwise add to root + const parentId = selectedNodeId || 'root'; + addNode(parentId, newNode); + }; + + // Group components by category + const categories = allConfigs.reduce((acc, config) => { + const category = getCategoryForType(config.type); + if (!acc[category]) { + acc[category] = []; + } + acc[category].push(config); + return acc; + }, {} as Record); + + return ( +
+
+

Components

+ + {Object.entries(categories).map(([category, components]) => ( +
+

+ {category} +

+
+ {components.map(config => ( + + ))} +
+
+ ))} +
+
+ ); +}; + +// Helper function to categorize components +function getCategoryForType(type: string): string { + if (['button', 'input', 'textarea', 'select', 'checkbox', 'switch', 'radio-group', 'slider', 'toggle', 'input-otp', 'calendar'].includes(type)) { + return 'Form'; + } + if (['div', 'span', 'text', 'separator'].includes(type)) { + return 'Basic'; + } + if (['card', 'tabs', 'accordion', 'collapsible'].includes(type)) { + return 'Layout'; + } + if (['dialog', 'sheet', 'popover', 'tooltip', 'alert-dialog', 'drawer', 'hover-card', 'dropdown-menu', 'context-menu'].includes(type)) { + return 'Overlay'; + } + if (['badge', 'avatar', 'alert'].includes(type)) { + return 'Data Display'; + } + if (['progress', 'skeleton', 'toaster'].includes(type)) { + return 'Feedback'; + } + if (['sidebar', 'sidebar-provider', 'sidebar-inset', 'header-bar'].includes(type)) { + return 'Navigation'; + } + if (['table', 'carousel', 'scroll-area', 'resizable'].includes(type)) { + return 'Complex'; + } + return 'Other'; +} diff --git a/packages/designer/src/components/Designer.tsx b/packages/designer/src/components/Designer.tsx new file mode 100644 index 000000000..e10f10cb8 --- /dev/null +++ b/packages/designer/src/components/Designer.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { DesignerProvider } from '../context/DesignerContext'; +import { ComponentPalette } from './ComponentPalette'; +import { Canvas } from './Canvas'; +import { PropertyPanel } from './PropertyPanel'; +import { Toolbar } from './Toolbar'; +import type { SchemaNode } from '@object-ui/protocol'; + +interface DesignerProps { + initialSchema?: SchemaNode; + onSchemaChange?: (schema: SchemaNode) => void; +} + +export const Designer: React.FC = ({ initialSchema, onSchemaChange }) => { + return ( + +
+ + +
+ {/* Left Panel - Component Palette */} +
+ +
+ + {/* Center - Canvas */} +
+ +
+ + {/* Right Panel - Property Panel */} +
+ +
+
+
+
+ ); +}; diff --git a/packages/designer/src/components/PropertyPanel.tsx b/packages/designer/src/components/PropertyPanel.tsx new file mode 100644 index 000000000..0c47c1754 --- /dev/null +++ b/packages/designer/src/components/PropertyPanel.tsx @@ -0,0 +1,172 @@ +import React from 'react'; +import { useDesigner } from '../context/DesignerContext'; +import { ComponentRegistry } from '@object-ui/renderer'; +import type { SchemaNode } from '@object-ui/protocol'; +import { Input, Label, Button } from '@object-ui/ui'; + +interface PropertyPanelProps { + className?: string; +} + +export const PropertyPanel: React.FC = ({ className }) => { + const { selectedNodeId, schema, updateNode, deleteNode } = useDesigner(); + + if (!selectedNodeId) { + return ( +
+
+ Select a component to edit its properties +
+
+ ); + } + + // Find the selected node + const selectedNode = findNodeById(schema, selectedNodeId); + if (!selectedNode) { + return ( +
+
+ Selected node not found +
+
+ ); + } + + const config = ComponentRegistry.getConfig(selectedNode.type); + + const handlePropertyChange = (propertyName: string, value: any) => { + updateNode(selectedNodeId, { [propertyName]: value }); + }; + + const handleDelete = () => { + if (confirm('Are you sure you want to delete this component?')) { + deleteNode(selectedNodeId); + } + }; + + return ( +
+
+
+

Properties

+ +
+ +
+
Component Type
+
{config?.label || selectedNode.type}
+
ID: {selectedNode.id}
+
+ +
+ {/* Render input fields based on component metadata */} + {config?.inputs?.map(input => { + const currentValue = (selectedNode as any)[input.name]; + + return ( +
+ + + {input.type === 'enum' && input.enum ? ( + + ) : input.type === 'boolean' ? ( +
+ handlePropertyChange(input.name, e.target.checked)} + className="rounded" + /> + +
+ ) : input.type === 'number' ? ( + handlePropertyChange(input.name, e.target.value ? Number(e.target.value) : undefined)} + placeholder={input.description} + /> + ) : ( + handlePropertyChange(input.name, e.target.value)} + placeholder={input.description} + /> + )} + + {input.description && ( +
{input.description}
+ )} +
+ ); + })} + + {/* Always show className editor */} +
+ + handlePropertyChange('className', e.target.value)} + placeholder="e.g., p-4 bg-white rounded" + /> +
+ Tailwind CSS utility classes +
+
+
+
+
+ ); +}; + +// Helper function to find a node by ID +function findNodeById(node: SchemaNode, id: string): SchemaNode | null { + if (node.id === id) return node; + + if (node.body) { + if (Array.isArray(node.body)) { + for (const child of node.body) { + const found = findNodeById(child, id); + if (found) return found; + } + } else if (typeof node.body === 'object') { + return findNodeById(node.body, id); + } + } + + return null; +} diff --git a/packages/designer/src/components/Toolbar.tsx b/packages/designer/src/components/Toolbar.tsx new file mode 100644 index 000000000..9a0515769 --- /dev/null +++ b/packages/designer/src/components/Toolbar.tsx @@ -0,0 +1,104 @@ +import React, { useState } from 'react'; +import { useDesigner } from '../context/DesignerContext'; +import { Button } from '@object-ui/ui'; +import type { SchemaNode } from '@object-ui/protocol'; + +interface ToolbarProps { + className?: string; +} + +export const Toolbar: React.FC = ({ className }) => { + const { schema, setSchema } = useDesigner(); + const [showJsonModal, setShowJsonModal] = useState(false); + const [jsonText, setJsonText] = useState(''); + const [jsonError, setJsonError] = useState(''); + + const handleExportJson = () => { + const json = JSON.stringify(schema, null, 2); + setJsonText(json); + setShowJsonModal(true); + setJsonError(''); + }; + + const handleImportJson = () => { + setJsonText(''); + setShowJsonModal(true); + setJsonError(''); + }; + + const handleApplyJson = () => { + try { + const parsed = JSON.parse(jsonText) as SchemaNode; + setSchema(parsed); + setShowJsonModal(false); + setJsonError(''); + } catch (error) { + setJsonError('Invalid JSON: ' + (error as Error).message); + } + }; + + const handleCopyJson = () => { + navigator.clipboard.writeText(JSON.stringify(schema, null, 2)); + alert('Schema copied to clipboard!'); + }; + + return ( + <> +
+
+

Object UI Designer

+
+ +
+ + + +
+
+ + {/* JSON Modal */} + {showJsonModal && ( +
+
+
+

Schema JSON

+ +
+ +
+