import { createClient, LiveObject, LiveMap, LiveList } from 'https://cdn.jsdelivr.net/npm/@liveblocks/client@3.12.1/+esm'; // Yjs imports - lazy loaded only when beta mode is used let Y = null; let WebsocketProvider = null; async function loadYjs() { if (!Y) { Y = await import('https://cdn.jsdelivr.net/npm/yjs@13.6.10/+esm'); const ywsModule = await import('https://cdn.jsdelivr.net/npm/y-websocket@2.0.4/+esm'); WebsocketProvider = ywsModule.WebsocketProvider; console.log('[LiveSync] Yjs modules loaded'); } } export class LiveSyncClient { constructor(appInstance) { this.app = appInstance; this.useBetaSync = appInstance.config.useBetaSync || false; // Beta mode flag this.client = null; this.room = null; this.userId = localStorage.getItem('color_rm_user_id'); this.ownerId = null; this.projectId = null; this.unsubscribes = []; this.isInitializing = true; this.root = null; // Yjs-specific (only used when useBetaSync=true) this.yjsDoc = null; this.yjsProvider = null; this.yjsRoot = null; // Track recent local page changes to prevent sync conflicts this.lastLocalPageChange = 0; this.PAGE_CHANGE_GRACE_PERIOD = 500; // 500ms grace period for fluid sync this.remoteTrails = {}; // Lock to prevent reconciliation during local page operations this._isLocalPageOperation = false; this._localPageOperationTimeout = null; } async init(ownerId, projectId) { // We need Registry to be available globally or passed in. // Assuming window.Registry is still global for now or we import it. const regUser = window.Registry?.getUsername(); if (regUser) this.userId = regUser; if (!this.userId) { this.userId = `user_${Math.random().toString(36).substring(2, 9)}`; localStorage.setItem('color_rm_user_id', this.userId); } this.ownerId = ownerId; this.projectId = projectId; // Beta mode: Use Yjs instead of Liveblocks if (this.useBetaSync) { return this._initYjs(ownerId, projectId); } const roomId = `room_${ownerId}`; // Update URL only if this is the main app (hacky check? or let the app handle it) // For now, only update hash if this sync client is attached to the main app. // We can check this via a config flag on the app. if (this.app.config.isMain) { window.location.hash = `/color_rm/${ownerId}/${projectId}`; } if (this.room && this.room.id === roomId) { console.log(`Liveblocks: Switching Project sub-key to ${projectId} in existing room.`); await this.setupProjectSync(projectId); return; } if (this.room) this.leave(); this.app.ui.setSyncStatus('syncing'); console.log(`Liveblocks: Connecting to Owner Room: ${roomId}`); if (!this.client) { // Get auth endpoint URL (supports bundled mode) const authEndpoint = window.Config ? window.Config.apiUrl('/api/liveblocks-auth') : '/api/liveblocks-auth'; this.client = createClient({ authEndpoint: authEndpoint, }); } const { room, leave } = this.client.enterRoom(roomId, { initialStorage: { projects: new LiveMap() }, // Use HTTP fallback for large messages that exceed websocket limits largeMessageStrategy: 'http' }); this.room = room; this.leave = leave; room.subscribe("status", (status) => { if (status === "connected") this.app.ui.setSyncStatus('saved'); else if (status === "disconnected") this.app.ui.setSyncStatus('offline'); if (this.app.renderDebug) this.app.renderDebug(); }); // Retry logic for storage initialization let root; let attempts = 0; const maxAttempts = 3; while (!root && attempts < maxAttempts) { attempts++; try { // Add simple timeout wrapper around storage fetch const storagePromise = room.getStorage(); const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("Storage fetch timed out")), 8000) ); const result = await Promise.race([storagePromise, timeoutPromise]); root = result.root; } catch(e) { console.warn(`Liveblocks: Storage fetch attempt ${attempts} failed:`, e); if (attempts === maxAttempts) { this.app.ui.showToast("Connection weak - Sync might be incomplete"); } else { await new Promise(r => setTimeout(r, 1000)); // Wait 1s before retry } } } if (!root) { console.error("Liveblocks: Critical failure - could not load storage."); this.isInitializing = false; return; } this.root = root; await this.setupProjectSync(projectId); this.isInitializing = false; console.log("Liveblocks: Room Ready."); // Start periodic page sync check (every 5 seconds) this.startPeriodicPageSync(); // CRITICAL: Immediate page sync after initialization // This ensures we catch any pages that were added while we were connecting setTimeout(() => { this._immediatePageSync(); }, 500); } /** * Initialize Yjs-based sync (beta mode) * Uses Yjs CRDTs over WebSocket for real-time collaboration */ async _initYjs(ownerId, projectId) { await loadYjs(); this.app.ui.setSyncStatus('syncing'); console.log(`[Yjs] Beta sync: Connecting for ${ownerId}/${projectId}`); // Update URL with beta prefix if (this.app.config.isMain) { window.location.hash = `/beta/color_rm/${ownerId}/${projectId}`; } // Create Yjs document this.yjsDoc = new Y.Doc(); // Connect to WebSocket - use Config for proper URL in Capacitor/bundled mode const roomId = `yjs_${ownerId}_${projectId}`; // Use Config.wsUrl for proper WebSocket URL (handles Capacitor bundled mode) const wsUrl = window.Config ? window.Config.wsUrl(`/yjs/${roomId}`) : `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/yjs/${roomId}`; console.log(`[Yjs] Connecting to: ${wsUrl}`); try { // Create WebSocket connection directly (simpler than y-websocket provider) this._yjsSocket = new WebSocket(wsUrl); this._yjsConnected = false; this._yjsSocket.onopen = () => { console.log('[Yjs] WebSocket connected'); this._yjsConnected = true; this.app.ui.setSyncStatus('saved'); if (this.app.renderDebug) this.app.renderDebug(); // Send initial state this._sendYjsUpdate(); }; this._yjsSocket.onmessage = (event) => { try { if (typeof event.data === 'string') { const msg = JSON.parse(event.data); this._handleYjsMessage(msg); } } catch (err) { console.error('[Yjs] Message parse error:', err); } }; this._yjsSocket.onclose = () => { console.log('[Yjs] WebSocket disconnected'); this._yjsConnected = false; this.app.ui.setSyncStatus('offline'); // Auto-reconnect after 3 seconds if (!this._yjsClosedIntentionally) { setTimeout(() => { if (!this._yjsConnected && !this._yjsClosedIntentionally) { console.log('[Yjs] Attempting reconnection...'); this._initYjs(this.ownerId, this.projectId); } }, 3000); } }; this._yjsSocket.onerror = (err) => { console.error('[Yjs] WebSocket error:', err); this._yjsConnected = false; }; // Set up leave function for cleanup this.leave = () => { this._yjsClosedIntentionally = true; if (this._yjsSocket) { this._yjsSocket.close(); this._yjsSocket = null; } this._yjsConnected = false; this.stopPeriodicPageSync(); console.log('[Yjs] Connection closed'); }; // Setup Yjs document structure (mirrors Liveblocks structure) this.yjsRoot = { metadata: this.yjsDoc.getMap('metadata'), pagesHistory: this.yjsDoc.getMap('pagesHistory'), pagesModifications: this.yjsDoc.getMap('pagesModifications'), pagesMetadata: this.yjsDoc.getMap('pagesMetadata'), bookmarks: this.yjsDoc.getArray('bookmarks'), colors: this.yjsDoc.getArray('colors') }; // Create fake awareness for presence this._yjsAwareness = new Map(); this._yjsClientId = Math.random().toString(36).slice(2); this.isInitializing = false; console.log('[Yjs] Beta sync initialized'); // Sync current state setTimeout(() => { this.syncHistory(); }, 500); } catch (err) { console.error('[Yjs] Failed to initialize:', err); this.app.ui.showToast('Beta sync connection failed'); this.isInitializing = false; } } /** * Send current state as Yjs update */ _sendYjsUpdate() { if (!this._yjsSocket || !this._yjsConnected) { console.warn('[Yjs] Cannot send update - not connected'); return; } const img = this.app.state.images[this.app.state.idx]; if (!img) { console.warn('[Yjs] Cannot send update - no image for current page'); return; } const msg = { type: 'state-update', pageIdx: this.app.state.idx, history: (img.history || []).filter(s => !s.deleted), metadata: { name: this.app.state.projectName, idx: this.app.state.idx, pageCount: this.app.state.images.length } }; this._yjsSocket.send(JSON.stringify(msg)); console.log(`[Yjs] Sent state update for page ${this.app.state.idx}: ${msg.history.length} strokes`); } /** * Handle incoming Yjs message */ _handleYjsMessage(msg) { if (this.isInitializing) return; if (msg.type === 'state-update') { // Apply remote state console.log(`[Yjs] Received state-update for page ${msg.pageIdx}: ${msg.history?.length || 0} strokes`); // Sync project name from metadata (like Liveblocks does) if (msg.metadata && msg.metadata.name) { const remoteName = msg.metadata.name; const localName = this.app.state.projectName; // If we're the owner and remote is "Untitled" but we have a real name, don't overwrite const isOwner = this.ownerId === this.userId; const remoteIsUntitled = remoteName === 'Untitled' || !remoteName; const localHasName = localName && localName !== 'Untitled'; if (!(isOwner && remoteIsUntitled && localHasName)) { // Accept remote name if (localName !== remoteName) { console.log(`[Yjs] Name Sync: "${localName}" -> "${remoteName}"`); this.app.state.projectName = remoteName; const titleEl = this.app.getElement('headerTitle'); if (titleEl) titleEl.innerText = remoteName; } } } const localImg = this.app.state.images[msg.pageIdx]; if (localImg && msg.history) { // For beta sync, we always accept remote state as the source of truth // This handles both additions and deletions const localNonDeleted = (localImg.history || []).filter(s => !s.deleted); const remoteLen = msg.history.length; const localLen = localNonDeleted.length; // Check if content actually differs (by comparing visible strokes) const needsUpdate = remoteLen !== localLen || JSON.stringify(msg.history) !== JSON.stringify(localNonDeleted); if (needsUpdate) { // Replace local history with remote (non-deleted strokes only) localImg.history = msg.history; console.log(`[Yjs] Applied remote state for page ${msg.pageIdx}: ${remoteLen} strokes (was ${localLen})`); // Only re-render if this is the current page if (msg.pageIdx === this.app.state.idx) { this.app.invalidateCache(); this.app.render(); } } } } else if (msg.type === 'page-structure') { // Handle page structure change from another client const remotePageCount = msg.pageCount || 0; const localPageCount = this.app.state.images.length; const remotePageIds = msg.pageIds || []; const localPageIds = this.app.state.images.map(img => img?.pageId).filter(Boolean); console.log(`[Yjs] Received page-structure: remote=${remotePageCount} pages, local=${localPageCount} pages`); // Check if structure actually differs (count or order) const countDiffers = remotePageCount !== localPageCount; const orderDiffers = JSON.stringify(remotePageIds) !== JSON.stringify(localPageIds); if (countDiffers || orderDiffers) { // Page structure changed - run reconciliation via R2 (source of truth) console.log(`[Yjs] Page structure mismatch (count: ${countDiffers}, order: ${orderDiffers}), triggering reconciliation...`); // Debounce to avoid rapid re-fetches if (this._yjsReconcileTimer) clearTimeout(this._yjsReconcileTimer); this._yjsReconcileTimer = setTimeout(() => { this.reconcilePageStructure(); }, 500); } else { console.log(`[Yjs] Page structure matches, no reconciliation needed`); } } else if (msg.type === 'presence') { // Update presence map this._yjsAwareness.set(msg.clientId, { userName: msg.userName, cursor: msg.cursor, pageIdx: msg.pageIdx, tool: msg.tool, isDrawing: msg.isDrawing, color: msg.color, size: msg.size }); console.log(`[Yjs] Received presence from ${msg.clientId}: page ${msg.pageIdx}`); // Follow mode: Navigate to the same page as the other user // (Skip if this is an echo of our own change or if we recently changed pages) const timeSinceLocalChange = Date.now() - (this.lastLocalPageChange || 0); if (msg.pageIdx !== undefined && msg.pageIdx !== this.app.state.idx) { if (timeSinceLocalChange < 500) { console.log(`[Yjs] Ignoring remote page=${msg.pageIdx}, local change was ${timeSinceLocalChange}ms ago`); } else { console.log(`[Yjs] Following remote user to page ${msg.pageIdx}`); this.lastLocalPageChange = Date.now(); // Prevent echo this.app.loadPage(msg.pageIdx, false); // false = don't broadcast back } } this.renderCursors(); this.renderUsers(); } } /** * Immediate page sync - called right after initialization * Simply runs reconciliation - R2 is the source of truth */ async _immediatePageSync() { console.log(`[_immediatePageSync] Running reconciliation...`); // Page reconciliation works for both modes (fetches from R2) await this.reconcilePageStructure(); // For beta mode, also sync current history if (this.useBetaSync) { console.log('[Yjs] Immediate history sync'); this.syncHistory(); } } /** * Start a local page operation - blocks reconciliation * Call this BEFORE adding/deleting/reordering pages locally */ startLocalPageOperation() { this._isLocalPageOperation = true; // Clear any existing timeout if (this._localPageOperationTimeout) { clearTimeout(this._localPageOperationTimeout); } console.log('[LiveSync] Local page operation started - reconciliation blocked'); } /** * End a local page operation - allows reconciliation after a delay * Call this AFTER the page operation is complete and synced to server */ endLocalPageOperation() { // Add a grace period before allowing reconciliation this._localPageOperationTimeout = setTimeout(() => { this._isLocalPageOperation = false; console.log('[LiveSync] Local page operation ended - reconciliation unblocked'); }, 3000); // 3 second grace period } /** * Starts a periodic check to ensure pages are synced with remote * This catches any missed updates due to race conditions or network issues */ startPeriodicPageSync() { // Clear any existing interval if (this._pageSyncInterval) { clearInterval(this._pageSyncInterval); } this._pageSyncInterval = setInterval(() => { if (this.isInitializing) return; // Just run reconciliation periodically - R2 is the source of truth this.reconcilePageStructure(); }, 10000); // Check every 10 seconds } /** * Stops the periodic page sync */ stopPeriodicPageSync() { if (this._pageSyncInterval) { clearInterval(this._pageSyncInterval); this._pageSyncInterval = null; } } /** * Force sync all pages - use this for manual recovery * This method is exposed globally for debugging via window.LiveSync.forceSyncPages() */ async forceSyncPages() { console.log('[forceSyncPages] Starting forced page synchronization...'); // Reset locks in case they're stuck this._isFetchingMissingPages = false; this.app.isFetchingBase = false; // Simply run reconciliation - R2 is the source of truth await this.reconcilePageStructure(); // Sync history and render this.syncHistory(); this.app.render(); console.log('[forceSyncPages] Complete. Local pages:', this.app.state.images.length); } async setupProjectSync(projectId) { if (!this.root) { const { root } = await this.room.getStorage(); this.root = root; } const projects = this.root.get("projects"); // Ensure the project structure exists if (!projects.has(projectId)) { console.log(`Liveblocks: Creating new project ${projectId} with name "${this.app.state.projectName}"`); projects.set(projectId, new LiveObject({ metadata: new LiveObject({ name: this.app.state.projectName || "Untitled", baseFileName: this.app.state.baseFileName || null, idx: 0, pageCount: 0, pageLocked: false, ownerId: this.ownerId }), pagesHistory: new LiveMap(), pagesModifications: new LiveMap(), pagesMetadata: new LiveMap(), bookmarks: new LiveList([]), colors: new LiveList([]) })); } else { // Project exists. // Safeguard: If I am the owner, and the remote name is "Untitled" or empty, but my local name is set... // This fixes the issue where a project might be initialized with default data and overwrite the local name. const remoteProject = projects.get(projectId); const remoteMeta = remoteProject.get("metadata"); const remoteName = remoteMeta.get("name"); if (this.ownerId === this.userId && (remoteName === "Untitled" || !remoteName) && this.app.state.projectName && this.app.state.projectName !== "Untitled") { console.log(`Liveblocks: Remote name is "${remoteName}", pushing local name: "${this.app.state.projectName}"`); remoteMeta.update({ name: this.app.state.projectName }); } // Ensure required LiveMaps exist (migration for older projects) if (!remoteProject.get("pagesModifications")) { console.log('[LiveSync] Adding missing pagesModifications LiveMap'); remoteProject.set("pagesModifications", new LiveMap()); } if (!remoteProject.get("pagesMetadata")) { console.log('[LiveSync] Adding missing pagesMetadata LiveMap'); remoteProject.set("pagesMetadata", new LiveMap()); } } await this.syncStorageToLocal(); // Refresh project-specific subscription this.unsubscribes.forEach(unsub => unsub()); // Keep track of other users' page structure versions this.otherUserVersions = {}; this.unsubscribes = [ this.room.subscribe(projects.get(projectId), () => { this.syncProjectData(); if (this.app.renderDebug) this.app.renderDebug(); }, { isDeep: true }), // Subscribe to Presence (Others) this.room.subscribe("others", (others) => { this.renderUsers(); this.renderCursors(); // Check if any other user has updated their page structure version others.forEach(user => { const presence = user.presence; if (!presence) return; // Handle page structure version changes if (presence.pageStructureVersion !== undefined && presence.pageCount !== undefined) { const oderId = user.connectionId || user.id; const currentVersion = this.otherUserVersions[oderId]; // Only trigger if we've seen this user before AND their version changed // This prevents triggering on initial connection when we first see other users if (currentVersion !== undefined && currentVersion < presence.pageStructureVersion) { this.otherUserVersions[oderId] = presence.pageStructureVersion; // Another user made a page structure change this.handlePageStructureChange(presence); } else if (currentVersion === undefined) { // First time seeing this user - just record their version, don't trigger this.otherUserVersions[oderId] = presence.pageStructureVersion; } } // Infinite canvas bounds are now auto-calculated from synced strokes // No need for explicit bounds sync via presence }); }) ]; // Initialize presence for self this.room.updatePresence({ userId: this.userId, userName: window.Registry?.getUsername() || this.userId, cursor: null, pageIdx: this.app.state.idx, pageStructureVersion: Date.now(), pageCount: this.app.state.images.length }); this.renderUsers(); // Perform initial page structure reconciliation after a short delay // This ensures we have the correct pages in the correct order setTimeout(() => { this.reconcilePageStructure(); }, 1500); } updateCursor(pt, tool, isDrawing, color, size) { // Beta mode: Use Yjs awareness if (this.useBetaSync) { if (!this._yjsSocket || !this._yjsConnected) return; // Send presence via WebSocket this._yjsSocket.send(JSON.stringify({ type: 'presence', clientId: this._yjsClientId, cursor: pt, pageIdx: this.app.state.idx, userName: window.Registry?.getUsername() || this.userId, tool: tool, isDrawing: isDrawing, color: color, size: size })); return; } if (!this.room) return; this.room.updatePresence({ cursor: pt, pageIdx: this.app.state.idx, userName: window.Registry?.getUsername() || this.userId, tool: tool, isDrawing: isDrawing, color: color, size: size // Note: pageStructureVersion and pageCount are only updated via notifyPageStructureChange() }); } renderCursors() { // Beta mode: Use Yjs awareness if (this.useBetaSync) { return this._renderCursorsYjs(); } const container = this.app.getElement('cursorLayer'); if (!container) return; // Clear old cursors but keep canvas const oldCursors = container.querySelectorAll('.remote-cursor'); oldCursors.forEach(el => el.remove()); if (!this.app.state.showCursors) return; if (!this.room) return; // Setup Trail Canvas let trailCanvas = container.querySelector('#remote-trails-canvas'); if (!trailCanvas) { trailCanvas = document.createElement('canvas'); trailCanvas.id = 'remote-trails-canvas'; trailCanvas.style.position = 'absolute'; trailCanvas.style.inset = '0'; trailCanvas.style.pointerEvents = 'none'; container.appendChild(trailCanvas); } const viewport = this.app.getElement('viewport'); const canvas = this.app.getElement('canvas'); if (!canvas || !viewport) return; const rect = canvas.getBoundingClientRect(); // Canvas on screen rect const viewRect = viewport.getBoundingClientRect(); // Viewport rect // 1. Align cursorLayer to match canvas exactly (fixes alignment & trail black screen issues) container.style.position = 'absolute'; container.style.width = rect.width + 'px'; container.style.height = rect.height + 'px'; container.style.left = (rect.left - viewRect.left) + 'px'; container.style.top = (rect.top - viewRect.top) + 'px'; container.style.inset = 'auto'; // Override CSS inset:0 // 2. Match trailCanvas resolution to main canvas internal resolution if (trailCanvas.width !== this.app.state.viewW || trailCanvas.height !== this.app.state.viewH) { trailCanvas.width = this.app.state.viewW; trailCanvas.height = this.app.state.viewH; } trailCanvas.style.width = '100%'; trailCanvas.style.height = '100%'; trailCanvas.style.backgroundColor = 'transparent'; // Ensure transparent const ctx = trailCanvas.getContext('2d'); // Reset transform to identity to ensure full clear ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, trailCanvas.width, trailCanvas.height); const others = this.room.getOthers(); let hasActiveTrails = false; others.forEach(user => { const presence = user.presence; if (!presence || !presence.cursor || presence.pageIdx !== this.app.state.idx) { if (this.remoteTrails[user.connectionId]) delete this.remoteTrails[user.connectionId]; return; } // Note: Page structure changes are handled by the "others" subscription, // no need to check here to avoid duplicate processing // --- Draw Live Trail --- if (presence.isDrawing && presence.tool === 'pen') { hasActiveTrails = true; let trail = this.remoteTrails[user.connectionId] || []; // Add point if new const lastPt = trail[trail.length - 1]; if (!lastPt || lastPt.x !== presence.cursor.x || lastPt.y !== presence.cursor.y) { trail.push(presence.cursor); } this.remoteTrails[user.connectionId] = trail; if (trail.length > 1) { ctx.save(); // Transform to match main canvas state ctx.translate(this.app.state.pan.x, this.app.state.pan.y); ctx.scale(this.app.state.zoom, this.app.state.zoom); ctx.beginPath(); ctx.moveTo(trail[0].x, trail[0].y); for (let i = 1; i < trail.length; i++) ctx.lineTo(trail[i].x, trail[i].y); ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.lineWidth = (presence.size || 3); // Smooth transition: Use user's color with opacity const hex = presence.color || '#000000'; let r=0, g=0, b=0; if(hex.length === 4) { r = parseInt(hex[1]+hex[1], 16); g = parseInt(hex[2]+hex[2], 16); b = parseInt(hex[3]+hex[3], 16); } else if (hex.length === 7) { r = parseInt(hex.slice(1,3), 16); g = parseInt(hex.slice(3,5), 16); b = parseInt(hex.slice(5,7), 16); } ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, 0.5)`; ctx.stroke(); ctx.restore(); } } else { if (this.remoteTrails[user.connectionId]) delete this.remoteTrails[user.connectionId]; } // --- Draw Cursor --- const div = document.createElement('div'); // ... (cursor drawing code) ... div.className = 'remote-cursor'; // Map canvas coordinates to screen coordinates relative to cursorLayer (which now matches canvas) // x_screen = (x_internal * zoom + pan) * (screen_width / internal_width) const scaleX = rect.width / this.app.state.viewW; const scaleY = rect.height / this.app.state.viewH; const x = (presence.cursor.x * this.app.state.zoom + this.app.state.pan.x) * scaleX; const y = (presence.cursor.y * this.app.state.zoom + this.app.state.pan.y) * scaleY; div.style.left = `${x}px`; div.style.top = `${y}px`; div.style.borderColor = 'var(--accent)'; div.innerHTML = `
${presence.userName || 'User'}
`; container.appendChild(div); }); // Hide trail canvas if no active trails to minimize risk of obstruction trailCanvas.style.display = hasActiveTrails ? 'block' : 'none'; } /** * Yjs version of renderCursors - uses awareness API */ _renderCursorsYjs() { const container = this.app.getElement('cursorLayer'); if (!container) return; // Clear old cursors const oldCursors = container.querySelectorAll('.remote-cursor'); oldCursors.forEach(el => el.remove()); if (!this.app.state.showCursors) return; if (!this._yjsAwareness || this._yjsAwareness.size === 0) return; const viewport = this.app.getElement('viewport'); const canvas = this.app.getElement('canvas'); if (!canvas || !viewport) return; const rect = canvas.getBoundingClientRect(); const viewRect = viewport.getBoundingClientRect(); // Align cursorLayer to match canvas container.style.position = 'absolute'; container.style.width = rect.width + 'px'; container.style.height = rect.height + 'px'; container.style.left = (rect.left - viewRect.left) + 'px'; container.style.top = (rect.top - viewRect.top) + 'px'; container.style.inset = 'auto'; // Setup Trail Canvas for Yjs let trailCanvas = container.querySelector('#remote-trails-canvas'); if (!trailCanvas) { trailCanvas = document.createElement('canvas'); trailCanvas.id = 'remote-trails-canvas'; trailCanvas.style.position = 'absolute'; trailCanvas.style.inset = '0'; trailCanvas.style.pointerEvents = 'none'; container.appendChild(trailCanvas); } // Match trailCanvas resolution to main canvas if (trailCanvas.width !== this.app.state.viewW || trailCanvas.height !== this.app.state.viewH) { trailCanvas.width = this.app.state.viewW; trailCanvas.height = this.app.state.viewH; } trailCanvas.style.width = '100%'; trailCanvas.style.height = '100%'; trailCanvas.style.backgroundColor = 'transparent'; const ctx = trailCanvas.getContext('2d'); ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, trailCanvas.width, trailCanvas.height); let hasActiveTrails = false; this._yjsAwareness.forEach((state, clientId) => { if (clientId === this._yjsClientId) return; // Skip self if (!state || !state.cursor || state.pageIdx !== this.app.state.idx) { if (this.remoteTrails[clientId]) delete this.remoteTrails[clientId]; return; } // Draw live trail if (state.isDrawing && state.tool === 'pen') { hasActiveTrails = true; let trail = this.remoteTrails[clientId] || []; const lastPt = trail[trail.length - 1]; if (!lastPt || lastPt.x !== state.cursor.x || lastPt.y !== state.cursor.y) { trail.push(state.cursor); } this.remoteTrails[clientId] = trail; if (trail.length > 1) { ctx.save(); ctx.translate(this.app.state.pan.x, this.app.state.pan.y); ctx.scale(this.app.state.zoom, this.app.state.zoom); ctx.beginPath(); ctx.moveTo(trail[0].x, trail[0].y); for (let i = 1; i < trail.length; i++) ctx.lineTo(trail[i].x, trail[i].y); ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.lineWidth = (state.size || 3); // Parse color for opacity const hex = state.color || '#000000'; let r=0, g=0, b=0; if(hex.length === 4) { r = parseInt(hex[1]+hex[1], 16); g = parseInt(hex[2]+hex[2], 16); b = parseInt(hex[3]+hex[3], 16); } else if (hex.length === 7) { r = parseInt(hex.slice(1,3), 16); g = parseInt(hex.slice(3,5), 16); b = parseInt(hex.slice(5,7), 16); } ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, 0.5)`; ctx.stroke(); ctx.restore(); } } else { if (this.remoteTrails[clientId]) delete this.remoteTrails[clientId]; } // Draw cursor const div = document.createElement('div'); div.className = 'remote-cursor'; const scaleX = rect.width / this.app.state.viewW; const scaleY = rect.height / this.app.state.viewH; const x = (state.cursor.x * this.app.state.zoom + this.app.state.pan.x) * scaleX; const y = (state.cursor.y * this.app.state.zoom + this.app.state.pan.y) * scaleY; div.style.left = `${x}px`; div.style.top = `${y}px`; div.style.borderColor = 'var(--accent)'; div.innerHTML = `
${state.userName || 'User'}
`; container.appendChild(div); }); // Hide trail canvas if no active trails trailCanvas.style.display = hasActiveTrails ? 'block' : 'none'; } renderUsers() { // Beta mode: Use Yjs awareness if (this.useBetaSync) { return this._renderUsersYjs(); } const el = this.app.getElement('userList'); if (!el) return; const others = this.room.getOthers(); const myName = window.Registry?.getUsername() || this.userId; let html = `
You (${myName})
`; others.forEach(user => { const info = user.presence; if (!info || !info.userId) return; const userName = info.userName || info.userId; html += `
${userName}
`; }); el.innerHTML = html; } /** * Yjs version of renderUsers - uses awareness map */ _renderUsersYjs() { const el = this.app.getElement('userList'); if (!el) return; const myName = window.Registry?.getUsername() || this.userId; let html = `
You (${myName})
`; if (this._yjsAwareness) { this._yjsAwareness.forEach((state, clientId) => { if (clientId === this._yjsClientId) return; // Skip self if (!state) return; const userName = state.userName || 'User'; html += `
${userName}
`; }); } el.innerHTML = html; } getProject() { if (!this.root || !this.projectId) return null; return this.root.get("projects").get(this.projectId); } async syncStorageToLocal() { const project = this.getProject(); if (!project) return; const metadata = project.get("metadata").toObject(); this.app.state.projectName = metadata.name; this.app.state.idx = metadata.idx; this.app.state.pageLocked = metadata.pageLocked; this.app.state.ownerId = metadata.ownerId; this.app.state.baseFileName = metadata.baseFileName || null; const titleEl = this.app.getElement('headerTitle'); if(titleEl) titleEl.innerText = metadata.name; this.app.state.bookmarks = project.get("bookmarks").toArray(); this.app.renderBookmarks(); this.app.state.colors = project.get("colors").toArray(); this.app.renderSwatches(); // Page sync: R2 is the source of truth - just run reconciliation console.log(`[syncStorageToLocal] Running reconciliation to sync pages from R2...`); await this.reconcilePageStructure(); this.syncHistory(); this.app.loadPage(this.app.state.idx, false); } syncProjectData() { const project = this.getProject(); if (!project || this.isInitializing) return; const metadata = project.get("metadata").toObject(); console.log(`Liveblocks Sync: Remote PageCount=${metadata.pageCount}, Local PageCount=${this.app.state.images.length}`); // Intelligent Name Sync: // If I am the owner, and remote is Untitled, but I have a name -> Update Remote // Otherwise -> Accept Remote console.log(`[LiveSync] Name Sync Check: Owner=${this.ownerId}, User=${this.userId}`); console.log(`[LiveSync] Remote Name: "${metadata.name}", Local Name: "${this.app.state.projectName}"`); if (this.ownerId === this.userId && (metadata.name === "Untitled" || !metadata.name) && this.app.state.projectName && this.app.state.projectName !== "Untitled") { console.log(`[LiveSync] DECISION: Correcting remote 'Untitled' name with local: "${this.app.state.projectName}"`); project.get("metadata").update({ name: this.app.state.projectName }); } else { if (this.app.state.projectName !== metadata.name) { console.log(`[LiveSync] DECISION: Accepting remote name: "${metadata.name}" (replacing "${this.app.state.projectName}")`); } this.app.state.projectName = metadata.name; } this.app.state.baseFileName = metadata.baseFileName || null; this.app.state.pageLocked = metadata.pageLocked; this.app.state.ownerId = metadata.ownerId; const titleEl = this.app.getElement('headerTitle'); if(titleEl) titleEl.innerText = this.app.state.projectName; // Only sync page index if: // 1. We haven't made a local page change recently (grace period) // 2. The remote change is from another user (not our own echo) const timeSinceLocalChange = Date.now() - this.lastLocalPageChange; const isWithinGracePeriod = timeSinceLocalChange < this.PAGE_CHANGE_GRACE_PERIOD; if (this.app.state.idx !== metadata.idx) { if (isWithinGracePeriod) { // Skip - this is likely our own change echoing back console.log(`Liveblocks: Ignoring remote idx=${metadata.idx}, local change was ${timeSinceLocalChange}ms ago`); } else { // Instant navigation - no debounce for following other users console.log(`Liveblocks: Accepting remote page change to idx=${metadata.idx}`); this.app.loadPage(metadata.idx, false); } } // Page sync: Debounce reconciliation if (metadata.pageCount !== this.app.state.images.length) { console.log(`Liveblocks: Page count mismatch (remote: ${metadata.pageCount}, local: ${this.app.state.images.length}). Scheduling reconciliation...`); this._scheduleReconciliation(); } // Check for R2 modification updates (large file sync) - debounced this._scheduleR2ModificationCheck(); this.syncHistory(); this.app.updateLockUI(); } // Debounced R2 modification check to avoid too many fetches _scheduleR2ModificationCheck() { if (this._r2CheckTimeout) { clearTimeout(this._r2CheckTimeout); } this._r2CheckTimeout = setTimeout(() => { this._checkR2ModificationUpdates(); }, 300); } // Check if any page has new R2 modifications we need to fetch _checkR2ModificationUpdates() { const project = this.getProject(); if (!project) return; const pagesMetadata = project.get("pagesMetadata"); if (!pagesMetadata) return; // Check each page for R2 updates (both base history and modifications) this.app.state.images.forEach((img, idx) => { if (!img) return; const pageMeta = pagesMetadata.get(idx.toString()); if (!pageMeta) return; const meta = pageMeta.toObject ? pageMeta.toObject() : pageMeta; // Check if base history needs to be fetched if (meta.hasBaseHistory && !img._baseHistory && img.pageId) { console.log(`[LiveSync] Base history needed for page ${idx}, fetching from R2...`); this._fetchBaseHistoryForPage(idx, img); } // Check if there are R2 modifications we haven't fetched yet if (meta.hasR2Modifications && meta.r2ModTimestamp) { const lastFetched = img._r2ModTimestamp || 0; if (meta.r2ModTimestamp > lastFetched) { console.log(`[LiveSync] R2 modifications updated for page ${idx}, fetching...`); this._fetchAndApplyR2Modifications(idx, img, meta.r2ModTimestamp); } } }); } // Fetch base history for a specific page async _fetchBaseHistoryForPage(pageIdx, img) { if (!img.pageId || img._baseHistoryFetching) return; img._baseHistoryFetching = true; try { const baseHistory = await this.fetchBaseHistory(img.pageId); if (baseHistory && baseHistory.length > 0) { img._baseHistory = baseHistory; img.hasBaseHistory = true; console.log(`[LiveSync] Fetched ${baseHistory.length} base history items for page ${pageIdx}`); // Re-sync history to merge base + deltas this.syncHistory(); this.app.render(); } } catch (e) { console.error(`[LiveSync] Failed to fetch base history for page ${pageIdx}:`, e); } finally { img._baseHistoryFetching = false; } } // Fetch R2 modifications and apply them async _fetchAndApplyR2Modifications(pageIdx, img, timestamp) { if (!img.pageId) return; const r2Mods = await this.fetchR2Modifications(img.pageId); if (r2Mods && Object.keys(r2Mods).length > 0) { img._r2Modifications = r2Mods; img._r2ModTimestamp = timestamp; // Re-sync history to apply the new modifications this.syncHistory(); this.app.render(); console.log(`[LiveSync] Applied ${Object.keys(r2Mods).length} R2 modifications for page ${pageIdx}`); } } // Debounced scheduler for page reconciliation (add/delete pages only) _scheduleReconciliation() { // Clear any pending reconciliation if (this._reconcileDebounceTimer) { clearTimeout(this._reconcileDebounceTimer); } // Debounce: wait 300ms before reconciling (coalesces rapid changes) this._reconcileDebounceTimer = setTimeout(() => { this.reconcilePageStructure(); }, 300); } /** * Fetches missing pages from backend with retry logic (3 attempts per page) * @param {number} expectedPageCount - The expected total page count from metadata * @param {number} maxRetries - Maximum retries per page (default: 3) */ async fetchMissingPagesWithRetry(expectedPageCount, maxRetries = 3) { // Prevent concurrent fetches if (this._isFetchingMissingPages) { console.log('[fetchMissingPagesWithRetry] Already fetching missing pages, skipping...'); return; } this._isFetchingMissingPages = true; console.log(`[fetchMissingPagesWithRetry] Starting fetch. Expected: ${expectedPageCount}, Current: ${this.app.state.images.length}`); try { let pagesAdded = 0; const failedPages = []; // First pass: try to fetch all missing pages for (let i = this.app.state.images.length; i < expectedPageCount; i++) { const success = await this._fetchSinglePageWithRetry(i, maxRetries); if (success) { pagesAdded++; } else { failedPages.push(i); } } // Second pass: retry failed pages with longer delays if (failedPages.length > 0) { console.log(`[fetchMissingPagesWithRetry] Retrying ${failedPages.length} failed pages with extended delay...`); await new Promise(r => setTimeout(r, 2000)); // Wait 2 seconds before retry for (const pageIdx of failedPages) { const success = await this._fetchSinglePageWithRetry(pageIdx, maxRetries, 1500); if (success) { pagesAdded++; } } } // Third pass: final attempt for any remaining missing pages const stillMissing = expectedPageCount - this.app.state.images.length; if (stillMissing > 0) { console.log(`[fetchMissingPagesWithRetry] Final attempt for ${stillMissing} still-missing pages...`); await new Promise(r => setTimeout(r, 3000)); // Wait 3 seconds for (let i = this.app.state.images.length; i < expectedPageCount; i++) { await this._fetchSinglePageWithRetry(i, maxRetries, 2000); } } if (pagesAdded > 0 || this.app.state.images.length > 0) { // Update UI const pt = this.app.getElement('pageTotal'); if (pt) pt.innerText = '/ ' + this.app.state.images.length; if (this.app.state.activeSideTab === 'pages') { this.app.renderPageSidebar(); } // Reload current page to ensure proper display if (this.app.loadPage) { await this.app.loadPage(this.app.state.idx, false); this.app.render(); } console.log(`[fetchMissingPagesWithRetry] Complete. Added ${pagesAdded} pages. Total: ${this.app.state.images.length}`); } } catch (error) { console.error('[fetchMissingPagesWithRetry] Critical error:', error); } finally { this._isFetchingMissingPages = false; } } /** * Fetch a single page by its UUID with retry logic * @param {string} pageId - The unique page ID (e.g., 'pdf_0', 'user_abc123') * @param {number} targetIndex - The index where this page should be inserted * @param {number} maxRetries - Maximum number of retries * @param {number} retryDelay - Delay between retries in ms * @returns {boolean} - Whether the fetch was successful */ async _fetchPageByIdWithRetry(pageId, targetIndex, maxRetries = 3, retryDelay = 1000, metadata = null) { // Check if page with this pageId already exists const existingPage = this.app.state.images.find(img => img && img.pageId === pageId); if (existingPage) { console.log(`[_fetchPageByIdWithRetry] Page ${pageId} already exists, skipping`); return true; } for (let attempt = 1; attempt <= maxRetries; attempt++) { try { // Use new UUID-based endpoint const url = window.Config?.apiUrl(`/api/color_rm/page/${this.app.state.sessionId}/${pageId}`) || `/api/color_rm/page/${this.app.state.sessionId}/${pageId}`; console.log(`[_fetchPageByIdWithRetry] Fetching page ${pageId} for index ${targetIndex}, attempt ${attempt}/${maxRetries}`); const response = await fetch(url); if (response.ok) { const blob = await response.blob(); // Validate blob if (!blob || blob.size === 0) { console.warn(`[_fetchPageByIdWithRetry] Page ${pageId} returned empty blob (size: ${blob?.size || 0}), retry...`); if (attempt < maxRetries) { await new Promise(r => setTimeout(r, retryDelay)); continue; } console.error(`[_fetchPageByIdWithRetry] Page ${pageId} is empty after ${maxRetries} attempts`); return false; } console.log(`[_fetchPageByIdWithRetry] Page ${pageId} blob size: ${blob.size} bytes`); const pageObj = { id: `${this.app.state.sessionId}_${targetIndex}`, sessionId: this.app.state.sessionId, pageIndex: targetIndex, pageId: pageId, // Store the UUID blob: blob, history: [] }; // Apply metadata (template type, infinite canvas, etc.) if (metadata) { if (metadata.templateType) { pageObj.templateType = metadata.templateType; pageObj.templateConfig = metadata.templateConfig; console.log(`[_fetchPageByIdWithRetry] Applied template metadata: ${metadata.templateType}`); } if (metadata.isInfinite) { pageObj.isInfinite = true; pageObj.vectorGrid = metadata.vectorGrid; pageObj.bounds = metadata.bounds; pageObj.origin = metadata.origin; console.log(`[_fetchPageByIdWithRetry] Applied infinite canvas metadata`); } } // Double-check if page still doesn't exist (race condition protection) const existsNow = this.app.state.images.find(img => img && img.pageId === pageId); if (!existsNow) { await this.app.dbPut('pages', pageObj); // Just add the page - _reorderPagesToMatchStructure will handle correct positioning this.app.state.images.push(pageObj); console.log(`[_fetchPageByIdWithRetry] Successfully added page ${pageId}`); return true; } else { console.log(`[_fetchPageByIdWithRetry] Page ${pageId} was added by another process`); return true; } } else if (response.status === 404) { // Page doesn't exist on backend - might be a PDF page (not uploaded individually) console.log(`[_fetchPageByIdWithRetry] Page ${pageId} not found (404)`); if (attempt < maxRetries) { await new Promise(r => setTimeout(r, retryDelay * 2)); continue; } return false; } else { console.warn(`[_fetchPageByIdWithRetry] Page ${pageId} fetch failed (status: ${response.status})`); if (attempt < maxRetries) { await new Promise(r => setTimeout(r, retryDelay)); continue; } return false; } } catch (err) { console.error(`[_fetchPageByIdWithRetry] Error fetching page ${pageId}:`, err); if (attempt < maxRetries) { await new Promise(r => setTimeout(r, retryDelay)); continue; } return false; } } return false; } /** * Fetch a single page with retry logic (legacy index-based - for backwards compatibility) * @param {number} pageIndex - The page index to fetch * @param {number} maxRetries - Maximum number of retries * @param {number} retryDelay - Delay between retries in ms * @returns {boolean} - Whether the fetch was successful */ async _fetchSinglePageWithRetry(pageIndex, maxRetries = 3, retryDelay = 1000) { // Skip if page already exists if (this.app.state.images[pageIndex]) { console.log(`[_fetchSinglePageWithRetry] Page ${pageIndex} already exists, skipping`); return true; } for (let attempt = 1; attempt <= maxRetries; attempt++) { try { // Try new UUID-based endpoint first with pdf_X format const pageId = `pdf_${pageIndex}`; const url = window.Config?.apiUrl(`/api/color_rm/page/${this.app.state.sessionId}/${pageId}`) || `/api/color_rm/page/${this.app.state.sessionId}/${pageId}`; console.log(`[_fetchSinglePageWithRetry] Fetching page ${pageIndex} (pageId: ${pageId}), attempt ${attempt}/${maxRetries}`); const response = await fetch(url); if (response.ok) { const blob = await response.blob(); // Validate blob if (!blob || blob.size === 0) { console.warn(`[_fetchSinglePageWithRetry] Page ${pageIndex} returned empty blob, retry...`); if (attempt < maxRetries) { await new Promise(r => setTimeout(r, retryDelay)); continue; } return false; } const pageObj = { id: `${this.app.state.sessionId}_${pageIndex}`, sessionId: this.app.state.sessionId, pageIndex: pageIndex, pageId: pageId, // Store the UUID blob: blob, history: [] }; // Double-check if page still doesn't exist (race condition protection) if (!this.app.state.images[pageIndex]) { // Ensure we don't have gaps in the array while (this.app.state.images.length < pageIndex) { console.warn(`[_fetchSinglePageWithRetry] Filling gap at index ${this.app.state.images.length}`); // Create placeholder for missing pages this.app.state.images.push(null); } await this.app.dbPut('pages', pageObj); if (this.app.state.images.length === pageIndex) { this.app.state.images.push(pageObj); } else { this.app.state.images[pageIndex] = pageObj; } console.log(`[_fetchSinglePageWithRetry] Successfully added page ${pageIndex}`); return true; } else { console.log(`[_fetchSinglePageWithRetry] Page ${pageIndex} was added by another process`); return true; } } else if (response.status === 404) { // Page doesn't exist on backend yet - might still be uploading console.log(`[_fetchSinglePageWithRetry] Page ${pageIndex} not found (404), waiting...`); if (attempt < maxRetries) { await new Promise(r => setTimeout(r, retryDelay * 2)); // Longer wait for 404 continue; } return false; } else { console.warn(`[_fetchSinglePageWithRetry] Page ${pageIndex} fetch failed (status: ${response.status})`); if (attempt < maxRetries) { await new Promise(r => setTimeout(r, retryDelay)); continue; } return false; } } catch (err) { console.error(`[_fetchSinglePageWithRetry] Error fetching page ${pageIndex}:`, err); if (attempt < maxRetries) { await new Promise(r => setTimeout(r, retryDelay)); continue; } return false; } } return false; } /** * Fetches the page structure from the server and reconciles with local pages * This is the main method for UUID-based page sync * * SOURCE OF TRUTH: The page structure file + base PDF + user-added pages in R2 * - PDF pages (pdf_*) are rendered from the base PDF file * - User pages (user_*) are fetched individually from R2 */ async reconcilePageStructure() { // Skip if a local page operation is in progress if (this._isLocalPageOperation) { console.log('[reconcilePageStructure] Skipped - local page operation in progress'); return; } console.log('[reconcilePageStructure] Starting page structure reconciliation...'); try { // STEP 1: Get the page structure (this is the source of truth for ordering) const structureUrl = window.Config?.apiUrl(`/api/color_rm/page_structure/${this.app.state.sessionId}`) || `/api/color_rm/page_structure/${this.app.state.sessionId}`; const structureResponse = await fetch(structureUrl); if (!structureResponse.ok) { console.log('[reconcilePageStructure] No structure file found, nothing to sync'); return; } const structure = await structureResponse.json(); const remotePageIds = structure.pageIds || []; const pdfPageCount = structure.pdfPageCount || 0; const pageMetadata = structure.pageMetadata || {}; // Template/infinite canvas metadata console.log(`[reconcilePageStructure] Remote structure: ${remotePageIds.length} pages (${pdfPageCount} from PDF)`); if (remotePageIds.length === 0) { console.log('[reconcilePageStructure] Empty structure, nothing to sync'); return; } // STEP 2: Get local page IDs const localPageIds = this.app.state.images.map(img => img?.pageId).filter(Boolean); console.log(`[reconcilePageStructure] Local has ${localPageIds.length} pages`); // STEP 3: Find missing pages const missingPageIds = remotePageIds.filter(pid => !localPageIds.includes(pid)); if (missingPageIds.length > 0) { console.log(`[reconcilePageStructure] Missing ${missingPageIds.length} pages: ${missingPageIds.join(', ')}`); // Separate PDF pages from user pages const missingPdfPages = missingPageIds.filter(pid => pid.startsWith('pdf_')); const missingUserPages = missingPageIds.filter(pid => pid.startsWith('user_')); // For PDF pages: render from base PDF if (missingPdfPages.length > 0) { console.log(`[reconcilePageStructure] Rendering ${missingPdfPages.length} PDF pages from base file...`); await this._renderPdfPagesFromBase(missingPdfPages); } // For user pages: fetch from R2 if (missingUserPages.length > 0) { console.log(`[reconcilePageStructure] Fetching ${missingUserPages.length} user pages from R2...`); let fetchedCount = 0; let failedCount = 0; for (const pageId of missingUserPages) { const targetIndex = this.app.state.images.length; // Pass page metadata (for templates, infinite canvas, etc.) const meta = pageMetadata[pageId] || null; const success = await this._fetchPageByIdWithRetry(pageId, targetIndex, 3, 1000, meta); if (success) { fetchedCount++; } else { failedCount++; console.warn(`[reconcilePageStructure] Failed to fetch page ${pageId}`); } } // Show toast to user about synced pages if (fetchedCount > 0) { this.app.ui.showToast(`Synced ${fetchedCount} new page${fetchedCount > 1 ? 's' : ''} from collaborator`); } if (failedCount > 0) { this.app.ui.showToast(`⚠ ${failedCount} page${failedCount > 1 ? 's' : ''} failed to sync`, 'warning'); } } } // STEP 4: Reorder local pages to match remote structure await this._reorderPagesToMatchStructure(remotePageIds); // STEP 5: Update UI const pt = this.app.getElement('pageTotal'); if (pt) pt.innerText = '/ ' + this.app.state.images.length; if (this.app.state.activeSideTab === 'pages') { this.app.renderPageSidebar(); } // Reload current page if anything changed if (missingPageIds.length > 0) { this.app.invalidateCache(); await this.app.loadPage(this.app.state.idx, false); } console.log(`[reconcilePageStructure] Complete. Local pages: ${this.app.state.images.length}`); } catch (error) { console.error('[reconcilePageStructure] Error:', error); } } /** * Renders PDF pages from the base PDF file * @param {string[]} pdfPageIds - Array of PDF page IDs like ['pdf_0', 'pdf_1', ...] */ async _renderPdfPagesFromBase(pdfPageIds) { try { // Fetch the base PDF file const baseUrl = window.Config?.apiUrl(`/api/color_rm/base_file/${this.app.state.sessionId}`) || `/api/color_rm/base_file/${this.app.state.sessionId}`; const response = await fetch(baseUrl); if (!response.ok) { console.error('[_renderPdfPagesFromBase] Failed to fetch base PDF'); return; } const blob = await response.blob(); // Check if it's a PDF if (!blob.type.includes('pdf')) { console.log('[_renderPdfPagesFromBase] Base file is not a PDF, treating as single image'); // Handle single image base file if (pdfPageIds.includes('pdf_0')) { const pageObj = { id: `${this.app.state.sessionId}_${this.app.state.images.length}`, sessionId: this.app.state.sessionId, pageIndex: this.app.state.images.length, pageId: 'pdf_0', blob: blob, history: [] }; await this.app.dbPut('pages', pageObj); this.app.state.images.push(pageObj); } return; } // Load PDF with pdf.js const arrayBuffer = await blob.arrayBuffer(); const pdf = await pdfjsLib.getDocument(arrayBuffer).promise; console.log(`[_renderPdfPagesFromBase] PDF has ${pdf.numPages} pages, rendering ${pdfPageIds.length} missing pages`); for (const pageId of pdfPageIds) { // Extract page number from pageId (e.g., 'pdf_0' -> 0, 'pdf_5' -> 5) const pageNum = parseInt(pageId.replace('pdf_', ''), 10); if (pageNum >= pdf.numPages) { console.warn(`[_renderPdfPagesFromBase] Page ${pageNum} exceeds PDF page count ${pdf.numPages}`); continue; } // pdf.js uses 1-indexed pages const page = await pdf.getPage(pageNum + 1); const viewport = page.getViewport({ scale: 1.5 }); const canvas = document.createElement('canvas'); canvas.width = viewport.width; canvas.height = viewport.height; await page.render({ canvasContext: canvas.getContext('2d'), viewport: viewport }).promise; const pageBlob = await new Promise(resolve => canvas.toBlob(resolve, 'image/jpeg', 0.9)); const pageObj = { id: `${this.app.state.sessionId}_${this.app.state.images.length}`, sessionId: this.app.state.sessionId, pageIndex: this.app.state.images.length, pageId: pageId, blob: pageBlob, history: [] }; await this.app.dbPut('pages', pageObj); this.app.state.images.push(pageObj); console.log(`[_renderPdfPagesFromBase] Rendered page ${pageId}`); } } catch (error) { console.error('[_renderPdfPagesFromBase] Error:', error); } } /** * Reorders local pages to match the remote structure order * @param {string[]} remotePageIds - Ordered array of page IDs from server */ async _reorderPagesToMatchStructure(remotePageIds) { const reorderedPages = []; const pageMap = new Map(); const usedPageIds = new Set(); // Build a map of pageId -> page object this.app.state.images.forEach(page => { if (page && page.pageId) { pageMap.set(page.pageId, page); } }); // Reorder based on remote structure remotePageIds.forEach((pageId, index) => { const page = pageMap.get(pageId); if (page) { page.pageIndex = index; page.id = `${this.app.state.sessionId}_${index}`; reorderedPages.push(page); usedPageIds.add(pageId); } }); // Append any local-only pages that weren't in the remote structure at the end this.app.state.images.forEach(page => { if (page && page.pageId && !usedPageIds.has(page.pageId)) { const newIndex = reorderedPages.length; page.pageIndex = newIndex; page.id = `${this.app.state.sessionId}_${newIndex}`; reorderedPages.push(page); console.log(`[_reorderPagesToMatchStructure] Appending local-only page ${page.pageId} at index ${newIndex}`); } }); // Only update if there were actual changes if (reorderedPages.length > 0) { // Check if order actually changed (or if page count changed) const orderChanged = reorderedPages.length !== this.app.state.images.length || reorderedPages.some((page, idx) => this.app.state.images[idx]?.pageId !== page.pageId ); if (orderChanged) { this.app.state.images = reorderedPages; // Update IndexedDB with new page indices for (const page of reorderedPages) { await this.app.dbPut('pages', page); } console.log(`[_reorderPagesToMatchStructure] Reordered ${reorderedPages.length} pages`); } } } /** * Fetches missing pages from backend when remote has more pages than local * @param {number} expectedPageCount - The expected total page count from metadata */ async fetchMissingPages(expectedPageCount) { // Delegate to the new retry-enabled method return this.fetchMissingPagesWithRetry(expectedPageCount, 3); } syncHistory() { // Beta mode: Use Yjs if (this.useBetaSync) { return this._syncHistoryYjs(); } const project = this.getProject(); if (!project || this.isInitializing) return; const pagesHistory = project.get("pagesHistory"); let currentIdxChanged = false; const pagesToFetchBase = []; // Priority: Update current page immediately const currentRemote = pagesHistory.get(this.app.state.idx.toString()); if (currentRemote) { const deltaHist = currentRemote.toArray(); const localImg = this.app.state.images[this.app.state.idx]; if (localImg) { // Check if we need to fetch base history first const pageMeta = this.getPageMetadata(this.app.state.idx); const needsBase = (pageMeta?.hasBaseHistory || localImg.hasBaseHistory) && !localImg._baseHistory; if (needsBase) { // Queue for base history fetch, use deltas only for now pagesToFetchBase.push(this.app.state.idx); localImg.history = deltaHist; } else if (localImg.hasBaseHistory && localImg._baseHistory) { // If page has base history, merge it with deltas and apply modifications // Get modifications from Liveblocks + R2 (if large) const liveblocksModifications = this.getPageModifications(this.app.state.idx); const r2Modifications = localImg._r2Modifications || {}; const modifications = { ...liveblocksModifications, ...r2Modifications }; // Apply modifications to base history const modifiedBase = localImg._baseHistory.map(item => { if (modifications[item.id]) { // Merge modification onto base item return { ...item, ...modifications[item.id] }; } return item; }); // Merge: modified base + deltas from Liveblocks localImg.history = [...modifiedBase, ...deltaHist]; } else { localImg.history = deltaHist; } currentIdxChanged = true; if (this.app.invalidateCache) this.app.invalidateCache(); // Auto-recalculate infinite canvas bounds from synced strokes if (localImg.isInfinite && this.app.recalculateInfiniteCanvasBounds) { this.app.recalculateInfiniteCanvasBounds(this.app.state.idx); } } } // Background sync all other pages (with null check) this.app.state.images.forEach((img, idx) => { if (!img) return; // Skip null/undefined pages (gaps) if (idx === this.app.state.idx) return; // Already handled const remote = pagesHistory.get(idx.toString()); if (remote) { const deltaHist = remote.toArray(); // Check if we need to fetch base history first const pageMeta = this.getPageMetadata(idx); const needsBase = (pageMeta?.hasBaseHistory || img.hasBaseHistory) && !img._baseHistory; if (needsBase) { // Queue for base history fetch, use deltas only for now pagesToFetchBase.push(idx); img.history = deltaHist; } else if (img.hasBaseHistory && img._baseHistory) { // If page has base history, merge it with deltas and apply modifications // Get modifications from Liveblocks + R2 (if large) const liveblocksModifications = this.getPageModifications(idx); const r2Modifications = img._r2Modifications || {}; const modifications = { ...liveblocksModifications, ...r2Modifications }; const modifiedBase = img._baseHistory.map(item => { if (modifications[item.id]) { return { ...item, ...modifications[item.id] }; } return item; }); img.history = [...modifiedBase, ...deltaHist]; } else { img.history = deltaHist; } // Auto-recalculate infinite canvas bounds for all infinite pages if (img.isInfinite && this.app.recalculateInfiniteCanvasBounds) { this.app.recalculateInfiniteCanvasBounds(idx); } } }); if (currentIdxChanged) this.app.render(); // Fetch base history for pages that need it (async, will trigger re-sync) if (pagesToFetchBase.length > 0) { this._fetchMissingBaseHistories(pagesToFetchBase); } } /** * Yjs version of syncHistory - syncs current page history via WebSocket */ _syncHistoryYjs() { if (this.isInitializing) return; // Use the simpler WebSocket-based sync this._sendYjsUpdate(); } // Fetches base history for multiple pages and re-syncs async _fetchMissingBaseHistories(pageIndices) { console.log(`[LiveSync] Fetching base history for ${pageIndices.length} pages...`); for (const pageIdx of pageIndices) { await this.ensureBaseHistory(pageIdx); } // Re-sync history now that base is loaded // Use a small delay to avoid recursion setTimeout(() => { console.log('[LiveSync] Re-syncing history after base fetch...'); this.syncHistory(); }, 100); } // Fetch and cache base history for a page (called when page is loaded) async ensureBaseHistory(pageIdx) { const localImg = this.app.state.images[pageIdx]; if (!localImg) return; // Check if page has base history and we haven't fetched it yet const pageMeta = this.getPageMetadata(pageIdx); if ((pageMeta?.hasBaseHistory || localImg.hasBaseHistory) && !localImg._baseHistory) { const pageId = localImg.pageId; if (pageId) { const baseHistory = await this.fetchBaseHistory(pageId); if (baseHistory.length > 0) { localImg._baseHistory = baseHistory; localImg.hasBaseHistory = true; // Re-sync to merge base + deltas this.syncHistory(); } } } // Also check for R2 modifications if they exist if (pageMeta?.hasR2Modifications && localImg.pageId) { const r2Mods = await this.fetchR2Modifications(localImg.pageId); if (r2Mods && Object.keys(r2Mods).length > 0) { localImg._r2Modifications = r2Mods; // Re-sync to apply modifications this.syncHistory(); } } } // Fetch modifications from R2 (for large modification sets) async fetchR2Modifications(pageId) { if (!this.app.state.sessionId || !pageId) return {}; try { const modsUrl = window.Config?.apiUrl(`/api/color_rm/modifications/${this.app.state.sessionId}/${pageId}`) || `/api/color_rm/modifications/${this.app.state.sessionId}/${pageId}`; const response = await fetch(modsUrl); if (!response.ok) return {}; const data = await response.json(); console.log(`[LiveSync] Fetched ${Object.keys(data.modifications || {}).length} R2 modifications for page ${pageId}`); return data.modifications || {}; } catch (e) { console.error('[LiveSync] Failed to fetch R2 modifications:', e); return {}; } } // --- Local -> Remote Updates --- updateMetadata(updates) { // Beta mode: Use Yjs if (this.useBetaSync) { if (!this.yjsRoot) return; this.yjsDoc.transact(() => { for (const [key, value] of Object.entries(updates)) { this.yjsRoot.metadata.set(key, value); } }); return; } const project = this.getProject(); if (project) project.get("metadata").update(updates); } addStroke(pageIdx, stroke) { // Beta mode: Use Yjs if (this.useBetaSync) { // For beta sync, just send the updated state via WebSocket // The stroke is already added to local history by the app this._sendYjsUpdate(); return; } const project = this.getProject(); if (!project) return; const pagesHistory = project.get("pagesHistory"); const key = pageIdx.toString(); if (!pagesHistory.has(key)) { pagesHistory.set(key, new LiveList([])); } pagesHistory.get(key).push(stroke); } setHistory(pageIdx, history) { // Beta mode: Use Yjs if (this.useBetaSync) { // For beta sync, send updated state via WebSocket this._sendYjsUpdate(); return; } const project = this.getProject(); if (!project) return; const pagesHistory = project.get("pagesHistory"); pagesHistory.set(pageIdx.toString(), new LiveList(history || [])); } /** * Sync page deltas for pages with base history (SVG imports) * Instead of syncing full history, only sync: * - deltas: new items (user scribbles) * - modifications: changes to base items (moves, deletes, etc.) */ syncPageDeltas(pageIdx, deltas, modifications) { // Beta mode: Use Yjs if (this.useBetaSync) { return this._syncPageDeltasYjs(pageIdx, deltas, modifications); } const project = this.getProject(); if (!project) return; const key = pageIdx.toString(); // Store deltas in pagesHistory (new items only) const pagesHistory = project.get("pagesHistory"); pagesHistory.set(key, new LiveList(deltas || [])); // Store modifications in a separate LiveMap let pagesMods = project.get("pagesModifications"); if (!pagesMods) { project.set("pagesModifications", new LiveMap()); pagesMods = project.get("pagesModifications"); } // Store modifications as a LiveObject if (Object.keys(modifications).length > 0) { pagesMods.set(key, new LiveObject(modifications)); } else if (pagesMods.has(key)) { pagesMods.delete(key); } console.log(`[LiveSync] Synced page ${pageIdx}: ${deltas.length} deltas, ${Object.keys(modifications).length} modifications`); } /** * Yjs version of syncPageDeltas */ _syncPageDeltasYjs(pageIdx, deltas, modifications) { if (!this.yjsRoot) return; const key = pageIdx.toString(); this.yjsDoc.transact(() => { // Store deltas this.yjsRoot.pagesHistory.set(key, deltas || []); // Store modifications if (Object.keys(modifications).length > 0) { this.yjsRoot.pagesModifications.set(key, modifications); } else if (this.yjsRoot.pagesModifications.has(key)) { this.yjsRoot.pagesModifications.delete(key); } }); console.log(`[Yjs] Synced page ${pageIdx}: ${deltas.length} deltas, ${Object.keys(modifications).length} modifications`); } /** * Get modifications for a page (for applying to base history) */ getPageModifications(pageIdx) { // Beta mode: Get from Yjs root if (this.useBetaSync) { if (!this.yjsRoot) return {}; const key = pageIdx.toString(); return this.yjsRoot.pagesModifications?.get(key) || {}; } const project = this.getProject(); if (!project) return {}; const pagesMods = project.get("pagesModifications"); if (!pagesMods) return {}; const mods = pagesMods.get(pageIdx.toString()); return mods ? mods.toObject() : {}; } // Update page metadata (e.g., hasBaseHistory flag for SVG imports) updatePageMetadata(pageIdx, metadata) { // Beta mode: Store in Yjs root if (this.useBetaSync) { if (!this.yjsRoot) return; const key = pageIdx.toString(); const existing = this.yjsRoot.pagesMetadata?.get(key) || {}; this.yjsRoot.pagesMetadata?.set(key, { ...existing, ...metadata }); return; } const project = this.getProject(); if (!project) return; // Store page metadata in a separate LiveMap let pagesMetadata = project.get("pagesMetadata"); if (!pagesMetadata) { // Initialize if not exists project.set("pagesMetadata", new LiveMap()); pagesMetadata = project.get("pagesMetadata"); } const key = pageIdx.toString(); const existing = pagesMetadata.get(key); if (existing) { existing.update(metadata); } else { pagesMetadata.set(key, new LiveObject(metadata)); } } // Get page metadata (for checking if page has base history) getPageMetadata(pageIdx) { // Beta mode: Get from Yjs root if (this.useBetaSync) { if (!this.yjsRoot) return null; const key = pageIdx.toString(); return this.yjsRoot.pagesMetadata?.get(key) || null; } const project = this.getProject(); if (!project) return null; const pagesMetadata = project.get("pagesMetadata"); if (!pagesMetadata) return null; const meta = pagesMetadata.get(pageIdx.toString()); return meta ? meta.toObject() : null; } // Fetch base history from R2 for a page (for SVG imports) async fetchBaseHistory(pageId) { if (!this.app.state.sessionId || !pageId) return []; try { const historyUrl = window.Config?.apiUrl(`/api/color_rm/history/${this.app.state.sessionId}/${pageId}`) || `/api/color_rm/history/${this.app.state.sessionId}/${pageId}`; const response = await fetch(historyUrl); if (response.ok) { const history = await response.json(); console.log(`[LiveSync] Fetched ${history.length} base history items from R2 for page ${pageId}`); return history; } } catch (e) { console.error('[LiveSync] Failed to fetch base history from R2:', e); } return []; } // DEPRECATED: Bounds are now auto-calculated from synced strokes via math // Kept for backwards compatibility but does nothing updateInfiniteCanvasBounds(pageIdx, bounds) { // No-op: bounds are derived from stroke history automatically // console.log(`[LiveSync] updateInfiniteCanvasBounds deprecated - bounds auto-calculated from strokes`); } // DEPRECATED: Bounds are now auto-calculated from synced strokes via math // Kept for backwards compatibility but does nothing handleInfiniteCanvasBoundsUpdate(presence) { // No-op: bounds are derived from stroke history automatically // When strokes sync via Liveblocks, recalculateInfiniteCanvasBounds() is called } updateBookmarks(bookmarks) { // Beta mode: Use Yjs if (this.useBetaSync) { if (!this.yjsRoot) return; this.yjsDoc.transact(() => { this.yjsRoot.bookmarks.delete(0, this.yjsRoot.bookmarks.length); (bookmarks || []).forEach(b => this.yjsRoot.bookmarks.push([b])); }); return; } const project = this.getProject(); if (project) project.set("bookmarks", new LiveList(bookmarks || [])); } updateColors(colors) { // Beta mode: Use Yjs if (this.useBetaSync) { if (!this.yjsRoot) return; this.yjsDoc.transact(() => { this.yjsRoot.colors.delete(0, this.yjsRoot.colors.length); (colors || []).forEach(c => this.yjsRoot.colors.push([c])); }); return; } const project = this.getProject(); if (project) project.set("colors", new LiveList(colors || [])); } // Add new page to remote storage addPage(pageIndex, pageData) { // Beta mode: Use Yjs if (this.useBetaSync) { if (!this.yjsRoot) return; const key = pageIndex.toString(); this.yjsDoc.transact(() => { this.yjsRoot.pagesHistory.set(key, []); // Update metadata this.yjsRoot.metadata.set('pageCount', Math.max( this.yjsRoot.metadata.get('pageCount') || 0, pageIndex + 1 )); }); // Broadcast update this._sendYjsUpdate(); return; } const project = this.getProject(); if (!project) return; const pagesHistory = project.get("pagesHistory"); const key = pageIndex.toString(); // Initialize with empty history for the new page pagesHistory.set(key, new LiveList([])); // Update page count in metadata const currentMetadata = project.get("metadata").toObject(); project.get("metadata").update({ pageCount: Math.max(currentMetadata.pageCount, pageIndex + 1) }); } // Reorder pages in remote storage reorderPages(fromIndex, toIndex) { // Beta mode: Use Yjs if (this.useBetaSync) { if (!this.yjsRoot) return; const fromKey = fromIndex.toString(); const toKey = toIndex.toString(); this.yjsDoc.transact(() => { const fromHistory = this.yjsRoot.pagesHistory.get(fromKey) || []; const toHistory = this.yjsRoot.pagesHistory.get(toKey) || []; this.yjsRoot.pagesHistory.set(fromKey, toHistory); this.yjsRoot.pagesHistory.set(toKey, fromHistory); }); return; } const project = this.getProject(); if (!project) return; const pagesHistory = project.get("pagesHistory"); const fromKey = fromIndex.toString(); const toKey = toIndex.toString(); // Get the histories to swap const fromHistory = pagesHistory.get(fromKey); const toHistory = pagesHistory.get(toKey); // If 'to' page doesn't exist, create empty history if (!toHistory) { pagesHistory.set(toKey, new LiveList([])); } // Swap histories if (fromHistory) { const fromArray = fromHistory.toArray(); const toArray = toHistory ? toHistory.toArray() : []; pagesHistory.set(fromKey, new LiveList(toArray)); pagesHistory.set(toKey, new LiveList(fromArray)); } else { pagesHistory.set(fromKey, new LiveList([])); } } // Update page count in metadata updatePageCount(count) { // Beta mode: Use Yjs if (this.useBetaSync) { if (!this.yjsRoot) return; this.yjsRoot.metadata.set('pageCount', count); return; } const project = this.getProject(); if (project) { project.get("metadata").update({ pageCount: count }); } } // Notify other users about page structure changes using presence (debounced) notifyPageStructureChange() { // Beta mode: Use Yjs - broadcast page structure change if (this.useBetaSync) { this._sendYjsPageStructure(); return; } // Debounce notifications to prevent flooding during rapid page changes if (this._notifyDebounceTimer) { clearTimeout(this._notifyDebounceTimer); } this._notifyDebounceTimer = setTimeout(() => { this._doNotifyPageStructureChange(); }, 100); // 100ms debounce for outgoing notifications } /** * Send page structure update via Yjs WebSocket */ _sendYjsPageStructure() { if (!this._yjsSocket || !this._yjsConnected) return; const msg = { type: 'page-structure', pageCount: this.app.state.images.length, pageIds: this.app.state.images.map(img => img?.pageId).filter(Boolean), currentIdx: this.app.state.idx }; this._yjsSocket.send(JSON.stringify(msg)); console.log(`[Yjs] Sent page structure: ${msg.pageCount} pages`); } /** * Send presence update via Yjs WebSocket */ _sendYjsPresence() { if (!this._yjsSocket || !this._yjsConnected) { console.warn('[Yjs] Cannot send presence - not connected'); return; } const msg = { type: 'presence', clientId: this._yjsClientId, userName: this.userName || 'Anonymous', pageIdx: this.app.state.idx, cursor: this._lastCursor || null, tool: this.app.state.tool, isDrawing: this.app.state.isDrawing || false, color: this.app.state.color, size: this.app.state.size }; this._yjsSocket.send(JSON.stringify(msg)); console.log(`[Yjs] Sent presence: page ${msg.pageIdx}`); } // Internal: Actually send the page structure change notification _doNotifyPageStructureChange() { // Beta mode: Already handled via _sendYjsPageStructure in notifyPageStructureChange if (this.useBetaSync) { this._sendYjsPageStructure(); return; } // Update presence with a timestamp to notify other users of changes if (this.room) { // Set flag to ignore our own page structure change notification this._ownPageStructureVersion = Date.now(); this.room.updatePresence({ pageStructureVersion: this._ownPageStructureVersion, pageCount: this.app.state.images.length, pageIdx: this.app.state.idx }); console.log(`[LiveSync] Notified page structure change: ${this.app.state.images.length} pages`); } } // Notify other users about page navigation (smart debounced) // When user is rapidly flipping pages, we wait until they stop notifyPageNavigation(pageIdx) { // Mark that we're in a page flip session this._isFlippingPages = true; this._pendingPageIdx = pageIdx; // Show visual feedback that we're flipping (dim canvas) this._showFlippingFeedback(true); // Debounce - only notify after user stops flipping for 300ms if (this._pageNavDebounceTimer) { clearTimeout(this._pageNavDebounceTimer); } this._pageNavDebounceTimer = setTimeout(() => { // Page flipping stopped - send final page this._isFlippingPages = false; this._showFlippingFeedback(false); // Beta mode: Update presence and sync page history if (this.useBetaSync) { // Mark time to prevent echo-back from other users this.lastLocalPageChange = Date.now(); this._sendYjsPresence(); // Also sync current page history so other clients see our page this._sendYjsUpdate(); console.log(`[Yjs] Page navigation complete: ${pageIdx}`); return; } // Mark the time of local change to prevent echo-back this.lastLocalPageChange = Date.now(); // Update metadata const project = this.getProject(); if (project) { project.get("metadata").update({ idx: pageIdx }); } // Update presence if (this.room) { this.room.updatePresence({ pageIdx: pageIdx }); } }, 300); // 300ms debounce - quick but prevents spam } // Visual feedback when user is rapidly flipping pages _showFlippingFeedback(show) { const canvas = this.app.getElement('canvas'); if (!canvas) return; if (show) { // Dim canvas slightly but keep content visible canvas.style.transition = 'opacity 0.1s ease'; canvas.style.opacity = '0.7'; } else { // Restore full opacity canvas.style.transition = 'opacity 0.2s ease'; canvas.style.opacity = '1'; } } // Handle page structure change notifications from other users (debounced) async handlePageStructureChange(message) { // Ignore our own page structure change notification if (message.pageStructureVersion === this._ownPageStructureVersion) { return; } // Debounce: Coalesce multiple rapid changes into one reconciliation if (this._handleStructureDebounceTimer) { clearTimeout(this._handleStructureDebounceTimer); } this._handleStructureDebounceTimer = setTimeout(async () => { await this._doHandlePageStructureChange(message); }, 500); // 500ms debounce for incoming changes } // Internal: Actually handle the page structure change async _doHandlePageStructureChange(message) { // Double-check we haven't processed very recently const now = Date.now(); const DEBOUNCE_MS = 800; if (this._lastPageStructureChange && (now - this._lastPageStructureChange) < DEBOUNCE_MS) { return; } this._lastPageStructureChange = now; // Always run reconciliation when notified of structure change console.log(`Page structure change detected: local=${this.app.state.images.length}, remote=${message.pageCount}`); // Add a small delay to allow server to receive the page structure update await new Promise(r => setTimeout(r, 300)); // Simply run reconciliation - it will fetch from R2 (source of truth) console.log('[handlePageStructureChange] Running reconciliation...'); await this.reconcilePageStructure(); } }