import React, { useState, useEffect, useRef } from 'react';
import { TransformWrapper, TransformComponent, ReactZoomPanPinchRef } from 'react-zoom-pan-pinch';
import DesignFrame from './DesignFrame';
import { calculateGridPosition, calculateFitToView, getGridMetrics, generateResponsiveConfig, buildHierarchyTree, calculateHierarchyPositions, getHierarchicalPosition, detectDesignRelationships } from '../utils/gridLayout';
import {
DesignFile,
CanvasState,
WebviewMessage,
ExtensionToWebviewMessage,
CanvasConfig,
ViewportMode,
FrameViewportState,
FramePositionState,
DragState,
GridPosition,
LayoutMode,
HierarchyTree,
ConnectionLine
} from '../types/canvas.types';
import ConnectionLines from './ConnectionLines';
import {
ZoomInIcon,
ZoomOutIcon,
HomeIcon,
ScaleIcon,
RefreshIcon,
GlobeIcon,
MobileIcon,
TabletIcon,
DesktopIcon,
TreeIcon,
LinkIcon
} from './Icons';
interface CanvasViewProps {
vscode: any;
nonce: string | null;
}
const CANVAS_CONFIG: CanvasConfig = {
frameSize: { width: 320, height: 400 },
gridSpacing: 50,
framesPerRow: 4,
minZoom: 0.1,
maxZoom: 5,
responsive: {
enableScaling: true,
minFrameSize: { width: 160, height: 200 },
maxFrameSize: { width: 400, height: 500 },
scaleWithZoom: false
},
viewports: {
desktop: { width: 1000, height: 600 },
tablet: { width: 640, height: 800 },
mobile: { width: 320, height: 550 }
},
hierarchy: {
horizontalSpacing: 180,
verticalSpacing: 120,
connectionLineWidth: 2,
connectionLineColor: 'var(--vscode-textLink-foreground)',
showConnections: true
}
};
const CanvasView: React.FC<CanvasViewProps> = ({ vscode, nonce }) => {
console.log('🎨 CanvasView component starting...');
console.log('📞 CanvasView props - vscode:', !!vscode, 'nonce:', nonce);
const [designFiles, setDesignFiles] = useState<DesignFile[]>([]);
const [selectedFrames, setSelectedFrames] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [currentZoom, setCurrentZoom] = useState(1);
const [currentConfig, setCurrentConfig] = useState<CanvasConfig>(CANVAS_CONFIG);
const [globalViewportMode, setGlobalViewportMode] = useState<ViewportMode>('tablet');
const [frameViewports, setFrameViewports] = useState<FrameViewportState>({});
const [useGlobalViewport, setUseGlobalViewport] = useState(true);
const [customPositions, setCustomPositions] = useState<FramePositionState>({});
const [dragState, setDragState] = useState<DragState>({
isDragging: false,
draggedFrame: null,
startPosition: { x: 0, y: 0 },
currentPosition: { x: 0, y: 0 },
offset: { x: 0, y: 0 }
});
const [layoutMode, setLayoutMode] = useState<LayoutMode>('grid');
const [hierarchyTree, setHierarchyTree] = useState<HierarchyTree | null>(null);
const [showConnections, setShowConnections] = useState(true);
const transformRef = useRef<ReactZoomPanPinchRef>(null);
console.log('✅ CanvasView state initialized successfully');
const getOptimalRenderMode = (_zoom: number): 'placeholder' | 'iframe' => {
return 'iframe';
};
const transformMouseToCanvasSpace = (clientX: number, clientY: number, canvasRect: DOMRect): GridPosition => {
const transformState = transformRef.current?.instance?.transformState;
const currentScale = transformState?.scale || 1;
const currentTranslateX = transformState?.positionX || 0;
const currentTranslateY = transformState?.positionY || 0;
const rawMouseX = clientX - canvasRect.left;
const rawMouseY = clientY - canvasRect.top;
return {
x: (rawMouseX - currentTranslateX) / currentScale,
y: (rawMouseY - currentTranslateY) / currentScale
};
};
const getFrameViewport = (fileName: string): ViewportMode => {
if (useGlobalViewport) {
return globalViewportMode;
}
return frameViewports[fileName] || 'desktop';
};
const handleFrameViewportChange = (fileName: string, viewport: ViewportMode) => {
setFrameViewports(prev => ({
...prev,
[fileName]: viewport
}));
};
const handleGlobalViewportChange = (viewport: ViewportMode) => {
setGlobalViewportMode(viewport);
if (useGlobalViewport) {
const newFrameViewports: FrameViewportState = {};
designFiles.forEach(file => {
newFrameViewports[file.name] = viewport;
});
setFrameViewports(newFrameViewports);
if (hierarchyTree && designFiles.length > 0) {
let totalWidth = 0;
let totalHeight = 0;
let frameCount = 0;
designFiles.forEach(file => {
const viewportDimensions = currentConfig.viewports[viewport];
totalWidth += viewportDimensions.width;
totalHeight += viewportDimensions.height + 50;
frameCount++;
});
const avgFrameDimensions = frameCount > 0 ? {
width: Math.round(totalWidth / frameCount),
height: Math.round(totalHeight / frameCount)
} : { width: 400, height: 550 };
const updatedTree = calculateHierarchyPositions(hierarchyTree, currentConfig, avgFrameDimensions);
setHierarchyTree(updatedTree);
}
}
};
const toggleGlobalViewport = () => {
const newUseGlobal = !useGlobalViewport;
setUseGlobalViewport(newUseGlobal);
if (newUseGlobal) {
const newFrameViewports: FrameViewportState = {};
designFiles.forEach(file => {
newFrameViewports[file.name] = globalViewportMode;
});
setFrameViewports(newFrameViewports);
}
};
useEffect(() => {
const updateConfig = () => {
const responsive = generateResponsiveConfig(CANVAS_CONFIG, window.innerWidth);
setCurrentConfig(responsive);
};
updateConfig();
window.addEventListener('resize', updateConfig);
return () => window.removeEventListener('resize', updateConfig);
}, []);
useEffect(() => {
const loadMessage: WebviewMessage = {
command: 'loadDesignFiles'
};
vscode.postMessage(loadMessage);
const messageHandler = (event: MessageEvent) => {
const message: ExtensionToWebviewMessage = event.data;
switch (message.command) {
case 'designFilesLoaded':
const filesWithDates = message.data.files.map(file => ({
...file,
modified: new Date(file.modified)
}));
const filesWithRelationships = detectDesignRelationships(filesWithDates);
setDesignFiles(filesWithRelationships);
const tree = buildHierarchyTree(filesWithRelationships);
let totalWidth = 0;
let totalHeight = 0;
let frameCount = 0;
filesWithRelationships.forEach(file => {
const frameViewport = getFrameViewport(file.name);
const viewportDimensions = currentConfig.viewports[frameViewport];
totalWidth += viewportDimensions.width;
totalHeight += viewportDimensions.height + 50;
frameCount++;
});
const avgFrameDimensions = frameCount > 0 ? {
width: Math.round(totalWidth / frameCount),
height: Math.round(totalHeight / frameCount)
} : { width: 400, height: 550 };
const positionedTree = calculateHierarchyPositions(tree, currentConfig, avgFrameDimensions);
setHierarchyTree(positionedTree);
setIsLoading(false);
setTimeout(() => {
if (transformRef.current) {
transformRef.current.resetTransform();
}
}, 100);
break;
case 'error':
setError(message.data.error);
setIsLoading(false);
break;
case 'fileChanged':
console.log('File changed:', message.data);
vscode.postMessage({ command: 'loadDesignFiles' });
break;
}
};
window.addEventListener('message', messageHandler);
return () => window.removeEventListener('message', messageHandler);
}, [vscode]);
const handleFrameSelect = (fileName: string) => {
setSelectedFrames([fileName]);
const selectedFile = designFiles.find(file => file.name === fileName);
const filePath = selectedFile ? selectedFile.path : fileName;
const selectMessage: WebviewMessage = {
command: 'selectFrame',
data: { fileName }
};
vscode.postMessage(selectMessage);
const contextMessage: WebviewMessage = {
command: 'setContextFromCanvas',
data: { fileName: filePath, type: 'frame' }
};
vscode.postMessage(contextMessage);
};
const handleSendToChat = (fileName: string, prompt: string) => {
const selectedFile = designFiles.find(file => file.name === fileName);
const filePath = selectedFile ? selectedFile.path : fileName;
const contextMessage: WebviewMessage = {
command: 'setContextFromCanvas',
data: { fileName: filePath, type: 'frame' }
};
vscode.postMessage(contextMessage);
const promptMessage: WebviewMessage = {
command: 'setChatPrompt',
data: { prompt }
};
vscode.postMessage(promptMessage);
};
const handleZoomIn = () => {
if (transformRef.current) {
const currentState = transformRef.current.instance?.transformState;
console.log('🔍 ZOOM IN - Before:', {
scale: currentState?.scale,
positionX: currentState?.positionX,
positionY: currentState?.positionY,
step: 0.05,
minScale: 0.1,
maxScale: 3,
smooth: false
});
transformRef.current.zoomIn(0.05);
setTimeout(() => {
const newState = transformRef.current?.instance?.transformState;
console.log('🔍 ZOOM IN - After:', {
scale: newState?.scale,
positionX: newState?.positionX,
positionY: newState?.positionY,
scaleDiff: newState?.scale ? (newState.scale - (currentState?.scale || 1)) : 0,
positionXDiff: newState?.positionX ? (newState.positionX - (currentState?.positionX || 0)) : 0,
positionYDiff: newState?.positionY ? (newState.positionY - (currentState?.positionY || 0)) : 0
});
}, 50);
}
};
const handleZoomOut = () => {
if (transformRef.current) {
const currentState = transformRef.current.instance?.transformState;
console.log('🔍 ZOOM OUT - Before:', {
scale: currentState?.scale,
positionX: currentState?.positionX,
positionY: currentState?.positionY,
step: 0.05
});
transformRef.current.zoomOut(0.05);
setTimeout(() => {
const newState = transformRef.current?.instance?.transformState;
console.log('🔍 ZOOM OUT - After:', {
scale: newState?.scale,
positionX: newState?.positionX,
positionY: newState?.positionY,
scaleDiff: newState?.scale ? (newState.scale - (currentState?.scale || 1)) : 0,
positionXDiff: newState?.positionX ? (newState.positionX - (currentState?.positionX || 0)) : 0,
positionYDiff: newState?.positionY ? (newState.positionY - (currentState?.positionY || 0)) : 0
});
}, 50);
}
};
const handleResetZoom = () => {
if (transformRef.current) {
const currentState = transformRef.current.instance?.transformState;
console.log('🔍 RESET ZOOM - Before:', {
scale: currentState?.scale,
positionX: currentState?.positionX,
positionY: currentState?.positionY
});
transformRef.current.resetTransform();
setTimeout(() => {
const newState = transformRef.current?.instance?.transformState;
console.log('🔍 RESET ZOOM - After:', {
scale: newState?.scale,
positionX: newState?.positionX,
positionY: newState?.positionY
});
}, 50);
}
};
const handleTransformChange = (ref: ReactZoomPanPinchRef) => {
const state = ref.state;
if (state.scale <= 0) {
console.error('🚨 INVALID SCALE DETECTED:', state.scale, '- Resetting to minimum');
ref.setTransform(state.positionX, state.positionY, 0.1);
return;
}
console.log('🔄 TRANSFORM CHANGE:', {
scale: state.scale,
positionX: state.positionX,
positionY: state.positionY,
previousScale: currentZoom
});
setCurrentZoom(state.scale);
};
const getFramePosition = (fileName: string, index: number): GridPosition => {
if (customPositions[fileName]) {
return customPositions[fileName];
}
if (layoutMode === 'hierarchy' && hierarchyTree) {
return getHierarchicalPosition(fileName, hierarchyTree);
}
const viewportMode = getFrameViewport(fileName);
const viewportDimensions = currentConfig.viewports[viewportMode];
const actualWidth = viewportDimensions.width;
const actualHeight = viewportDimensions.height + 50;
const col = index % currentConfig.framesPerRow;
const row = Math.floor(index / currentConfig.framesPerRow);
const x = col * (Math.max(actualWidth, currentConfig.frameSize.width) + currentConfig.gridSpacing);
const y = row * (Math.max(actualHeight, currentConfig.frameSize.height) + currentConfig.gridSpacing);
return { x, y };
};
const handleDragStart = (fileName: string, startPos: GridPosition, mouseEvent: React.MouseEvent) => {
const canvasGrid = document.querySelector('.canvas-grid') as HTMLElement;
if (!canvasGrid) return;
const canvasRect = canvasGrid.getBoundingClientRect();
const canvasMousePos = transformMouseToCanvasSpace(mouseEvent.clientX, mouseEvent.clientY, canvasRect);
if (!selectedFrames.includes(fileName)) {
setSelectedFrames([fileName]);
}
setDragState({
isDragging: true,
draggedFrame: fileName,
startPosition: startPos,
currentPosition: startPos,
offset: {
x: canvasMousePos.x - startPos.x,
y: canvasMousePos.y - startPos.y
}
});
};
const handleDragMove = (mousePos: GridPosition) => {
if (!dragState.isDragging || !dragState.draggedFrame) return;
const newPosition = {
x: mousePos.x - dragState.offset.x,
y: mousePos.y - dragState.offset.y
};
setDragState(prev => ({
...prev,
currentPosition: newPosition
}));
};
const handleDragEnd = () => {
if (!dragState.isDragging || !dragState.draggedFrame) return;
const gridSize = 25;
const snappedPosition = {
x: Math.round(dragState.currentPosition.x / gridSize) * gridSize,
y: Math.round(dragState.currentPosition.y / gridSize) * gridSize
};
setCustomPositions(prev => ({
...prev,
[dragState.draggedFrame!]: snappedPosition
}));
setDragState({
isDragging: false,
draggedFrame: null,
startPosition: { x: 0, y: 0 },
currentPosition: { x: 0, y: 0 },
offset: { x: 0, y: 0 }
});
};
const handleResetPositions = () => {
setCustomPositions({});
};
const updateConnectionPositions = (connections: ConnectionLine[], files: DesignFile[]): ConnectionLine[] => {
return connections.map(connection => {
const fromIndex = files.findIndex(f => f.name === connection.fromFrame);
const toIndex = files.findIndex(f => f.name === connection.toFrame);
if (fromIndex === -1 || toIndex === -1) {
return connection;
}
const fromPosition = getFramePosition(connection.fromFrame, fromIndex);
const toPosition = getFramePosition(connection.toFrame, toIndex);
const fromViewport = getFrameViewport(connection.fromFrame);
const toViewport = getFrameViewport(connection.toFrame);
const fromDimensions = currentConfig.viewports[fromViewport];
const toDimensions = currentConfig.viewports[toViewport];
const fromConnectionPoint = {
x: fromPosition.x + fromDimensions.width,
y: fromPosition.y + (fromDimensions.height + 50) / 2
};
const toConnectionPoint = {
x: toPosition.x,
y: toPosition.y + (toDimensions.height + 50) / 2
};
return {
...connection,
fromPosition: fromConnectionPoint,
toPosition: toConnectionPoint
};
});
};
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && !e.shiftKey) {
switch (e.key) {
case '=':
case '+':
e.preventDefault();
handleZoomIn();
break;
case '-':
e.preventDefault();
handleZoomOut();
break;
case '0':
e.preventDefault();
handleResetZoom();
break;
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
if (isLoading) {
return (
<div className="canvas-loading">
<div className="loading-spinner">
<div className="spinner"></div>
<p>Loading design files...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="canvas-error">
<div className="error-message">
<h3>Error loading canvas</h3>
<p>{error}</p>
<button onClick={() => window.location.reload()}>
Retry
</button>
</div>
</div>
);
}
if (designFiles.length === 0) {
return (
<div className="canvas-empty">
<div className="empty-state">
<h3>No design files found in <code>.superdesign/design_iterations/</code></h3>
<p>Prompt Superdesign OR Cursor/Windsurf/Claude Code to design UI like <kbd>Help me design a calculator UI</kbd> and preview the UI here</p>
</div>
</div>
);
}
return (
<div className="canvas-container">
{/* Canvas Controls - Clean Minimal Design */}
<div className="canvas-toolbar">
{/* Navigation Section */}
<div className="toolbar-section">
<div className="control-group">
<button className="toolbar-btn zoom-btn" onClick={handleZoomOut} title="Zoom Out (Cmd/Ctrl + -)">
<ZoomOutIcon />
</button>
<div className="zoom-display">
<span className="zoom-value">{Math.round(currentZoom * 100)}%</span>
</div>
<button className="toolbar-btn zoom-btn" onClick={handleZoomIn} title="Zoom In (Cmd/Ctrl + +)">
<ZoomInIcon />
</button>
<div className="toolbar-divider"></div>
<button className="toolbar-btn" onClick={handleResetZoom} title="Reset Zoom (Cmd/Ctrl + 0)">
<HomeIcon />
</button>
<button className="toolbar-btn" onClick={handleResetPositions} title="Reset Frame Positions">
<RefreshIcon />
</button>
</div>
</div>
{/* Layout Section */}
<div className="toolbar-section">
<div className="control-group">
<div className="layout-toggle">
<button
className={`toggle-btn ${layoutMode === 'grid' ? 'active' : ''}`}
onClick={() => setLayoutMode('grid')}
title="Grid Layout"
>
<ScaleIcon />
</button>
<button
className={`toggle-btn ${layoutMode === 'hierarchy' ? 'active' : ''}`}
onClick={() => setLayoutMode('hierarchy')}
title="Hierarchy Layout"
disabled={!hierarchyTree || hierarchyTree.nodes.size === 0}
>
<TreeIcon />
</button>
</div>
{layoutMode === 'hierarchy' && (
<button
className={`toolbar-btn connection-btn ${showConnections ? 'active' : ''}`}
onClick={() => setShowConnections(!showConnections)}
title="Toggle Connection Lines"
>
<LinkIcon />
</button>
)}
</div>
</div>
{/* Viewport Section */}
<div className="toolbar-section">
<div className="control-group">
<button
className={`toolbar-btn viewport-mode-btn ${useGlobalViewport ? 'active' : ''}`}
onClick={toggleGlobalViewport}
title="Toggle Global Viewport Mode"
>
<GlobeIcon />
</button>
<div className="viewport-selector">
<button
className={`viewport-btn ${globalViewportMode === 'mobile' && useGlobalViewport ? 'active' : ''}`}
onClick={() => handleGlobalViewportChange('mobile')}
title="Mobile View (375×667)"
disabled={!useGlobalViewport}
>
<MobileIcon />
</button>
<button
className={`viewport-btn ${globalViewportMode === 'tablet' && useGlobalViewport ? 'active' : ''}`}
onClick={() => handleGlobalViewportChange('tablet')}
title="Tablet View (768×1024)"
disabled={!useGlobalViewport}
>
<TabletIcon />
</button>
<button
className={`viewport-btn ${globalViewportMode === 'desktop' && useGlobalViewport ? 'active' : ''}`}
onClick={() => handleGlobalViewportChange('desktop')}
title="Desktop View (1200×800)"
disabled={!useGlobalViewport}
>
<DesktopIcon />
</button>
</div>
</div>
</div>
</div>
{/* Infinite Canvas */}
<TransformWrapper
ref={transformRef}
initialScale={1}
minScale={0.1} // Lower min scale to prevent negative values
maxScale={3} // Higher max scale for more zoom range
limitToBounds={false}
smooth={false} // Disable smooth for better performance
disablePadding={true} // Disable padding to prevent position jumps
doubleClick={{
disabled: false,
mode: "zoomIn",
step: 50, // Moderate double-click zoom step
animationTime: 150 // Quick double-click zoom
}}
wheel={{
wheelDisabled: true, // Disable wheel zoom
touchPadDisabled: false, // Enable trackpad pan
step: 0.05 // Even smaller zoom steps
}}
panning={{
disabled: dragState.isDragging,
velocityDisabled: true, // Disable velocity for immediate response
wheelPanning: true // Enable trackpad panning
}}
pinch={{
disabled: false, // Keep pinch zoom enabled
step: 1 // Ultra-fine pinch steps
}}
centerOnInit={true}
onTransformed={(ref) => handleTransformChange(ref)}
onZoom={(ref) => {
const state = ref.state;
// Check for invalid scale and fix it
if (state.scale <= 0) {
console.error('🚨 ZOOM EVENT - Invalid scale:', state.scale, '- Fixing...');
ref.setTransform(state.positionX, state.positionY, 0.1);
return;
}
console.log('📏 ZOOM EVENT:', {
scale: state.scale,
positionX: state.positionX,
positionY: state.positionY,
event: 'onZoom'
});
}}
onPanning={(ref) => {
console.log('👆 PAN EVENT:', {
scale: ref.state.scale,
positionX: ref.state.positionX,
positionY: ref.state.positionY,
event: 'onPanning'
});
}}
onZoomStart={(ref) => {
console.log('🔍 ZOOM START:', {
scale: ref.state.scale,
positionX: ref.state.positionX,
positionY: ref.state.positionY,
event: 'onZoomStart'
});
}}
onZoomStop={(ref) => {
console.log('🔍 ZOOM STOP:', {
scale: ref.state.scale,
positionX: ref.state.positionX,
positionY: ref.state.positionY,
event: 'onZoomStop'
});
}}
>
<TransformComponent
wrapperClass="canvas-transform-wrapper"
contentClass="canvas-transform-content"
>
<div
className={`canvas-grid ${dragState.isDragging ? 'dragging' : ''}`}
onMouseMove={(e) => {
if (dragState.isDragging) {
const rect = e.currentTarget.getBoundingClientRect();
const mousePos = transformMouseToCanvasSpace(e.clientX, e.clientY, rect);
handleDragMove(mousePos);
}
}}
onMouseUp={handleDragEnd}
onMouseLeave={handleDragEnd}
onClick={(e) => {
// Clear selection when clicking on empty space
if (e.target === e.currentTarget) {
setSelectedFrames([]);
// Also clear context in chat
const clearContextMessage: WebviewMessage = {
command: 'setContextFromCanvas',
data: { fileName: '', type: 'clear' }
};
vscode.postMessage(clearContextMessage);
}
}}
>
{/* Connection Lines (render behind frames) */}
{layoutMode === 'hierarchy' && hierarchyTree && showConnections && (
<ConnectionLines
connections={updateConnectionPositions(hierarchyTree.connections, designFiles)}
containerBounds={hierarchyTree.bounds}
isVisible={showConnections}
zoomLevel={currentZoom}
/>
)}
{designFiles.map((file, index) => {
const frameViewport = getFrameViewport(file.name);
const viewportDimensions = currentConfig.viewports[frameViewport];
// Use actual viewport dimensions (add frame border/header space)
const actualWidth = viewportDimensions.width;
const actualHeight = viewportDimensions.height + 50; // Add space for header
// Get position (custom or default grid)
const position = getFramePosition(file.name, index);
// If this frame is being dragged, use current drag position
const finalPosition = dragState.isDragging && dragState.draggedFrame === file.name
? dragState.currentPosition
: position;
return (
<DesignFrame
key={file.name}
file={file}
position={finalPosition}
dimensions={{ width: actualWidth, height: actualHeight }}
isSelected={selectedFrames.includes(file.name)}
onSelect={handleFrameSelect}
renderMode={getOptimalRenderMode(currentZoom)}
viewport={frameViewport}
viewportDimensions={viewportDimensions}
onViewportChange={handleFrameViewportChange}
useGlobalViewport={useGlobalViewport}
onDragStart={handleDragStart}
isDragging={dragState.isDragging && dragState.draggedFrame === file.name}
nonce={nonce}
onSendToChat={handleSendToChat}
/>
);
})}
</div>
</TransformComponent>
</TransformWrapper>
</div>
);
};
export default CanvasView;