// Add CSS link to the document 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); } export default { name: 'RenderingCanvas', template: `
`, props: { hubUrl: { type: String, default: '/renderingHub' }, autoConnect: { type: Boolean, default: true }, width: { type: Number, default: 800 }, height: { type: Number, default: 600 } }, data() { return { canvasId: `rendering-canvas-${Math.random().toString(36).substr(2, 9)}`, connection: null, sessionId: null, isConnected: false, isInitialized: false, hasFocus: false, mouseState: { isDown: false, button: 0, lastX: 0, lastY: 0 }, compressionSupported: typeof DecompressionStream !== 'undefined', isFirefox: navigator.userAgent.toLowerCase().indexOf('firefox') > -1, resizeObserver: null } }, async mounted() { await this.setupCanvas(); this.setupResizeObserver(); if (this.autoConnect) { await this.connect(); } // Add visibility change listener this.handleVisibilityChange = this.handleVisibilityChange.bind(this); document.addEventListener('visibilitychange', this.handleVisibilityChange); }, async beforeUnmount() { // Remove visibility change listener document.removeEventListener('visibilitychange', this.handleVisibilityChange); if (this.resizeObserver) { this.resizeObserver.disconnect(); } if (this.connection) { await this.disconnect(); } }, watch: { // No longer needed since we manage connection internally }, methods: { async connect() { try { // Create SignalR connection this.connection = new signalR.HubConnectionBuilder() .withUrl(this.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 canvas = this.$refs.renderCanvas; const rect = 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 canvas = this.$refs.renderCanvas; const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, canvas.width, canvas.height); this.$emit('disconnected'); } catch (err) { console.error('Disconnect failed:', err); } } }, async setupCanvas() { const canvas = this.$refs.renderCanvas; canvas.width = this.width; canvas.height = this.height; this.isInitialized = true; this.$emit('initialized', this.canvasId); }, setupResizeObserver() { this.resizeObserver = new ResizeObserver(entries => { for (let entry of entries) { const { width, height } = entry.contentRect; this.handleResize(width, height); } }); this.resizeObserver.observe(this.$refs.renderCanvas.parentElement); }, async handleResize(width, height) { const canvas = this.$refs.renderCanvas; canvas.width = Math.floor(width); canvas.height = Math.floor(height); if (this.connection && this.connection.state === 'Connected') { try { await this.connection.invoke("HandleResize", canvas.width, 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}`); } } }, focusCanvas() { this.$refs.renderCanvas.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.$refs.renderCanvas.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.$refs.renderCanvas.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.$refs.renderCanvas.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; const rect = this.$refs.renderCanvas.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; // 處理所有觸控點,而不只是第一個 for (let i = 0; i < e.changedTouches.length; i++) { const touch = e.changedTouches[i]; const rect = this.$refs.renderCanvas.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; // 處理所有觸控點以支援多點觸控手勢 for (let i = 0; i < e.changedTouches.length; i++) { const touch = e.changedTouches[i]; const rect = this.$refs.renderCanvas.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; // 處理所有結束的觸控點 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; // 處理所有取消的觸控點(例如觸控被系統中斷) 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 canvas = this.$refs.renderCanvas; const ctx = 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 that can be called from parent 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}`); } } }, // Public method to manually connect if autoConnect is false async manualConnect() { if (!this.isConnected) { await this.connect(); } }, // Public method to manually disconnect async manualDisconnect() { await this.disconnect(); }, // Getters for component state getConnectionState() { return { isConnected: this.isConnected, sessionId: this.sessionId, connectionState: this.connection ? this.connection.state : 'Not initialized' }; }, // Get session ID getSessionId() { return this.sessionId; } } }