引入hasOpenedFile状态标记,用于跟踪用户是否已打开或操作过文件 - 修改欢迎界面显示逻辑,仅在无内容且未打开文件时显示 - 调整保存/另存为/新建场景等操作的权限判断 - 更新标题栏显示逻辑,支持已打开文件但无内容的场景 - 在文件拖放、加载等操作时自动标记文件打开状态
1515 lines
43 KiB
Vue
1515 lines
43 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* CanvasView Component
|
||
* WebGPU canvas component for rendering and interaction
|
||
*/
|
||
import { ref, onMounted, onUnmounted, watch } from 'vue';
|
||
import { Renderer } from '../core/renderer';
|
||
import { getSceneManager } from '../core/scene';
|
||
import { getInputHandler } from '../core/input';
|
||
import { getHistoryManager } from '../core/history';
|
||
import { getSceneService } from '../utils/scene-service';
|
||
import type { MediaElement, RenderElement, ImageData, GifData, VideoData, TextData } from '../types';
|
||
import { loadImageFromBase64 } from '../utils/image';
|
||
import { GifAnimator, type GifData as GifDataUtil } from '../utils/gif';
|
||
import { VideoController, createVideoFromUrl } from '../utils/video';
|
||
import { renderTextToCanvas } from '../utils/text';
|
||
|
||
// ============================================
|
||
// Props & Emits
|
||
// ============================================
|
||
|
||
const props = defineProps<{
|
||
width?: number;
|
||
height?: number;
|
||
}>();
|
||
|
||
const emit = defineEmits<{
|
||
(e: 'element-selected', id: string): void;
|
||
(e: 'element-moved', id: string, x: number, y: number): void;
|
||
(e: 'element-resized', id: string, width: number, height: number): void;
|
||
(e: 'context-menu', x: number, y: number): void;
|
||
(e: 'file-drop', files: File[]): void;
|
||
(e: 'webgpu-error', message: string): void;
|
||
(e: 'initialized'): void;
|
||
}>();
|
||
|
||
// ============================================
|
||
// Refs
|
||
// ============================================
|
||
|
||
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||
const containerRef = ref<HTMLDivElement | null>(null);
|
||
const isInitialized = ref(false);
|
||
const webgpuError = ref<string | null>(null);
|
||
|
||
// ============================================
|
||
// Core Instances
|
||
// ============================================
|
||
|
||
let renderer: Renderer | null = null;
|
||
const sceneManager = getSceneManager();
|
||
const inputHandler = getInputHandler();
|
||
const historyManager = getHistoryManager();
|
||
const sceneService = getSceneService();
|
||
|
||
// ============================================
|
||
// Media Element Texture Management
|
||
// ============================================
|
||
|
||
// Map to track GIF animators for each GIF element
|
||
const gifAnimators: Map<string, GifAnimator> = new Map();
|
||
|
||
// Map to track video controllers for each video element
|
||
const videoControllers: Map<string, VideoController> = new Map();
|
||
|
||
// Map to track loaded textures for elements
|
||
const elementTextures: Map<string, GPUTexture> = new Map();
|
||
|
||
// Map to track video elements for texture updates
|
||
const videoElements: Map<string, HTMLVideoElement> = new Map();
|
||
|
||
// Map to track text texture scale factors
|
||
const textTextureScales: Map<string, number> = new Map();
|
||
|
||
// ============================================
|
||
// Element Resize State
|
||
// ============================================
|
||
|
||
// Edge detection threshold in pixels
|
||
const EDGE_THRESHOLD = 10;
|
||
|
||
// Resize handle types
|
||
type ResizeHandle = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'top' | 'bottom' | 'left' | 'right' | null;
|
||
|
||
// Current resize state (supports both single and multi-selection)
|
||
let resizeState: {
|
||
isMultiSelect: boolean;
|
||
elementId: string; // For single selection
|
||
handle: ResizeHandle;
|
||
startX: number;
|
||
startY: number;
|
||
// For single element resize
|
||
startWidth: number;
|
||
startHeight: number;
|
||
startElementX: number;
|
||
startElementY: number;
|
||
aspectRatio: number;
|
||
// For multi-selection resize
|
||
startBounds: { x: number; y: number; width: number; height: number } | null;
|
||
accumulatedDeltaX: number;
|
||
accumulatedDeltaY: number;
|
||
} | null = null;
|
||
|
||
// Track accumulated delta for resize operations
|
||
let resizeAccumulatedX = 0;
|
||
let resizeAccumulatedY = 0;
|
||
|
||
// Store initial positions for multi-selection resize
|
||
let multiResizeStartPositions: Array<{
|
||
id: string;
|
||
x: number;
|
||
y: number;
|
||
width: number;
|
||
height: number;
|
||
}> = [];
|
||
|
||
// ============================================
|
||
// Initialization
|
||
// ============================================
|
||
|
||
async function initWebGPU() {
|
||
if (!canvasRef.value) return;
|
||
|
||
try {
|
||
// Check WebGPU support
|
||
const supported = await Renderer.isSupported();
|
||
if (!supported) {
|
||
webgpuError.value = 'WebGPU is not supported. Please update your browser or graphics driver.';
|
||
emit('webgpu-error', webgpuError.value);
|
||
return;
|
||
}
|
||
|
||
// Create and initialize renderer
|
||
renderer = new Renderer();
|
||
renderer.setOnError((error) => {
|
||
webgpuError.value = error.message;
|
||
emit('webgpu-error', error.message);
|
||
});
|
||
|
||
await renderer.init(canvasRef.value);
|
||
|
||
// Set initial size
|
||
updateCanvasSize();
|
||
|
||
// Start render loop
|
||
renderer.startRenderLoop();
|
||
|
||
// Attach input handler
|
||
inputHandler.attach(canvasRef.value);
|
||
setupInputHandlers();
|
||
|
||
isInitialized.value = true;
|
||
emit('initialized');
|
||
|
||
console.log('CanvasView initialized');
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : 'Failed to initialize WebGPU';
|
||
webgpuError.value = message;
|
||
emit('webgpu-error', message);
|
||
}
|
||
}
|
||
|
||
// ============================================
|
||
// Canvas Size Management
|
||
// ============================================
|
||
|
||
function updateCanvasSize() {
|
||
if (!canvasRef.value || !containerRef.value || !renderer) return;
|
||
|
||
const rect = containerRef.value.getBoundingClientRect();
|
||
const width = Math.floor(props.width || rect.width || window.innerWidth);
|
||
const height = Math.floor(props.height || rect.height || window.innerHeight);
|
||
|
||
// Set CSS size to match container exactly
|
||
canvasRef.value.style.width = `${width}px`;
|
||
canvasRef.value.style.height = `${height}px`;
|
||
|
||
// Set actual canvas pixel size (for WebGPU rendering)
|
||
renderer.resize(width, height);
|
||
}
|
||
|
||
// ResizeObserver for responsive canvas
|
||
let resizeObserver: ResizeObserver | null = null;
|
||
let resizeRAF: number | null = null;
|
||
|
||
function setupResizeObserver() {
|
||
if (!containerRef.value) return;
|
||
|
||
// Use ResizeObserver for container size changes
|
||
resizeObserver = new ResizeObserver(() => {
|
||
// Use requestAnimationFrame to batch resize updates
|
||
if (resizeRAF !== null) {
|
||
cancelAnimationFrame(resizeRAF);
|
||
}
|
||
resizeRAF = requestAnimationFrame(() => {
|
||
updateCanvasSize();
|
||
resizeRAF = null;
|
||
});
|
||
});
|
||
resizeObserver.observe(containerRef.value);
|
||
|
||
// Also listen to window resize for immediate response
|
||
window.addEventListener('resize', handleWindowResize);
|
||
}
|
||
|
||
function handleWindowResize() {
|
||
// Immediate update on window resize
|
||
updateCanvasSize();
|
||
}
|
||
|
||
// ============================================
|
||
// Input Handlers
|
||
// ============================================
|
||
|
||
function setupInputHandlers() {
|
||
inputHandler.onAction((action, data) => {
|
||
handleInputAction(action, data);
|
||
});
|
||
}
|
||
|
||
function handleInputAction(action: string, data: unknown) {
|
||
const d = data as Record<string, unknown>;
|
||
|
||
switch (action) {
|
||
// Click handling
|
||
case 'click':
|
||
handleClick(d.x as number, d.y as number, d.shift as boolean, d.ctrl as boolean);
|
||
break;
|
||
|
||
// Drag operations
|
||
case 'dragStart':
|
||
handleDragStart(d.x as number, d.y as number, d.shift as boolean);
|
||
break;
|
||
|
||
// Pan operations
|
||
case 'pan':
|
||
handlePan(d.deltaX as number, d.deltaY as number);
|
||
break;
|
||
|
||
// Zoom operations
|
||
case 'zoom':
|
||
handleZoom(d.delta as number, d.x as number, d.y as number);
|
||
break;
|
||
case 'zoomDrag':
|
||
handleZoomDrag(d.deltaX as number, d.x as number, d.y as number);
|
||
break;
|
||
|
||
// Element operations
|
||
case 'elementDrag':
|
||
handleElementDrag(d.deltaX as number, d.deltaY as number);
|
||
break;
|
||
case 'elementDragEnd':
|
||
handleElementDragEnd();
|
||
break;
|
||
|
||
// Element resize operations
|
||
case 'elementResize':
|
||
handleElementResize(d.deltaX as number, d.deltaY as number);
|
||
break;
|
||
case 'elementResizeEnd':
|
||
handleElementResizeEnd();
|
||
break;
|
||
|
||
// Selection operations
|
||
case 'selectionUpdate':
|
||
handleSelectionUpdate(d as { startX: number; startY: number; width: number; height: number });
|
||
break;
|
||
case 'selectionEnd':
|
||
handleSelectionEnd(d as { startX: number; startY: number; width: number; height: number });
|
||
break;
|
||
|
||
// Context menu
|
||
case 'contextMenu':
|
||
emit('context-menu', d.x as number, d.y as number);
|
||
break;
|
||
|
||
// Mouse move for cursor updates
|
||
case 'mouseMove':
|
||
updateResizeCursor(d.x as number, d.y as number);
|
||
break;
|
||
|
||
// Keyboard shortcuts
|
||
case 'undo':
|
||
handleUndo();
|
||
break;
|
||
case 'redo':
|
||
handleRedo();
|
||
break;
|
||
case 'delete':
|
||
handleDelete();
|
||
break;
|
||
case 'selectAll':
|
||
sceneManager.selectAll();
|
||
syncRendererElements();
|
||
break;
|
||
case 'deselect':
|
||
sceneManager.deselectAll();
|
||
syncRendererElements();
|
||
break;
|
||
}
|
||
}
|
||
|
||
// ============================================
|
||
// Click Handling
|
||
// ============================================
|
||
|
||
function handleClick(x: number, y: number, shift: boolean, _ctrl: boolean) {
|
||
console.log('handleClick called at:', x, y);
|
||
const element = sceneManager.getElementAtPoint(x, y);
|
||
|
||
if (element) {
|
||
// Record state for undo
|
||
recordState();
|
||
|
||
if (shift) {
|
||
sceneManager.toggleSelection(element.id);
|
||
} else {
|
||
sceneManager.selectElement(element.id);
|
||
}
|
||
|
||
// Bring selected element to front
|
||
sceneManager.bringToFront();
|
||
|
||
emit('element-selected', element.id);
|
||
} else {
|
||
console.log('handleClick - no element at point, deselecting all');
|
||
sceneManager.deselectAll();
|
||
}
|
||
|
||
syncRendererElements();
|
||
}
|
||
|
||
// ============================================
|
||
// Drag Handling
|
||
// ============================================
|
||
|
||
function handleDragStart(x: number, y: number, shift: boolean) {
|
||
console.log('handleDragStart at:', x, y, 'selected elements:', sceneManager.selectedElements.value.length);
|
||
|
||
const selectedElements = sceneManager.selectedElements.value;
|
||
|
||
// Check for multi-selection resize first
|
||
if (selectedElements.length > 1) {
|
||
const bounds = sceneManager.getSelectionBounds();
|
||
if (bounds) {
|
||
const handle = detectResizeHandleOnBounds(x, y, bounds);
|
||
if (handle) {
|
||
console.log('Starting multi-select resize with handle:', handle);
|
||
startMultiResize(bounds, handle, x, y);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Check for single element resize
|
||
for (const element of selectedElements) {
|
||
const handle = detectResizeHandle(x, y, element);
|
||
console.log('Checking resize handle for element:', element.id, 'handle:', handle);
|
||
if (handle) {
|
||
console.log('Starting resize with handle:', handle);
|
||
startResize(element, handle, x, y);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Not resizing, check for element at point
|
||
const element = sceneManager.getElementAtPoint(x, y);
|
||
|
||
if (element) {
|
||
// Start element dragging
|
||
if (!element.selected) {
|
||
if (!shift) {
|
||
sceneManager.deselectAll();
|
||
}
|
||
sceneManager.selectElement(element.id, shift);
|
||
}
|
||
|
||
// Bring selected element(s) to front when starting drag
|
||
sceneManager.bringToFront();
|
||
|
||
inputHandler.startDragging();
|
||
recordState();
|
||
} else {
|
||
// Start selection box
|
||
inputHandler.startSelection(x, y);
|
||
}
|
||
|
||
syncRendererElements();
|
||
}
|
||
|
||
function handleElementDrag(deltaX: number, deltaY: number) {
|
||
const scale = sceneManager.viewTransform.value.scale;
|
||
sceneManager.moveSelected(deltaX / scale, deltaY / scale);
|
||
syncRendererElements();
|
||
}
|
||
|
||
function handleElementDragEnd() {
|
||
// Element positions already updated
|
||
sceneService.markDirty();
|
||
syncRendererElements();
|
||
}
|
||
|
||
// ============================================
|
||
// Selection Box Handling
|
||
// ============================================
|
||
|
||
function handleSelectionUpdate(rect: { startX: number; startY: number; width: number; height: number }) {
|
||
// Convert screen coordinates to world coordinates
|
||
const startWorld = sceneManager.screenToWorld(rect.startX, rect.startY);
|
||
const endWorld = sceneManager.screenToWorld(rect.startX + rect.width, rect.startY + rect.height);
|
||
|
||
// Calculate world rect - ensure we use the correct corner as origin
|
||
const worldX = Math.min(startWorld.x, endWorld.x);
|
||
const worldY = Math.min(startWorld.y, endWorld.y);
|
||
const worldWidth = Math.abs(endWorld.x - startWorld.x);
|
||
const worldHeight = Math.abs(endWorld.y - startWorld.y);
|
||
|
||
// Debug logging
|
||
console.log('Selection rect:', { worldX, worldY, worldWidth, worldHeight });
|
||
const elements = sceneManager.getAllElements();
|
||
elements.forEach(el => {
|
||
console.log('Element:', el.id, { x: el.x, y: el.y, width: el.width, height: el.height });
|
||
});
|
||
|
||
sceneManager.selectInRect(worldX, worldY, worldWidth, worldHeight);
|
||
syncRendererElements();
|
||
}
|
||
|
||
function handleSelectionEnd(rect: { startX: number; startY: number; width: number; height: number }) {
|
||
handleSelectionUpdate(rect);
|
||
}
|
||
|
||
// ============================================
|
||
// Pan & Zoom Handling
|
||
// ============================================
|
||
|
||
function handlePan(deltaX: number, deltaY: number) {
|
||
console.log('handlePan:', deltaX, deltaY);
|
||
sceneManager.pan(deltaX, deltaY);
|
||
syncViewTransform();
|
||
}
|
||
|
||
function handleZoom(delta: number, x: number, y: number) {
|
||
sceneManager.zoom(delta, x, y);
|
||
syncViewTransform();
|
||
// Update text textures if zoom level increased significantly
|
||
updateTextTexturesForZoom();
|
||
}
|
||
|
||
function handleZoomDrag(deltaX: number, x: number, y: number) {
|
||
const zoomDelta = deltaX * 0.005;
|
||
sceneManager.zoom(zoomDelta, x, y);
|
||
syncViewTransform();
|
||
// Update text textures if zoom level increased significantly
|
||
updateTextTexturesForZoom();
|
||
}
|
||
|
||
// ============================================
|
||
// History Operations
|
||
// ============================================
|
||
|
||
function recordState() {
|
||
const state = sceneManager.getState();
|
||
historyManager.record(state.elements, state.viewTransform);
|
||
}
|
||
|
||
function handleUndo() {
|
||
const currentState = sceneManager.getState();
|
||
const snapshot = historyManager.createSnapshot(currentState.elements, currentState.viewTransform);
|
||
const previousState = historyManager.undo(snapshot);
|
||
|
||
if (previousState) {
|
||
sceneManager.restoreState({
|
||
elements: previousState.elements,
|
||
viewTransform: previousState.viewTransform,
|
||
});
|
||
// Clear selection after undo
|
||
sceneManager.deselectAll();
|
||
syncRendererElements();
|
||
syncViewTransform();
|
||
}
|
||
}
|
||
|
||
function handleRedo() {
|
||
const currentState = sceneManager.getState();
|
||
const snapshot = historyManager.createSnapshot(currentState.elements, currentState.viewTransform);
|
||
const nextState = historyManager.redo(snapshot);
|
||
|
||
if (nextState) {
|
||
sceneManager.restoreState({
|
||
elements: nextState.elements,
|
||
viewTransform: nextState.viewTransform,
|
||
});
|
||
// Clear selection after redo
|
||
sceneManager.deselectAll();
|
||
syncRendererElements();
|
||
syncViewTransform();
|
||
}
|
||
}
|
||
|
||
async function handleDelete() {
|
||
if (sceneManager.getSelectedCount() > 0) {
|
||
recordState();
|
||
sceneManager.deleteSelected();
|
||
sceneService.markDirty();
|
||
await syncRendererElements();
|
||
// Force render update
|
||
renderer?.requestRender();
|
||
}
|
||
}
|
||
|
||
// ============================================
|
||
// Renderer Sync
|
||
// ============================================
|
||
|
||
async function syncRendererElements() {
|
||
if (!renderer) return;
|
||
|
||
// Clear existing render elements
|
||
renderer.clearElements();
|
||
|
||
// Get all scene elements
|
||
const elements = sceneManager.getAllElements();
|
||
|
||
console.log('syncRendererElements - elements:', elements.map(el => ({ id: el.id, selected: el.selected })));
|
||
|
||
// Track which element IDs are still in the scene
|
||
const currentElementIds = new Set(elements.map(el => el.id));
|
||
|
||
// Clean up textures for removed elements
|
||
for (const elementId of elementTextures.keys()) {
|
||
if (!currentElementIds.has(elementId)) {
|
||
cleanupElementTexture(elementId);
|
||
}
|
||
}
|
||
|
||
// Load textures for new elements and add all elements to renderer
|
||
for (const element of elements) {
|
||
// Load texture if not already loaded
|
||
if (!elementTextures.has(element.id)) {
|
||
// Don't await here to avoid blocking - texture will be loaded async
|
||
loadElementTexture(element);
|
||
}
|
||
|
||
const renderElement = createRenderElement(element);
|
||
if (renderElement) {
|
||
console.log('Adding render element:', renderElement.id, 'selected:', renderElement.selected);
|
||
renderer.addElement(renderElement);
|
||
}
|
||
}
|
||
|
||
renderer.requestRender();
|
||
}
|
||
|
||
function syncViewTransform() {
|
||
if (!renderer) return;
|
||
renderer.setViewTransform(sceneManager.viewTransform.value);
|
||
}
|
||
|
||
/**
|
||
* Create a render element with proper texture based on element type
|
||
*/
|
||
function createRenderElement(element: MediaElement): RenderElement | null {
|
||
if (!renderer) return null;
|
||
|
||
const textureManager = renderer.getTextureManager();
|
||
if (!textureManager) return null;
|
||
|
||
// Check if we already have a texture for this element
|
||
let texture = elementTextures.get(element.id);
|
||
|
||
// If texture exists, use it (for GIF/video, texture is updated separately)
|
||
if (texture) {
|
||
return {
|
||
id: element.id,
|
||
texture,
|
||
x: element.x,
|
||
y: element.y,
|
||
width: element.width,
|
||
height: element.height,
|
||
rotation: element.rotation,
|
||
zIndex: element.zIndex,
|
||
selected: element.selected,
|
||
};
|
||
}
|
||
|
||
// Use placeholder texture while actual texture is being loaded
|
||
const placeholderTexture = textureManager.getPlaceholderTexture();
|
||
if (!placeholderTexture) return null;
|
||
|
||
return {
|
||
id: element.id,
|
||
texture: placeholderTexture,
|
||
x: element.x,
|
||
y: element.y,
|
||
width: element.width,
|
||
height: element.height,
|
||
rotation: element.rotation,
|
||
zIndex: element.zIndex,
|
||
selected: element.selected,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Load and create texture for an image element
|
||
*/
|
||
async function loadImageTexture(element: MediaElement): Promise<void> {
|
||
if (!renderer) return;
|
||
|
||
const textureManager = renderer.getTextureManager();
|
||
if (!textureManager) return;
|
||
|
||
const data = element.data as ImageData;
|
||
if (!data.src) return;
|
||
|
||
try {
|
||
const imageBitmap = await loadImageFromBase64(data.src);
|
||
const texture = textureManager.createFromImage(element.id, imageBitmap);
|
||
elementTextures.set(element.id, texture);
|
||
imageBitmap.close();
|
||
// Re-sync to update the render element with the loaded texture
|
||
syncRendererElements();
|
||
} catch (error) {
|
||
console.error(`Failed to load image texture for element ${element.id}:`, error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Load and create texture for a GIF element with animation support
|
||
*/
|
||
async function loadGifTexture(element: MediaElement): Promise<void> {
|
||
if (!renderer) return;
|
||
|
||
const textureManager = renderer.getTextureManager();
|
||
if (!textureManager) return;
|
||
|
||
const data = element.data as GifData;
|
||
|
||
// Check if we have frames in the data
|
||
if (data.frames && data.frames.length > 0) {
|
||
// Create texture from first frame
|
||
const firstFrame = data.frames[0];
|
||
const texture = textureManager.createFromImage(element.id, firstFrame);
|
||
elementTextures.set(element.id, texture);
|
||
|
||
// Create GIF animator if multiple frames
|
||
if (data.frames.length > 1) {
|
||
const gifData: GifDataUtil = {
|
||
frames: data.frames.map((bitmap, index) => ({
|
||
bitmap,
|
||
delay: data.frameDelays[index] || 100,
|
||
index,
|
||
})),
|
||
totalDuration: data.frameDelays.reduce((sum, d) => sum + d, 0),
|
||
width: data.originalWidth,
|
||
height: data.originalHeight,
|
||
loopCount: 0,
|
||
originalData: data.src,
|
||
};
|
||
|
||
const animator = new GifAnimator(gifData);
|
||
animator.setOnFrameChange((frame) => {
|
||
// Update texture with new frame
|
||
if (textureManager.hasTexture(element.id)) {
|
||
textureManager.updateFromImage(element.id, frame.bitmap);
|
||
renderer?.requestRender();
|
||
}
|
||
});
|
||
animator.play();
|
||
gifAnimators.set(element.id, animator);
|
||
}
|
||
|
||
// Re-sync to update the render element with the loaded texture
|
||
syncRendererElements();
|
||
} else if (data.src) {
|
||
// Fallback: load from base64 as static image
|
||
try {
|
||
const imageBitmap = await loadImageFromBase64(data.src);
|
||
const texture = textureManager.createFromImage(element.id, imageBitmap);
|
||
elementTextures.set(element.id, texture);
|
||
imageBitmap.close();
|
||
// Re-sync to update the render element with the loaded texture
|
||
syncRendererElements();
|
||
} catch (error) {
|
||
console.error(`Failed to load GIF texture for element ${element.id}:`, error);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Load and create texture for a video element with frame updates
|
||
*/
|
||
async function loadVideoTexture(element: MediaElement): Promise<void> {
|
||
if (!renderer) return;
|
||
|
||
const textureManager = renderer.getTextureManager();
|
||
if (!textureManager) return;
|
||
|
||
const data = element.data as VideoData;
|
||
if (!data.src) return;
|
||
|
||
try {
|
||
const video = await createVideoFromUrl(data.src);
|
||
video.muted = data.muted;
|
||
|
||
// Wait for video to be ready
|
||
await new Promise<void>((resolve, reject) => {
|
||
if (video.readyState >= 3) {
|
||
resolve();
|
||
return;
|
||
}
|
||
video.oncanplay = () => resolve();
|
||
video.onerror = () => reject(new Error('Video load failed'));
|
||
});
|
||
|
||
// Create texture from video
|
||
const texture = textureManager.createFromVideo(element.id, video);
|
||
elementTextures.set(element.id, texture);
|
||
videoElements.set(element.id, video);
|
||
|
||
// Create video controller for playback and frame updates
|
||
const controller = new VideoController(video);
|
||
controller.setLoopPoints(data.loopStart, data.loopEnd);
|
||
controller.setFrameUpdateCallback(() => {
|
||
// Update texture with current video frame
|
||
if (textureManager.hasTexture(element.id)) {
|
||
textureManager.updateFromVideo(element.id, video);
|
||
renderer?.requestRender();
|
||
}
|
||
});
|
||
|
||
// Start playback and frame updates
|
||
await controller.play();
|
||
videoControllers.set(element.id, controller);
|
||
|
||
// Re-sync to update the render element with the loaded texture
|
||
syncRendererElements();
|
||
} catch (error) {
|
||
console.error(`Failed to load video texture for element ${element.id}:`, error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Load and create texture for a text element
|
||
*/
|
||
function loadTextTexture(element: MediaElement, scale: number = 1): void {
|
||
if (!renderer) return;
|
||
|
||
const textureManager = renderer.getTextureManager();
|
||
if (!textureManager) return;
|
||
|
||
const data = element.data as TextData;
|
||
if (!data.content) return;
|
||
|
||
try {
|
||
// Use higher resolution for better quality when zoomed in
|
||
// Minimum scale factor of 2 for retina-like quality
|
||
const textureScale = Math.max(2, Math.ceil(scale));
|
||
const scaledFontSize = data.fontSize * textureScale;
|
||
|
||
// Render text to canvas at higher resolution
|
||
const canvas = renderTextToCanvas(data.content, {
|
||
fontSize: scaledFontSize,
|
||
color: data.color,
|
||
backgroundColor: 'transparent',
|
||
});
|
||
|
||
// Create texture from canvas
|
||
const texture = textureManager.createFromCanvas(element.id, canvas);
|
||
elementTextures.set(element.id, texture);
|
||
|
||
// Store the texture scale for reference
|
||
textTextureScales.set(element.id, textureScale);
|
||
|
||
// Re-sync to update the render element with the loaded texture
|
||
syncRendererElements();
|
||
} catch (error) {
|
||
console.error(`Failed to load text texture for element ${element.id}:`, error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Update text texture when zoom level changes significantly
|
||
*/
|
||
function updateTextTexturesForZoom(): void {
|
||
const currentScale = sceneManager.viewTransform.value.scale;
|
||
const elements = sceneManager.getAllElements();
|
||
|
||
for (const element of elements) {
|
||
if (element.type === 'text') {
|
||
const currentTextureScale = textTextureScales.get(element.id) || 2;
|
||
const neededScale = Math.max(2, Math.ceil(currentScale));
|
||
|
||
// Only regenerate if we need higher resolution
|
||
if (neededScale > currentTextureScale) {
|
||
// Clean up old texture
|
||
const textureManager = renderer?.getTextureManager();
|
||
if (textureManager && textureManager.hasTexture(element.id)) {
|
||
textureManager.destroy(element.id);
|
||
}
|
||
elementTextures.delete(element.id);
|
||
|
||
// Regenerate at higher resolution
|
||
loadTextTexture(element, currentScale);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Load texture for an element based on its type
|
||
*/
|
||
async function loadElementTexture(element: MediaElement): Promise<void> {
|
||
// Skip if texture already exists
|
||
if (elementTextures.has(element.id)) return;
|
||
|
||
switch (element.type) {
|
||
case 'image':
|
||
await loadImageTexture(element);
|
||
break;
|
||
case 'gif':
|
||
await loadGifTexture(element);
|
||
break;
|
||
case 'video':
|
||
await loadVideoTexture(element);
|
||
break;
|
||
case 'text':
|
||
loadTextTexture(element);
|
||
break;
|
||
// YouTube elements would need special handling (iframe or external API)
|
||
default:
|
||
break;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Clean up texture and associated resources for an element
|
||
*/
|
||
function cleanupElementTexture(elementId: string): void {
|
||
// Clean up GIF animator
|
||
const animator = gifAnimators.get(elementId);
|
||
if (animator) {
|
||
animator.destroy();
|
||
gifAnimators.delete(elementId);
|
||
}
|
||
|
||
// Clean up video controller
|
||
const controller = videoControllers.get(elementId);
|
||
if (controller) {
|
||
controller.destroy();
|
||
videoControllers.delete(elementId);
|
||
}
|
||
|
||
// Clean up video element
|
||
videoElements.delete(elementId);
|
||
|
||
// Clean up texture
|
||
const textureManager = renderer?.getTextureManager();
|
||
if (textureManager && textureManager.hasTexture(elementId)) {
|
||
textureManager.destroy(elementId);
|
||
}
|
||
elementTextures.delete(elementId);
|
||
|
||
// Clean up text texture scale
|
||
textTextureScales.delete(elementId);
|
||
}
|
||
|
||
/**
|
||
* Clean up all element textures and resources
|
||
*/
|
||
function cleanupAllTextures(): void {
|
||
// Clean up all GIF animators
|
||
for (const animator of gifAnimators.values()) {
|
||
animator.destroy();
|
||
}
|
||
gifAnimators.clear();
|
||
|
||
// Clean up all video controllers
|
||
for (const controller of videoControllers.values()) {
|
||
controller.destroy();
|
||
}
|
||
videoControllers.clear();
|
||
|
||
// Clear video elements
|
||
videoElements.clear();
|
||
|
||
// Clear text texture scales
|
||
textTextureScales.clear();
|
||
|
||
// Clear texture references
|
||
elementTextures.clear();
|
||
}
|
||
|
||
// ============================================
|
||
// Element Resize Detection and Handling
|
||
// ============================================
|
||
|
||
/**
|
||
* Detect resize handle on a bounding box
|
||
*/
|
||
function detectResizeHandleOnBounds(
|
||
screenX: number,
|
||
screenY: number,
|
||
bounds: { x: number; y: number; width: number; height: number }
|
||
): ResizeHandle {
|
||
const scale = sceneManager.viewTransform.value.scale;
|
||
const threshold = EDGE_THRESHOLD / scale;
|
||
|
||
const worldPos = sceneManager.screenToWorld(screenX, screenY);
|
||
const wx = worldPos.x;
|
||
const wy = worldPos.y;
|
||
|
||
const left = bounds.x;
|
||
const right = bounds.x + bounds.width;
|
||
const top = bounds.y;
|
||
const bottom = bounds.y + bounds.height;
|
||
|
||
const nearLeft = Math.abs(wx - left) <= threshold;
|
||
const nearRight = Math.abs(wx - right) <= threshold;
|
||
const nearTop = Math.abs(wy - top) <= threshold;
|
||
const nearBottom = Math.abs(wy - bottom) <= threshold;
|
||
|
||
const withinX = wx >= left - threshold && wx <= right + threshold;
|
||
const withinY = wy >= top - threshold && wy <= bottom + threshold;
|
||
|
||
if (!withinX || !withinY) return null;
|
||
|
||
if (nearTop && nearLeft) return 'top-left';
|
||
if (nearTop && nearRight) return 'top-right';
|
||
if (nearBottom && nearLeft) return 'bottom-left';
|
||
if (nearBottom && nearRight) return 'bottom-right';
|
||
if (nearTop) return 'top';
|
||
if (nearBottom) return 'bottom';
|
||
if (nearLeft) return 'left';
|
||
if (nearRight) return 'right';
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Detect which resize handle (if any) is at the given screen position
|
||
*/
|
||
function detectResizeHandle(screenX: number, screenY: number, element: MediaElement): ResizeHandle {
|
||
const scale = sceneManager.viewTransform.value.scale;
|
||
const threshold = EDGE_THRESHOLD / scale;
|
||
|
||
// Convert screen coordinates to world coordinates
|
||
const worldPos = sceneManager.screenToWorld(screenX, screenY);
|
||
const wx = worldPos.x;
|
||
const wy = worldPos.y;
|
||
|
||
// Element bounds in world coordinates
|
||
const left = element.x;
|
||
const right = element.x + element.width;
|
||
const top = element.y;
|
||
const bottom = element.y + element.height;
|
||
|
||
// Check if point is near element edges
|
||
const nearLeft = Math.abs(wx - left) <= threshold;
|
||
const nearRight = Math.abs(wx - right) <= threshold;
|
||
const nearTop = Math.abs(wy - top) <= threshold;
|
||
const nearBottom = Math.abs(wy - bottom) <= threshold;
|
||
|
||
// Check if point is within element bounds (with threshold)
|
||
const withinX = wx >= left - threshold && wx <= right + threshold;
|
||
const withinY = wy >= top - threshold && wy <= bottom + threshold;
|
||
|
||
if (!withinX || !withinY) return null;
|
||
|
||
// Corner handles (prioritize corners)
|
||
if (nearTop && nearLeft) return 'top-left';
|
||
if (nearTop && nearRight) return 'top-right';
|
||
if (nearBottom && nearLeft) return 'bottom-left';
|
||
if (nearBottom && nearRight) return 'bottom-right';
|
||
|
||
// Edge handles
|
||
if (nearTop) return 'top';
|
||
if (nearBottom) return 'bottom';
|
||
if (nearLeft) return 'left';
|
||
if (nearRight) return 'right';
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Get cursor style for a resize handle
|
||
*/
|
||
function getCursorForHandle(handle: ResizeHandle): string {
|
||
switch (handle) {
|
||
case 'top-left':
|
||
case 'bottom-right':
|
||
return 'nwse-resize';
|
||
case 'top-right':
|
||
case 'bottom-left':
|
||
return 'nesw-resize';
|
||
case 'top':
|
||
case 'bottom':
|
||
return 'ns-resize';
|
||
case 'left':
|
||
case 'right':
|
||
return 'ew-resize';
|
||
default:
|
||
return 'default';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Start resizing an element
|
||
*/
|
||
function startResize(element: MediaElement, handle: ResizeHandle, screenX: number, screenY: number): void {
|
||
if (!handle) return;
|
||
|
||
// Reset accumulated deltas
|
||
resizeAccumulatedX = 0;
|
||
resizeAccumulatedY = 0;
|
||
|
||
resizeState = {
|
||
isMultiSelect: false,
|
||
elementId: element.id,
|
||
handle,
|
||
startX: screenX,
|
||
startY: screenY,
|
||
startWidth: element.width,
|
||
startHeight: element.height,
|
||
startElementX: element.x,
|
||
startElementY: element.y,
|
||
aspectRatio: element.width / element.height,
|
||
startBounds: null,
|
||
accumulatedDeltaX: 0,
|
||
accumulatedDeltaY: 0,
|
||
};
|
||
|
||
inputHandler.startResizing();
|
||
recordState();
|
||
}
|
||
|
||
/**
|
||
* Start resizing multiple selected elements as a group
|
||
*/
|
||
function startMultiResize(
|
||
bounds: { x: number; y: number; width: number; height: number },
|
||
handle: ResizeHandle,
|
||
screenX: number,
|
||
screenY: number
|
||
): void {
|
||
if (!handle) return;
|
||
|
||
// Reset accumulated deltas
|
||
resizeAccumulatedX = 0;
|
||
resizeAccumulatedY = 0;
|
||
|
||
resizeState = {
|
||
isMultiSelect: true,
|
||
elementId: '', // Not used for multi-select
|
||
handle,
|
||
startX: screenX,
|
||
startY: screenY,
|
||
startWidth: bounds.width,
|
||
startHeight: bounds.height,
|
||
startElementX: bounds.x,
|
||
startElementY: bounds.y,
|
||
aspectRatio: bounds.width / bounds.height,
|
||
startBounds: { ...bounds },
|
||
accumulatedDeltaX: 0,
|
||
accumulatedDeltaY: 0,
|
||
};
|
||
|
||
// Store initial positions of all selected elements for proportional scaling
|
||
const selectedElements = sceneManager.selectedElements.value;
|
||
multiResizeStartPositions = selectedElements.map(el => ({
|
||
id: el.id,
|
||
x: el.x,
|
||
y: el.y,
|
||
width: el.width,
|
||
height: el.height,
|
||
}));
|
||
|
||
inputHandler.startResizing();
|
||
recordState();
|
||
}
|
||
|
||
/**
|
||
* Handle element resize during drag
|
||
* Maintains aspect ratio for proportional resizing
|
||
*/
|
||
function handleElementResize(deltaX: number, deltaY: number): void {
|
||
if (!resizeState) return;
|
||
|
||
const scale = sceneManager.viewTransform.value.scale;
|
||
|
||
// Accumulate deltas (convert screen delta to world delta)
|
||
resizeAccumulatedX += deltaX / scale;
|
||
resizeAccumulatedY += deltaY / scale;
|
||
|
||
// Use accumulated deltas for calculation
|
||
const totalDeltaX = resizeAccumulatedX;
|
||
const totalDeltaY = resizeAccumulatedY;
|
||
|
||
// Handle multi-selection resize
|
||
if (resizeState.isMultiSelect && resizeState.startBounds) {
|
||
handleMultiElementResize(totalDeltaX, totalDeltaY);
|
||
return;
|
||
}
|
||
|
||
const element = sceneManager.getElement(resizeState.elementId);
|
||
if (!element) return;
|
||
|
||
let newWidth = resizeState.startWidth;
|
||
let newHeight = resizeState.startHeight;
|
||
let newX = resizeState.startElementX;
|
||
let newY = resizeState.startElementY;
|
||
|
||
const handle = resizeState.handle;
|
||
const aspectRatio = resizeState.aspectRatio;
|
||
|
||
// Calculate new dimensions based on handle using accumulated total delta
|
||
switch (handle) {
|
||
case 'bottom-right':
|
||
// Resize from bottom-right corner (most common)
|
||
newWidth = Math.max(20, resizeState.startWidth + totalDeltaX);
|
||
newHeight = newWidth / aspectRatio;
|
||
break;
|
||
|
||
case 'bottom-left':
|
||
// Resize from bottom-left corner
|
||
newWidth = Math.max(20, resizeState.startWidth - totalDeltaX);
|
||
newHeight = newWidth / aspectRatio;
|
||
newX = resizeState.startElementX + (resizeState.startWidth - newWidth);
|
||
break;
|
||
|
||
case 'top-right':
|
||
// Resize from top-right corner
|
||
newWidth = Math.max(20, resizeState.startWidth + totalDeltaX);
|
||
newHeight = newWidth / aspectRatio;
|
||
newY = resizeState.startElementY + (resizeState.startHeight - newHeight);
|
||
break;
|
||
|
||
case 'top-left':
|
||
// Resize from top-left corner
|
||
newWidth = Math.max(20, resizeState.startWidth - totalDeltaX);
|
||
newHeight = newWidth / aspectRatio;
|
||
newX = resizeState.startElementX + (resizeState.startWidth - newWidth);
|
||
newY = resizeState.startElementY + (resizeState.startHeight - newHeight);
|
||
break;
|
||
|
||
case 'right':
|
||
// Resize from right edge (proportional)
|
||
newWidth = Math.max(20, resizeState.startWidth + totalDeltaX);
|
||
newHeight = newWidth / aspectRatio;
|
||
// Center vertically
|
||
newY = resizeState.startElementY + (resizeState.startHeight - newHeight) / 2;
|
||
break;
|
||
|
||
case 'left':
|
||
// Resize from left edge (proportional)
|
||
newWidth = Math.max(20, resizeState.startWidth - totalDeltaX);
|
||
newHeight = newWidth / aspectRatio;
|
||
newX = resizeState.startElementX + (resizeState.startWidth - newWidth);
|
||
// Center vertically
|
||
newY = resizeState.startElementY + (resizeState.startHeight - newHeight) / 2;
|
||
break;
|
||
|
||
case 'bottom':
|
||
// Resize from bottom edge (proportional)
|
||
newHeight = Math.max(20, resizeState.startHeight + totalDeltaY);
|
||
newWidth = newHeight * aspectRatio;
|
||
// Center horizontally
|
||
newX = resizeState.startElementX + (resizeState.startWidth - newWidth) / 2;
|
||
break;
|
||
|
||
case 'top':
|
||
// Resize from top edge (proportional)
|
||
newHeight = Math.max(20, resizeState.startHeight - totalDeltaY);
|
||
newWidth = newHeight * aspectRatio;
|
||
newY = resizeState.startElementY + (resizeState.startHeight - newHeight);
|
||
// Center horizontally
|
||
newX = resizeState.startElementX + (resizeState.startWidth - newWidth) / 2;
|
||
break;
|
||
}
|
||
|
||
// Update element
|
||
sceneManager.updateElement(resizeState.elementId, {
|
||
x: newX,
|
||
y: newY,
|
||
width: newWidth,
|
||
height: newHeight,
|
||
});
|
||
|
||
// Emit resize event
|
||
emit('element-resized', resizeState.elementId, newWidth, newHeight);
|
||
|
||
syncRendererElements();
|
||
}
|
||
|
||
/**
|
||
* Handle multi-element resize (group resize)
|
||
*/
|
||
function handleMultiElementResize(totalDeltaX: number, totalDeltaY: number): void {
|
||
if (!resizeState || !resizeState.startBounds) return;
|
||
|
||
const startBounds = resizeState.startBounds;
|
||
const handle = resizeState.handle;
|
||
const aspectRatio = resizeState.aspectRatio;
|
||
|
||
// Calculate new bounds based on handle
|
||
let newWidth = startBounds.width;
|
||
let newHeight = startBounds.height;
|
||
let newX = startBounds.x;
|
||
let newY = startBounds.y;
|
||
|
||
switch (handle) {
|
||
case 'bottom-right':
|
||
newWidth = Math.max(20, startBounds.width + totalDeltaX);
|
||
newHeight = newWidth / aspectRatio;
|
||
break;
|
||
|
||
case 'bottom-left':
|
||
newWidth = Math.max(20, startBounds.width - totalDeltaX);
|
||
newHeight = newWidth / aspectRatio;
|
||
newX = startBounds.x + (startBounds.width - newWidth);
|
||
break;
|
||
|
||
case 'top-right':
|
||
newWidth = Math.max(20, startBounds.width + totalDeltaX);
|
||
newHeight = newWidth / aspectRatio;
|
||
newY = startBounds.y + (startBounds.height - newHeight);
|
||
break;
|
||
|
||
case 'top-left':
|
||
newWidth = Math.max(20, startBounds.width - totalDeltaX);
|
||
newHeight = newWidth / aspectRatio;
|
||
newX = startBounds.x + (startBounds.width - newWidth);
|
||
newY = startBounds.y + (startBounds.height - newHeight);
|
||
break;
|
||
|
||
case 'right':
|
||
newWidth = Math.max(20, startBounds.width + totalDeltaX);
|
||
newHeight = newWidth / aspectRatio;
|
||
newY = startBounds.y + (startBounds.height - newHeight) / 2;
|
||
break;
|
||
|
||
case 'left':
|
||
newWidth = Math.max(20, startBounds.width - totalDeltaX);
|
||
newHeight = newWidth / aspectRatio;
|
||
newX = startBounds.x + (startBounds.width - newWidth);
|
||
newY = startBounds.y + (startBounds.height - newHeight) / 2;
|
||
break;
|
||
|
||
case 'bottom':
|
||
newHeight = Math.max(20, startBounds.height + totalDeltaY);
|
||
newWidth = newHeight * aspectRatio;
|
||
newX = startBounds.x + (startBounds.width - newWidth) / 2;
|
||
break;
|
||
|
||
case 'top':
|
||
newHeight = Math.max(20, startBounds.height - totalDeltaY);
|
||
newWidth = newHeight * aspectRatio;
|
||
newY = startBounds.y + (startBounds.height - newHeight);
|
||
newX = startBounds.x + (startBounds.width - newWidth) / 2;
|
||
break;
|
||
}
|
||
|
||
// Apply proportional scaling to all selected elements
|
||
for (const startPos of multiResizeStartPositions) {
|
||
// Calculate relative position within the original bounds
|
||
const relX = (startPos.x - startBounds.x) / startBounds.width;
|
||
const relY = (startPos.y - startBounds.y) / startBounds.height;
|
||
const relWidth = startPos.width / startBounds.width;
|
||
const relHeight = startPos.height / startBounds.height;
|
||
|
||
// Apply scaling
|
||
const elementNewX = newX + relX * newWidth;
|
||
const elementNewY = newY + relY * newHeight;
|
||
const elementNewWidth = relWidth * newWidth;
|
||
const elementNewHeight = relHeight * newHeight;
|
||
|
||
sceneManager.updateElement(startPos.id, {
|
||
x: elementNewX,
|
||
y: elementNewY,
|
||
width: elementNewWidth,
|
||
height: elementNewHeight,
|
||
});
|
||
}
|
||
|
||
syncRendererElements();
|
||
}
|
||
|
||
/**
|
||
* End element resize operation
|
||
*/
|
||
function handleElementResizeEnd(): void {
|
||
resizeState = null;
|
||
resizeAccumulatedX = 0;
|
||
resizeAccumulatedY = 0;
|
||
multiResizeStartPositions = [];
|
||
sceneService.markDirty();
|
||
syncRendererElements();
|
||
}
|
||
|
||
/**
|
||
* Update cursor based on mouse position over selected elements
|
||
*/
|
||
function updateResizeCursor(screenX: number, screenY: number): void {
|
||
if (!canvasRef.value) return;
|
||
|
||
const selectedElements = sceneManager.selectedElements.value;
|
||
|
||
// Check for multi-selection bounds first
|
||
if (selectedElements.length > 1) {
|
||
const bounds = sceneManager.getSelectionBounds();
|
||
if (bounds) {
|
||
const handle = detectResizeHandleOnBounds(screenX, screenY, bounds);
|
||
if (handle) {
|
||
canvasRef.value.style.cursor = getCursorForHandle(handle);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Check single element resize handles
|
||
for (const element of selectedElements) {
|
||
const handle = detectResizeHandle(screenX, screenY, element);
|
||
if (handle) {
|
||
canvasRef.value.style.cursor = getCursorForHandle(handle);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Reset cursor if not over any resize handle
|
||
canvasRef.value.style.cursor = 'default';
|
||
}
|
||
|
||
// ============================================
|
||
// File Drop Handling
|
||
// ============================================
|
||
|
||
function handleDragOver(event: DragEvent) {
|
||
event.preventDefault();
|
||
event.dataTransfer!.dropEffect = 'copy';
|
||
}
|
||
|
||
function handleDrop(event: DragEvent) {
|
||
event.preventDefault();
|
||
|
||
const files = event.dataTransfer?.files;
|
||
if (files && files.length > 0) {
|
||
const fileArray = Array.from(files);
|
||
emit('file-drop', fileArray);
|
||
}
|
||
}
|
||
|
||
// ============================================
|
||
// Lifecycle
|
||
// ============================================
|
||
|
||
onMounted(async () => {
|
||
setupResizeObserver();
|
||
await initWebGPU();
|
||
});
|
||
|
||
onUnmounted(() => {
|
||
// Clean up resize handling
|
||
if (resizeObserver) {
|
||
resizeObserver.disconnect();
|
||
resizeObserver = null;
|
||
}
|
||
if (resizeRAF !== null) {
|
||
cancelAnimationFrame(resizeRAF);
|
||
resizeRAF = null;
|
||
}
|
||
window.removeEventListener('resize', handleWindowResize);
|
||
|
||
inputHandler.detach();
|
||
|
||
// Clean up all element textures and resources
|
||
cleanupAllTextures();
|
||
|
||
if (renderer) {
|
||
renderer.destroy();
|
||
renderer = null;
|
||
}
|
||
});
|
||
|
||
// Watch for prop changes
|
||
watch(() => [props.width, props.height], () => {
|
||
updateCanvasSize();
|
||
});
|
||
|
||
// ============================================
|
||
// Exposed Methods
|
||
// ============================================
|
||
|
||
defineExpose({
|
||
getRenderer: () => renderer,
|
||
getSceneManager: () => sceneManager,
|
||
syncRendererElements,
|
||
syncViewTransform,
|
||
isInitialized: () => isInitialized.value,
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<div
|
||
ref="containerRef"
|
||
class="canvas-container"
|
||
@dragover="handleDragOver"
|
||
@drop="handleDrop"
|
||
>
|
||
<canvas
|
||
ref="canvasRef"
|
||
class="canvas"
|
||
:class="{ 'canvas--error': webgpuError }"
|
||
/>
|
||
|
||
<!-- WebGPU Error Overlay -->
|
||
<div v-if="webgpuError" class="canvas-error">
|
||
<div class="canvas-error__icon">⚠️</div>
|
||
<div class="canvas-error__title">WebGPU 不可用</div>
|
||
<div class="canvas-error__message">{{ webgpuError }}</div>
|
||
<div class="canvas-error__hint">
|
||
请更新您的浏览器或显卡驱动程序
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Loading Overlay -->
|
||
<div v-if="!isInitialized && !webgpuError" class="canvas-loading">
|
||
<div class="canvas-loading__spinner"></div>
|
||
<div class="canvas-loading__text">初始化中...</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.canvas-container {
|
||
position: relative;
|
||
width: 100%;
|
||
height: 100%;
|
||
overflow: hidden;
|
||
background-color: #191919;
|
||
}
|
||
|
||
.canvas {
|
||
display: block;
|
||
cursor: default;
|
||
/* Size is set via JavaScript to match container exactly */
|
||
}
|
||
|
||
.canvas--error {
|
||
opacity: 0.3;
|
||
}
|
||
|
||
.canvas-error {
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
text-align: center;
|
||
color: #fff;
|
||
padding: 2rem;
|
||
background: rgba(0, 0, 0, 0.8);
|
||
border-radius: 8px;
|
||
max-width: 400px;
|
||
}
|
||
|
||
.canvas-error__icon {
|
||
font-size: 3rem;
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.canvas-error__title {
|
||
font-size: 1.5rem;
|
||
font-weight: bold;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
|
||
.canvas-error__message {
|
||
color: #ff6b6b;
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.canvas-error__hint {
|
||
color: #888;
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.canvas-loading {
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
text-align: center;
|
||
color: #fff;
|
||
}
|
||
|
||
.canvas-loading__spinner {
|
||
width: 40px;
|
||
height: 40px;
|
||
border: 3px solid #333;
|
||
border-top-color: #00c8ff;
|
||
border-radius: 50%;
|
||
animation: spin 1s linear infinite;
|
||
margin: 0 auto 1rem;
|
||
}
|
||
|
||
.canvas-loading__text {
|
||
color: #888;
|
||
}
|
||
|
||
@keyframes spin {
|
||
to {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
</style>
|