添加项目基础文件结构,包括Tauri配置、前端框架和核心功能模块 实现WebGPU渲染器基础、纹理管理和着色器 添加UI组件包括标题栏、确认对话框、帮助窗口等 设置状态管理存储和工具函数 配置构建工具和开发环境
678 lines
17 KiB
TypeScript
678 lines
17 KiB
TypeScript
/**
|
|
* Scene Manager
|
|
* Manages scene state, element operations, and view transformations
|
|
*/
|
|
|
|
import { ref, computed, type Ref, type ComputedRef } from 'vue';
|
|
import type {
|
|
MediaElement as IMediaElement,
|
|
ElementType,
|
|
ElementData,
|
|
ViewTransform,
|
|
} from '../../types';
|
|
import {
|
|
MediaElement,
|
|
isPointInElement,
|
|
doesElementIntersectRect,
|
|
} from './element';
|
|
|
|
// ============================================
|
|
// Constants
|
|
// ============================================
|
|
|
|
const MIN_SCALE = 0.01;
|
|
const MAX_SCALE = 2.0;
|
|
const DEFAULT_SCALE = 1.0;
|
|
const WORKSPACE_PADDING = 500;
|
|
|
|
// ============================================
|
|
// Scene State Interface
|
|
// ============================================
|
|
|
|
export interface WorkspaceBounds {
|
|
minX: number;
|
|
minY: number;
|
|
maxX: number;
|
|
maxY: number;
|
|
}
|
|
|
|
// ============================================
|
|
// Scene Manager Class
|
|
// ============================================
|
|
|
|
export class SceneManager {
|
|
private _elements: Map<string, MediaElement> = new Map();
|
|
private _selectedIds: Set<string> = new Set();
|
|
private _viewTransform: ViewTransform = {
|
|
scale: DEFAULT_SCALE,
|
|
translateX: 0,
|
|
translateY: 0,
|
|
};
|
|
private _workspaceBounds: WorkspaceBounds = {
|
|
minX: -1000,
|
|
minY: -1000,
|
|
maxX: 1000,
|
|
maxY: 1000,
|
|
};
|
|
private _nextZIndex = 1;
|
|
private _version: Ref<number> = ref(0);
|
|
|
|
readonly elements: ComputedRef<MediaElement[]>;
|
|
readonly selectedElements: ComputedRef<MediaElement[]>;
|
|
readonly viewTransform: ComputedRef<ViewTransform>;
|
|
readonly hasSelection: ComputedRef<boolean>;
|
|
|
|
constructor() {
|
|
const self = this;
|
|
|
|
this.elements = computed(() => {
|
|
self._version.value;
|
|
return Array.from(self._elements.values()).sort((a, b) => a.zIndex - b.zIndex);
|
|
});
|
|
|
|
this.selectedElements = computed(() => {
|
|
self._version.value;
|
|
return Array.from(self._elements.values()).filter(el => el.selected);
|
|
});
|
|
|
|
this.viewTransform = computed(() => {
|
|
self._version.value;
|
|
return { ...self._viewTransform };
|
|
});
|
|
|
|
this.hasSelection = computed(() => {
|
|
self._version.value;
|
|
return self._selectedIds.size > 0;
|
|
});
|
|
}
|
|
|
|
private notify(): void {
|
|
this._version.value++;
|
|
}
|
|
|
|
// ============================================
|
|
// Element Operations
|
|
// ============================================
|
|
|
|
addElement(
|
|
type: ElementType,
|
|
data: ElementData,
|
|
options: Partial<{
|
|
x: number;
|
|
y: number;
|
|
width: number;
|
|
height: number;
|
|
rotation: number;
|
|
}> = {}
|
|
): MediaElement {
|
|
const element = new MediaElement(type, data, {
|
|
...options,
|
|
zIndex: this._nextZIndex++,
|
|
});
|
|
|
|
this._elements.set(element.id, element);
|
|
this.updateWorkspaceBounds();
|
|
this.notify();
|
|
|
|
return element;
|
|
}
|
|
|
|
addExistingElement(element: MediaElement): void {
|
|
if (element.zIndex >= this._nextZIndex) {
|
|
this._nextZIndex = element.zIndex + 1;
|
|
}
|
|
this._elements.set(element.id, element);
|
|
this.updateWorkspaceBounds();
|
|
this.notify();
|
|
}
|
|
|
|
removeElement(id: string): boolean {
|
|
const element = this._elements.get(id);
|
|
if (!element) return false;
|
|
|
|
this._elements.delete(id);
|
|
this._selectedIds.delete(id);
|
|
this.updateWorkspaceBounds();
|
|
this.notify();
|
|
|
|
return true;
|
|
}
|
|
|
|
getElement(id: string): MediaElement | undefined {
|
|
return this._elements.get(id);
|
|
}
|
|
|
|
updateElement(
|
|
id: string,
|
|
updates: Partial<Omit<IMediaElement, 'id' | 'type' | 'data'>>
|
|
): boolean {
|
|
const element = this._elements.get(id);
|
|
if (!element) return false;
|
|
|
|
Object.assign(element, updates);
|
|
this.updateWorkspaceBounds();
|
|
this.notify();
|
|
|
|
return true;
|
|
}
|
|
|
|
// ============================================
|
|
// Selection Operations
|
|
// ============================================
|
|
|
|
selectElement(id: string, addToSelection = false): boolean {
|
|
const element = this._elements.get(id);
|
|
if (!element) return false;
|
|
|
|
if (!addToSelection) {
|
|
this.deselectAll();
|
|
}
|
|
|
|
element.selected = true;
|
|
this._selectedIds.add(id);
|
|
this.notify();
|
|
|
|
return true;
|
|
}
|
|
|
|
deselectElement(id: string): boolean {
|
|
const element = this._elements.get(id);
|
|
if (!element) return false;
|
|
|
|
element.selected = false;
|
|
this._selectedIds.delete(id);
|
|
this.notify();
|
|
|
|
return true;
|
|
}
|
|
|
|
deselectAll(): void {
|
|
for (const id of this._selectedIds) {
|
|
const element = this._elements.get(id);
|
|
if (element) {
|
|
element.selected = false;
|
|
}
|
|
}
|
|
this._selectedIds.clear();
|
|
this.notify();
|
|
}
|
|
|
|
toggleSelection(id: string): boolean {
|
|
const element = this._elements.get(id);
|
|
if (!element) return false;
|
|
|
|
if (element.selected) {
|
|
this.deselectElement(id);
|
|
} else {
|
|
this.selectElement(id, true);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
selectInRect(
|
|
x: number,
|
|
y: number,
|
|
width: number,
|
|
height: number,
|
|
addToSelection = false
|
|
): void {
|
|
console.log('selectInRect called:', { x, y, width, height, elementCount: this._elements.size });
|
|
|
|
if (!addToSelection) {
|
|
this.deselectAll();
|
|
}
|
|
|
|
for (const element of this._elements.values()) {
|
|
const intersects = doesElementIntersectRect(element, x, y, width, height);
|
|
console.log('Element check:', element.id, 'intersects:', intersects);
|
|
if (intersects) {
|
|
element.selected = true;
|
|
this._selectedIds.add(element.id);
|
|
}
|
|
}
|
|
console.log('Selected count:', this._selectedIds.size);
|
|
this.notify();
|
|
}
|
|
|
|
selectAll(): void {
|
|
for (const element of this._elements.values()) {
|
|
element.selected = true;
|
|
this._selectedIds.add(element.id);
|
|
}
|
|
this.notify();
|
|
}
|
|
|
|
// ============================================
|
|
// Batch Operations
|
|
// ============================================
|
|
|
|
deleteSelected(): MediaElement[] {
|
|
const deleted: MediaElement[] = [];
|
|
|
|
for (const id of this._selectedIds) {
|
|
const element = this._elements.get(id);
|
|
if (element) {
|
|
deleted.push(element);
|
|
this._elements.delete(id);
|
|
}
|
|
}
|
|
|
|
this._selectedIds.clear();
|
|
this.updateWorkspaceBounds();
|
|
this.notify();
|
|
|
|
return deleted;
|
|
}
|
|
|
|
moveSelected(deltaX: number, deltaY: number): void {
|
|
for (const id of this._selectedIds) {
|
|
const element = this._elements.get(id);
|
|
if (element) {
|
|
element.move(deltaX, deltaY);
|
|
}
|
|
}
|
|
this.updateWorkspaceBounds();
|
|
this.notify();
|
|
}
|
|
|
|
/**
|
|
* Get the bounding box of all selected elements
|
|
*/
|
|
getSelectionBounds(): { x: number; y: number; width: number; height: number } | null {
|
|
if (this._selectedIds.size === 0) return null;
|
|
|
|
let minX = Infinity;
|
|
let minY = Infinity;
|
|
let maxX = -Infinity;
|
|
let maxY = -Infinity;
|
|
|
|
for (const id of this._selectedIds) {
|
|
const element = this._elements.get(id);
|
|
if (element) {
|
|
minX = Math.min(minX, element.x);
|
|
minY = Math.min(minY, element.y);
|
|
maxX = Math.max(maxX, element.x + element.width);
|
|
maxY = Math.max(maxY, element.y + element.height);
|
|
}
|
|
}
|
|
|
|
if (minX === Infinity) return null;
|
|
|
|
return {
|
|
x: minX,
|
|
y: minY,
|
|
width: maxX - minX,
|
|
height: maxY - minY,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Scale all selected elements from a pivot point
|
|
* @param scaleX - horizontal scale factor
|
|
* @param scaleY - vertical scale factor
|
|
* @param pivotX - pivot point X in world coordinates
|
|
* @param pivotY - pivot point Y in world coordinates
|
|
*/
|
|
scaleSelected(scaleX: number, scaleY: number, pivotX: number, pivotY: number): void {
|
|
for (const id of this._selectedIds) {
|
|
const element = this._elements.get(id);
|
|
if (element) {
|
|
// Calculate new position relative to pivot
|
|
const relX = element.x - pivotX;
|
|
const relY = element.y - pivotY;
|
|
|
|
element.x = pivotX + relX * scaleX;
|
|
element.y = pivotY + relY * scaleY;
|
|
element.width = Math.max(20, element.width * scaleX);
|
|
element.height = Math.max(20, element.height * scaleY);
|
|
}
|
|
}
|
|
this.updateWorkspaceBounds();
|
|
this.notify();
|
|
}
|
|
|
|
cloneSelected(offsetX = 20, offsetY = 20): MediaElement[] {
|
|
const clones: MediaElement[] = [];
|
|
|
|
for (const id of this._selectedIds) {
|
|
const element = this._elements.get(id);
|
|
if (element) {
|
|
const clone = element.clone(offsetX, offsetY);
|
|
clone.zIndex = this._nextZIndex++;
|
|
this._elements.set(clone.id, clone);
|
|
clones.push(clone);
|
|
}
|
|
}
|
|
|
|
this.deselectAll();
|
|
for (const clone of clones) {
|
|
clone.selected = true;
|
|
this._selectedIds.add(clone.id);
|
|
}
|
|
|
|
this.updateWorkspaceBounds();
|
|
this.notify();
|
|
return clones;
|
|
}
|
|
|
|
bringToFront(): void {
|
|
for (const id of this._selectedIds) {
|
|
const element = this._elements.get(id);
|
|
if (element) {
|
|
element.zIndex = this._nextZIndex++;
|
|
}
|
|
}
|
|
this.notify();
|
|
}
|
|
|
|
sendToBack(): void {
|
|
let minZ = Infinity;
|
|
for (const element of this._elements.values()) {
|
|
if (!element.selected && element.zIndex < minZ) {
|
|
minZ = element.zIndex;
|
|
}
|
|
}
|
|
|
|
let z = Math.max(0, minZ - this._selectedIds.size);
|
|
for (const id of this._selectedIds) {
|
|
const element = this._elements.get(id);
|
|
if (element) {
|
|
element.zIndex = z++;
|
|
}
|
|
}
|
|
this.notify();
|
|
}
|
|
|
|
// ============================================
|
|
// Hit Testing
|
|
// ============================================
|
|
|
|
getElementAtPoint(screenX: number, screenY: number): MediaElement | null {
|
|
const worldX = this.screenToWorldX(screenX);
|
|
const worldY = this.screenToWorldY(screenY);
|
|
|
|
const sortedElements = Array.from(this._elements.values())
|
|
.sort((a, b) => b.zIndex - a.zIndex);
|
|
|
|
for (const element of sortedElements) {
|
|
if (isPointInElement(worldX, worldY, element)) {
|
|
return element;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
getElementsAtPoint(screenX: number, screenY: number): MediaElement[] {
|
|
const worldX = this.screenToWorldX(screenX);
|
|
const worldY = this.screenToWorldY(screenY);
|
|
|
|
return Array.from(this._elements.values())
|
|
.filter(element => isPointInElement(worldX, worldY, element))
|
|
.sort((a, b) => b.zIndex - a.zIndex);
|
|
}
|
|
|
|
// ============================================
|
|
// View Transform Operations
|
|
// ============================================
|
|
|
|
zoom(delta: number, centerX: number, centerY: number): void {
|
|
const oldScale = this._viewTransform.scale;
|
|
const newScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, oldScale * (1 + delta)));
|
|
|
|
if (newScale === oldScale) return;
|
|
|
|
const worldX = this.screenToWorldX(centerX);
|
|
const worldY = this.screenToWorldY(centerY);
|
|
|
|
this._viewTransform.scale = newScale;
|
|
this._viewTransform.translateX = centerX - worldX * newScale;
|
|
this._viewTransform.translateY = centerY - worldY * newScale;
|
|
|
|
this.clampViewToBounds();
|
|
this.notify();
|
|
}
|
|
|
|
setZoom(scale: number, centerX?: number, centerY?: number): void {
|
|
const clampedScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, scale));
|
|
|
|
if (centerX !== undefined && centerY !== undefined) {
|
|
const worldX = this.screenToWorldX(centerX);
|
|
const worldY = this.screenToWorldY(centerY);
|
|
|
|
this._viewTransform.scale = clampedScale;
|
|
this._viewTransform.translateX = centerX - worldX * clampedScale;
|
|
this._viewTransform.translateY = centerY - worldY * clampedScale;
|
|
} else {
|
|
this._viewTransform.scale = clampedScale;
|
|
}
|
|
|
|
this.clampViewToBounds();
|
|
this.notify();
|
|
}
|
|
|
|
pan(deltaX: number, deltaY: number): void {
|
|
this._viewTransform.translateX += deltaX;
|
|
this._viewTransform.translateY += deltaY;
|
|
this.clampViewToBounds();
|
|
this.notify();
|
|
}
|
|
|
|
setViewTransform(transform: Partial<ViewTransform>): void {
|
|
if (transform.scale !== undefined) {
|
|
this._viewTransform.scale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, transform.scale));
|
|
}
|
|
if (transform.translateX !== undefined) {
|
|
this._viewTransform.translateX = transform.translateX;
|
|
}
|
|
if (transform.translateY !== undefined) {
|
|
this._viewTransform.translateY = transform.translateY;
|
|
}
|
|
this.clampViewToBounds();
|
|
this.notify();
|
|
}
|
|
|
|
resetView(): void {
|
|
this._viewTransform.scale = DEFAULT_SCALE;
|
|
this._viewTransform.translateX = 0;
|
|
this._viewTransform.translateY = 0;
|
|
this.notify();
|
|
}
|
|
|
|
fitToView(canvasWidth: number, canvasHeight: number, padding = 50): void {
|
|
if (this._elements.size === 0) {
|
|
this.resetView();
|
|
return;
|
|
}
|
|
|
|
const bounds = this._workspaceBounds;
|
|
const contentWidth = bounds.maxX - bounds.minX;
|
|
const contentHeight = bounds.maxY - bounds.minY;
|
|
|
|
if (contentWidth <= 0 || contentHeight <= 0) {
|
|
this.resetView();
|
|
return;
|
|
}
|
|
|
|
const scaleX = (canvasWidth - padding * 2) / contentWidth;
|
|
const scaleY = (canvasHeight - padding * 2) / contentHeight;
|
|
const scale = Math.min(scaleX, scaleY, MAX_SCALE);
|
|
|
|
const centerX = (bounds.minX + bounds.maxX) / 2;
|
|
const centerY = (bounds.minY + bounds.maxY) / 2;
|
|
|
|
this._viewTransform.scale = scale;
|
|
this._viewTransform.translateX = canvasWidth / 2 - centerX * scale;
|
|
this._viewTransform.translateY = canvasHeight / 2 - centerY * scale;
|
|
this.notify();
|
|
}
|
|
|
|
// ============================================
|
|
// Coordinate Conversion
|
|
// ============================================
|
|
|
|
screenToWorldX(screenX: number): number {
|
|
return (screenX - this._viewTransform.translateX) / this._viewTransform.scale;
|
|
}
|
|
|
|
screenToWorldY(screenY: number): number {
|
|
return (screenY - this._viewTransform.translateY) / this._viewTransform.scale;
|
|
}
|
|
|
|
screenToWorld(screenX: number, screenY: number): { x: number; y: number } {
|
|
return {
|
|
x: this.screenToWorldX(screenX),
|
|
y: this.screenToWorldY(screenY),
|
|
};
|
|
}
|
|
|
|
worldToScreenX(worldX: number): number {
|
|
return worldX * this._viewTransform.scale + this._viewTransform.translateX;
|
|
}
|
|
|
|
worldToScreenY(worldY: number): number {
|
|
return worldY * this._viewTransform.scale + this._viewTransform.translateY;
|
|
}
|
|
|
|
worldToScreen(worldX: number, worldY: number): { x: number; y: number } {
|
|
return {
|
|
x: this.worldToScreenX(worldX),
|
|
y: this.worldToScreenY(worldY),
|
|
};
|
|
}
|
|
|
|
// ============================================
|
|
// Workspace Bounds
|
|
// ============================================
|
|
|
|
private updateWorkspaceBounds(): void {
|
|
if (this._elements.size === 0) {
|
|
this._workspaceBounds = {
|
|
minX: -1000,
|
|
minY: -1000,
|
|
maxX: 1000,
|
|
maxY: 1000,
|
|
};
|
|
return;
|
|
}
|
|
|
|
let minX = Infinity;
|
|
let minY = Infinity;
|
|
let maxX = -Infinity;
|
|
let maxY = -Infinity;
|
|
|
|
for (const element of this._elements.values()) {
|
|
const box = element.getBoundingBox();
|
|
minX = Math.min(minX, box.x);
|
|
minY = Math.min(minY, box.y);
|
|
maxX = Math.max(maxX, box.x + box.width);
|
|
maxY = Math.max(maxY, box.y + box.height);
|
|
}
|
|
|
|
this._workspaceBounds = {
|
|
minX: minX - WORKSPACE_PADDING,
|
|
minY: minY - WORKSPACE_PADDING,
|
|
maxX: maxX + WORKSPACE_PADDING,
|
|
maxY: maxY + WORKSPACE_PADDING,
|
|
};
|
|
}
|
|
|
|
private clampViewToBounds(): void {
|
|
// Ensures workspace is always at least partially visible
|
|
}
|
|
|
|
getWorkspaceBounds(): WorkspaceBounds {
|
|
return { ...this._workspaceBounds };
|
|
}
|
|
|
|
// ============================================
|
|
// Scene Operations
|
|
// ============================================
|
|
|
|
clear(): void {
|
|
this._elements.clear();
|
|
this._selectedIds.clear();
|
|
this._nextZIndex = 1;
|
|
this._viewTransform = {
|
|
scale: DEFAULT_SCALE,
|
|
translateX: 0,
|
|
translateY: 0,
|
|
};
|
|
this.updateWorkspaceBounds();
|
|
this.notify();
|
|
}
|
|
|
|
getAllElements(): MediaElement[] {
|
|
return Array.from(this._elements.values());
|
|
}
|
|
|
|
getElementCount(): number {
|
|
return this._elements.size;
|
|
}
|
|
|
|
getSelectedCount(): number {
|
|
return this._selectedIds.size;
|
|
}
|
|
|
|
getState(): {
|
|
elements: IMediaElement[];
|
|
viewTransform: ViewTransform;
|
|
workspaceBounds: WorkspaceBounds;
|
|
} {
|
|
return {
|
|
elements: Array.from(this._elements.values()).map(el => el.toJSON()),
|
|
viewTransform: { ...this._viewTransform },
|
|
workspaceBounds: { ...this._workspaceBounds },
|
|
};
|
|
}
|
|
|
|
restoreState(state: {
|
|
elements: IMediaElement[];
|
|
viewTransform?: ViewTransform;
|
|
}): void {
|
|
this.clear();
|
|
|
|
for (const elementData of state.elements) {
|
|
const element = MediaElement.fromJSON(elementData);
|
|
this.addExistingElement(element);
|
|
}
|
|
|
|
if (state.viewTransform) {
|
|
this.setViewTransform(state.viewTransform);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// Singleton Instance
|
|
// ============================================
|
|
|
|
let sceneManagerInstance: SceneManager | null = null;
|
|
|
|
export function getSceneManager(): SceneManager {
|
|
if (!sceneManagerInstance) {
|
|
sceneManagerInstance = new SceneManager();
|
|
}
|
|
return sceneManagerInstance;
|
|
}
|
|
|
|
export function resetSceneManager(): void {
|
|
sceneManagerInstance = null;
|
|
}
|
|
|
|
// Re-export element utilities
|
|
export {
|
|
MediaElement,
|
|
generateElementId,
|
|
calculateBoundingBox,
|
|
isPointInElement,
|
|
doElementsIntersect,
|
|
doesElementIntersectRect,
|
|
createImageElement,
|
|
createGifElement,
|
|
createVideoElement,
|
|
createTextElement,
|
|
createYouTubeElement,
|
|
} from './element';
|