570 lines
18 KiB
JavaScript
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;
|
|
}
|