diff --git a/package.json b/package.json index e89f13de..b8ca18b9 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "joi": "^17.6.0", "jsonwebtoken": "^9.0.2", "jwt-decode": "^4.0.0", + "lodash": "^4.17.21", "loglevel": "^1.8.0", "loglevel-plugin-remote": "^0.6.8", "maxun-core": "^0.0.7", @@ -66,6 +67,7 @@ "react-transition-group": "^4.4.2", "sequelize": "^6.37.3", "sequelize-typescript": "^2.1.6", + "sharp": "^0.33.5", "socket.io": "^4.4.1", "socket.io-client": "^4.4.1", "styled-components": "^5.3.3", @@ -97,6 +99,7 @@ "@types/cookie-parser": "^1.4.7", "@types/express": "^4.17.13", "@types/js-cookie": "^3.0.6", + "@types/lodash": "^4.17.14", "@types/loglevel": "^1.6.3", "@types/node": "22.7.9", "@types/node-cron": "^3.0.11", diff --git a/perf/performance.ts b/perf/performance.ts new file mode 100644 index 00000000..c50ef850 --- /dev/null +++ b/perf/performance.ts @@ -0,0 +1,181 @@ +// Frontend Performance Monitoring +export class FrontendPerformanceMonitor { + private metrics: { + fps: number[]; + memoryUsage: MemoryInfo[]; + renderTime: number[]; + eventLatency: number[]; + }; + private lastFrameTime: number; + private frameCount: number; + + constructor() { + this.metrics = { + fps: [], + memoryUsage: [], + renderTime: [], + eventLatency: [], + }; + this.lastFrameTime = performance.now(); + this.frameCount = 0; + + // Start monitoring + this.startMonitoring(); + } + + private startMonitoring(): void { + // Monitor FPS + const measureFPS = () => { + const currentTime = performance.now(); + const elapsed = currentTime - this.lastFrameTime; + this.frameCount++; + + if (elapsed >= 1000) { // Calculate FPS every second + const fps = Math.round((this.frameCount * 1000) / elapsed); + this.metrics.fps.push(fps); + this.frameCount = 0; + this.lastFrameTime = currentTime; + } + requestAnimationFrame(measureFPS); + }; + requestAnimationFrame(measureFPS); + + // Monitor Memory Usage + if (window.performance && (performance as any).memory) { + setInterval(() => { + const memory = (performance as any).memory; + this.metrics.memoryUsage.push({ + usedJSHeapSize: memory.usedJSHeapSize, + totalJSHeapSize: memory.totalJSHeapSize, + timestamp: Date.now() + }); + }, 1000); + } + } + + // Monitor Canvas Render Time + public measureRenderTime(renderFunction: () => void): void { + const startTime = performance.now(); + renderFunction(); + const endTime = performance.now(); + this.metrics.renderTime.push(endTime - startTime); + } + + // Monitor Event Latency + public measureEventLatency(event: MouseEvent | KeyboardEvent): void { + const latency = performance.now() - event.timeStamp; + this.metrics.eventLatency.push(latency); + } + + // Get Performance Report + public getPerformanceReport(): PerformanceReport { + return { + averageFPS: this.calculateAverage(this.metrics.fps), + averageRenderTime: this.calculateAverage(this.metrics.renderTime), + averageEventLatency: this.calculateAverage(this.metrics.eventLatency), + memoryTrend: this.getMemoryTrend(), + lastMemoryUsage: this.metrics.memoryUsage[this.metrics.memoryUsage.length - 1] + }; + } + + private calculateAverage(array: number[]): number { + return array.length ? array.reduce((a, b) => a + b) / array.length : 0; + } + + private getMemoryTrend(): MemoryTrend { + if (this.metrics.memoryUsage.length < 2) return 'stable'; + const latest = this.metrics.memoryUsage[this.metrics.memoryUsage.length - 1]; + const previous = this.metrics.memoryUsage[this.metrics.memoryUsage.length - 2]; + const change = latest.usedJSHeapSize - previous.usedJSHeapSize; + if (change > 1000000) return 'increasing'; // 1MB threshold + if (change < -1000000) return 'decreasing'; + return 'stable'; + } +} + +// Backend Performance Monitoring +export class BackendPerformanceMonitor { + private metrics: { + screenshotTimes: number[]; + emitTimes: number[]; + memoryUsage: NodeJS.MemoryUsage[]; + }; + + constructor() { + this.metrics = { + screenshotTimes: [], + emitTimes: [], + memoryUsage: [] + }; + this.startMonitoring(); + } + + private startMonitoring(): void { + // Monitor Memory Usage + setInterval(() => { + this.metrics.memoryUsage.push(process.memoryUsage()); + }, 1000); + } + + public async measureScreenshotPerformance( + makeScreenshot: () => Promise + ): Promise { + const startTime = process.hrtime(); + await makeScreenshot(); + const [seconds, nanoseconds] = process.hrtime(startTime); + this.metrics.screenshotTimes.push(seconds * 1000 + nanoseconds / 1000000); + } + + public measureEmitPerformance(emitFunction: () => void): void { + const startTime = process.hrtime(); + emitFunction(); + const [seconds, nanoseconds] = process.hrtime(startTime); + this.metrics.emitTimes.push(seconds * 1000 + nanoseconds / 1000000); + } + + public getPerformanceReport(): BackendPerformanceReport { + return { + averageScreenshotTime: this.calculateAverage(this.metrics.screenshotTimes), + averageEmitTime: this.calculateAverage(this.metrics.emitTimes), + currentMemoryUsage: this.metrics.memoryUsage[this.metrics.memoryUsage.length - 1], + memoryTrend: this.getMemoryTrend() + }; + } + + private calculateAverage(array: number[]): number { + return array.length ? array.reduce((a, b) => a + b) / array.length : 0; + } + + private getMemoryTrend(): MemoryTrend { + if (this.metrics.memoryUsage.length < 2) return 'stable'; + const latest = this.metrics.memoryUsage[this.metrics.memoryUsage.length - 1]; + const previous = this.metrics.memoryUsage[this.metrics.memoryUsage.length - 2]; + const change = latest.heapUsed - previous.heapUsed; + if (change > 1000000) return 'increasing'; + if (change < -1000000) return 'decreasing'; + return 'stable'; + } +} + +interface MemoryInfo { + usedJSHeapSize: number; + totalJSHeapSize: number; + timestamp: number; +} + +type MemoryTrend = 'increasing' | 'decreasing' | 'stable'; + +interface PerformanceReport { + averageFPS: number; + averageRenderTime: number; + averageEventLatency: number; + memoryTrend: MemoryTrend; + lastMemoryUsage: MemoryInfo; +} + +interface BackendPerformanceReport { + averageScreenshotTime: number; + averageEmitTime: number; + currentMemoryUsage: NodeJS.MemoryUsage; + memoryTrend: MemoryTrend; +} \ No newline at end of file diff --git a/server/src/browser-management/classes/RemoteBrowser.ts b/server/src/browser-management/classes/RemoteBrowser.ts index 2c45d146..8ff4f601 100644 --- a/server/src/browser-management/classes/RemoteBrowser.ts +++ b/server/src/browser-management/classes/RemoteBrowser.ts @@ -9,6 +9,8 @@ import { chromium } from 'playwright-extra'; import stealthPlugin from 'puppeteer-extra-plugin-stealth'; import { PlaywrightBlocker } from '@cliqz/adblocker-playwright'; import fetch from 'cross-fetch'; +import { throttle } from 'lodash'; +import sharp from 'sharp'; import logger from '../../logger'; import { InterpreterSettings, RemoteBrowserOptions } from "../../types"; @@ -16,8 +18,30 @@ import { WorkflowGenerator } from "../../workflow-management/classes/Generator"; import { WorkflowInterpreter } from "../../workflow-management/classes/Interpreter"; import { getDecryptedProxyConfig } from '../../routes/proxy'; import { getInjectableScript } from 'idcac-playwright'; + chromium.use(stealthPlugin()); +const MEMORY_CONFIG = { + gcInterval: 60000, // 1 minute + maxHeapSize: 2048 * 1024 * 1024, // 2GB + heapUsageThreshold: 0.85 // 85% +}; + +const SCREENCAST_CONFIG: { + format: "jpeg" | "png"; + maxWidth: number; + maxHeight: number; + targetFPS: number; + compressionQuality: number; + maxQueueSize: number; +} = { + format: 'jpeg', + maxWidth: 900, + maxHeight: 400, + targetFPS: 30, + compressionQuality: 0.8, + maxQueueSize: 2 +}; /** * This class represents a remote browser instance. @@ -78,6 +102,11 @@ export class RemoteBrowser { */ public interpreter: WorkflowInterpreter; + + private screenshotQueue: Buffer[] = []; + private isProcessingScreenshot = false; + private screencastInterval: NodeJS.Timeout | null = null + /** * Initializes a new instances of the {@link Generator} and {@link WorkflowInterpreter} classes and * assigns the socket instance everywhere. @@ -90,6 +119,46 @@ export class RemoteBrowser { this.generator = new WorkflowGenerator(socket); } + private initializeMemoryManagement(): void { + setInterval(() => { + const memoryUsage = process.memoryUsage(); + const heapUsageRatio = memoryUsage.heapUsed / MEMORY_CONFIG.maxHeapSize; + + if (heapUsageRatio > MEMORY_CONFIG.heapUsageThreshold) { + logger.warn('High memory usage detected, triggering cleanup'); + this.performMemoryCleanup(); + } + + // Clear screenshot queue if it's too large + if (this.screenshotQueue.length > SCREENCAST_CONFIG.maxQueueSize) { + this.screenshotQueue = this.screenshotQueue.slice(-SCREENCAST_CONFIG.maxQueueSize); + } + }, MEMORY_CONFIG.gcInterval); + } + + private async performMemoryCleanup(): Promise { + this.screenshotQueue = []; + this.isProcessingScreenshot = false; + + if (global.gc) { + global.gc(); + } + + // Reset CDP session if needed + if (this.client) { + try { + await this.stopScreencast(); + this.client = null; + if (this.currentPage) { + this.client = await this.currentPage.context().newCDPSession(this.currentPage); + await this.startScreencast(); + } + } catch (error) { + logger.error('Error resetting CDP session:', error); + } + } + } + /** * Normalizes URLs to prevent navigation loops while maintaining consistent format */ @@ -157,7 +226,7 @@ export class RemoteBrowser { 'Mozilla/5.0 (Windows NT 11.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.5938.62 Safari/537.36', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:118.0) Gecko/20100101 Firefox/118.0', ]; - + return userAgents[Math.floor(Math.random() * userAgents.length)]; } @@ -178,7 +247,7 @@ export class RemoteBrowser { "--disable-extensions", "--no-sandbox", "--disable-dev-shm-usage", - ], + ], })); const proxyConfig = await getDecryptedProxyConfig(userId); let proxyOptions: { server: string, username?: string, password?: string } = { server: '' }; @@ -251,11 +320,11 @@ export class RemoteBrowser { this.client = await this.currentPage.context().newCDPSession(this.currentPage); await blocker.disableBlockingInPage(this.currentPage); console.log('Adblocker initialized'); - } catch (error: any) { + } catch (error: any) { console.warn('Failed to initialize adblocker, continuing without it:', error.message); // Still need to set up the CDP session even if blocker fails this.client = await this.currentPage.context().newCDPSession(this.currentPage); - } + } }; /** @@ -319,7 +388,7 @@ export class RemoteBrowser { return; } this.client.on('Page.screencastFrame', ({ data: base64, sessionId }) => { - this.emitScreenshot(base64) + this.emitScreenshot(Buffer.from(base64, 'base64')) setTimeout(async () => { try { if (!this.client) { @@ -339,16 +408,49 @@ export class RemoteBrowser { * If an interpretation was running it will be stopped. * @returns {Promise} */ - public switchOff = async (): Promise => { - await this.interpreter.stopInterpretation(); - if (this.browser) { - await this.stopScreencast(); - await this.browser.close(); - } else { - logger.log('error', 'Browser wasn\'t initialized'); - logger.log('error', 'Switching off the browser failed'); + public async switchOff(): Promise { + try { + await this.interpreter.stopInterpretation(); + + if (this.screencastInterval) { + clearInterval(this.screencastInterval); + } + + if (this.client) { + await this.stopScreencast(); + } + + if (this.browser) { + await this.browser.close(); + } + + this.screenshotQueue = []; + //this.performanceMonitor.reset(); + + } catch (error) { + logger.error('Error during browser shutdown:', error); } - }; + } + + private async optimizeScreenshot(screenshot: Buffer): Promise { + try { + return await sharp(screenshot) + .jpeg({ + quality: Math.round(SCREENCAST_CONFIG.compressionQuality * 100), + progressive: true + }) + .resize({ + width: SCREENCAST_CONFIG.maxWidth, + height: SCREENCAST_CONFIG.maxHeight, + fit: 'inside', + withoutEnlargement: true + }) + .toBuffer(); + } catch (error) { + logger.error('Screenshot optimization failed:', error); + return screenshot; + } + } /** * Makes and emits a single screenshot to the client side. @@ -358,7 +460,7 @@ export class RemoteBrowser { try { const screenshot = await this.currentPage?.screenshot(); if (screenshot) { - this.emitScreenshot(screenshot.toString('base64')); + this.emitScreenshot(screenshot); } } catch (e) { const { message } = e as Error; @@ -490,37 +592,85 @@ export class RemoteBrowser { * Should be called only once after the browser is fully initialized. * @returns {Promise} */ - private startScreencast = async (): Promise => { + private async startScreencast(): Promise { if (!this.client) { - logger.log('warn', 'client is not initialized'); + logger.warn('Client is not initialized'); return; } - await this.client.send('Page.startScreencast', { format: 'jpeg', quality: 75 }); - logger.log('info', `Browser started with screencasting a page.`); - }; - /** - * Unsubscribes the current page from the screencast session. - * @returns {Promise} - */ - private stopScreencast = async (): Promise => { - if (!this.client) { - logger.log('error', 'client is not initialized'); - logger.log('error', 'Screencast stop failed'); - } else { - await this.client.send('Page.stopScreencast'); - logger.log('info', `Browser stopped with screencasting.`); + try { + await this.client.send('Page.startScreencast', { + format: SCREENCAST_CONFIG.format, + }); + + // Set up screencast frame handler + this.client.on('Page.screencastFrame', async ({ data, sessionId }) => { + try { + const buffer = Buffer.from(data, 'base64'); + await this.emitScreenshot(buffer); + await this.client?.send('Page.screencastFrameAck', { sessionId }); + } catch (error) { + logger.error('Screencast frame processing failed:', error); + } + }); + + logger.info('Screencast started successfully'); + } catch (error) { + logger.error('Failed to start screencast:', error); } - }; + } + + private async stopScreencast(): Promise { + if (!this.client) { + logger.error('Client is not initialized'); + return; + } + + try { + await this.client.send('Page.stopScreencast'); + this.screenshotQueue = []; + this.isProcessingScreenshot = false; + logger.info('Screencast stopped successfully'); + } catch (error) { + logger.error('Failed to stop screencast:', error); + } + } + /** * Helper for emitting the screenshot of browser's active page through websocket. * @param payload the screenshot binary data * @returns void */ - private emitScreenshot = (payload: any): void => { - const dataWithMimeType = ('data:image/jpeg;base64,').concat(payload); - this.socket.emit('screencast', dataWithMimeType); - logger.log('debug', `Screenshot emitted`); + private emitScreenshot = async (payload: Buffer): Promise => { + if (this.isProcessingScreenshot) { + if (this.screenshotQueue.length < SCREENCAST_CONFIG.maxQueueSize) { + this.screenshotQueue.push(payload); + } + return; + } + + this.isProcessingScreenshot = true; + + try { + const optimizedScreenshot = await this.optimizeScreenshot(payload); + const base64Data = optimizedScreenshot.toString('base64'); + const dataWithMimeType = `data:image/jpeg;base64,${base64Data}`; + + this.socket.emit('screencast', dataWithMimeType); + logger.debug('Screenshot emitted'); + } catch (error) { + logger.error('Screenshot emission failed:', error); + } finally { + this.isProcessingScreenshot = false; + + if (this.screenshotQueue.length > 0) { + const nextScreenshot = this.screenshotQueue.shift(); + if (nextScreenshot) { + setTimeout(() => this.emitScreenshot(nextScreenshot), 1000 / SCREENCAST_CONFIG.targetFPS); + } + } + } }; + } diff --git a/src/components/atoms/canvas.tsx b/src/components/atoms/canvas.tsx index e71a4d93..8fd4f791 100644 --- a/src/components/atoms/canvas.tsx +++ b/src/components/atoms/canvas.tsx @@ -1,21 +1,147 @@ -import React, { useCallback, useEffect, useRef } from 'react'; +import React, { useCallback, useEffect, useRef, useMemo, Suspense } from 'react'; import { useSocketStore } from '../../context/socket'; -import { getMappedCoordinates } from "../../helpers/inputHelpers"; import { useGlobalInfoStore } from "../../context/globalInfo"; import { useActionContext } from '../../context/browserActions'; -import DatePicker from './DatePicker'; -import Dropdown from './Dropdown'; -import TimePicker from './TimePicker'; -import DateTimeLocalPicker from './DateTimeLocalPicker'; +const DatePicker = React.lazy(() => import('./DatePicker')); +const Dropdown = React.lazy(() => import('./Dropdown')); +const TimePicker = React.lazy(() => import('./TimePicker')); +const DateTimeLocalPicker = React.lazy(() => import('./DateTimeLocalPicker')); -interface CreateRefCallback { - (ref: React.RefObject): void; +class RAFScheduler { + private queue: Set<() => void> = new Set(); + private isProcessing: boolean = false; + private frameId: number | null = null; + + schedule(callback: () => void): void { + this.queue.add(callback); + if (!this.isProcessing) { + this.process(); + } + } + + private process = (): void => { + this.isProcessing = true; + this.frameId = requestAnimationFrame(() => { + const callbacks = Array.from(this.queue); + this.queue.clear(); + + callbacks.forEach(callback => { + try { + callback(); + } catch (error) { + console.error('RAF Scheduler error:', error); + } + }); + + this.isProcessing = false; + this.frameId = null; + + if (this.queue.size > 0) { + this.process(); + } + }); + } + + clear(): void { + this.queue.clear(); + if (this.frameId !== null) { + cancelAnimationFrame(this.frameId); + this.frameId = null; + } + this.isProcessing = false; + } +} + +class EventDebouncer { + private highPriorityQueue: Array<() => void> = []; + private lowPriorityQueue: Array<() => void> = []; + private processing: boolean = false; + private scheduler: RAFScheduler; + + constructor(scheduler: RAFScheduler) { + this.scheduler = scheduler; + } + + add(callback: () => void, highPriority: boolean = false): void { + if (highPriority) { + this.highPriorityQueue.push(callback); + } else { + this.lowPriorityQueue.push(callback); + } + + if (!this.processing) { + this.process(); + } + } + + private process(): void { + this.processing = true; + this.scheduler.schedule(() => { + while (this.highPriorityQueue.length > 0) { + const callback = this.highPriorityQueue.shift(); + callback?.(); + } + + if (this.lowPriorityQueue.length > 0) { + const callback = this.lowPriorityQueue.shift(); + callback?.(); + + if (this.lowPriorityQueue.length > 0) { + this.process(); + } + } + + this.processing = false; + }); + } + + clear(): void { + this.highPriorityQueue = []; + this.lowPriorityQueue = []; + this.processing = false; + } +} + +// Optimized measurement cache with LRU +class MeasurementCache { + private cache: Map; + private maxSize: number; + + constructor(maxSize: number = 100) { + this.cache = new Map(); + this.maxSize = maxSize; + } + + get(element: HTMLElement): DOMRect | undefined { + const cached = this.cache.get(element); + if (cached) { + // Refresh the entry + this.cache.delete(element); + this.cache.set(element, cached); + } + return cached; + } + + set(element: HTMLElement, rect: DOMRect): void { + if (this.cache.size >= this.maxSize) { + // Remove oldest entry + const firstKey = this.cache.keys().next().value; + if (firstKey !== undefined) { + this.cache.delete(firstKey); + } + } + this.cache.set(element, rect); + } + + clear(): void { + this.cache.clear(); + } } interface CanvasProps { width: number; height: number; - onCreateRef: CreateRefCallback; + onCreateRef: (ref: React.RefObject) => void; } /** @@ -26,225 +152,229 @@ export interface Coordinates { y: number; }; -const Canvas = ({ width, height, onCreateRef }: CanvasProps) => { - +const Canvas = React.memo(({ width, height, onCreateRef }: CanvasProps) => { const canvasRef = useRef(null); const { socket } = useSocketStore(); const { setLastAction, lastAction } = useGlobalInfoStore(); const { getText, getList } = useActionContext(); - const getTextRef = useRef(getText); - const getListRef = useRef(getList); - const [datePickerInfo, setDatePickerInfo] = React.useState<{ - coordinates: Coordinates; - selector: string; - } | null>(null); + const scheduler = useRef(new RAFScheduler()); + const debouncer = useRef(new EventDebouncer(scheduler.current)); + const measurementCache = useRef(new MeasurementCache(50)); + //const performanceMonitor = useRef(new FrontendPerformanceMonitor()); - const [dropdownInfo, setDropdownInfo] = React.useState<{ - coordinates: Coordinates; - selector: string; - options: Array<{ - value: string; - text: string; - disabled: boolean; - selected: boolean; - }>; - } | null>(null); + const refs = useRef({ + getText, + getList, + lastMousePosition: { x: 0, y: 0 }, + lastFrameTime: 0, + context: null as CanvasRenderingContext2D | null, + }); - const [timePickerInfo, setTimePickerInfo] = React.useState<{ - coordinates: Coordinates; - selector: string; - } | null>(null); - - const [dateTimeLocalInfo, setDateTimeLocalInfo] = React.useState<{ - coordinates: Coordinates; - selector: string; - } | null>(null); - - const notifyLastAction = (action: string) => { - if (lastAction !== action) { - setLastAction(action); + const [state, dispatch] = React.useReducer((state: any, action: any) => { + switch (action.type) { + case 'BATCH_UPDATE': + return { ...state, ...action.payload }; + default: + return state; } - }; + }, { + datePickerInfo: null, + dropdownInfo: null, + timePickerInfo: null, + dateTimeLocalInfo: null + }); - const lastMousePosition = useRef({ x: 0, y: 0 }); + const getEventCoordinates = useCallback((event: MouseEvent): { x: number; y: number } => { + if (!canvasRef.current) return { x: 0, y: 0 }; - useEffect(() => { - getTextRef.current = getText; - getListRef.current = getList; - }, [getText, getList]); - - useEffect(() => { - if (socket) { - socket.on('showDatePicker', (info: {coordinates: Coordinates, selector: string}) => { - setDatePickerInfo(info); - }); - - socket.on('showDropdown', (info: { - coordinates: Coordinates, - selector: string, - options: Array<{ - value: string; - text: string; - disabled: boolean; - selected: boolean; - }>; - }) => { - setDropdownInfo(info); - }); - - socket.on('showTimePicker', (info: {coordinates: Coordinates, selector: string}) => { - setTimePickerInfo(info); - }); - - socket.on('showDateTimePicker', (info: {coordinates: Coordinates, selector: string}) => { - setDateTimeLocalInfo(info); - }); - - return () => { - socket.off('showDatePicker'); - socket.off('showDropdown'); - socket.off('showTimePicker'); - socket.off('showDateTimePicker'); - }; + let rect = measurementCache.current.get(canvasRef.current); + if (!rect) { + rect = canvasRef.current.getBoundingClientRect(); + measurementCache.current.set(canvasRef.current, rect); } - }, [socket]); - const onMouseEvent = useCallback((event: MouseEvent) => { - if (socket && canvasRef.current) { - // Get the canvas bounding rectangle - const rect = canvasRef.current.getBoundingClientRect(); - const clickCoordinates = { - x: event.clientX - rect.left, // Use relative x coordinate - y: event.clientY - rect.top, // Use relative y coordinate - }; + return { + x: event.clientX - rect.left, + y: event.clientY - rect.top + }; + }, []); - switch (event.type) { - case 'mousedown': - if (getTextRef.current === true) { + const handleMouseEvent = useCallback((event: MouseEvent) => { + if (!socket || !canvasRef.current) return; + + //performanceMonitor.current.measureEventLatency(event); + const coordinates = getEventCoordinates(event); + + switch (event.type) { + case 'mousedown': + debouncer.current.add(() => { + if (refs.current.getText) { console.log('Capturing Text...'); - } else if (getListRef.current === true) { + } else if (refs.current.getList) { console.log('Capturing List...'); } else { - socket.emit('input:mousedown', clickCoordinates); + socket.emit('input:mousedown', coordinates); } - notifyLastAction('click'); - break; - case 'mousemove': - if (lastMousePosition.current.x !== clickCoordinates.x || - lastMousePosition.current.y !== clickCoordinates.y) { - lastMousePosition.current = { - x: clickCoordinates.x, - y: clickCoordinates.y, - }; - socket.emit('input:mousemove', { - x: clickCoordinates.x, - y: clickCoordinates.y, - }); - notifyLastAction('move'); - } - break; - case 'wheel': - const wheelEvent = event as WheelEvent; - const deltas = { - deltaX: Math.round(wheelEvent.deltaX), - deltaY: Math.round(wheelEvent.deltaY), - }; - socket.emit('input:wheel', deltas); - notifyLastAction('scroll'); - break; - default: - console.log('Default mouseEvent registered'); - return; - } - } - }, [socket]); + setLastAction('click'); + }, true); // High priority + break; - const onKeyboardEvent = useCallback((event: KeyboardEvent) => { - if (socket) { + case 'mousemove': + if (refs.current.lastMousePosition.x !== coordinates.x || + refs.current.lastMousePosition.y !== coordinates.y) { + debouncer.current.add(() => { + refs.current.lastMousePosition = coordinates; + socket.emit('input:mousemove', coordinates); + setLastAction('move'); + }); + } + break; + + case 'wheel': + const wheelEvent = event as WheelEvent; + debouncer.current.add(() => { + socket.emit('input:wheel', { + deltaX: Math.round(wheelEvent.deltaX), + deltaY: Math.round(wheelEvent.deltaY) + }); + setLastAction('scroll'); + }); + break; + } + }, [socket, getEventCoordinates]); + + const handleKeyboardEvent = useCallback((event: KeyboardEvent) => { + if (!socket) return; + + debouncer.current.add(() => { switch (event.type) { case 'keydown': - socket.emit('input:keydown', { key: event.key, coordinates: lastMousePosition.current }); - notifyLastAction(`${event.key} pressed`); + socket.emit('input:keydown', { + key: event.key, + coordinates: refs.current.lastMousePosition + }); + setLastAction(`${event.key} pressed`); break; case 'keyup': socket.emit('input:keyup', event.key); break; - default: - console.log('Default keyEvent registered'); - return; } - } + }, event.type === 'keydown'); // High priority for keydown }, [socket]); + // Setup and cleanup + useEffect(() => { + if (!canvasRef.current) return; + + const canvas = canvasRef.current; + refs.current.context = canvas.getContext('2d', { + alpha: false, + desynchronized: true + }); + + onCreateRef(canvasRef); + + const options = { passive: true }; + canvas.addEventListener('mousedown', handleMouseEvent, options); + canvas.addEventListener('mousemove', handleMouseEvent, options); + canvas.addEventListener('wheel', handleMouseEvent, options); + canvas.addEventListener('keydown', handleKeyboardEvent, options); + canvas.addEventListener('keyup', handleKeyboardEvent, options); + + return () => { + canvas.removeEventListener('mousedown', handleMouseEvent); + canvas.removeEventListener('mousemove', handleMouseEvent); + canvas.removeEventListener('wheel', handleMouseEvent); + canvas.removeEventListener('keydown', handleKeyboardEvent); + canvas.removeEventListener('keyup', handleKeyboardEvent); + + scheduler.current.clear(); + debouncer.current.clear(); + measurementCache.current.clear(); + }; + }, [handleMouseEvent, handleKeyboardEvent, onCreateRef]); + + // Performance monitoring + // useEffect(() => { + // const intervalId = setInterval(() => { + // console.log('Performance Report:', performanceMonitor.current.getPerformanceReport()); + // }, 20000); + + // return () => clearInterval(intervalId); + // }, []); useEffect(() => { - if (canvasRef.current) { - onCreateRef(canvasRef); - canvasRef.current.addEventListener('mousedown', onMouseEvent); - canvasRef.current.addEventListener('mousemove', onMouseEvent); - canvasRef.current.addEventListener('wheel', onMouseEvent, { passive: true }); - canvasRef.current.addEventListener('keydown', onKeyboardEvent); - canvasRef.current.addEventListener('keyup', onKeyboardEvent); + if (!socket) return; - return () => { - if (canvasRef.current) { - canvasRef.current.removeEventListener('mousedown', onMouseEvent); - canvasRef.current.removeEventListener('mousemove', onMouseEvent); - canvasRef.current.removeEventListener('wheel', onMouseEvent); - canvasRef.current.removeEventListener('keydown', onKeyboardEvent); - canvasRef.current.removeEventListener('keyup', onKeyboardEvent); - } + const handlers = { + showDatePicker: (info: any) => dispatch({ type: 'BATCH_UPDATE', payload: { datePickerInfo: info } }), + showDropdown: (info: any) => dispatch({ type: 'BATCH_UPDATE', payload: { dropdownInfo: info } }), + showTimePicker: (info: any) => dispatch({ type: 'BATCH_UPDATE', payload: { timePickerInfo: info } }), + showDateTimePicker: (info: any) => dispatch({ type: 'BATCH_UPDATE', payload: { dateTimeLocalInfo: info } }) + }; - }; - } else { - console.log('Canvas not initialized'); - } + Object.entries(handlers).forEach(([event, handler]) => socket.on(event, handler)); + return () => { + Object.keys(handlers).forEach(event => socket.off(event)); + }; + }, [socket]); - }, [onMouseEvent]); + const memoizedDimensions = useMemo(() => ({ + width: width || 900, + height: height || 400 + }), [width, height]); return ( -
+
- {datePickerInfo && ( - setDatePickerInfo(null)} - /> - )} - {dropdownInfo && ( - setDropdownInfo(null)} - /> - )} - {timePickerInfo && ( - setTimePickerInfo(null)} - /> - )} - {dateTimeLocalInfo && ( - setDateTimeLocalInfo(null)} - /> - )} + + {state.datePickerInfo && ( + dispatch({ + type: 'BATCH_UPDATE', + payload: { datePickerInfo: null } + })} + /> + )} + {state.dropdownInfo && ( + dispatch({ + type: 'BATCH_UPDATE', + payload: { dropdownInfo: null } + })} + /> + )} + {state.timePickerInfo && ( + dispatch({ type: 'SET_TIME_PICKER', payload: null })} + /> + )} + {state.dateTimeLocalInfo && ( + dispatch({ type: 'SET_DATETIME_PICKER', payload: null })} + /> + )} +
); +}); -}; - +Canvas.displayName = 'Canvas'; export default Canvas; \ No newline at end of file diff --git a/src/components/organisms/BrowserWindow.tsx b/src/components/organisms/BrowserWindow.tsx index 5a49a89f..b43ea75b 100644 --- a/src/components/organisms/BrowserWindow.tsx +++ b/src/components/organisms/BrowserWindow.tsx @@ -378,7 +378,6 @@ export const BrowserWindow = () => { } }, [paginationMode, resetPaginationSelector]); - return (
{