my-multiplayer-app / scripts /ColorRmApp.js
Jaimodiji's picture
Upload folder using huggingface_hub
aeb61fc verified
import { ColorRmRenderer } from './modules/ColorRmRenderer.js';
import { ColorRmStorage } from './modules/ColorRmStorage.js';
import { ColorRmBox } from './modules/ColorRmBox.js';
import { ColorRmInput } from './modules/ColorRmInput.js';
import { ColorRmUI } from './modules/ColorRmUI.js';
import { ColorRmSession } from './modules/ColorRmSession.js';
import { ColorRmExport } from './modules/ColorRmExport.js';
import { PerformanceManager } from './modules/ColorRmPerformance.js';
import { ColorRmSvgImporter } from './modules/ColorRmSvgImporter.js';
export class ColorRmApp {
constructor(config = {}) {
this.config = {
isMain: true,
container: null,
collaborative: true, // Set to false for local-only mode (split view)
dbName: 'ColorRM_SOTA_V12', // Allow separate DB for split view
...config
};
this.container = this.config.container;
this.state = {
sessionId: null, images: [], idx: 0,
colors: [], customSwatches: JSON.parse(localStorage.getItem('crm_custom_colors') || '[]'),
strict: 15, tool: 'none', bg: 'transparent',
penColor: '#ef4444', penSize: 3, eraserSize: 20, eraserType: 'stroke',
textSize: 40,
shapeType: 'rectangle', shapeBorder: '#3b82f6', shapeFill: 'transparent', shapeWidth: 3,
selection: [], dlSelection: [], isLivePreview: false, guideLines: [], activeShapeRatio: false, previewOn: false,
bookmarks: [], activeSideTab: 'tools', projectName: "Untitled", baseFileName: null,
clipboardBox: [],
ownerId: null, pageLocked: false,
selectedSessions: new Set(), isMultiSelect: false, showCursors: true,
zoom: 1, pan: { x: 0, y: 0 },
// Sync control
syncEnabled: true,
// Eraser options
eraserOptions: {
scribble: true,
text: true,
shapes: true,
images: false
}
};
this.cache = {
currentImg: null,
lab: null,
// Offscreen canvas for caching committed strokes
committedCanvas: null,
committedCtx: null,
lastHistoryLength: 0, // Track when to invalidate cache
isDirty: true // Flag to rebuild cache
};
this.db = null;
// Performance flags
this.renderPending = false;
this.saveTimeout = null;
this.ui = null;
this.liveSync = null;
this.registry = null;
this.iroP = null;
// SOTA Performance Manager
this.performanceManager = new PerformanceManager();
this.lastCursorUpdateTime = 0;
this.cursorUpdateThrottle = 30; // 30ms throttle, approx 33fps
}
async init(ui, registry, LiveSyncClient) {
this.ui = ui;
this.registry = registry;
// 1. Initialize Database (use configured DB name)
this.db = await new Promise(r => {
const req = indexedDB.open(this.config.dbName, 2);
req.onupgradeneeded = e => {
const d = e.target.result;
if(!d.objectStoreNames.contains('sessions')) d.createObjectStore('sessions', { keyPath: 'id' });
if(!d.objectStoreNames.contains('pages')) d.createObjectStore('pages', { keyPath: 'id' }).createIndex('sessionId','sessionId');
if(!d.objectStoreNames.contains('folders')) d.createObjectStore('folders', { keyPath: 'id' });
};
req.onsuccess = e => r(e.target.result);
});
// Only sync registry for collaborative mode
if (this.config.collaborative && this.registry) {
await this.registry.sync();
}
// 2. Setup UI
this.setupUI();
this.setupDrawing();
this.makeDraggable();
this.setupShortcuts();
// Check for PDF import redirect immediately
const urlParams = new URLSearchParams(window.location.search);
const importPdfUri = urlParams.get('importPdf');
// Priority: Check session storage for pending MULTI-FILE imports
// If found, we skip the URL param to avoid double import (one from URL, one from storage)
const hasPendingFiles = sessionStorage.getItem('pending_shared_uris');
if (importPdfUri && !hasPendingFiles) {
console.log("ColorRmApp: Found importPdf param early:", importPdfUri);
this.ui.showToast("Importing PDF...");
}
// 3. Initialize PDF.js Worker
if (window.pdfjsLib) {
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
}
// 4. Parse URL early to detect beta mode BEFORE creating LiveSyncClient
let ownerId, projectId;
if (this.config.isMain) {
// Parse URL for Main App
const hashPath = window.location.hash.replace(/^#\/?/, '');
const parts = hashPath.split('/').filter(Boolean);
// Detect beta mode from URL: /#/beta/color_rm/{ownerId}/{projectId}
if (parts[0] === 'beta') {
this.config.useBetaSync = true;
parts.shift(); // Remove 'beta' prefix
console.log('[ColorRM] Beta mode enabled - using Yjs sync');
}
ownerId = parts[1];
projectId = parts[2];
}
// 5. Initialize LiveSync (only for collaborative mode)
// Now useBetaSync is already set if beta URL was detected
if (this.config.collaborative && LiveSyncClient) {
this.liveSync = new LiveSyncClient(this);
// Failsafe for missing userId
const regUser = this.registry ? this.registry.getUsername() : null;
if (regUser) this.liveSync.userId = regUser;
if (!this.liveSync.userId) {
this.liveSync.userId = `user_${Math.random().toString(36).substring(2, 9)}`;
localStorage.setItem('color_rm_user_id', this.liveSync.userId);
}
} else {
// Ensure LiveSync is null if not collaborative
this.liveSync = null;
}
if (this.config.isMain) {
// If owner or project is missing from URL, try to load last project OR show dashboard
// SKIP THIS IF IMPORTING PDF - we will create a new project anyway
if ((!ownerId || !projectId) && !importPdfUri) {
const lastSess = await this.db.transaction('sessions', 'readonly').objectStore('sessions').getAll();
if (lastSess && lastSess.length > 0) {
const latest = lastSess.sort((a,b) => b.lastMod - a.lastMod)[0];
ownerId = latest.ownerId || (this.liveSync ? this.liveSync.userId : 'local');
projectId = latest.id;
// Preserve beta prefix if in beta mode
const prefix = this.config.useBetaSync ? '#/beta/color_rm' : '#/color_rm';
window.location.replace(`${prefix}/${ownerId}/${projectId}`);
} else {
this.ui.showDashboard();
return;
}
}
} else {
// Secondary App: Use config provided IDs or default to empty/new
ownerId = this.config.ownerId || (this.liveSync ? this.liveSync.userId : 'local');
projectId = this.config.projectId;
if (!projectId) {
// If no project provided for split view, wait for PDF import
console.log("ColorRmApp (Secondary): No projectId provided. Ready for import.");
return;
}
}
// If not importing, open session normally
if (!importPdfUri) {
this.state.ownerId = ownerId;
this.state.sessionId = projectId;
await this.openSession(projectId);
// Only initialize LiveSync for collaborative mode
if (this.config.collaborative && this.liveSync) {
await this.liveSync.init(ownerId, projectId);
}
// 5. Sync Base File (only for collaborative mode)
if (this.config.collaborative) {
try {
const res = await fetch(window.Config?.apiUrl(`/api/color_rm/base_file/${projectId}`) || `/api/color_rm/base_file/${projectId}`, { method: 'GET' });
if (res.ok) {
if (this.state.images.length === 0) {
console.log("Liveblocks: Downloading base file from server...");
const blob = await res.blob();
await this.importBaseFile(blob);
if (this.liveSync && this.liveSync.syncHistory) this.liveSync.syncHistory();
}
} else if (res.status === 404) {
if (this.state.images.length > 0 && this.state.images[0].blob) {
console.log("Liveblocks: Server missing base file. Healing/Uploading...");
this.reuploadBaseFile();
}
}
} catch(e) {
console.error("Liveblocks: Sync check error:", e);
}
}
}
// 6. Initialize Android Intent Handling for URLs and PDF files
this.initializeAndroidIntentHandling();
// 7. Process PDF Import if present AND no pending files in storage
if (importPdfUri && !hasPendingFiles) {
console.log("ColorRmApp: Processing deferred importPdf:", importPdfUri);
// Clean URL
window.history.replaceState({}, document.title, window.location.pathname + window.location.hash);
// Execute immediately with safety wrapper
try {
console.log("ColorRmApp: Executing handlePdfFileFromUri NOW for:", importPdfUri);
this.handlePdfFileFromUri(importPdfUri);
} catch(e) {
console.error("ColorRmApp: CRITICAL ERROR handling importPdf:", e);
this.ui.showToast("Critical Error Importing PDF");
}
}
}
// Initialize Android Intent Handling for URLs and PDF files
initializeAndroidIntentHandling() {
console.log("ColorRmApp: initializeAndroidIntentHandling started");
// Expose global handler for Native Android calls (MainActivity.java)
window.handleSharedFile = (uri) => {
console.log("ColorRmApp: Global handleSharedFile called with:", uri);
this.handlePdfFileFromUri(uri);
};
window.handleSharedUrl = (url) => {
console.log("ColorRmApp: Global handleSharedUrl called with:", url);
this.handleIncomingUrl(url);
};
window.handleSharedFiles = (uris) => {
console.log("ColorRmApp: Global handleSharedFiles called with:", uris);
this.handlePdfFilesFromUris(uris);
};
const checkNative = () => {
if (window.AndroidNative) {
try {
const pendingFile = window.AndroidNative.getPendingFileUri?.();
if (pendingFile) {
console.log('ColorRmApp: Found native pending file:', pendingFile);
this.handlePdfFileFromUri(pendingFile);
}
const pendingFiles = window.AndroidNative.getPendingFileUris?.();
if (pendingFiles) {
console.log('ColorRmApp: Found native pending files:', pendingFiles);
try {
const uris = JSON.parse(pendingFiles);
if (Array.isArray(uris) && uris.length > 0) {
this.handlePdfFilesFromUris(uris);
}
} catch (e) {
console.error("ColorRmApp: Error parsing pending files JSON:", e);
}
}
const pendingText = window.AndroidNative.getPendingSharedText?.();
if (pendingText) {
console.log('ColorRmApp: Found native pending text:', pendingText);
this.handleIncomingUrl(pendingText);
}
} catch (e) {
console.error("ColorRmApp: Error checking native pending intents:", e);
}
}
};
// 1. Process JS-buffered items
if (window.pendingFileUri) {
console.log("ColorRmApp: Processing buffered file intent:", window.pendingFileUri);
this.handlePdfFileFromUri(window.pendingFileUri);
window.pendingFileUri = null;
}
if (window.pendingUrl) {
console.log("ColorRmApp: Processing buffered url intent:", window.pendingUrl);
this.handleIncomingUrl(window.pendingUrl);
window.pendingUrl = null;
}
// Check sessionStorage for multi-file imports from React redirect
const sessionUris = sessionStorage.getItem('pending_shared_uris');
if (sessionUris) {
try {
console.log("ColorRmApp: Found pending URIs in storage");
const uris = JSON.parse(sessionUris);
sessionStorage.removeItem('pending_shared_uris'); // Clear immediately
if (Array.isArray(uris) && uris.length > 0) {
this.handlePdfFilesFromUris(uris);
return; // Skip native check to avoid double processing if one matches
}
} catch (e) {
console.error("ColorRmApp: Error parsing session URIs", e);
}
}
// 2. Poll Native-buffered items immediately
checkNative();
// 3. Setup Capacitor Listeners (if available)
if (window.Capacitor && window.Capacitor.Plugins) {
const { Plugins } = window.Capacitor;
const App = Plugins.App;
if (App) {
App.addListener('appUrlOpen', (data) => {
console.log('ColorRmApp: App URL opened:', data.url);
if (data.url) {
if (data.url.startsWith('file://') || data.url.startsWith('content://')) {
this.handlePdfFileFromUri(data.url);
} else {
this.handleIncomingUrl(data.url);
}
}
});
App.addListener('appStateChange', (state) => {
if (state.isActive) {
console.log("ColorRmApp: App resumed, checking native buffer...");
checkNative();
}
});
// Check launch URL
App.getLaunchUrl().then(ret => {
if (ret && ret.url) {
console.log('ColorRmApp: Launch URL:', ret.url);
if (ret.url.startsWith('file://') || ret.url.startsWith('content://')) {
this.handlePdfFileFromUri(ret.url);
} else {
this.handleIncomingUrl(ret.url);
}
}
});
}
}
}
// Check for initial intent when app starts
async checkInitialIntent() {
if (window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.App) {
try {
const { App } = window.Capacitor.Plugins;
const launchInfo = await App.getLaunchUri();
if (launchInfo && launchInfo.url) {
const url = launchInfo.url;
console.log('Initial launch URL:', url);
// Check if it's a file URL
if (url.startsWith('file://') || url.startsWith('content://')) {
if (url.toLowerCase().endsWith('.pdf')) {
console.log('PDF file opened via initial intent:', url);
this.handlePdfFileFromUri(url);
}
} else {
// Handle as regular URL
this.handleIncomingUrl(url);
}
}
} catch (error) {
console.error('Error checking initial intent:', error);
}
}
}
// Handle incoming URLs (deep links, project links, etc.)
handleIncomingUrl(url) {
try {
// Check for file/content schemes first
if (url.startsWith('file://') || url.startsWith('content://')) {
console.log("ColorRmApp: Handling content/file URI directly:", url);
this.handlePdfFileFromUri(url);
return;
}
const parsedUrl = new URL(url);
const pathname = parsedUrl.pathname;
const hash = parsedUrl.hash;
// Check if this is a ColorRM project URL
const colorRmRegex = /\/color_rm\/([^\/]+)\/([^\/]+)/;
const match = pathname.match(colorRmRegex) || hash.match(colorRmRegex);
if (match) {
// Extract ownerId and projectId from the URL
const ownerId = match[1];
const projectId = match[2];
// Switch to the specified project
this.switchProject(ownerId, projectId);
} else {
// For other URLs, try to parse as a hash route
const hashRoute = hash.replace(/^#\/?/, '');
if (hashRoute) {
// Navigate using the hash router
window.location.hash = `#${hashRoute}`;
// Reload the page to handle the new route
location.reload();
}
}
} catch (error) {
console.error('Error handling incoming URL:', error);
}
}
// Handle incoming PDF files from file path
async handlePdfFile(filePath) {
try {
// For file paths, we need to read the file differently
// This function handles traditional file paths
const file = await this.convertFilePathToFileObject(filePath);
if (file) {
// Check if a project with the same name already exists
const fileName = filePath.split('/').pop() || 'imported_pdf.pdf';
const projectName = fileName.replace(/\.[^/.]+$/, ""); // Remove extension
const existingProjects = await this.dbGetAll('sessions');
const existingProject = existingProjects.find(proj => proj.name === projectName);
if (existingProject) {
// Ask user if they want to create a new project or open existing
const choice = await this.ui.showPrompt(
"Project Already Exists",
"Type 'open' to open existing project, or 'new' to create a new one",
"open"
);
if (choice && choice.toLowerCase() === 'new') {
// Create new project with PDF
await this.createProjectFromPdf(file, `${projectName}_copy`);
} else {
// Open existing project
this.switchProject(existingProject.ownerId || 'local', existingProject.id);
}
} else {
// Create new project with PDF
await this.createProjectFromPdf(file, projectName);
}
}
} catch (error) {
console.error('Error handling PDF file:', error);
this.ui.showToast('Error opening PDF file');
}
}
// Handle incoming PDF files from URI (file:// or content://)
async handlePdfFileFromUri(uri) {
console.log("handlePdfFileFromUri: Starting with URI:", uri);
try {
// Convert URI to file object
const file = await this.convertUriToFileObject(uri);
if (file) {
console.log("handlePdfFileFromUri: File object created:", file.name, file.size, file.type);
// Check if a project with the same name already exists
// Use file.name as it's more reliable (handled in convertUriToFileObject)
const fileName = file.name;
const projectName = fileName.replace(/\.[^/.]+$/, ""); // Remove extension
const existingProjects = await this.dbGetAll('sessions');
const existingProject = existingProjects.find(proj => proj.name === projectName);
if (existingProject) {
console.log("handlePdfFileFromUri: Project exists, asking user...");
// Ask user if they want to create a new project or open existing
const choice = await this.ui.showPrompt(
"Project Already Exists",
"Type 'open' to open existing project, or 'new' to create a new one",
"open"
);
if (choice && choice.toLowerCase() === 'new') {
// Create new project with PDF
await this.createProjectFromPdf(file, `${projectName}_copy`);
} else {
// Open existing project
this.switchProject(existingProject.ownerId || 'local', existingProject.id);
}
} else {
console.log("handlePdfFileFromUri: Creating new project...");
// Create new project with PDF
await this.createProjectFromPdf(file, projectName);
}
} else {
console.error("handlePdfFileFromUri: Failed to create File object from URI");
this.ui.showToast('Failed to load file from shared URI');
}
} catch (error) {
console.error('Error handling PDF file from URI:', error);
this.ui.showToast('Error opening PDF file');
}
}
// Handle multiple incoming PDF files from URIs
async handlePdfFilesFromUris(uris) {
console.log("handlePdfFilesFromUris: Starting with URIs:", uris);
try {
this.ui.showToast("Processing multiple files...");
const files = [];
for (const uri of uris) {
const file = await this.convertUriToFileObject(uri);
if (file) {
files.push(file);
} else {
console.error("Failed to convert URI to file:", uri);
}
}
if (files.length > 0) {
console.log(`handlePdfFilesFromUris: Converted ${files.length} files. Starting bulk import loop...`);
this.handleExternalFiles(files);
} else {
this.ui.showToast("Failed to process shared files");
}
} catch (error) {
console.error('Error handling multiple PDF files:', error);
this.ui.showToast('Error opening files');
}
}
async handleExternalFiles(files) {
this.ui.toggleLoader(true, `Importing ${files.length} projects...`);
// We set isBulkImporting to true to influence project naming logic in ColorRmSession
this.isBulkImporting = true;
try {
for (let i = 0; i < files.length; i++) {
const file = files[i];
// Create a unique project name based on the file
const firstFileName = file.name || `Imported Project ${i+1}`;
const projectName = firstFileName.replace(/\.[^/.]+$/, "");
const projectId = `proj_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
const ownerId = this.liveSync?.userId || 'local';
this.ui.updateProgress((i / files.length) * 100, `Creating project ${i + 1} of ${files.length}: ${projectName}...`);
console.log(`handleExternalFiles: Creating project ${i+1}/${files.length}: ${projectName}`);
// 1. Create the new project structure
await this.createNewProject(false, projectId, ownerId, projectName);
// 2. Import the file into this project
// Note: passing skipUpload=false so it uploads to server if online
// passing lazy=true so it only gets metadata (page count) and doesn't process images yet
await this.handleImport({ target: { files: [file] } }, false, true);
// Small delay to ensure DB writes settle before next iteration
await new Promise(r => setTimeout(r, 500));
}
} catch(e) {
console.error("Bulk import failed:", e);
this.ui.showToast("Import error occurred");
} finally {
this.isBulkImporting = false;
this.ui.toggleLoader(false);
this.ui.showToast(`Imported ${files.length} projects`);
// Reload the session list or dashboard to show new projects
// If we are currently in a project view, we might want to go to dashboard
this.ui.showDashboard();
if (this.loadSessionList) this.loadSessionList();
}
}
// Convert file path to File object
async convertFilePathToFileObject(filePath) {
try {
if (window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {
const { Filesystem } = window.Capacitor.Plugins;
// Read the PDF file
const fileData = await Filesystem.readFile({
path: filePath
});
// Convert base64 data to Blob
const binaryString = atob(fileData.data);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
const blob = new Blob([bytes], { type: 'application/pdf' });
// Create a file object from the blob
const fileName = filePath.split('/').pop() || 'imported_pdf.pdf';
return new File([blob], fileName, { type: 'application/pdf' });
}
} catch (error) {
console.error('Error converting file path to file object:', error);
}
return null;
}
// Convert URI to File object
async convertUriToFileObject(uri) {
console.log("convertUriToFileObject: Converting:", uri);
try {
// Priority: Try Android Native Interface for content:// URIs
if (uri.startsWith('content://') && window.AndroidNative && window.AndroidNative.readContentUri) {
console.log("Using AndroidNative to read content URI:", uri);
const base64Data = window.AndroidNative.readContentUri(uri);
if (base64Data) {
console.log("AndroidNative read successful, data length:", base64Data.length);
const binaryString = atob(base64Data);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
const blob = new Blob([bytes], { type: 'application/pdf' });
// Try to get filename from URI
let fileName = 'imported_pdf.pdf';
// 1. Try Native getFileName
if (window.AndroidNative && window.AndroidNative.getFileName) {
const nativeName = window.AndroidNative.getFileName(uri);
if (nativeName) fileName = nativeName;
}
// 2. Fallback to URI parsing
else {
try {
const parts = uri.split('/');
const lastPart = parts[parts.length - 1];
if (lastPart && lastPart.indexOf('.') > -1) {
fileName = decodeURIComponent(lastPart);
}
} catch(e) {}
}
console.log("Created File object:", fileName);
return new File([blob], fileName, { type: 'application/pdf' });
} else {
console.error("AndroidNative returned null/empty for URI");
}
}
if (window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Filesystem) {
const { Filesystem } = window.Capacitor.Plugins;
// For content:// URIs, we need to handle them differently
// First, try to read directly if it's a file:// URI
if (uri.startsWith('file://')) {
const filePath = uri.substring(7); // Remove 'file://' prefix
return await this.convertFilePathToFileObject(filePath);
} else if (uri.startsWith('content://')) {
// Fallback to Capacitor Filesystem if Native interface didn't work
console.warn("AndroidNative not available, trying Capacitor Filesystem for content URI");
const fileData = await Filesystem.readFile({
path: uri
});
// Convert base64 data to Blob
const binaryString = atob(fileData.data);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
const blob = new Blob([bytes], { type: 'application/pdf' });
// Create a file object from the blob
const fileName = uri.split('/').pop().split('?')[0] || 'imported_pdf.pdf';
return new File([blob], fileName, { type: 'application/pdf' });
}
}
} catch (error) {
console.error('Error converting URI to file object:', error);
}
return null;
}
// Create a new project from a PDF file
async createProjectFromPdf(file, projectName) {
try {
// Generate a unique project ID
const projectId = `proj_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
const ownerId = this.liveSync?.userId || 'local';
// Create a new project
await this.createNewProject(false, projectId, ownerId, projectName);
// Import the PDF file
const event = { target: { files: [file] } };
await this.handleImport(event, true); // Pass true to skip upload for local file
this.ui.showToast(`Created new project from PDF: ${projectName}`);
} catch (error) {
console.error('Error creating project from PDF:', error);
this.ui.showToast('Error creating project from PDF');
}
}
// Check for any pending intents when app becomes active
async checkPendingIntents() {
if (window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.App) {
try {
const { App } = window.Capacitor.Plugins;
const savedIntent = await App.getLaunchUri();
if (savedIntent && savedIntent.url) {
// Check if it's a file URL or content URL
const url = savedIntent.url;
if (url.startsWith('file://') || url.startsWith('content://')) {
this.handlePdfFileFromUri(url);
} else {
this.handleIncomingUrl(url);
}
}
} catch (error) {
console.error('Error checking pending intents:', error);
}
}
}
getElement(id) {
if (this.container) {
// Try scoped lookup first
const el = this.container.querySelector(`#${id}`);
if (el) return el;
// Optional: fallback to class or data attribute if we move away from IDs
const dataEl = this.container.querySelector(`[data-id="${id}"]`);
if (dataEl) return dataEl;
// When container is set, do NOT fall back to document.getElementById
return null;
}
return document.getElementById(id);
}
async openSession(id) {
this.state.sessionId = id;
const session = await this.dbGet('sessions', id);
if(session) {
this.state.projectName = session.name || "Untitled";
this.state.ownerId = session.ownerId || this.state.ownerId;
const titleEl = this.getElement('headerTitle');
if (titleEl) titleEl.innerText = session.name;
if(session.state) Object.assign(this.state, session.state);
if(!this.state.bookmarks) this.state.bookmarks = [];
if(!this.state.clipboardBox) this.state.clipboardBox = [];
if(this.state.showCursors === undefined) this.state.showCursors = true;
// SOTA preference defaults
if(!this.state.eraserOptions) this.state.eraserOptions = { scribble: true, text: true, shapes: true, images: false };
if(!this.state.lassoOptions) this.state.lassoOptions = { scribble: true, text: true, shapes: true, images: true };
if(this.state.eraserType === undefined) this.state.eraserType = 'stroke';
if(this.state.stabilization === undefined) this.state.stabilization = 0;
if(this.state.holdToShape === undefined) this.state.holdToShape = false;
if(this.state.spenEngineEnabled === undefined) this.state.spenEngineEnabled = true;
const cToggle = this.getElement('cursorToggle');
if(cToggle) cToggle.checked = this.state.showCursors;
this.renderBookmarks();
if(this.liveSync && this.liveSync.renderCursors) this.liveSync.renderCursors();
}
return new Promise((resolve) => {
const q = this.db.transaction('pages').objectStore('pages').index('sessionId').getAll(id);
q.onsuccess = () => {
this.state.images = q.result.sort((a,b)=>a.pageIndex-b.pageIndex);
this.ui.hideDashboard();
this.updateLockUI();
const targetIdx = (session && session.idx !== undefined) ? session.idx : 0;
if(this.state.images.length>0) {
this.loadPage(targetIdx).then(resolve);
} else {
resolve();
}
if(this.state.activeSideTab === 'pages') this.renderPageSidebar();
if(this.state.activeSideTab === 'box') this.renderBox();
}
q.onerror = () => resolve();
});
}
async loadPage(i, broadcast = true) {
if(i<0 || i>=this.state.images.length) return;
// Debounce rapid navigation - queue the target and animate to it
const now = Date.now();
const navigationCooldown = 50; // Reduced from 150ms for snappier feel
if (this._lastNavTime && now - this._lastNavTime < navigationCooldown) {
// Queue this as the target page
this._queuedPage = i;
if (!this._navDebounceTimer) {
this._navDebounceTimer = setTimeout(() => {
this._navDebounceTimer = null;
if (this._queuedPage !== null && this._queuedPage !== this.state.idx) {
this.loadPage(this._queuedPage, broadcast);
}
this._queuedPage = null;
}, navigationCooldown);
}
return;
}
this._lastNavTime = now;
// Determine animation direction
const direction = i > this.state.idx ? 'left' : 'right';
const viewport = this.getElement('viewport');
const canvas = this.getElement('canvas');
// Skip animation if only one page or same page
const shouldAnimate = this.state.images.length > 1 && this.state.idx !== i;
// Apply exit animation non-blocking
if (canvas && viewport && shouldAnimate) {
canvas.style.transition = 'transform 0.15s ease-out, opacity 0.15s ease-out';
canvas.style.transform = direction === 'left' ? 'translateX(-30px)' : 'translateX(30px)';
canvas.style.opacity = '0.6'; // Less transparent for better feel
// Removed blocking await
}
// Auto-compact current page before switching (if leaving a page)
if (this.state.idx !== i && this.state.images[this.state.idx]) {
this.checkAutoCompact();
}
// Invalidate cache when loading new page
this.invalidateCache();
// Mark this as a local page change to prevent sync conflicts
if (broadcast && this.liveSync) {
this.liveSync.lastLocalPageChange = Date.now();
}
if (this.liveSync) {
const project = this.liveSync.getProject();
if (project) {
const remoteHistory = project.get("pagesHistory").get(i.toString());
if (remoteHistory) {
this.state.images[i].history = remoteHistory.toArray();
}
}
}
if (broadcast && this.state.pageLocked && this.state.ownerId !== this.liveSync.userId) {
this.ui.showToast("Page is locked by presenter.");
return;
}
let item = this.state.images[i];
if (!item) {
console.warn(`Page ${i} missing from state. Skipping loadPage.`);
return;
}
// If the page doesn't have a blob, try to fetch it from the backend
if (!item.blob && this.config.collaborative && this.state.ownerId) {
try {
const response = await fetch(window.Config?.apiUrl(`/api/color_rm/page_file/${this.state.sessionId}/${i}`) || `/api/color_rm/page_file/${this.state.sessionId}/${i}`);
if (response.ok) {
const blob = await response.blob();
item.blob = blob;
// Update the database with the fetched blob
await this.dbPut('pages', item);
} else {
console.warn(`Page ${i} not found on backend. Attempting to fetch from base file...`);
// If page not found, try to get base file (first page)
if (i === 0) {
const baseResponse = await fetch(window.Config?.apiUrl(`/api/color_rm/base_file/${this.state.sessionId}`) || `/api/color_rm/base_file/${this.state.sessionId}`);
if (baseResponse.ok) {
const blob = await baseResponse.blob();
item.blob = blob;
await this.dbPut('pages', item);
}
}
}
} catch (err) {
console.error(`Error fetching page ${i} from backend:`, err);
}
}
if (!item || !item.blob) {
console.warn(`Page ${i} missing blob data. Skipping loadPage.`);
return;
}
this.state.idx = i;
const pageInput = this.getElement('pageInput');
if (pageInput) pageInput.value = i + 1;
const pageTotal = this.getElement('pageTotal');
if (pageTotal) pageTotal.innerText = '/ ' + this.state.images.length;
this.renderBookmarks();
if(!item.history) item.history = [];
// Revoke old page blob URL to prevent memory leak
if (this.currentPageBlobUrl) {
URL.revokeObjectURL(this.currentPageBlobUrl);
}
const img = new Image();
this.currentPageBlobUrl = URL.createObjectURL(item.blob);
img.src = this.currentPageBlobUrl;
return new Promise((resolve) => {
img.onload = () => {
this.cache.currentImg = img;
const c = this.getElement('canvas');
if (!c) return resolve();
const max = 2000;
let w=img.width, h=img.height;
if(w>max || h>max) { const r = Math.min(max/w, max/h); w*=r; h*=r; }
c.width=w; c.height=h; this.state.viewW=w; this.state.viewH=h;
// Toggle infinite canvas mode for seamless fullscreen display
const viewport = this.getElement('viewport');
if (viewport) {
if (item.isInfinite) {
viewport.classList.add('infinite-canvas-mode');
// For infinite canvas, resize canvas to fill viewport
const vRect = viewport.getBoundingClientRect();
c.width = vRect.width;
c.height = vRect.height;
this.state.viewW = c.width;
this.state.viewH = c.height;
} else {
viewport.classList.remove('infinite-canvas-mode');
}
}
const ctx = c.getContext('2d', {willReadFrequently:true});
ctx.drawImage(img,0,0,w,h);
const d = ctx.getImageData(0,0,w,h).data;
this.cache.lab = new Float32Array(w*h*3);
for(let k=0,j=0; k<d.length; k+=4,j+=3) {
const [l,a,b] = this.rgbToLab(d[k],d[k+1],d[k+2]);
this.cache.lab[j]=l; this.cache.lab[j+1]=a; this.cache.lab[j+2]=b;
}
// Apply enter animation (only if multiple pages)
if (canvas && shouldAnimate) {
canvas.style.transform = direction === 'left' ? 'translateX(30px)' : 'translateX(-30px)';
canvas.style.opacity = '0.3';
// Force reflow
canvas.offsetHeight;
// Animate in
canvas.style.transition = 'transform 0.2s ease-out, opacity 0.15s ease-out';
canvas.style.transform = 'translateX(0)';
canvas.style.opacity = '1';
} else if (canvas) {
// Reset any lingering transform/opacity without animation
canvas.style.transition = 'none';
canvas.style.transform = 'translateX(0)';
canvas.style.opacity = '1';
}
this.render();
if (broadcast) {
this.saveSessionState();
// Use debounced page navigation notification
if (this.liveSync) {
this.liveSync.notifyPageNavigation(this.state.idx);
}
}
// Fetch base history from R2 if page has SVG import data
if (this.liveSync && item.hasBaseHistory) {
this.liveSync.ensureBaseHistory(this.state.idx);
}
resolve();
};
});
}
}
// Mixin all modules into the prototype
Object.assign(ColorRmApp.prototype, ColorRmRenderer);
Object.assign(ColorRmApp.prototype, ColorRmStorage);
Object.assign(ColorRmApp.prototype, ColorRmBox);
Object.assign(ColorRmApp.prototype, ColorRmInput);
Object.assign(ColorRmApp.prototype, ColorRmUI);
Object.assign(ColorRmApp.prototype, ColorRmSession);
Object.assign(ColorRmApp.prototype, ColorRmExport);
// Make SVG importer available for importing SVG files
ColorRmApp.prototype.svgImporter = ColorRmSvgImporter;
// Ensure the app instance has access to export methods for other modules
ColorRmApp.prototype.sanitizeFilename = ColorRmExport.sanitizeFilename;
// The methods are already properly mixed in via Object.assign, so no need to rebind them
// The functions are already bound to the prototype correctly