Files
esengine/packages/editor-app/src/components/EditorViewport.tsx
YHH 63f006ab62 feat: 添加跨平台运行时、资产系统和UI适配功能 (#256)
* feat(platform-common): 添加WASM加载器和环境检测API

* feat(rapier2d): 新增Rapier2D WASM绑定包

* feat(physics-rapier2d): 添加跨平台WASM加载器

* feat(asset-system): 添加运行时资产目录和bundle格式

* feat(asset-system-editor): 新增编辑器资产管理包

* feat(editor-core): 添加构建系统和模块管理

* feat(editor-app): 重构浏览器预览使用import maps

* feat(platform-web): 添加BrowserRuntime和资产读取

* feat(engine): 添加材质系统和着色器管理

* feat(material): 新增材质系统和着色器编辑器

* feat(tilemap): 增强tilemap编辑器和动画系统

* feat(modules): 添加module.json配置

* feat(core): 添加module.json和类型定义更新

* chore: 更新依赖和构建配置

* refactor(plugins): 更新插件模板使用ModuleManifest

* chore: 添加第三方依赖库

* chore: 移除BehaviourTree-ai和ecs-astar子模块

* docs: 更新README和文档主题样式

* fix: 修复Rust文档测试和添加rapier2d WASM绑定

* fix(tilemap-editor): 修复画布高DPI屏幕分辨率适配问题

* feat(ui): 添加UI屏幕适配系统(CanvasScaler/SafeArea)

* fix(ecs-engine-bindgen): 添加缺失的ecs-framework-math依赖

* fix: 添加缺失的包依赖修复CI构建

* fix: 修复CodeQL检测到的代码问题

* fix: 修复构建错误和缺失依赖

* fix: 修复类型检查错误

* fix(material-system): 修复tsconfig配置支持TypeScript项目引用

* fix(editor-core): 修复Rollup构建配置添加tauri external

* fix: 修复CodeQL检测到的代码问题

* fix: 修复CodeQL检测到的代码问题
2025-12-03 22:15:22 +08:00

391 lines
13 KiB
TypeScript

/**
* EditorViewport Component
* 编辑器视口组件
*
* A reusable viewport component for editor panels that need engine rendering.
* Supports camera controls, overlays, and preview scenes.
*
* 用于需要引擎渲染的编辑器面板的可重用视口组件。
* 支持相机控制、覆盖层和预览场景。
*/
import { useEffect, useRef, useCallback, useState, forwardRef, useImperativeHandle } from 'react';
import type { ViewportCameraConfig, IViewportOverlay } from '@esengine/editor-core';
import { ViewportService } from '../services/ViewportService';
import '../styles/EditorViewport.css';
/**
* EditorViewport configuration
* 编辑器视口配置
*/
export interface EditorViewportConfig {
/** Unique viewport identifier | 唯一视口标识符 */
viewportId: string;
/** Initial camera config | 初始相机配置 */
initialCamera?: ViewportCameraConfig;
/** Whether to show grid | 是否显示网格 */
showGrid?: boolean;
/** Whether to show gizmos | 是否显示辅助线 */
showGizmos?: boolean;
/** Background clear color | 背景清除颜色 */
clearColor?: { r: number; g: number; b: number; a: number };
/** Min zoom level | 最小缩放级别 */
minZoom?: number;
/** Max zoom level | 最大缩放级别 */
maxZoom?: number;
/** Enable camera pan | 启用相机平移 */
enablePan?: boolean;
/** Enable camera zoom | 启用相机缩放 */
enableZoom?: boolean;
}
/**
* EditorViewport props
* 编辑器视口属性
*/
export interface EditorViewportProps extends EditorViewportConfig {
/** Class name for styling | 样式类名 */
className?: string;
/** Called when camera changes | 相机变化时的回调 */
onCameraChange?: (camera: ViewportCameraConfig) => void;
/** Called when viewport is ready | 视口准备就绪时的回调 */
onReady?: () => void;
/** Called on mouse down | 鼠标按下时的回调 */
onMouseDown?: (e: React.MouseEvent, worldPos: { x: number; y: number }) => void;
/** Called on mouse move | 鼠标移动时的回调 */
onMouseMove?: (e: React.MouseEvent, worldPos: { x: number; y: number }) => void;
/** Called on mouse up | 鼠标抬起时的回调 */
onMouseUp?: (e: React.MouseEvent, worldPos: { x: number; y: number }) => void;
/** Called on mouse wheel | 鼠标滚轮时的回调 */
onWheel?: (e: React.WheelEvent, worldPos: { x: number; y: number }) => void;
/** Render custom overlays | 渲染自定义覆盖层 */
renderOverlays?: () => React.ReactNode;
}
/**
* EditorViewport handle for imperative access
* 编辑器视口句柄,用于命令式访问
*/
export interface EditorViewportHandle {
/** Get current camera | 获取当前相机 */
getCamera(): ViewportCameraConfig;
/** Set camera | 设置相机 */
setCamera(camera: ViewportCameraConfig): void;
/** Reset camera to initial state | 重置相机到初始状态 */
resetCamera(): void;
/** Convert screen coordinates to world coordinates | 将屏幕坐标转换为世界坐标 */
screenToWorld(screenX: number, screenY: number): { x: number; y: number };
/** Convert world coordinates to screen coordinates | 将世界坐标转换为屏幕坐标 */
worldToScreen(worldX: number, worldY: number): { x: number; y: number };
/** Get canvas element | 获取画布元素 */
getCanvas(): HTMLCanvasElement | null;
/** Request render | 请求渲染 */
requestRender(): void;
}
/**
* EditorViewport Component
* 编辑器视口组件
*/
export const EditorViewport = forwardRef<EditorViewportHandle, EditorViewportProps>(function EditorViewport(
{
viewportId,
initialCamera = { x: 0, y: 0, zoom: 1 },
showGrid = true,
showGizmos = false,
clearColor,
minZoom = 0.1,
maxZoom = 10,
enablePan = true,
enableZoom = true,
className,
onCameraChange,
onReady,
onMouseDown,
onMouseMove,
onMouseUp,
onWheel,
renderOverlays
},
ref
) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [isReady, setIsReady] = useState(false);
// Camera state
const [camera, setCamera] = useState<ViewportCameraConfig>(initialCamera);
const cameraRef = useRef(camera);
// Drag state
const isDraggingRef = useRef(false);
const lastMousePosRef = useRef({ x: 0, y: 0 });
// Keep camera ref in sync
useEffect(() => {
cameraRef.current = camera;
}, [camera]);
// Screen to world conversion
const screenToWorld = useCallback((screenX: number, screenY: number): { x: number; y: number } => {
const canvas = canvasRef.current;
if (!canvas) return { x: 0, y: 0 };
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
// Convert to canvas pixel coordinates
const canvasX = (screenX - rect.left) * dpr;
const canvasY = (screenY - rect.top) * dpr;
// Convert to centered coordinates (Y-up)
const centeredX = canvasX - canvas.width / 2;
const centeredY = canvas.height / 2 - canvasY;
// Apply inverse zoom and add camera position
const cam = cameraRef.current;
const worldX = centeredX / cam.zoom + cam.x;
const worldY = centeredY / cam.zoom + cam.y;
return { x: worldX, y: worldY };
}, []);
// World to screen conversion
const worldToScreen = useCallback((worldX: number, worldY: number): { x: number; y: number } => {
const canvas = canvasRef.current;
if (!canvas) return { x: 0, y: 0 };
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const cam = cameraRef.current;
// Apply camera transform
const centeredX = (worldX - cam.x) * cam.zoom;
const centeredY = (worldY - cam.y) * cam.zoom;
// Convert from centered coordinates
const canvasX = centeredX + canvas.width / 2;
const canvasY = canvas.height / 2 - centeredY;
// Convert to screen coordinates
const screenX = canvasX / dpr + rect.left;
const screenY = canvasY / dpr + rect.top;
return { x: screenX, y: screenY };
}, []);
// Request render
const requestRender = useCallback(() => {
const viewportService = ViewportService.getInstance();
if (viewportService.isInitialized()) {
viewportService.renderToViewport(viewportId);
}
}, [viewportId]);
// Expose imperative handle
useImperativeHandle(ref, () => ({
getCamera: () => cameraRef.current,
setCamera: (newCamera: ViewportCameraConfig) => {
setCamera(newCamera);
onCameraChange?.(newCamera);
},
resetCamera: () => {
setCamera(initialCamera);
onCameraChange?.(initialCamera);
},
screenToWorld,
worldToScreen,
getCanvas: () => canvasRef.current,
requestRender
}), [initialCamera, screenToWorld, worldToScreen, onCameraChange, requestRender]);
// Initialize viewport
useEffect(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
const canvasId = `editor-viewport-canvas-${viewportId}`;
canvas.id = canvasId;
const viewportService = ViewportService.getInstance();
// Wait for service to be initialized
const checkInit = () => {
if (viewportService.isInitialized()) {
// Register viewport
viewportService.registerViewport(viewportId, canvasId);
viewportService.setViewportConfig(viewportId, showGrid, showGizmos);
viewportService.setViewportCamera(viewportId, camera);
setIsReady(true);
onReady?.();
} else {
// Retry after a short delay
setTimeout(checkInit, 100);
}
};
checkInit();
return () => {
if (viewportService.isInitialized()) {
viewportService.unregisterViewport(viewportId);
}
};
}, [viewportId]);
// Update viewport config when props change
useEffect(() => {
if (!isReady) return;
const viewportService = ViewportService.getInstance();
if (viewportService.isInitialized()) {
viewportService.setViewportConfig(viewportId, showGrid, showGizmos);
}
}, [viewportId, showGrid, showGizmos, isReady]);
// Sync camera to viewport service
useEffect(() => {
if (!isReady) return;
const viewportService = ViewportService.getInstance();
if (viewportService.isInitialized()) {
viewportService.setViewportCamera(viewportId, camera);
}
}, [viewportId, camera, isReady]);
// Handle resize
useEffect(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
const resizeCanvas = () => {
const rect = container.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
canvas.style.width = `${rect.width}px`;
canvas.style.height = `${rect.height}px`;
if (isReady) {
const viewportService = ViewportService.getInstance();
if (viewportService.isInitialized()) {
viewportService.resizeViewport(viewportId, canvas.width, canvas.height);
}
}
};
resizeCanvas();
let rafId: number | null = null;
const resizeObserver = new ResizeObserver(() => {
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
rafId = requestAnimationFrame(() => {
resizeCanvas();
rafId = null;
});
});
resizeObserver.observe(container);
return () => {
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
resizeObserver.disconnect();
};
}, [viewportId, isReady]);
// Mouse handlers
const handleMouseDown = useCallback((e: React.MouseEvent) => {
const worldPos = screenToWorld(e.clientX, e.clientY);
// Middle or right button for camera pan
if (enablePan && (e.button === 1 || e.button === 2)) {
isDraggingRef.current = true;
lastMousePosRef.current = { x: e.clientX, y: e.clientY };
e.preventDefault();
}
onMouseDown?.(e, worldPos);
}, [enablePan, screenToWorld, onMouseDown]);
const handleMouseMove = useCallback((e: React.MouseEvent) => {
const worldPos = screenToWorld(e.clientX, e.clientY);
if (isDraggingRef.current && enablePan) {
const deltaX = e.clientX - lastMousePosRef.current.x;
const deltaY = e.clientY - lastMousePosRef.current.y;
const dpr = window.devicePixelRatio || 1;
setCamera(prev => {
const newCamera = {
...prev,
x: prev.x - (deltaX * dpr) / prev.zoom,
y: prev.y + (deltaY * dpr) / prev.zoom
};
onCameraChange?.(newCamera);
return newCamera;
});
lastMousePosRef.current = { x: e.clientX, y: e.clientY };
}
onMouseMove?.(e, worldPos);
}, [enablePan, screenToWorld, onMouseMove, onCameraChange]);
const handleMouseUp = useCallback((e: React.MouseEvent) => {
const worldPos = screenToWorld(e.clientX, e.clientY);
isDraggingRef.current = false;
onMouseUp?.(e, worldPos);
}, [screenToWorld, onMouseUp]);
const handleWheel = useCallback((e: React.WheelEvent) => {
const worldPos = screenToWorld(e.clientX, e.clientY);
if (enableZoom) {
e.preventDefault();
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
setCamera(prev => {
const newZoom = Math.max(minZoom, Math.min(maxZoom, prev.zoom * zoomFactor));
const newCamera = { ...prev, zoom: newZoom };
onCameraChange?.(newCamera);
return newCamera;
});
}
onWheel?.(e, worldPos);
}, [enableZoom, minZoom, maxZoom, screenToWorld, onWheel, onCameraChange]);
const handleContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault();
}, []);
return (
<div
ref={containerRef}
className={`editor-viewport ${className || ''}`}
>
<canvas
ref={canvasRef}
className="editor-viewport-canvas"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onWheel={handleWheel}
onContextMenu={handleContextMenu}
/>
{renderOverlays?.()}
</div>
);
});
export default EditorViewport;