tune
This commit is contained in:
parent
6e28b59d6f
commit
556b31d02d
@ -5,7 +5,7 @@ using Hi.Webapi.Services;
|
|||||||
|
|
||||||
namespace Hi.Webapi
|
namespace Hi.Webapi
|
||||||
{
|
{
|
||||||
public class Program
|
class Program
|
||||||
{
|
{
|
||||||
public static void Main(string[] args)
|
public static void Main(string[] args)
|
||||||
{
|
{
|
||||||
@ -65,8 +65,8 @@ namespace Hi.Webapi
|
|||||||
// 在開發環境中不使用 HTTPS 重定向
|
// 在開發環境中不使用 HTTPS 重定向
|
||||||
// app.UseHttpsRedirection();
|
// app.UseHttpsRedirection();
|
||||||
|
|
||||||
// 添加靜態文件支援
|
//// 添加靜態文件支援
|
||||||
app.UseStaticFiles();
|
//app.UseStaticFiles();
|
||||||
|
|
||||||
app.UseCors("AllowAll");
|
app.UseCors("AllowAll");
|
||||||
|
|
||||||
@ -77,9 +77,6 @@ namespace Hi.Webapi
|
|||||||
// 映射 SignalR Hub
|
// 映射 SignalR Hub
|
||||||
app.MapHub<RenderingHub>("/renderingHub");
|
app.MapHub<RenderingHub>("/renderingHub");
|
||||||
|
|
||||||
// 添加一個簡單的首頁
|
|
||||||
app.MapGet("/", () => Results.Redirect("/demo-vue.html"));
|
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,734 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<title>UJoin HiNC Demo</title>
|
|
||||||
<style>
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
background-color: #f0f0f0;
|
|
||||||
height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
width: 95%;
|
|
||||||
height: 95vh;
|
|
||||||
max-width: 1400px;
|
|
||||||
background-color: white;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
background-color: #2196F3;
|
|
||||||
color: white;
|
|
||||||
padding: 20px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status {
|
|
||||||
background-color: #e8f5e9;
|
|
||||||
padding: 10px;
|
|
||||||
margin: 10px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status.connected {
|
|
||||||
background-color: #c8e6c9;
|
|
||||||
color: #2e7d32;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status.error {
|
|
||||||
background-color: #ffcdd2;
|
|
||||||
color: #c62828;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls {
|
|
||||||
padding: 20px;
|
|
||||||
background-color: #f5f5f5;
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls button {
|
|
||||||
padding: 10px 20px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
background-color: #2196F3;
|
|
||||||
color: white;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
transition: background-color 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls button:hover {
|
|
||||||
background-color: #1976D2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls button:disabled {
|
|
||||||
background-color: #ccc;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-controls {
|
|
||||||
padding: 10px 20px;
|
|
||||||
background-color: #e3f2fd;
|
|
||||||
display: flex;
|
|
||||||
gap: 5px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-controls button {
|
|
||||||
padding: 8px 16px;
|
|
||||||
border: 1px solid #2196F3;
|
|
||||||
border-radius: 4px;
|
|
||||||
background-color: white;
|
|
||||||
color: #2196F3;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 13px;
|
|
||||||
transition: all 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-controls button:hover {
|
|
||||||
background-color: #2196F3;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.canvas-container {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
flex: 1;
|
|
||||||
min-height: 400px;
|
|
||||||
background-color: #f8f8f8;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 10px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
overflow: hidden; /* 防止滾動條 */
|
|
||||||
}
|
|
||||||
|
|
||||||
#renderCanvas {
|
|
||||||
width: calc(100% - 20px); /* 減去 padding */
|
|
||||||
height: calc(100% - 20px); /* 減去 padding */
|
|
||||||
display: block;
|
|
||||||
cursor: grab;
|
|
||||||
border: 2px solid #666;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.3s, box-shadow 0.3s;
|
|
||||||
box-sizing: border-box;
|
|
||||||
background-color: white; /* 確保有背景色 */
|
|
||||||
}
|
|
||||||
|
|
||||||
#renderCanvas:focus {
|
|
||||||
border-color: #4CAF50;
|
|
||||||
box-shadow: 0 0 8px rgba(76, 175, 80, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
#renderCanvas:active {
|
|
||||||
cursor: grabbing;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-container {
|
|
||||||
padding: 20px;
|
|
||||||
background-color: #f5f5f5;
|
|
||||||
height: 200px;
|
|
||||||
overflow-y: auto;
|
|
||||||
font-family: monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-entry {
|
|
||||||
margin: 2px 0;
|
|
||||||
padding: 2px 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-entry.info {
|
|
||||||
color: #1976D2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-entry.error {
|
|
||||||
color: #c62828;
|
|
||||||
background-color: #ffebee;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-entry.warning {
|
|
||||||
color: #f57c00;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-entry.debug {
|
|
||||||
color: #666;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<h1>HiNC WebAPI 渲染畫布示例</h1>
|
|
||||||
|
|
||||||
<div style="padding: 10px; background-color: #fff3cd; color: #856404; border: 1px solid #ffeaa7; border-radius: 4px; margin: 10px;">
|
|
||||||
<strong>提示:</strong>點擊畫布以啟用鍵盤控制。畫布獲得焦點時邊框會變成綠色。
|
|
||||||
<br>鍵盤控制:F1-F4切換視圖,方向鍵旋轉視圖,PageUp/PageDown縮放,Home重置視圖
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="view-controls">
|
|
||||||
<button onclick="setView('front')">前視圖</button>
|
|
||||||
<button onclick="setView('back')">後視圖</button>
|
|
||||||
<button onclick="setView('left')">左視圖</button>
|
|
||||||
<button onclick="setView('right')">右視圖</button>
|
|
||||||
<button onclick="setView('top')">頂視圖</button>
|
|
||||||
<button onclick="setView('bottom')">底視圖</button>
|
|
||||||
<button onclick="setView('isometric')">等角視圖</button>
|
|
||||||
<button onclick="setView('home')">主視圖</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="canvas-container">
|
|
||||||
<canvas id="renderCanvas" tabindex="0"></canvas>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="log-container" id="logContainer"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/microsoft-signalr/8.0.0/signalr.min.js"></script>
|
|
||||||
<script>
|
|
||||||
let connection = null;
|
|
||||||
let sessionId = null;
|
|
||||||
let canvas = null;
|
|
||||||
let canvasInitialized = false;
|
|
||||||
let compressionSupported = false;
|
|
||||||
let isFirefox = false;
|
|
||||||
|
|
||||||
// 頁面加載時自動連接
|
|
||||||
window.addEventListener('load', async () => {
|
|
||||||
canvas = document.getElementById('renderCanvas');
|
|
||||||
compressionSupported = typeof DecompressionStream !== 'undefined';
|
|
||||||
isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1;
|
|
||||||
|
|
||||||
console.log('頁面加載完成,開始連接服務器...');
|
|
||||||
await connect();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 頁面卸載時斷開連接
|
|
||||||
window.addEventListener('beforeunload', () => {
|
|
||||||
if (connection && connection.state === signalR.HubConnectionState.Connected) {
|
|
||||||
connection.stop();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 處理頁面可見性變化
|
|
||||||
document.addEventListener('visibilitychange', async () => {
|
|
||||||
console.log(`頁面可見性變化: ${document.visibilityState}`);
|
|
||||||
|
|
||||||
if (connection && connection.state === signalR.HubConnectionState.Connected) {
|
|
||||||
try {
|
|
||||||
await connection.invoke("HandleVisibilityChange", document.visibilityState);
|
|
||||||
console.log(`已通知服務器可見性狀態: ${document.visibilityState}`);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("處理可見性變化失敗:", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function connect() {
|
|
||||||
try {
|
|
||||||
connection = new signalR.HubConnectionBuilder()
|
|
||||||
.withUrl("/renderingHub")
|
|
||||||
.configureLogging(signalR.LogLevel.Information)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
// 設置消息處理器
|
|
||||||
connection.on("ImageUpdate", handleImageUpdate);
|
|
||||||
connection.on("CanvasInitialized", (id) => {
|
|
||||||
sessionId = id;
|
|
||||||
canvasInitialized = true;
|
|
||||||
console.log("畫布初始化完成,會話ID:", sessionId);
|
|
||||||
|
|
||||||
// 自動載入測試物件
|
|
||||||
loadTestObjects();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 連接到服務器
|
|
||||||
await connection.start();
|
|
||||||
console.log("已連接到服務器");
|
|
||||||
|
|
||||||
// 初始化畫布
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
const width = Math.floor(rect.width);
|
|
||||||
const height = Math.floor(rect.height);
|
|
||||||
console.log(`初始化畫布 - 寬度: ${width}, 高度: ${height}`);
|
|
||||||
|
|
||||||
// 設置畫布的實際像素大小
|
|
||||||
canvas.width = width;
|
|
||||||
canvas.height = height;
|
|
||||||
|
|
||||||
if (width <= 0 || height <= 0) {
|
|
||||||
console.error("畫布大小無效,延遲初始化...");
|
|
||||||
setTimeout(async () => {
|
|
||||||
const newRect = canvas.getBoundingClientRect();
|
|
||||||
const newWidth = Math.floor(newRect.width);
|
|
||||||
const newHeight = Math.floor(newRect.height);
|
|
||||||
console.log(`重新嘗試初始化畫布 - 寬度: ${newWidth}, 高度: ${newHeight}`);
|
|
||||||
canvas.width = newWidth;
|
|
||||||
canvas.height = newHeight;
|
|
||||||
await connection.invoke("InitializeCanvas", newWidth, newHeight);
|
|
||||||
}, 100);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await connection.invoke("InitializeCanvas", width, height);
|
|
||||||
|
|
||||||
// 設置事件處理
|
|
||||||
setupMouseEvents();
|
|
||||||
setupKeyboardEvents();
|
|
||||||
setupTouchEvents();
|
|
||||||
|
|
||||||
// 監聽窗口大小變化
|
|
||||||
window.addEventListener('resize', handleWindowResize);
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
console.error("連接失敗:", err);
|
|
||||||
setTimeout(() => connect(), 5000); // 5秒後重試
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 斷開連接
|
|
||||||
async function disconnect() {
|
|
||||||
console.log('===== 開始斷開連接 =====');
|
|
||||||
if (connection) {
|
|
||||||
try {
|
|
||||||
console.log('正在停止 SignalR 連接...');
|
|
||||||
await connection.stop();
|
|
||||||
console.log('✓ SignalR 連接已停止');
|
|
||||||
updateStatus('未連接', false);
|
|
||||||
sessionId = null;
|
|
||||||
|
|
||||||
console.log('清除 Canvas...');
|
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
||||||
console.log('✓ Canvas 已清除');
|
|
||||||
|
|
||||||
window.frameCount = 0;
|
|
||||||
console.log('===== 斷開連接完成 =====');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('斷開連接失敗: ' + err);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn('沒有活動的連接');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 載入測試對象
|
|
||||||
async function loadTestObjects() {
|
|
||||||
console.log('開始載入測試對象');
|
|
||||||
|
|
||||||
if (!sessionId) {
|
|
||||||
console.warn('尚未連接到服務器,無法載入測試對象');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const url = `/api/rendering/test-objects/${sessionId}`;
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
console.log('測試對象載入成功');
|
|
||||||
} else {
|
|
||||||
const errorText = await response.text();
|
|
||||||
console.error(`載入測試對象失敗: ${response.status} - ${errorText}`);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('載入測試對象時出錯:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 設置視圖
|
|
||||||
async function setView(viewType) {
|
|
||||||
console.log(`===== 切換視圖: ${viewType} =====`);
|
|
||||||
|
|
||||||
if (connection && connection.state === 'Connected') {
|
|
||||||
try {
|
|
||||||
console.log(`正在調用 SetView('${viewType}')...`);
|
|
||||||
await connection.invoke('SetView', viewType);
|
|
||||||
console.log(`✓ 視圖已成功切換到: ${viewType}`);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`切換視圖失敗: ${err}`);
|
|
||||||
console.error('錯誤詳情: ' + JSON.stringify(err));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn(`無法切換視圖: 連接狀態為 ${connection ? connection.state : 'null'}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 渲染幀
|
|
||||||
function renderFrame(rgba, width, height) {
|
|
||||||
try {
|
|
||||||
// 檢查參數
|
|
||||||
if (!rgba || !width || !height) {
|
|
||||||
console.error(`無效的渲染參數 - rgba: ${rgba ? 'exists' : 'null'}, width: ${width}, height: ${height}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 檢查數據長度
|
|
||||||
const expectedLength = width * height * 4;
|
|
||||||
if (rgba.length !== expectedLength) {
|
|
||||||
console.error(`數據長度不匹配 - 預期: ${expectedLength}, 實際: ${rgba.length}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 確保 rgba 是正確的類型
|
|
||||||
let uint8Array;
|
|
||||||
if (rgba instanceof Uint8ClampedArray) {
|
|
||||||
uint8Array = rgba;
|
|
||||||
} else if (rgba instanceof Array || rgba instanceof Uint8Array) {
|
|
||||||
uint8Array = new Uint8ClampedArray(rgba);
|
|
||||||
} else {
|
|
||||||
console.error(`未知的數據類型: ${typeof rgba}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const imageData = new ImageData(uint8Array, width, height);
|
|
||||||
ctx.putImageData(imageData, 0, 0);
|
|
||||||
// 不記錄每一幀,避免日誌過多
|
|
||||||
// log('info', '幀已渲染');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('渲染錯誤: ' + err.message);
|
|
||||||
console.error('詳細錯誤:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleImageUpdate(compressedData, originalLength, width, height) {
|
|
||||||
try {
|
|
||||||
console.log(`收到圖像更新 - 寬度: ${width}, 高度: ${height}, 原始長度: ${originalLength}, 壓縮數據類型: ${typeof compressedData}`);
|
|
||||||
|
|
||||||
let imageData;
|
|
||||||
|
|
||||||
if (compressionSupported) {
|
|
||||||
// 解壓縮數據
|
|
||||||
const decompressed = await decompressData(compressedData, originalLength);
|
|
||||||
imageData = new Uint8ClampedArray(decompressed);
|
|
||||||
} else {
|
|
||||||
// 不支持壓縮,直接使用數據
|
|
||||||
imageData = new Uint8ClampedArray(compressedData);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`圖像數據準備就緒,長度: ${imageData.length}`);
|
|
||||||
|
|
||||||
// 創建 ImageData 對象並繪製到畫布
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
const imgData = new ImageData(imageData, width, height);
|
|
||||||
ctx.putImageData(imgData, 0, 0);
|
|
||||||
|
|
||||||
console.log('圖像已繪製到畫布');
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
console.error("處理圖像更新時出錯:", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function decompressData(compressedData, originalLength) {
|
|
||||||
try {
|
|
||||||
let bytes;
|
|
||||||
|
|
||||||
// 檢查輸入類型
|
|
||||||
if (compressedData instanceof Uint8Array) {
|
|
||||||
bytes = compressedData;
|
|
||||||
} else if (compressedData instanceof ArrayBuffer) {
|
|
||||||
bytes = new Uint8Array(compressedData);
|
|
||||||
} else if (typeof compressedData === 'string') {
|
|
||||||
// 如果是 Base64 字符串,轉換為 Uint8Array
|
|
||||||
const binaryString = atob(compressedData);
|
|
||||||
bytes = new Uint8Array(binaryString.length);
|
|
||||||
for (let i = 0; i < binaryString.length; i++) {
|
|
||||||
bytes[i] = binaryString.charCodeAt(i);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.error("未知的壓縮數據類型:", typeof compressedData);
|
|
||||||
throw new Error("未知的壓縮數據類型");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用 DecompressionStream 解壓縮
|
|
||||||
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;
|
|
||||||
} catch (err) {
|
|
||||||
console.error("解壓縮失敗:", err);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupMouseEvents() {
|
|
||||||
let isMouseDown = false;
|
|
||||||
let lastX = 0;
|
|
||||||
let lastY = 0;
|
|
||||||
let mouseButton = 0;
|
|
||||||
|
|
||||||
canvas.addEventListener('mousedown', async (e) => {
|
|
||||||
if (!canvasInitialized) return;
|
|
||||||
|
|
||||||
isMouseDown = true;
|
|
||||||
mouseButton = e.button;
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
lastX = e.clientX - rect.left;
|
|
||||||
lastY = e.clientY - rect.top;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await connection.invoke("HandleMouseDown", lastX, lastY, e.button);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("鼠標按下處理失敗:", err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
canvas.addEventListener('mousemove', async (e) => {
|
|
||||||
if (!canvasInitialized) return;
|
|
||||||
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
const x = e.clientX - rect.left;
|
|
||||||
const y = e.clientY - rect.top;
|
|
||||||
|
|
||||||
// 計算 buttonMask
|
|
||||||
const buttonMask = isMouseDown ? (1 << mouseButton) : 0;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await connection.invoke("HandleMouseMove", x, y, buttonMask);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("鼠標移動處理失敗:", err);
|
|
||||||
}
|
|
||||||
|
|
||||||
lastX = x;
|
|
||||||
lastY = y;
|
|
||||||
});
|
|
||||||
|
|
||||||
canvas.addEventListener('mouseup', async (e) => {
|
|
||||||
if (!canvasInitialized) return;
|
|
||||||
|
|
||||||
isMouseDown = false;
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
const x = e.clientX - rect.left;
|
|
||||||
const y = e.clientY - rect.top;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await connection.invoke("HandleMouseUp", x, y, e.button);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("鼠標釋放處理失敗:", err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
canvas.addEventListener('wheel', async (e) => {
|
|
||||||
if (!canvasInitialized) return;
|
|
||||||
|
|
||||||
e.preventDefault();
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
const x = e.clientX - rect.left;
|
|
||||||
const y = e.clientY - rect.top;
|
|
||||||
|
|
||||||
// 根據瀏覽器類型調整滾輪縮放因子
|
|
||||||
const browserBrand = isFirefox ? 'firefox' : 'chrome';
|
|
||||||
|
|
||||||
try {
|
|
||||||
await connection.invoke("HandleMouseWheel", x, y, e.deltaX, e.deltaY, browserBrand);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("鼠標滾輪處理失敗:", err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 防止右鍵菜單
|
|
||||||
canvas.addEventListener('contextmenu', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupKeyboardEvents() {
|
|
||||||
// Canvas 焦點管理
|
|
||||||
canvas.addEventListener('click', () => {
|
|
||||||
console.log('Canvas clicked, focusing...');
|
|
||||||
canvas.focus();
|
|
||||||
});
|
|
||||||
|
|
||||||
canvas.addEventListener('focus', () => {
|
|
||||||
console.log('Canvas focused');
|
|
||||||
canvas.style.borderColor = '#28a745';
|
|
||||||
});
|
|
||||||
|
|
||||||
canvas.addEventListener('blur', () => {
|
|
||||||
console.log('Canvas lost focus');
|
|
||||||
canvas.style.borderColor = '#dee2e6';
|
|
||||||
});
|
|
||||||
|
|
||||||
// 鍵盤事件
|
|
||||||
canvas.addEventListener('keydown', async (e) => {
|
|
||||||
console.log(`鍵盤按下: key=${e.key}, code=${e.code}, ctrl=${e.ctrlKey}, shift=${e.shiftKey}, alt=${e.altKey}`);
|
|
||||||
|
|
||||||
if (!canvasInitialized) {
|
|
||||||
console.warn('Canvas 尚未初始化,忽略鍵盤事件');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 防止瀏覽器默認行為(如 F5 刷新頁面)
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await connection.invoke("HandleKeyDown", e.key, e.code, e.ctrlKey, e.shiftKey, e.altKey);
|
|
||||||
console.log('鍵盤按下事件已發送到服務器');
|
|
||||||
} catch (err) {
|
|
||||||
console.error("鍵盤按下處理失敗:", err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
canvas.addEventListener('keyup', async (e) => {
|
|
||||||
console.log(`鍵盤釋放: key=${e.key}, code=${e.code}`);
|
|
||||||
|
|
||||||
if (!canvasInitialized) {
|
|
||||||
console.warn('Canvas 尚未初始化,忽略鍵盤事件');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await connection.invoke("HandleKeyUp", e.key, e.code, e.ctrlKey, e.shiftKey, e.altKey);
|
|
||||||
console.log('鍵盤釋放事件已發送到服務器');
|
|
||||||
} catch (err) {
|
|
||||||
console.error("鍵盤釋放處理失敗:", err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupTouchEvents() {
|
|
||||||
let touchStartX = 0;
|
|
||||||
let touchStartY = 0;
|
|
||||||
|
|
||||||
canvas.addEventListener('touchstart', async (e) => {
|
|
||||||
if (!canvasInitialized) return;
|
|
||||||
|
|
||||||
e.preventDefault();
|
|
||||||
const touch = e.touches[0];
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
touchStartX = touch.clientX - rect.left;
|
|
||||||
touchStartY = touch.clientY - rect.top;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await connection.invoke("HandleTouchDown", touch.identifier, touchStartX, touchStartY);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("觸摸開始處理失敗:", err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
canvas.addEventListener('touchmove', async (e) => {
|
|
||||||
if (!canvasInitialized) return;
|
|
||||||
|
|
||||||
e.preventDefault();
|
|
||||||
const touch = e.touches[0];
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
const x = touch.clientX - rect.left;
|
|
||||||
const y = touch.clientY - rect.top;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await connection.invoke("HandleTouchMove", touch.identifier, x, y);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("觸摸移動處理失敗:", err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
canvas.addEventListener('touchend', async (e) => {
|
|
||||||
if (!canvasInitialized) return;
|
|
||||||
|
|
||||||
e.preventDefault();
|
|
||||||
const touch = e.changedTouches[0];
|
|
||||||
|
|
||||||
try {
|
|
||||||
await connection.invoke("HandleTouchUp", touch.identifier);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("觸摸結束處理失敗:", err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let resizeTimeout = null;
|
|
||||||
|
|
||||||
async function handleWindowResize() {
|
|
||||||
// 使用防抖來避免頻繁調整
|
|
||||||
if (resizeTimeout) {
|
|
||||||
clearTimeout(resizeTimeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
resizeTimeout = setTimeout(async () => {
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
const width = Math.floor(rect.width);
|
|
||||||
const height = Math.floor(rect.height);
|
|
||||||
|
|
||||||
console.log(`調整畫布大小: ${width}x${height}`);
|
|
||||||
|
|
||||||
// 保存當前畫布內容
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
|
||||||
|
|
||||||
// 更新畫布大小
|
|
||||||
canvas.width = width;
|
|
||||||
canvas.height = height;
|
|
||||||
|
|
||||||
// 嘗試恢復畫布內容(如果大小相容)
|
|
||||||
try {
|
|
||||||
if (imageData.width > 0 && imageData.height > 0) {
|
|
||||||
ctx.putImageData(imageData, 0, 0);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('無法恢復畫布內容,等待服務器重繪');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (connection && connection.state === signalR.HubConnectionState.Connected) {
|
|
||||||
try {
|
|
||||||
// 通知服務器調整大小並重新渲染
|
|
||||||
await connection.invoke("HandleResize", width, height);
|
|
||||||
console.log('已通知服務器調整大小');
|
|
||||||
} catch (err) {
|
|
||||||
console.error("調整大小失敗:", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 300); // 300ms 防抖延遲
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
@ -1,115 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-TW">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Simple Vue.js Rendering Canvas Demo</title>
|
|
||||||
<!-- Use vue.global.prod.js for production deployment -->
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/vue@3/dist/vue.global.js"></script>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/@microsoft/signalr@latest/dist/browser/signalr.min.js"></script>
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
padding: 20px;
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.demo-container {
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.canvas-wrapper {
|
|
||||||
height: 600px;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="app">
|
|
||||||
<div class="demo-container">
|
|
||||||
<h1>Simple Rendering Canvas Demo</h1>
|
|
||||||
|
|
||||||
<div class="controls" v-if="canvas">
|
|
||||||
<!-- View dropdown component -->
|
|
||||||
<rendering-canvas-view-dropdown
|
|
||||||
:rendering-canvas="canvas"
|
|
||||||
></rendering-canvas-view-dropdown>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="canvas-wrapper">
|
|
||||||
<!-- Self-contained canvas component -->
|
|
||||||
<rendering-canvas
|
|
||||||
ref="canvas"
|
|
||||||
:auto-connect="true"
|
|
||||||
@initialized="onCanvasInitialized"
|
|
||||||
@server-initialized="onServerInitialized"
|
|
||||||
></rendering-canvas>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script type="module">
|
|
||||||
import RenderingCanvas from './disp/rendering-canvas.js';
|
|
||||||
import RenderingCanvasViewDropdown from './disp/rendering-canvas-view-dropdown.js';
|
|
||||||
|
|
||||||
const { createApp } = Vue;
|
|
||||||
|
|
||||||
createApp({
|
|
||||||
components: {
|
|
||||||
RenderingCanvas,
|
|
||||||
RenderingCanvasViewDropdown
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
canvas: null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
// Make the canvas reference available after mounting
|
|
||||||
this.$nextTick(() => {
|
|
||||||
this.canvas = this.$refs.canvas;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
onCanvasInitialized(canvasId) {
|
|
||||||
console.log('Canvas initialized:', canvasId);
|
|
||||||
// Update the canvas reference to ensure dropdown is enabled
|
|
||||||
this.canvas = this.$refs.canvas;
|
|
||||||
},
|
|
||||||
|
|
||||||
async onServerInitialized(sessionId) {
|
|
||||||
console.log('Server initialized with session:', sessionId);
|
|
||||||
// Load test objects for this demo
|
|
||||||
await this.loadTestObjects(sessionId);
|
|
||||||
},
|
|
||||||
|
|
||||||
async loadTestObjects(sessionId) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/rendering/test-objects/${sessionId}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
console.log('Test objects loaded successfully');
|
|
||||||
} else {
|
|
||||||
const errorText = await response.text();
|
|
||||||
console.error(`Failed to load test objects: ${response.status} - ${errorText}`);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Error loading test objects: ${err}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}).mount('#app');
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
@ -1,185 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
517
wwwroot/disp/rendering-canvas-vue.js
Normal file
517
wwwroot/disp/rendering-canvas-vue.js
Normal file
@ -0,0 +1,517 @@
|
|||||||
|
// 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 = '/_content/hi.webapi/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"
|
||||||
|
@touchcancel.prevent="handleTouchCancel"
|
||||||
|
@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;
|
||||||
|
|
||||||
|
// 處理所有觸控點,而不只是第一個
|
||||||
|
for (let i = 0; i < e.changedTouches.length; i++) {
|
||||||
|
const touch = e.changedTouches[i];
|
||||||
|
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;
|
||||||
|
|
||||||
|
// 處理所有觸控點以支援多點觸控手勢
|
||||||
|
for (let i = 0; i < e.changedTouches.length; i++) {
|
||||||
|
const touch = e.changedTouches[i];
|
||||||
|
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;
|
||||||
|
|
||||||
|
// 處理所有結束的觸控點
|
||||||
|
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;
|
||||||
|
|
||||||
|
// 處理所有取消的觸控點(例如觸控被系統中斷)
|
||||||
|
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 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,342 +1,370 @@
|
|||||||
// Add CSS link to the document if not already present
|
/**
|
||||||
if (!document.querySelector('link[href*="rendering-canvas.css"]')) {
|
* Pure JavaScript RenderingCanvas class
|
||||||
const link = document.createElement('link');
|
* A reusable canvas component for rendering with SignalR
|
||||||
link.rel = 'stylesheet';
|
*/
|
||||||
link.href = './disp/rendering-canvas.css';
|
class RenderingCanvas {
|
||||||
document.head.appendChild(link);
|
constructor(containerId, options = {}) {
|
||||||
}
|
// Default options
|
||||||
|
this.options = {
|
||||||
|
hubUrl: '/renderingHub',
|
||||||
|
autoConnect: true,
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
|
||||||
export default {
|
// Initialize properties
|
||||||
name: 'RenderingCanvas',
|
this.containerId = containerId;
|
||||||
template: `
|
this.container = document.getElementById(containerId);
|
||||||
<div class="canvas-container">
|
this.canvasId = `rendering-canvas-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
<canvas
|
this.connection = null;
|
||||||
ref="renderCanvas"
|
this.sessionId = null;
|
||||||
:id="canvasId"
|
this.isConnected = false;
|
||||||
tabindex="0"
|
this.isInitialized = false;
|
||||||
@contextmenu.prevent
|
this.hasFocus = false;
|
||||||
@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: {
|
// Mouse state tracking
|
||||||
type: Number,
|
this.mouseState = {
|
||||||
default: 800
|
isDown: false,
|
||||||
},
|
button: 0,
|
||||||
height: {
|
lastX: 0,
|
||||||
type: Number,
|
lastY: 0
|
||||||
default: 600
|
};
|
||||||
|
|
||||||
|
// 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);
|
||||||
}
|
}
|
||||||
},
|
|
||||||
data() {
|
// Create canvas element
|
||||||
return {
|
this.createCanvas();
|
||||||
canvasId: `rendering-canvas-${Math.random().toString(36).substr(2, 9)}`,
|
|
||||||
connection: null,
|
// Set up event listeners
|
||||||
sessionId: null,
|
this.setupEventListeners();
|
||||||
isConnected: false,
|
|
||||||
isInitialized: false,
|
// Set up resize observer
|
||||||
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();
|
this.setupResizeObserver();
|
||||||
|
|
||||||
if (this.autoConnect) {
|
// Auto-connect if enabled
|
||||||
await this.connect();
|
if (this.options.autoConnect) {
|
||||||
|
this.connect();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Add visibility change listener
|
createCanvas() {
|
||||||
this.handleVisibilityChange = this.handleVisibilityChange.bind(this);
|
// Create canvas wrapper
|
||||||
document.addEventListener('visibilitychange', this.handleVisibilityChange);
|
const wrapper = document.createElement('div');
|
||||||
},
|
wrapper.className = 'canvas-container';
|
||||||
async beforeUnmount() {
|
|
||||||
// Remove visibility change listener
|
|
||||||
document.removeEventListener('visibilitychange', this.handleVisibilityChange);
|
|
||||||
|
|
||||||
if (this.resizeObserver) {
|
// Create canvas element
|
||||||
this.resizeObserver.disconnect();
|
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;
|
||||||
|
|
||||||
if (this.connection) {
|
// Add canvas to wrapper and container
|
||||||
await this.disconnect();
|
wrapper.appendChild(this.canvas);
|
||||||
}
|
this.container.innerHTML = '';
|
||||||
},
|
this.container.appendChild(wrapper);
|
||||||
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.isInitialized = true;
|
||||||
this.connection.on("ImageUpdate", (compressedData, originalLength, width, height) => {
|
this.emit('initialized', this.canvasId);
|
||||||
this.handleImageUpdate(compressedData, originalLength, width, height);
|
}
|
||||||
});
|
|
||||||
|
|
||||||
this.connection.on("CanvasInitialized", (id) => {
|
setupEventListeners() {
|
||||||
this.sessionId = id;
|
// Context menu prevention
|
||||||
this.$emit('serverInitialized', id);
|
this.canvas.addEventListener('contextmenu', (e) => e.preventDefault());
|
||||||
});
|
|
||||||
|
|
||||||
// Start connection
|
// Mouse events
|
||||||
await this.connection.start();
|
this.canvas.addEventListener('mousedown', (e) => this.handleMouseDown(e));
|
||||||
this.isConnected = true;
|
this.canvas.addEventListener('mousemove', (e) => this.handleMouseMove(e));
|
||||||
this.$emit('connected');
|
this.canvas.addEventListener('mouseup', (e) => this.handleMouseUp(e));
|
||||||
|
this.canvas.addEventListener('wheel', (e) => this.handleWheel(e), { passive: false });
|
||||||
|
|
||||||
// Initialize server canvas
|
// Keyboard events
|
||||||
const canvas = this.$refs.renderCanvas;
|
this.canvas.addEventListener('keydown', (e) => this.handleKeyDown(e));
|
||||||
const rect = canvas.getBoundingClientRect();
|
this.canvas.addEventListener('keyup', (e) => this.handleKeyUp(e));
|
||||||
const width = Math.floor(rect.width);
|
|
||||||
const height = Math.floor(rect.height);
|
|
||||||
|
|
||||||
await this.connection.invoke("InitializeCanvas", width, height);
|
// 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 });
|
||||||
|
|
||||||
} catch (err) {
|
// Focus events
|
||||||
this.$emit('error', `Connection failed: ${err}`);
|
this.canvas.addEventListener('click', () => this.focusCanvas());
|
||||||
console.error('Connection failed:', err);
|
this.canvas.addEventListener('focus', () => this.onFocus());
|
||||||
|
this.canvas.addEventListener('blur', () => this.onBlur());
|
||||||
|
|
||||||
// Retry after 5 seconds
|
// Visibility change
|
||||||
setTimeout(() => this.connect(), 5000);
|
document.addEventListener('visibilitychange', () => this.handleVisibilityChange());
|
||||||
}
|
}
|
||||||
},
|
|
||||||
|
|
||||||
async disconnect() {
|
// SignalR connection methods
|
||||||
if (this.connection) {
|
async connect() {
|
||||||
try {
|
try {
|
||||||
await this.connection.stop();
|
// Create SignalR connection
|
||||||
this.connection = null;
|
this.connection = new signalR.HubConnectionBuilder()
|
||||||
this.sessionId = null;
|
.withUrl(this.options.hubUrl)
|
||||||
this.isConnected = false;
|
.configureLogging(signalR.LogLevel.Information)
|
||||||
|
.build();
|
||||||
|
|
||||||
// Clear canvas
|
// Set up event handlers
|
||||||
const canvas = this.$refs.renderCanvas;
|
this.connection.on("ImageUpdate", (compressedData, originalLength, width, height) => {
|
||||||
const ctx = canvas.getContext('2d');
|
this.handleImageUpdate(compressedData, originalLength, width, height);
|
||||||
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) {
|
this.connection.on("CanvasInitialized", (id) => {
|
||||||
const canvas = this.$refs.renderCanvas;
|
this.sessionId = id;
|
||||||
canvas.width = Math.floor(width);
|
this.emit('serverInitialized', id);
|
||||||
canvas.height = Math.floor(height);
|
});
|
||||||
|
|
||||||
if (this.connection && this.connection.state === 'Connected') {
|
// Start connection
|
||||||
try {
|
await this.connection.start();
|
||||||
await this.connection.invoke("HandleResize", canvas.width, canvas.height);
|
this.isConnected = true;
|
||||||
} catch (err) {
|
this.emit('connected');
|
||||||
this.$emit('error', `Resize failed: ${err}`);
|
|
||||||
}
|
// 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);
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async handleVisibilityChange() {
|
// Resize handling
|
||||||
if (this.connection && this.connection.state === 'Connected') {
|
setupResizeObserver() {
|
||||||
try {
|
this.resizeObserver = new ResizeObserver(entries => {
|
||||||
await this.connection.invoke("HandleVisibilityChange", document.visibilityState);
|
for (let entry of entries) {
|
||||||
this.$emit('visibilityChanged', document.visibilityState);
|
const { width, height } = entry.contentRect;
|
||||||
} catch (err) {
|
this.handleResize(width, height);
|
||||||
this.$emit('error', `Visibility change failed: ${err}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
});
|
||||||
|
this.resizeObserver.observe(this.canvas.parentElement);
|
||||||
|
}
|
||||||
|
|
||||||
focusCanvas() {
|
async handleResize(width, height) {
|
||||||
this.$refs.renderCanvas.focus();
|
this.canvas.width = Math.floor(width);
|
||||||
},
|
this.canvas.height = Math.floor(height);
|
||||||
|
|
||||||
onFocus() {
|
if (this.connection && this.connection.state === 'Connected') {
|
||||||
this.hasFocus = true;
|
try {
|
||||||
this.$emit('focus');
|
await this.connection.invoke("HandleResize", this.canvas.width, this.canvas.height);
|
||||||
},
|
} catch (err) {
|
||||||
|
this.emit('error', `Resize failed: ${err}`);
|
||||||
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) {
|
async handleVisibilityChange() {
|
||||||
if (!this.isInitialized) return;
|
if (this.connection && this.connection.state === 'Connected') {
|
||||||
|
try {
|
||||||
const rect = this.$refs.renderCanvas.getBoundingClientRect();
|
await this.connection.invoke("HandleVisibilityChange", document.visibilityState);
|
||||||
const x = e.clientX - rect.left;
|
this.emit('visibilityChanged', document.visibilityState);
|
||||||
const y = e.clientY - rect.top;
|
} catch (err) {
|
||||||
|
this.emit('error', `Visibility change failed: ${err}`);
|
||||||
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;
|
// Focus management
|
||||||
this.mouseState.lastY = y;
|
focusCanvas() {
|
||||||
},
|
this.canvas.focus();
|
||||||
|
}
|
||||||
|
|
||||||
async handleMouseUp(e) {
|
onFocus() {
|
||||||
if (!this.isInitialized) return;
|
this.hasFocus = true;
|
||||||
|
this.emit('focus');
|
||||||
|
}
|
||||||
|
|
||||||
this.mouseState.isDown = false;
|
onBlur() {
|
||||||
|
this.hasFocus = false;
|
||||||
|
this.emit('blur');
|
||||||
|
}
|
||||||
|
|
||||||
const rect = this.$refs.renderCanvas.getBoundingClientRect();
|
// Mouse event handlers
|
||||||
const x = e.clientX - rect.left;
|
async handleMouseDown(e) {
|
||||||
const y = e.clientY - rect.top;
|
if (!this.isInitialized || !this.connection) return;
|
||||||
|
|
||||||
if (this.connection && this.connection.state === 'Connected') {
|
this.mouseState.isDown = true;
|
||||||
try {
|
this.mouseState.button = e.button;
|
||||||
await this.connection.invoke("HandleMouseUp", x, y, e.button);
|
|
||||||
} catch (err) {
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
this.$emit('error', `Mouse up failed: ${err}`);
|
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 handleWheel(e) {
|
async handleMouseMove(e) {
|
||||||
if (!this.isInitialized) return;
|
if (!this.isInitialized) return;
|
||||||
|
|
||||||
const rect = this.$refs.renderCanvas.getBoundingClientRect();
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
const x = e.clientX - rect.left;
|
const x = e.clientX - rect.left;
|
||||||
const y = e.clientY - rect.top;
|
const y = e.clientY - rect.top;
|
||||||
|
|
||||||
const browserBrand = this.isFirefox ? 'firefox' : 'chrome';
|
const buttonMask = this.mouseState.isDown ? (1 << this.mouseState.button) : 0;
|
||||||
|
|
||||||
if (this.connection && this.connection.state === 'Connected') {
|
if (this.connection && this.connection.state === 'Connected') {
|
||||||
try {
|
try {
|
||||||
await this.connection.invoke("HandleMouseWheel",
|
await this.connection.invoke("HandleMouseMove", x, y, buttonMask);
|
||||||
x, y, e.deltaX, e.deltaY, browserBrand
|
} catch (err) {
|
||||||
);
|
this.emit('error', `Mouse move failed: ${err}`);
|
||||||
} catch (err) {
|
|
||||||
this.$emit('error', `Mouse wheel failed: ${err}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
|
||||||
// Keyboard event handlers
|
this.mouseState.lastX = x;
|
||||||
async handleKeyDown(e) {
|
this.mouseState.lastY = y;
|
||||||
if (!this.isInitialized) return;
|
}
|
||||||
|
|
||||||
e.preventDefault();
|
async handleMouseUp(e) {
|
||||||
|
if (!this.isInitialized) return;
|
||||||
|
|
||||||
if (this.connection && this.connection.state === 'Connected') {
|
this.mouseState.isDown = false;
|
||||||
try {
|
|
||||||
await this.connection.invoke("HandleKeyDown",
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
e.key, e.code, e.ctrlKey, e.shiftKey, e.altKey
|
const x = e.clientX - rect.left;
|
||||||
);
|
const y = e.clientY - rect.top;
|
||||||
} catch (err) {
|
|
||||||
this.$emit('error', `Key down failed: ${err}`);
|
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 handleKeyUp(e) {
|
async handleWheel(e) {
|
||||||
if (!this.isInitialized) return;
|
if (!this.isInitialized) return;
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (this.connection && this.connection.state === 'Connected') {
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
try {
|
const x = e.clientX - rect.left;
|
||||||
await this.connection.invoke("HandleKeyUp",
|
const y = e.clientY - rect.top;
|
||||||
e.key, e.code, e.ctrlKey, e.shiftKey, e.altKey
|
|
||||||
);
|
const browserBrand = this.isFirefox ? 'firefox' : 'chrome';
|
||||||
} catch (err) {
|
|
||||||
this.$emit('error', `Key up failed: ${err}`);
|
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}`);
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Touch event handlers
|
// Keyboard event handlers
|
||||||
async handleTouchStart(e) {
|
async handleKeyDown(e) {
|
||||||
if (!this.isInitialized) return;
|
if (!this.isInitialized) return;
|
||||||
|
|
||||||
const touch = e.touches[0];
|
e.preventDefault();
|
||||||
const rect = this.$refs.renderCanvas.getBoundingClientRect();
|
|
||||||
|
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 x = touch.clientX - rect.left;
|
||||||
const y = touch.clientY - rect.top;
|
const y = touch.clientY - rect.top;
|
||||||
|
|
||||||
@ -344,16 +372,21 @@ export default {
|
|||||||
try {
|
try {
|
||||||
await this.connection.invoke("HandleTouchDown", touch.identifier, x, y);
|
await this.connection.invoke("HandleTouchDown", touch.identifier, x, y);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.$emit('error', `Touch start failed: ${err}`);
|
this.emit('error', `Touch start failed: ${err}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async handleTouchMove(e) {
|
async handleTouchMove(e) {
|
||||||
if (!this.isInitialized) return;
|
if (!this.isInitialized) return;
|
||||||
|
|
||||||
const touch = e.touches[0];
|
e.preventDefault();
|
||||||
const rect = this.$refs.renderCanvas.getBoundingClientRect();
|
|
||||||
|
// 處理所有觸控點以支援多點觸控手勢
|
||||||
|
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 x = touch.clientX - rect.left;
|
||||||
const y = touch.clientY - rect.top;
|
const y = touch.clientY - rect.top;
|
||||||
|
|
||||||
@ -361,130 +394,177 @@ export default {
|
|||||||
try {
|
try {
|
||||||
await this.connection.invoke("HandleTouchMove", touch.identifier, x, y);
|
await this.connection.invoke("HandleTouchMove", touch.identifier, x, y);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.$emit('error', `Touch move failed: ${err}`);
|
this.emit('error', `Touch move failed: ${err}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async handleTouchEnd(e) {
|
async handleTouchEnd(e) {
|
||||||
if (!this.isInitialized) return;
|
if (!this.isInitialized) return;
|
||||||
|
|
||||||
const touch = e.changedTouches[0];
|
e.preventDefault();
|
||||||
|
|
||||||
|
// 處理所有結束的觸控點
|
||||||
|
for (let i = 0; i < e.changedTouches.length; i++) {
|
||||||
|
const touch = e.changedTouches[i];
|
||||||
|
|
||||||
if (this.connection && this.connection.state === 'Connected') {
|
if (this.connection && this.connection.state === 'Connected') {
|
||||||
try {
|
try {
|
||||||
await this.connection.invoke("HandleTouchUp", touch.identifier);
|
await this.connection.invoke("HandleTouchUp", touch.identifier);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.$emit('error', `Touch end failed: ${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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
}
|
}
|
Loading…
x
Reference in New Issue
Block a user