Spaces:
Sleeping
Sleeping
| 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 = ` | |
| <div class="cursor-pointer"></div> | |
| <div class="cursor-label">${presence.userName || 'User'}</div> | |
| `; | |
| 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 = ` | |
| <div class="cursor-pointer"></div> | |
| <div class="cursor-label">${state.userName || 'User'}</div> | |
| `; | |
| 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 = ` | |
| <div class="user-item self"> | |
| <div class="user-dot" style="background:var(--primary)"></div> | |
| <span>You (${myName})</span> | |
| </div> | |
| `; | |
| others.forEach(user => { | |
| const info = user.presence; | |
| if (!info || !info.userId) return; | |
| const userName = info.userName || info.userId; | |
| html += ` | |
| <div class="user-item"> | |
| <div class="user-dot" style="background:var(--accent)"></div> | |
| <span>${userName}</span> | |
| </div> | |
| `; | |
| }); | |
| 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 = ` | |
| <div class="user-item self"> | |
| <div class="user-dot" style="background:var(--primary)"></div> | |
| <span>You (${myName})</span> | |
| </div> | |
| `; | |
| if (this._yjsAwareness) { | |
| this._yjsAwareness.forEach((state, clientId) => { | |
| if (clientId === this._yjsClientId) return; // Skip self | |
| if (!state) return; | |
| const userName = state.userName || 'User'; | |
| html += ` | |
| <div class="user-item"> | |
| <div class="user-dot" style="background:var(--accent)"></div> | |
| <span>${userName}</span> | |
| </div> | |
| `; | |
| }); | |
| } | |
| 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(); | |
| } | |
| } | |