From 556b31d02d1585a39f5bbc37d309fc3d7449ac6d Mon Sep 17 00:00:00 2001 From: iambossTC Date: Sat, 19 Jul 2025 16:18:09 +0800 Subject: [PATCH] tune --- Program.cs | 9 +- wwwroot/demo-native.html | 734 -------------- wwwroot/demo-vue.html | 115 --- .../disp/rendering-canvas-view-dropdown.js | 185 ---- wwwroot/disp/rendering-canvas-vue.js | 517 ++++++++++ wwwroot/disp/rendering-canvas.js | 948 ++++++++++-------- 6 files changed, 1034 insertions(+), 1474 deletions(-) delete mode 100644 wwwroot/demo-native.html delete mode 100644 wwwroot/demo-vue.html delete mode 100644 wwwroot/disp/rendering-canvas-view-dropdown.js create mode 100644 wwwroot/disp/rendering-canvas-vue.js diff --git a/Program.cs b/Program.cs index 0224031..8ee8908 100644 --- a/Program.cs +++ b/Program.cs @@ -5,7 +5,7 @@ using Hi.Webapi.Services; namespace Hi.Webapi { - public class Program + class Program { public static void Main(string[] args) { @@ -65,8 +65,8 @@ namespace Hi.Webapi // 在開發環境中不使用 HTTPS 重定向 // app.UseHttpsRedirection(); - // 添加靜態文件支援 - app.UseStaticFiles(); + //// 添加靜態文件支援 + //app.UseStaticFiles(); app.UseCors("AllowAll"); @@ -77,9 +77,6 @@ namespace Hi.Webapi // 映射 SignalR Hub app.MapHub("/renderingHub"); - // 添加一個簡單的首頁 - app.MapGet("/", () => Results.Redirect("/demo-vue.html")); - app.Run(); } } diff --git a/wwwroot/demo-native.html b/wwwroot/demo-native.html deleted file mode 100644 index 82882c1..0000000 --- a/wwwroot/demo-native.html +++ /dev/null @@ -1,734 +0,0 @@ - - - - - UJoin HiNC Demo - - - -
-

HiNC WebAPI 渲染畫布示例

- -
- 提示:點擊畫布以啟用鍵盤控制。畫布獲得焦點時邊框會變成綠色。 -
鍵盤控制:F1-F4切換視圖,方向鍵旋轉視圖,PageUp/PageDown縮放,Home重置視圖 -
- -
- - - - - - - - -
- -
- -
- -
-
- - - - - \ No newline at end of file diff --git a/wwwroot/demo-vue.html b/wwwroot/demo-vue.html deleted file mode 100644 index e08fde9..0000000 --- a/wwwroot/demo-vue.html +++ /dev/null @@ -1,115 +0,0 @@ - - - - - - Simple Vue.js Rendering Canvas Demo - - - - - - -
-
-

Simple Rendering Canvas Demo

- -
- - -
- -
- - -
-
-
- - - - \ No newline at end of file diff --git a/wwwroot/disp/rendering-canvas-view-dropdown.js b/wwwroot/disp/rendering-canvas-view-dropdown.js deleted file mode 100644 index 5defbde..0000000 --- a/wwwroot/disp/rendering-canvas-view-dropdown.js +++ /dev/null @@ -1,185 +0,0 @@ -export default { - name: 'RenderingCanvasViewDropdown', - template: ` -
- - - - -
- - - - - - - - -
-
- `, - props: { - renderingCanvas: { - type: Object, - default: null - }, - useBootstrap: { - type: Boolean, - default: false - }, - locale: { - type: String, - default: 'zh-TW' - } - }, - computed: { - canvasReady() { - return this.renderingCanvas && this.renderingCanvas.isInitialized && this.renderingCanvas.isConnected; - }, - // Localization - viewLabel() { - return this.getLocalizedText('view', '視圖'); - }, - frontLabel() { - return this.getLocalizedText('front', '前視圖'); - }, - backLabel() { - return this.getLocalizedText('back', '後視圖'); - }, - leftLabel() { - return this.getLocalizedText('left', '左視圖'); - }, - rightLabel() { - return this.getLocalizedText('right', '右視圖'); - }, - topLabel() { - return this.getLocalizedText('top', '頂視圖'); - }, - bottomLabel() { - return this.getLocalizedText('bottom', '底視圖'); - }, - isometricLabel() { - return this.getLocalizedText('isometric', '等角視圖'); - }, - homeLabel() { - return this.getLocalizedText('home', '主視圖'); - } - }, - methods: { - async setView(viewType) { - if (this.renderingCanvas && this.renderingCanvas.setView) { - try { - await this.renderingCanvas.setView(viewType); - } catch (err) { - console.error(`Failed to set view: ${err}`); - } - } else { - console.warn('No rendering canvas component provided or setView method not available'); - } - }, - - getLocalizedText(key, defaultText) { - // Simple localization - can be extended with actual i18n library - const translations = { - 'en-US': { - view: 'View', - front: 'Front', - back: 'Back', - left: 'Left', - right: 'Right', - top: 'Top', - bottom: 'Bottom', - isometric: 'Isometric', - home: 'Home' - }, - 'zh-TW': { - view: '視圖', - front: '前視圖', - back: '後視圖', - left: '左視圖', - right: '右視圖', - top: '頂視圖', - bottom: '底視圖', - isometric: '等角視圖', - home: '主視圖' - } - }; - - const localeTranslations = translations[this.locale] || translations['zh-TW']; - return localeTranslations[key] || defaultText; - } - } -} \ No newline at end of file diff --git a/wwwroot/disp/rendering-canvas-vue.js b/wwwroot/disp/rendering-canvas-vue.js new file mode 100644 index 0000000..31e2bc4 --- /dev/null +++ b/wwwroot/disp/rendering-canvas-vue.js @@ -0,0 +1,517 @@ +// 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; + } + } +} \ No newline at end of file diff --git a/wwwroot/disp/rendering-canvas.js b/wwwroot/disp/rendering-canvas.js index 8be1c54..9e1e2d0 100644 --- a/wwwroot/disp/rendering-canvas.js +++ b/wwwroot/disp/rendering-canvas.js @@ -1,342 +1,370 @@ -// 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 = './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 +/** + * 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); } - }, - 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(); + + // Create canvas element + this.createCanvas(); + + // Set up event listeners + this.setupEventListeners(); + + // Set up resize observer this.setupResizeObserver(); - if (this.autoConnect) { - await this.connect(); + // Auto-connect if enabled + if (this.options.autoConnect) { + this.connect(); } + } + + createCanvas() { + // Create canvas wrapper + const wrapper = document.createElement('div'); + wrapper.className = 'canvas-container'; - // 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); + // 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; - if (this.resizeObserver) { - this.resizeObserver.disconnect(); - } + // Add canvas to wrapper and container + wrapper.appendChild(this.canvas); + this.container.innerHTML = ''; + this.container.appendChild(wrapper); - 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); - } - }, + this.isInitialized = true; + this.emit('initialized', this.canvasId); + } + + setupEventListeners() { + // Context menu prevention + this.canvas.addEventListener('contextmenu', (e) => e.preventDefault()); - 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); - } - } - }, + // 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 }); - - async setupCanvas() { - const canvas = this.$refs.renderCanvas; - canvas.width = this.width; - canvas.height = this.height; + // 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(); - 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); - } + // Set up event handlers + this.connection.on("ImageUpdate", (compressedData, originalLength, width, height) => { + this.handleImageUpdate(compressedData, originalLength, 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}`); - } + 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); } - }, - - 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}`); - } + } + } + + // 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); - 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}`); - } + 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 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}`); - } + } + } + + 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}`); } - - this.mouseState.lastX = x; - this.mouseState.lastY = y; - }, + } + } + + // 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; - 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}`); - } + 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; - 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}`); - } + 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}`); } - }, + } - // 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}`); - } + 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; - 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}`); - } + 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; - // Touch event handlers - async handleTouchStart(e) { - if (!this.isInitialized) return; - - const touch = e.touches[0]; - const rect = this.$refs.renderCanvas.getBoundingClientRect(); + 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; @@ -344,16 +372,21 @@ export default { try { await this.connection.invoke("HandleTouchDown", touch.identifier, x, y); } catch (err) { - this.$emit('error', `Touch start failed: ${err}`); + this.emit('error', `Touch start failed: ${err}`); } } - }, + } + } + + async handleTouchMove(e) { + if (!this.isInitialized) return; - async handleTouchMove(e) { - if (!this.isInitialized) return; - - const touch = e.touches[0]; - const rect = this.$refs.renderCanvas.getBoundingClientRect(); + 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; @@ -361,130 +394,177 @@ export default { try { await this.connection.invoke("HandleTouchMove", touch.identifier, x, y); } catch (err) { - this.$emit('error', `Touch move failed: ${err}`); + this.emit('error', `Touch move failed: ${err}`); } } - }, + } + } + + async handleTouchEnd(e) { + if (!this.isInitialized) return; - async handleTouchEnd(e) { - if (!this.isInitialized) return; - - const touch = e.changedTouches[0]; + 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}`); + this.emit('error', `Touch end 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; } } + + 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; } \ No newline at end of file