Files
pureref-tauri/src/core/scene/index.ts
小煜 f5b8a563cb feat: 初始化项目基础架构和核心功能
添加项目基础文件结构,包括Tauri配置、前端框架和核心功能模块
实现WebGPU渲染器基础、纹理管理和着色器
添加UI组件包括标题栏、确认对话框、帮助窗口等
设置状态管理存储和工具函数
配置构建工具和开发环境
2025-12-19 16:22:27 +08:00

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