Hi.Webapi/wwwroot/disp/rendering-canvas.js
2025-07-19 16:18:09 +08:00

570 lines
18 KiB
JavaScript

/**
* 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;
}