/** * PixiCanvas — main React component wrapping PixiJS v8 + pixi-viewport. * * Drop-in replacement for FabricCanvas.tsx. Manages the Application lifecycle, * viewport (pan/zoom), scene loading, and exposes an imperative handle for * parent components. */ import { forwardRef, useEffect, useImperativeHandle, useRef, useCallback, useState, } from 'react'; import { Application } from 'pixi.js'; import { Viewport } from 'pixi-viewport'; import { SceneManager, getItemWorldBounds, type SceneItem } from './SceneManager'; import { TextureManager } from './TextureManager'; import { ImageSprite } from './sprites/ImageSprite'; import { VideoSprite } from './sprites/VideoSprite'; import { SpringManager } from './spring'; import { convertFabricToV2 } from './scene-format'; import type { SceneData } from './scene-format'; // --------------------------------------------------------------------------- // Public interfaces // --------------------------------------------------------------------------- export interface PixiCanvasHandle { getViewport: () => Viewport | null; getScene: () => SceneManager | null; getApp: () => Application | null; fitAll: () => void; getZoom: () => number; setZoom: (zoom: number) => void; } export interface PixiCanvasProps { canvasState?: string | object | null; currentTool: string; boardId?: string; onChange?: () => void; } // --------------------------------------------------------------------------- // Viewport persistence helpers // --------------------------------------------------------------------------- interface ViewportState { x: number; y: number; scale: number; } function vpStorageKey(boardId: string): string { return `refboard:viewport:${boardId}`; } function loadViewportState(boardId: string): ViewportState | null { try { const raw = localStorage.getItem(vpStorageKey(boardId)); if (!raw) return null; const parsed = JSON.parse(raw); if ( typeof parsed.x === 'number' && typeof parsed.y === 'number' && typeof parsed.scale === 'number' ) { return parsed as ViewportState; } } catch { // ignore malformed data } return null; } function saveViewportState(boardId: string, vp: Viewport): void { const center = vp.center; const state: ViewportState = { x: center.x, y: center.y, scale: vp.scale.x, }; try { localStorage.setItem(vpStorageKey(boardId), JSON.stringify(state)); } catch { // storage full — not critical } } // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- const PixiCanvas = forwardRef( function PixiCanvas({ canvasState, currentTool, boardId, onChange }, ref) { const containerRef = useRef(null); const appRef = useRef(null); const viewportRef = useRef(null); const sceneRef = useRef(null); const initialLoadDone = useRef(false); const spaceHeld = useRef(false); const onChangeRef = useRef(onChange); const [pixiReady, setPixiReady] = useState(false); // Keep onChange ref current without re-running effects onChangeRef.current = onChange; // ── Initialization (runs once) ──────────────────────────────────── useEffect(() => { const container = containerRef.current; if (!container) return; let destroyed = false; const app = new Application(); const textures = new TextureManager(); const springs = new SpringManager(); (async () => { await app.init({ backgroundAlpha: 0, resizeTo: container, antialias: true, autoDensity: true, resolution: window.devicePixelRatio, preserveDrawingBuffer: true, }); if (destroyed) { app.destroy(true, { children: true }); return; } container.appendChild(app.canvas as HTMLCanvasElement); // -- Viewport ------------------------------------------------------- const viewport = new Viewport({ screenWidth: container.clientWidth, screenHeight: container.clientHeight, worldWidth: 4000, worldHeight: 4000, events: app.renderer.events, }); viewport .drag({ mouseButtons: 'middle' }) .pinch() .wheel({ smooth: 5, percent: 0.08 }) .decelerate({ friction: 0.92 }) .clampZoom({ minScale: 0.1, maxScale: 5 }); app.stage.addChild(viewport); // -- Scene Manager --------------------------------------------------- const scene = new SceneManager(viewport, textures, springs); scene.onChange = () => onChangeRef.current?.(); // -- Spring ticker --------------------------------------------------- app.ticker.add((ticker) => { springs.tick(ticker.deltaMS / 1000); }); // -- Visibility culling ticker (runs every 200ms, not every frame) -- // Manages GPU/memory budgets for both images and videos. // Distance-based priority: nearest to viewport center get resources first. const MAX_LOADED_TEXTURES = 60; // max image textures in GPU const MAX_INITIALIZED_VIDEOS = 6; // max