This commit is contained in:
2025-07-18 20:25:57 +08:00
commit 68c8096686
15 changed files with 2582 additions and 0 deletions
@@ -0,0 +1,185 @@
export default {
name: 'RenderingCanvasViewDropdown',
template: `
<div class="view-dropdown">
<button
type="button"
class="btn btn-info dropdown-toggle"
data-bs-toggle="dropdown"
aria-expanded="false"
v-if="useBootstrap"
>
<span class="oi oi-magnifying-glass">🔍</span>
</button>
<ul class="dropdown-menu" v-if="useBootstrap">
<li><h6 class="dropdown-header">{{ viewLabel }}</h6></li>
<li>
<button
type="button"
class="dropdown-item text-nowrap"
:title="frontLabel + ' (F1)'"
@click="setView('front')"
:disabled="!canvasReady"
>{{ frontLabel }}</button>
</li>
<li>
<button
type="button"
class="dropdown-item text-nowrap"
:title="backLabel"
@click="setView('back')"
:disabled="!canvasReady"
>{{ backLabel }}</button>
</li>
<li>
<button
type="button"
class="dropdown-item text-nowrap"
:title="rightLabel + ' (F2)'"
@click="setView('right')"
:disabled="!canvasReady"
>{{ rightLabel }}</button>
</li>
<li>
<button
type="button"
class="dropdown-item text-nowrap"
:title="leftLabel"
@click="setView('left')"
:disabled="!canvasReady"
>{{ leftLabel }}</button>
</li>
<li>
<button
type="button"
class="dropdown-item text-nowrap"
:title="topLabel + ' (F3)'"
@click="setView('top')"
:disabled="!canvasReady"
>{{ topLabel }}</button>
</li>
<li>
<button
type="button"
class="dropdown-item text-nowrap"
:title="bottomLabel"
@click="setView('bottom')"
:disabled="!canvasReady"
>{{ bottomLabel }}</button>
</li>
<li>
<button
type="button"
class="dropdown-item text-nowrap"
:title="isometricLabel + ' (F4)'"
@click="setView('isometric')"
:disabled="!canvasReady"
>{{ isometricLabel }}</button>
</li>
</ul>
<!-- Non-Bootstrap version -->
<div class="view-controls" v-if="!useBootstrap">
<button @click="setView('front')" :disabled="!canvasReady">{{ frontLabel }}</button>
<button @click="setView('back')" :disabled="!canvasReady">{{ backLabel }}</button>
<button @click="setView('left')" :disabled="!canvasReady">{{ leftLabel }}</button>
<button @click="setView('right')" :disabled="!canvasReady">{{ rightLabel }}</button>
<button @click="setView('top')" :disabled="!canvasReady">{{ topLabel }}</button>
<button @click="setView('bottom')" :disabled="!canvasReady">{{ bottomLabel }}</button>
<button @click="setView('isometric')" :disabled="!canvasReady">{{ isometricLabel }}</button>
<button @click="setView('home')" :disabled="!canvasReady">{{ homeLabel }}</button>
</div>
</div>
`,
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;
}
}
}
+38
View File
@@ -0,0 +1,38 @@
/* Rendering Canvas Component Styles */
.canvas-container {
position: relative;
width: 100%;
height: 100%;
background-color: #f8f8f8;
}
.canvas-container canvas {
background-color: rgba(204, 204, 204, 0.5);
touch-action: none;
width: 100%;
height: 100%;
object-fit: none;
border: 0;
padding: 0;
margin: 0;
display: block;
cursor: grab;
outline: none;
transition: box-shadow 0.3s, border-color 0.3s;
}
.canvas-container canvas:focus {
box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.4);
}
.canvas-container canvas:active {
cursor: grabbing;
}
/* Context menu disabled */
.canvas-container canvas {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
+490
View File
@@ -0,0 +1,490 @@
// 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;
}
}
}