/** * 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 = new Map(); private _selectedIds: Set = 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 = ref(0); readonly elements: ComputedRef; readonly selectedElements: ComputedRef; readonly viewTransform: ComputedRef; readonly hasSelection: ComputedRef; 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> ): 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): 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';