Hi.Sample.Webapi/wwwroot/disp/rendering-canvas.js
2025-07-18 20:25:57 +08:00

490 lines
17 KiB
JavaScript

// 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: `
<div class="canvas-container">
<canvas
ref="renderCanvas"
:id="canvasId"
tabindex="0"
@contextmenu.prevent
@mousedown="handleMouseDown"
@mousemove="handleMouseMove"
@mouseup="handleMouseUp"
@wheel.prevent="handleWheel"
@keydown="handleKeyDown"
@keyup="handleKeyUp"
@touchstart.prevent="handleTouchStart"
@touchmove.prevent="handleTouchMove"
@touchend.prevent="handleTouchEnd"
@click="focusCanvas"
@focus="onFocus"
@blur="onBlur"
></canvas>
</div>
`,
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;
const touch = e.touches[0];
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;
const touch = e.touches[0];
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;
const touch = e.changedTouches[0];
if (this.connection && this.connection.state === 'Connected') {
try {
await this.connection.invoke("HandleTouchUp", touch.identifier);
} catch (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;
}
}
}