Files
pureref-tauri/src/components/CanvasView.vue
小煜 75c26d479a feat(文件管理): 添加已打开文件状态标记及相应逻辑
引入hasOpenedFile状态标记,用于跟踪用户是否已打开或操作过文件
- 修改欢迎界面显示逻辑,仅在无内容且未打开文件时显示
- 调整保存/另存为/新建场景等操作的权限判断
- 更新标题栏显示逻辑,支持已打开文件但无内容的场景
- 在文件拖放、加载等操作时自动标记文件打开状态
2025-12-19 20:46:30 +08:00

1515 lines
43 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>