/** * Pure JavaScript RenderingCanvas class * A reusable canvas component for rendering with SignalR */ class RenderingCanvas { constructor(containerId, options = {}) { // Default options this.options = { hubUrl: '/renderingHub', autoConnect: true, width: 800, height: 600, ...options }; // Initialize properties this.containerId = containerId; this.container = document.getElementById(containerId); this.canvasId = `rendering-canvas-${Math.random().toString(36).substr(2, 9)}`; this.connection = null; this.sessionId = null; this.isConnected = false; this.isInitialized = false; this.hasFocus = false; // Mouse state tracking this.mouseState = { isDown: false, button: 0, lastX: 0, lastY: 0 }; // Browser detection this.compressionSupported = typeof DecompressionStream !== 'undefined'; this.isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1; this.resizeObserver = null; // Event callbacks this.callbacks = { connected: null, disconnected: null, initialized: null, serverInitialized: null, error: null, imageUpdated: null, viewChanged: null, visibilityChanged: null, focus: null, blur: null }; // Initialize the component this.init(); } init() { // Add CSS if not already present if (!document.querySelector('link[href*="rendering-canvas.css"]')) { const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = '/_content/hi.webapi/disp/rendering-canvas.css'; document.head.appendChild(link); } // Create canvas element this.createCanvas(); // Set up event listeners this.setupEventListeners(); // Set up resize observer this.setupResizeObserver(); // Auto-connect if enabled if (this.options.autoConnect) { this.connect(); } } createCanvas() { // Create canvas wrapper const wrapper = document.createElement('div'); wrapper.className = 'canvas-container'; // Create canvas element this.canvas = document.createElement('canvas'); this.canvas.id = this.canvasId; this.canvas.tabIndex = 0; this.canvas.width = this.options.width; this.canvas.height = this.options.height; // Add canvas to wrapper and container wrapper.appendChild(this.canvas); this.container.innerHTML = ''; this.container.appendChild(wrapper); this.isInitialized = true; this.emit('initialized', this.canvasId); } setupEventListeners() { // Context menu prevention this.canvas.addEventListener('contextmenu', (e) => e.preventDefault()); // Mouse events this.canvas.addEventListener('mousedown', (e) => this.handleMouseDown(e)); this.canvas.addEventListener('mousemove', (e) => this.handleMouseMove(e)); this.canvas.addEventListener('mouseup', (e) => this.handleMouseUp(e)); this.canvas.addEventListener('wheel', (e) => this.handleWheel(e), { passive: false }); // Keyboard events this.canvas.addEventListener('keydown', (e) => this.handleKeyDown(e)); this.canvas.addEventListener('keyup', (e) => this.handleKeyUp(e)); // Touch events this.canvas.addEventListener('touchstart', (e) => this.handleTouchStart(e), { passive: false }); this.canvas.addEventListener('touchmove', (e) => this.handleTouchMove(e), { passive: false }); this.canvas.addEventListener('touchend', (e) => this.handleTouchEnd(e), { passive: false }); this.canvas.addEventListener('touchcancel', (e) => this.handleTouchCancel(e), { passive: false }); // Focus events this.canvas.addEventListener('click', () => this.focusCanvas()); this.canvas.addEventListener('focus', () => this.onFocus()); this.canvas.addEventListener('blur', () => this.onBlur()); // Visibility change document.addEventListener('visibilitychange', () => this.handleVisibilityChange()); } // SignalR connection methods async connect() { try { // Create SignalR connection this.connection = new signalR.HubConnectionBuilder() .withUrl(this.options.hubUrl) .configureLogging(signalR.LogLevel.Information) .build(); // Set up event handlers this.connection.on("ImageUpdate", (compressedData, originalLength, width, height) => { this.handleImageUpdate(compressedData, originalLength, width, height); }); this.connection.on("CanvasInitialized", (id) => { this.sessionId = id; this.emit('serverInitialized', id); }); // Start connection await this.connection.start(); this.isConnected = true; this.emit('connected'); // Initialize server canvas const rect = this.canvas.getBoundingClientRect(); const width = Math.floor(rect.width); const height = Math.floor(rect.height); await this.connection.invoke("InitializeCanvas", width, height); } catch (err) { this.emit('error', `Connection failed: ${err}`); console.error('Connection failed:', err); // Retry after 5 seconds setTimeout(() => this.connect(), 5000); } } async disconnect() { if (this.connection) { try { await this.connection.stop(); this.connection = null; this.sessionId = null; this.isConnected = false; // Clear canvas const ctx = this.canvas.getContext('2d'); ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); this.emit('disconnected'); } catch (err) { console.error('Disconnect failed:', err); } } } // Resize handling setupResizeObserver() { this.resizeObserver = new ResizeObserver(entries => { for (let entry of entries) { const { width, height } = entry.contentRect; this.handleResize(width, height); } }); this.resizeObserver.observe(this.canvas.parentElement); } async handleResize(width, height) { this.canvas.width = Math.floor(width); this.canvas.height = Math.floor(height); if (this.connection && this.connection.state === 'Connected') { try { await this.connection.invoke("HandleResize", this.canvas.width, this.canvas.height); } catch (err) { this.emit('error', `Resize failed: ${err}`); } } } async handleVisibilityChange() { if (this.connection && this.connection.state === 'Connected') { try { await this.connection.invoke("HandleVisibilityChange", document.visibilityState); this.emit('visibilityChanged', document.visibilityState); } catch (err) { this.emit('error', `Visibility change failed: ${err}`); } } } // Focus management focusCanvas() { this.canvas.focus(); } onFocus() { this.hasFocus = true; this.emit('focus'); } onBlur() { this.hasFocus = false; this.emit('blur'); } // Mouse event handlers async handleMouseDown(e) { if (!this.isInitialized || !this.connection) return; this.mouseState.isDown = true; this.mouseState.button = e.button; const rect = this.canvas.getBoundingClientRect(); this.mouseState.lastX = e.clientX - rect.left; this.mouseState.lastY = e.clientY - rect.top; if (this.connection.state === 'Connected') { try { await this.connection.invoke("HandleMouseDown", this.mouseState.lastX, this.mouseState.lastY, e.button ); } catch (err) { this.emit('error', `Mouse down failed: ${err}`); } } } async handleMouseMove(e) { if (!this.isInitialized) return; const rect = this.canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; const buttonMask = this.mouseState.isDown ? (1 << this.mouseState.button) : 0; if (this.connection && this.connection.state === 'Connected') { try { await this.connection.invoke("HandleMouseMove", x, y, buttonMask); } catch (err) { this.emit('error', `Mouse move failed: ${err}`); } } this.mouseState.lastX = x; this.mouseState.lastY = y; } async handleMouseUp(e) { if (!this.isInitialized) return; this.mouseState.isDown = false; const rect = this.canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; if (this.connection && this.connection.state === 'Connected') { try { await this.connection.invoke("HandleMouseUp", x, y, e.button); } catch (err) { this.emit('error', `Mouse up failed: ${err}`); } } } async handleWheel(e) { if (!this.isInitialized) return; e.preventDefault(); const rect = this.canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; const browserBrand = this.isFirefox ? 'firefox' : 'chrome'; if (this.connection && this.connection.state === 'Connected') { try { await this.connection.invoke("HandleMouseWheel", x, y, e.deltaX, e.deltaY, browserBrand ); } catch (err) { this.emit('error', `Mouse wheel failed: ${err}`); } } } // Keyboard event handlers async handleKeyDown(e) { if (!this.isInitialized) return; e.preventDefault(); if (this.connection && this.connection.state === 'Connected') { try { await this.connection.invoke("HandleKeyDown", e.key, e.code, e.ctrlKey, e.shiftKey, e.altKey ); } catch (err) { this.emit('error', `Key down failed: ${err}`); } } } async handleKeyUp(e) { if (!this.isInitialized) return; e.preventDefault(); if (this.connection && this.connection.state === 'Connected') { try { await this.connection.invoke("HandleKeyUp", e.key, e.code, e.ctrlKey, e.shiftKey, e.altKey ); } catch (err) { this.emit('error', `Key up failed: ${err}`); } } } // Touch event handlers async handleTouchStart(e) { if (!this.isInitialized) return; e.preventDefault(); // 處理所有觸控點,支援多點觸控 for (let i = 0; i < e.changedTouches.length; i++) { const touch = e.changedTouches[i]; const rect = this.canvas.getBoundingClientRect(); const x = touch.clientX - rect.left; const y = touch.clientY - rect.top; if (this.connection && this.connection.state === 'Connected') { try { await this.connection.invoke("HandleTouchDown", touch.identifier, x, y); } catch (err) { this.emit('error', `Touch start failed: ${err}`); } } } } async handleTouchMove(e) { if (!this.isInitialized) return; e.preventDefault(); // 處理所有觸控點以支援多點觸控手勢 for (let i = 0; i < e.changedTouches.length; i++) { const touch = e.changedTouches[i]; const rect = this.canvas.getBoundingClientRect(); const x = touch.clientX - rect.left; const y = touch.clientY - rect.top; if (this.connection && this.connection.state === 'Connected') { try { await this.connection.invoke("HandleTouchMove", touch.identifier, x, y); } catch (err) { this.emit('error', `Touch move failed: ${err}`); } } } } async handleTouchEnd(e) { if (!this.isInitialized) return; e.preventDefault(); // 處理所有結束的觸控點 for (let i = 0; i < e.changedTouches.length; i++) { const touch = e.changedTouches[i]; if (this.connection && this.connection.state === 'Connected') { try { await this.connection.invoke("HandleTouchUp", touch.identifier); } catch (err) { this.emit('error', `Touch end failed: ${err}`); } } } } async handleTouchCancel(e) { if (!this.isInitialized) return; e.preventDefault(); // 處理所有取消的觸控點 for (let i = 0; i < e.changedTouches.length; i++) { const touch = e.changedTouches[i]; if (this.connection && this.connection.state === 'Connected') { try { await this.connection.invoke("HandleTouchUp", touch.identifier); } catch (err) { this.emit('error', `Touch cancel failed: ${err}`); } } } } // Image update handler async handleImageUpdate(compressedData, originalLength, width, height) { try { let imageData; if (this.compressionSupported) { const decompressed = await this.decompressData(compressedData, originalLength); imageData = new Uint8ClampedArray(decompressed); } else { imageData = new Uint8ClampedArray(compressedData); } const ctx = this.canvas.getContext('2d'); const imgData = new ImageData(imageData, width, height); ctx.putImageData(imgData, 0, 0); this.emit('imageUpdated'); } catch (err) { this.emit('error', `Image update failed: ${err}`); } } async decompressData(compressedData, originalLength) { let bytes; if (compressedData instanceof Uint8Array) { bytes = compressedData; } else if (compressedData instanceof ArrayBuffer) { bytes = new Uint8Array(compressedData); } else if (typeof compressedData === 'string') { const binaryString = atob(compressedData); bytes = new Uint8Array(binaryString.length); for (let i = 0; i < binaryString.length; i++) { bytes[i] = binaryString.charCodeAt(i); } } else { throw new Error(`Unknown compressed data type: ${typeof compressedData}`); } const stream = new ReadableStream({ start(controller) { controller.enqueue(bytes); controller.close(); } }); const decompressedStream = stream.pipeThrough(new DecompressionStream('gzip')); const reader = decompressedStream.getReader(); const chunks = []; while (true) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); } const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0); const result = new Uint8Array(totalLength); let offset = 0; for (const chunk of chunks) { result.set(chunk, offset); offset += chunk.length; } return result; } // Public methods async setView(viewType) { if (this.connection && this.connection.state === 'Connected') { try { await this.connection.invoke('SetView', viewType); this.emit('viewChanged', viewType); } catch (err) { this.emit('error', `Set view failed: ${err}`); } } } // Event emitter system on(event, callback) { if (this.callbacks.hasOwnProperty(event)) { this.callbacks[event] = callback; } } emit(event, ...args) { if (this.callbacks[event]) { this.callbacks[event](...args); } } // Getters getConnectionState() { return { isConnected: this.isConnected, sessionId: this.sessionId, connectionState: this.connection ? this.connection.state : 'Not initialized' }; } getSessionId() { return this.sessionId; } // Cleanup destroy() { // Remove event listeners document.removeEventListener('visibilitychange', this.handleVisibilityChange); // Disconnect SignalR if (this.connection) { this.disconnect(); } // Stop resize observer if (this.resizeObserver) { this.resizeObserver.disconnect(); } // Clear container this.container.innerHTML = ''; } } // Export for use as module if (typeof module !== 'undefined' && module.exports) { module.exports = RenderingCanvas; }