490 lines
17 KiB
JavaScript
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;
|
|
}
|
|
}
|
|
}
|