Skip to content

Commit 39b03a1

Browse files
Copilothuangyiirene
andcommitted
Add CanvasDesigner mode with free-form absolute positioning
Co-authored-by: huangyiirene <7665279+huangyiirene@users.noreply.github.com>
1 parent ecfec4d commit 39b03a1

File tree

7 files changed

+524
-7
lines changed

7 files changed

+524
-7
lines changed

examples/designer-modes/src/App.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ function App() {
2525
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'
2626
}`}
2727
>
28-
Form Designer
28+
Form
2929
</button>
3030
<button
3131
onClick={() => setMode('layout')}
@@ -35,7 +35,17 @@ function App() {
3535
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'
3636
}`}
3737
>
38-
Layout Designer
38+
Layout
39+
</button>
40+
<button
41+
onClick={() => setMode('canvas')}
42+
className={`px-4 py-2 rounded-lg font-medium transition-all ${
43+
mode === 'canvas'
44+
? 'bg-amber-500 text-white shadow-lg'
45+
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'
46+
}`}
47+
>
48+
Canvas
3949
</button>
4050
<button
4151
onClick={() => setMode('general')}
@@ -45,7 +55,7 @@ function App() {
4555
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'
4656
}`}
4757
>
48-
General Designer
58+
General
4959
</button>
5060
</div>
5161
</div>
Lines changed: 345 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,345 @@
1+
/**
2+
* CanvasDesigner - Free-form canvas designer with absolute positioning
3+
*
4+
* This component provides a canvas-like interface where components can be:
5+
* - Dragged to any position (absolute positioning)
6+
* - Resized freely
7+
* - Positioned with x,y coordinates
8+
* - Arranged on a visual canvas
9+
*/
10+
11+
import React, { useCallback, useState, useRef } from 'react';
12+
import { DesignerProvider } from '../context/DesignerContext';
13+
import { PropertyPanel } from './PropertyPanel';
14+
import { FilteredComponentPalette } from './FilteredComponentPalette';
15+
import { ComponentTree } from './ComponentTree';
16+
import { useDesigner } from '../context/DesignerContext';
17+
import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts';
18+
import type { SchemaNode } from '@object-ui/core';
19+
import type { CanvasDesignerConfig } from '../types/designer-modes';
20+
import { SchemaRenderer } from '@object-ui/react';
21+
import { ResizeHandles } from './ResizeHandle';
22+
import { ComponentRegistry } from '@object-ui/core';
23+
import { cn } from '@object-ui/components';
24+
25+
interface CanvasDesignerProps {
26+
initialSchema?: SchemaNode;
27+
onSchemaChange?: (schema: SchemaNode) => void;
28+
config?: Partial<CanvasDesignerConfig>;
29+
}
30+
31+
// Canvas-specific component categories (all visual components)
32+
const CANVAS_CATEGORIES = {
33+
'Basic': ['div', 'card', 'text', 'button', 'image'],
34+
'Form': ['input', 'textarea', 'select', 'checkbox', 'switch'],
35+
'Layout': ['stack', 'grid'],
36+
'Display': ['badge', 'avatar', 'separator']
37+
};
38+
39+
// Allowed components for canvas designer (all visual components)
40+
const CANVAS_COMPONENTS = [
41+
'div', 'card', 'text', 'span', 'button', 'image',
42+
'input', 'textarea', 'select', 'checkbox', 'switch', 'label',
43+
'stack', 'grid', 'separator',
44+
'badge', 'avatar'
45+
];
46+
47+
const FreeFormCanvas: React.FC<{ showGrid?: boolean; gridSize?: number }> = ({
48+
showGrid = true,
49+
gridSize = 20
50+
}) => {
51+
const {
52+
schema,
53+
selectedNodeId,
54+
setSelectedNodeId,
55+
draggingType,
56+
setDraggingType,
57+
addNode,
58+
updateNode,
59+
resizingNode,
60+
setResizingNode,
61+
} = useDesigner();
62+
63+
const canvasRef = useRef<HTMLDivElement>(null);
64+
const [draggedNode, setDraggedNode] = useState<{ id: string; startX: number; startY: number; offsetX: number; offsetY: number } | null>(null);
65+
66+
// Handle drop from palette
67+
const handleDrop = useCallback((e: React.DragEvent) => {
68+
e.preventDefault();
69+
70+
if (!canvasRef.current || !draggingType) return;
71+
72+
const rect = canvasRef.current.getBoundingClientRect();
73+
const x = e.clientX - rect.left;
74+
const y = e.clientY - rect.top;
75+
76+
// Snap to grid if enabled
77+
const snappedX = showGrid ? Math.round(x / gridSize) * gridSize : x;
78+
const snappedY = showGrid ? Math.round(y / gridSize) * gridSize : y;
79+
80+
// Create new node with absolute positioning
81+
const config = ComponentRegistry.getConfig(draggingType);
82+
const newNode: SchemaNode = {
83+
type: draggingType,
84+
...(config?.defaultProps || {}),
85+
style: {
86+
position: 'absolute',
87+
left: `${snappedX}px`,
88+
top: `${snappedY}px`,
89+
...(config?.defaultProps?.style || {}),
90+
},
91+
};
92+
93+
// Add to root
94+
addNode(schema.id || null, newNode);
95+
setDraggingType(null);
96+
}, [draggingType, schema.id, addNode, setDraggingType, showGrid, gridSize]);
97+
98+
const handleDragOver = useCallback((e: React.DragEvent) => {
99+
e.preventDefault();
100+
e.dataTransfer.dropEffect = 'copy';
101+
}, []);
102+
103+
// Handle canvas component drag start
104+
const handleCanvasDragStart = useCallback((e: React.MouseEvent, nodeId: string) => {
105+
const target = e.currentTarget as HTMLElement;
106+
const rect = target.getBoundingClientRect();
107+
108+
setDraggedNode({
109+
id: nodeId,
110+
startX: e.clientX,
111+
startY: e.clientY,
112+
offsetX: e.clientX - rect.left,
113+
offsetY: e.clientY - rect.top,
114+
});
115+
}, []);
116+
117+
// Handle canvas component drag
118+
const handleCanvasDrag = useCallback((e: MouseEvent) => {
119+
if (!draggedNode || !canvasRef.current) return;
120+
121+
const rect = canvasRef.current.getBoundingClientRect();
122+
const x = e.clientX - rect.left - draggedNode.offsetX;
123+
const y = e.clientY - rect.top - draggedNode.offsetY;
124+
125+
// Snap to grid
126+
const snappedX = showGrid ? Math.round(x / gridSize) * gridSize : x;
127+
const snappedY = showGrid ? Math.round(y / gridSize) * gridSize : y;
128+
129+
// Update node position
130+
updateNode(draggedNode.id, {
131+
style: {
132+
position: 'absolute',
133+
left: `${snappedX}px`,
134+
top: `${snappedY}px`,
135+
},
136+
});
137+
}, [draggedNode, updateNode, showGrid, gridSize]);
138+
139+
// Setup mouse move/up listeners
140+
React.useEffect(() => {
141+
if (!draggedNode) return;
142+
143+
const handleMouseMove = (e: MouseEvent) => handleCanvasDrag(e);
144+
const handleMouseUp = () => setDraggedNode(null);
145+
146+
document.addEventListener('mousemove', handleMouseMove);
147+
document.addEventListener('mouseup', handleMouseUp);
148+
149+
return () => {
150+
document.removeEventListener('mousemove', handleMouseMove);
151+
document.removeEventListener('mouseup', handleMouseUp);
152+
};
153+
}, [draggedNode, handleCanvasDrag]);
154+
155+
// Render canvas children with absolute positioning
156+
const renderCanvasChildren = (nodes: SchemaNode[]) => {
157+
if (!Array.isArray(nodes)) return null;
158+
159+
return nodes.map((node) => {
160+
const isSelected = node.id === selectedNodeId;
161+
const isResizable = ComponentRegistry.getConfig(node.type)?.resizable || false;
162+
163+
return (
164+
<div
165+
key={node.id}
166+
className={cn(
167+
'canvas-node',
168+
isSelected && 'ring-2 ring-blue-500',
169+
'cursor-move'
170+
)}
171+
style={{
172+
...(node.style as React.CSSProperties),
173+
position: 'absolute',
174+
}}
175+
onClick={(e) => {
176+
e.stopPropagation();
177+
setSelectedNodeId(node.id || null);
178+
}}
179+
onMouseDown={(e) => {
180+
e.preventDefault();
181+
if (node.id) handleCanvasDragStart(e, node.id);
182+
}}
183+
>
184+
<SchemaRenderer schema={node} />
185+
186+
{/* Resize handles for selected node */}
187+
{isSelected && isResizable && (
188+
<ResizeHandles
189+
nodeId={node.id || ''}
190+
onResizeStart={(direction) => {
191+
const element = document.querySelector(`[data-obj-id="${node.id}"]`) as HTMLElement;
192+
if (element) {
193+
setResizingNode({
194+
nodeId: node.id || '',
195+
direction,
196+
startX: 0,
197+
startY: 0,
198+
startWidth: element.offsetWidth,
199+
startHeight: element.offsetHeight,
200+
});
201+
}
202+
}}
203+
/>
204+
)}
205+
</div>
206+
);
207+
});
208+
};
209+
210+
return (
211+
<div
212+
ref={canvasRef}
213+
className="relative w-full h-full overflow-auto bg-white"
214+
style={{
215+
backgroundImage: showGrid
216+
? `
217+
linear-gradient(to right, #e5e7eb 1px, transparent 1px),
218+
linear-gradient(to bottom, #e5e7eb 1px, transparent 1px)
219+
`
220+
: undefined,
221+
backgroundSize: showGrid ? `${gridSize}px ${gridSize}px` : undefined,
222+
}}
223+
onDrop={handleDrop}
224+
onDragOver={handleDragOver}
225+
onClick={() => setSelectedNodeId(null)}
226+
>
227+
{/* Canvas content */}
228+
{schema.body && Array.isArray(schema.body) && renderCanvasChildren(schema.body)}
229+
230+
{/* Empty state */}
231+
{(!schema.body || (Array.isArray(schema.body) && schema.body.length === 0)) && (
232+
<div className="absolute inset-0 flex items-center justify-center text-gray-400">
233+
<div className="text-center">
234+
<p className="text-lg font-medium">Free-form Canvas</p>
235+
<p className="text-sm mt-2">Drag components here to position them freely</p>
236+
</div>
237+
</div>
238+
)}
239+
</div>
240+
);
241+
};
242+
243+
export const CanvasDesignerContent: React.FC<{ config?: Partial<CanvasDesignerConfig> }> = ({ config }) => {
244+
const {
245+
undo,
246+
redo,
247+
copyNode,
248+
cutNode,
249+
duplicateNode,
250+
pasteNode,
251+
removeNode,
252+
selectedNodeId,
253+
canUndo,
254+
canRedo
255+
} = useDesigner();
256+
257+
// Use shared keyboard shortcuts hook
258+
useKeyboardShortcuts({
259+
undo,
260+
redo,
261+
copyNode,
262+
cutNode,
263+
duplicateNode,
264+
pasteNode,
265+
removeNode,
266+
selectedNodeId,
267+
canUndo,
268+
canRedo,
269+
});
270+
271+
return (
272+
<div className="h-full flex flex-col bg-white text-gray-900 font-sans">
273+
{/* Header */}
274+
<div className="h-12 border-b border-gray-200 bg-gradient-to-r from-amber-50 to-orange-50 flex items-center px-4">
275+
<h1 className="text-sm font-bold text-transparent bg-clip-text bg-gradient-to-r from-amber-600 to-orange-600">
276+
Canvas Designer
277+
</h1>
278+
<div className="ml-4 text-xs text-gray-500">
279+
Free-form design with absolute positioning
280+
</div>
281+
</div>
282+
283+
<div className="flex-1 flex overflow-hidden">
284+
{/* Left Sidebar - Canvas Components & Component Tree */}
285+
<div className="w-64 md:w-72 flex-shrink-0 z-10 shadow-[1px_0_5px_rgba(0,0,0,0.03)] h-full flex flex-col border-r border-gray-200">
286+
{/* Component Palette */}
287+
<div className="flex-1 min-h-0 overflow-hidden">
288+
<FilteredComponentPalette
289+
className="h-full"
290+
allowedComponents={CANVAS_COMPONENTS}
291+
categories={CANVAS_CATEGORIES}
292+
title="Canvas Components"
293+
/>
294+
</div>
295+
296+
{/* Component Tree */}
297+
<div className="h-64 border-t border-gray-200 overflow-hidden">
298+
<ComponentTree className="h-full" />
299+
</div>
300+
</div>
301+
302+
{/* Main Canvas Area */}
303+
<div className="flex-1 relative bg-gray-50 z-0 min-w-0">
304+
<FreeFormCanvas
305+
showGrid={config?.showGrid !== false}
306+
gridSize={config?.gridSize || 20}
307+
/>
308+
</div>
309+
310+
{/* Right Sidebar - Property Panel */}
311+
<div className="w-72 md:w-80 flex-shrink-0 z-10 shadow-[-1px_0_5px_rgba(0,0,0,0.03)] h-full">
312+
<PropertyPanel className="h-full border-l-0 shadow-none border-l custom-scrollbar" />
313+
</div>
314+
</div>
315+
</div>
316+
);
317+
};
318+
319+
export const CanvasDesigner: React.FC<CanvasDesignerProps> = ({
320+
initialSchema,
321+
onSchemaChange,
322+
config
323+
}) => {
324+
// Default initial schema for canvas with absolute positioning root
325+
const defaultCanvasSchema: SchemaNode = {
326+
type: 'div',
327+
className: 'relative',
328+
style: {
329+
width: '100%',
330+
height: '100%',
331+
minHeight: '600px',
332+
},
333+
id: 'canvas-root',
334+
body: []
335+
};
336+
337+
return (
338+
<DesignerProvider
339+
initialSchema={initialSchema || defaultCanvasSchema}
340+
onSchemaChange={onSchemaChange}
341+
>
342+
<CanvasDesignerContent config={config} />
343+
</DesignerProvider>
344+
);
345+
};

0 commit comments

Comments
 (0)