feat: 初始化项目基础架构和核心功能
添加项目基础文件结构,包括Tauri配置、前端框架和核心功能模块 实现WebGPU渲染器基础、纹理管理和着色器 添加UI组件包括标题栏、确认对话框、帮助窗口等 设置状态管理存储和工具函数 配置构建工具和开发环境
This commit is contained in:
677
src/core/scene/index.ts
Normal file
677
src/core/scene/index.ts
Normal file
@@ -0,0 +1,677 @@
|
||||
/**
|
||||
* 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';
|
||||
Reference in New Issue
Block a user