/** * CanvasEventLogger — silently logs TLDraw canvas activity during transcription sessions. * Attaches to the editor instance and buffers events, flushing to API every 5 seconds. */ import { Editor, TLStoreEventInfo } from '@tldraw/tldraw'; import { useTranscriptionStore } from '../../stores/transcriptionStore'; import { API_BASE } from '../../../../config/apiConfig'; export class CanvasEventLogger { private editor: Editor | null = null; private sessionId: string | null = null; private buffer: Array<{ eventType: string; payload: Record; sessionElapsedSeconds?: number; pageId?: string; shapeIds?: string[]; snapshotUrl?: string; }> = []; private flushInterval: ReturnType | null = null; private snapshotInterval: ReturnType | null = null; private snapshotIntervalMs: number = 60000; // 60 seconds default private isAttached: boolean = false; /** * Attach the logger to a TLDraw editor instance. * Starts buffering events and periodic snapshots. */ attach(editor: Editor, sessionId: string): void { if (this.isAttached) { this.detach(); } this.editor = editor; this.sessionId = sessionId; this.isAttached = true; // Listen to store changes for shape/page events const unsubscribe = editor.store.listen( (info: TLStoreEventInfo) => this.onStoreChange(info), { source: 'user' } // Only user-initiated changes, not programmatic ); // Store unsubscribe function for cleanup (editor as any).__canvasEventLoggerUnsubscribe = unsubscribe; // Start periodic flush this.flushInterval = setInterval(() => this.flush(), 5000); // Start periodic snapshots this.snapshotInterval = setInterval(() => this.captureSnapshot(), this.snapshotIntervalMs); // Capture initial snapshot this.captureSnapshot(); console.log('[CanvasEventLogger] Attached to editor for session', sessionId); } /** * Detach the logger from the editor. * Flushes any pending events and stops intervals. */ detach(): void { if (!this.isAttached) return; // Flush remaining events this.flush(); // Stop intervals if (this.flushInterval) { clearInterval(this.flushInterval); this.flushInterval = null; } if (this.snapshotInterval) { clearInterval(this.snapshotInterval); this.snapshotInterval = null; } // Unsubscribe from store listener if (this.editor && (this.editor as any).__canvasEventLoggerUnsubscribe) { (this.editor as any).__canvasEventLoggerUnsubscribe(); delete (this.editor as any).__canvasEventLoggerUnsubscribe; } this.editor = null; this.sessionId = null; this.isAttached = false; console.log('[CanvasEventLogger] Detached from editor'); } /** * Handle store changes to detect canvas events. */ private onStoreChange(info: TLStoreEventInfo): void { if (!this.editor || !this.sessionId) return; const { added, removed, updated } = info; const elapsedSeconds = useTranscriptionStore.getState().elapsedSeconds; // Handle shape creation if (added.size > 0) { for (const [id, shape] of added) { this.buffer.push({ eventType: 'shape_created', payload: { shapeId: id, shapeType: shape.type, pageId: this.editor?.currentPageId, }, sessionElapsedSeconds: elapsedSeconds, pageId: this.editor?.currentPageId, shapeIds: [id], }); } } // Handle shape deletion if (removed.size > 0) { for (const [id] of removed) { this.buffer.push({ eventType: 'shape_deleted', payload: { shapeId: id }, sessionElapsedSeconds: elapsedSeconds, shapeIds: [id], }); } } // Handle page changes if (updated.size > 0) { for (const [id, { old, new: updatedItem }] of updated) { if (id === 'page' && old?.currentPageId !== updatedItem?.currentPageId) { this.buffer.push({ eventType: 'page_changed', payload: { fromPageId: old?.currentPageId, toPageId: updatedItem?.currentPageId, }, sessionElapsedSeconds: elapsedSeconds, pageId: updatedItem?.currentPageId, }); // Capture snapshot on page change this.captureSnapshot(); } } } // Handle ink/draw strokes for (const [id, shape] of added) { if (shape.type === 'draw') { this.buffer.push({ eventType: 'ink_added', payload: { shapeId: id, strokeCount: (shape as any).points?.length || 0 }, sessionElapsedSeconds: elapsedSeconds, shapeIds: [id], }); } } // Flush immediately for significant events (page changes, ink) if (this.buffer.length >= 5) { this.flush(); } } /** * Capture a snapshot of the current canvas state. * Uploads to Supabase Storage and returns the URL. */ private async captureSnapshot(): Promise { if (!this.editor || !this.sessionId) return null; try { // Get SVG element from editor const svgElement = this.editor.svg?.current; if (!svgElement) { console.warn('[CanvasEventLogger] No SVG element available for snapshot'); return null; } // Serialize SVG to string const svgString = new XMLSerializer().serializeToString(svgElement); const blob = new Blob([svgString], { type: 'image/svg+xml' }); // For now, store as base64 in the event payload // In Phase 3, upload to Supabase Storage const reader = new FileReader(); const base64Data = await new Promise((resolve) => { reader.onloadend = () => resolve(reader.result as string); reader.readAsDataURL(blob); }); // Store snapshot URL reference (will be replaced with actual storage URL in Phase 3) const snapshotUrl = `canvas-snapshots/${this.sessionId}/${Date.now()}.svg`; this.buffer.push({ eventType: 'canvas_snapshot', payload: { snapshotUrl, pageId: this.editor.currentPageId }, sessionElapsedSeconds: useTranscriptionStore.getState().elapsedSeconds, pageId: this.editor.currentPageId, snapshotUrl, }); console.log('[CanvasEventLogger] Snapshot captured:', snapshotUrl); return snapshotUrl; } catch (error) { console.error('[CanvasEventLogger] Failed to capture snapshot:', error); return null; } } /** * Flush buffered events to the API. */ private async flush(): void { if (this.buffer.length === 0) return; const events = [...this.buffer]; this.buffer = []; try { const response = await fetch(`${API_BASE}/transcribe/canvas-events`, { method: 'POST', headers: { 'Content-Type': 'application/json', // TODO: Add auth header in Phase 3 }, body: JSON.stringify({ events }), }); if (!response.ok) { console.error('[CanvasEventLogger] Failed to flush canvas events:', response.status); } else { console.log('[CanvasEventLogger] Flushed', events.length, 'events'); } } catch (error) { console.error('[CanvasEventLogger] Error flushing canvas events:', error); // Re-queue events for retry this.buffer = [...events, ...this.buffer]; } } /** * Manually trigger a snapshot capture. */ async captureSnapshotNow(): Promise { return this.captureSnapshot(); } /** * Get the current buffer size (for debugging). */ getBufferSize(): number { return this.buffer.length; } }