From f6cdaa8bce9f24483fac92d44d908db2fd48692f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B5=E4=B8=80=E5=AF=B0?= Date: Sun, 15 Mar 2026 22:32:19 +0800 Subject: [PATCH] fix bug for vnc stand mode --- ui/src/ui/components/claw-computer-panel.ts | 547 ++++++++++++++++++-- 1 file changed, 494 insertions(+), 53 deletions(-) diff --git a/ui/src/ui/components/claw-computer-panel.ts b/ui/src/ui/components/claw-computer-panel.ts index f56253c4fa7..4bd6121111e 100644 --- a/ui/src/ui/components/claw-computer-panel.ts +++ b/ui/src/ui/components/claw-computer-panel.ts @@ -1,10 +1,8 @@ // @ts-ignore - noVNC types are not available import RFB from "@novnc/novnc"; -// ui/src/ui/components/claw-computer-panel.ts import { LitElement, html, css } from "lit"; import { customElement, state, property } from "lit/decorators.js"; import { createRef, ref, Ref } from "lit/directives/ref.js"; -// @ts-ignore - noVNC types are not available // Compatible with both .default and non-.default versions // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -13,7 +11,7 @@ const RFBClass = (RFB as any).default || RFB; // RFB instance type definition interface RFBInstance { disconnect(): void; - addEventListener(event: string, callback: (e?: unknown) => void): void; + addEventListener(event: string, callback: (e: unknown) => void): void; scaleViewport: boolean; clipViewport: boolean; resizeSession: boolean; @@ -30,9 +28,31 @@ export class ClawComputerPanel extends LitElement { @state() isConnected = false; @state() isFitted = true; + // Floating window state + @state() private isFloating = false; + @state() private dockedOffsetY = 0; + @state() private dockedOffsetX = 0; + @state() private floatingRect = { x: 0, y: 0, width: 600, height: 400 }; + private rfb: RFBInstance | null = null; private screenRef: Ref = createRef(); - private autoConnectAttempted = false; + private dragStart = { x: 0, y: 0 }; + private initialRect = { + x: 0, + y: 0, + width: 0, + height: 0, + offsetY: 0, + offsetX: 0, + containerHeight: 0, + containerTop: 0, + containerLeft: 0, + containerWidth: 0, + }; + private isDragging = false; + private isResizing = false; + private resizeEdge = ""; + private aspectRatio = 1; @property({ type: Boolean }) enabled = false; @@ -55,13 +75,40 @@ export class ClawComputerPanel extends LitElement { background: var(--bg-accent); color: var(--text); font-family: system-ui, sans-serif; + --vnc-border-color: var(--border); + --vnc-window-bg: color-mix(in srgb, var(--bg-accent), black 10%); } + + @media (prefers-color-scheme: light) { + :host { + --vnc-border-color: color-mix(in srgb, var(--border), black 15%); + --vnc-window-bg: color-mix(in srgb, var(--bg-accent), black 10%); + } + } + + @media (prefers-color-scheme: dark) { + :host { + --vnc-border-color: color-mix(in srgb, var(--border), white 15%); + --vnc-window-bg: color-mix(in srgb, var(--bg-accent), white 5%); + } + } + + :host([theme="light"]) { + --vnc-border-color: color-mix(in srgb, var(--border), black 15%); + --vnc-window-bg: color-mix(in srgb, var(--bg-accent), black 10%); + } + :host([theme="dark"]) { + --vnc-border-color: color-mix(in srgb, var(--border), white 15%); + --vnc-window-bg: color-mix(in srgb, var(--bg-accent), white 5%); + } + .container { height: 100%; display: flex; flex-direction: column; position: relative; } + .screen-container { flex: 1; width: 100%; @@ -73,46 +120,52 @@ export class ClawComputerPanel extends LitElement { align-items: center; justify-content: center; } + .screen { - /* Remove fixed width/height to allow shrink-wrapping */ width: auto; height: auto; - /* Maximize but preserve aspect ratio via flex item behavior */ max-width: 100%; max-height: 100%; display: flex; align-items: center; justify-content: center; - - /* macOS-style window border wrapper */ - padding: 6px; - padding-top: 36px; /* Extra space for title bar controls */ - background: color-mix( - in srgb, - var(--bg-accent), - black 10% - ); /* Ensure darker than container */ + padding: 5.4px; + padding-top: 32.4px; + background: var(--vnc-window-bg); border-radius: 6px; box-shadow: - 0 0 0 1px var(--border), + 0 0 0 1px var(--vnc-border-color, var(--border)), 0 20px 50px rgba(0, 0, 0, 0.4); box-sizing: border-box; position: relative; + pointer-events: none; + } + + .screen > *:not(.drag-handle):not(.window-controls):not(.resize-handle) { + pointer-events: auto; + } + + .screen.dragging > *:not(.drag-handle):not(.window-controls):not(.resize-handle) { + pointer-events: none !important; + } + + .screen.dragging canvas { + pointer-events: none !important; } - /* Window controls (fake traffic lights) */ .window-controls { position: absolute; - top: 12px; - left: 12px; + top: 10.8px; + left: 10.8px; display: flex; - gap: 8px; - z-index: 20; + gap: 7.2px; + z-index: 25; + pointer-events: auto; } .window-control { - width: 12px; - height: 12px; + width: 10.8px; + height: 10.8px; border-radius: 50%; cursor: pointer; border: 1px solid rgba(0, 0, 0, 0.1); @@ -120,6 +173,7 @@ export class ClawComputerPanel extends LitElement { transform 0.1s, opacity 0.2s; } + .window-control:hover { opacity: 0.8; transform: scale(1.1); @@ -129,32 +183,29 @@ export class ClawComputerPanel extends LitElement { background-color: #27c93f; border-color: #1aab29; } + .window-control.close { background-color: #ff5f56; border-color: #e0443e; } - .screen > div:not(.window-controls) { - /* Fix for noVNC wrapper div */ - display: flex !important; - align-items: center !important; - justify-content: center !important; - width: 100% !important; - height: 100% !important; - background: transparent !important; + + .window-control.minimize { + background-color: #ffbd2e; } + .screen canvas { - /* Force canvas to maintain aspect ratio within container */ max-width: 100% !important; max-height: 100% !important; width: auto !important; height: auto !important; - /* Remove default canvas outline/border */ outline: none; - display: block; /* Remove inline gap */ + display: block; margin: auto !important; border-radius: 0; box-shadow: none; + pointer-events: auto !important; } + .status-overlay { position: absolute; top: 50%; @@ -166,20 +217,144 @@ export class ClawComputerPanel extends LitElement { color: var(--text); font-weight: 500; pointer-events: none; - z-index: 10; + z-index: 100; border: 1px solid var(--border); box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); } + + .screen.floating { + position: fixed; + z-index: 9999; + top: 0; + left: 0; + max-width: none; + max-height: none; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5); + } + + .drag-handle { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 32.4px; + cursor: grab; + z-index: 20; + pointer-events: auto; + } + + .drag-handle:active { + cursor: grabbing; + } + + .resize-handle { + position: absolute; + background: transparent; + z-index: 30; + pointer-events: auto; + } + + .resize-handle.top { + top: -5px; + left: 0; + right: 0; + height: 10px; + cursor: ns-resize; + } + + .resize-handle.bottom { + bottom: -5px; + left: 0; + right: 0; + height: 10px; + cursor: ns-resize; + } + + .resize-handle.left { + left: -5px; + top: 0; + bottom: 0; + width: 10px; + cursor: ew-resize; + } + + .resize-handle.right { + right: -5px; + top: 0; + bottom: 0; + width: 10px; + cursor: ew-resize; + } + + .resize-handle.top-left { + top: -5px; + left: -5px; + width: 15px; + height: 15px; + cursor: nwse-resize; + z-index: 35; + } + + .resize-handle.top-right { + top: -5px; + right: -5px; + width: 15px; + height: 15px; + cursor: nesw-resize; + z-index: 35; + } + + .resize-handle.bottom-left { + bottom: -5px; + left: -5px; + width: 15px; + height: 15px; + cursor: nesw-resize; + z-index: 35; + } + + .resize-handle.bottom-right { + bottom: -5px; + right: -5px; + width: 15px; + height: 15px; + cursor: nwse-resize; + z-index: 35; + } `; render() { + const screenStyle = this.isFloating + ? `transform: translate(${this.floatingRect.x}px, ${this.floatingRect.y}px); width: ${this.floatingRect.width}px; height: ${this.floatingRect.height}px;` + : `transform: translate(${this.dockedOffsetX}px, ${this.dockedOffsetY}px);`; + return html`
${!this.isConnected ? html`
${this.status}
` : null}
-
+
+
+ ${ + this.isFloating + ? html` +
this.handleResizeStart(e, "top")}>
+
this.handleResizeStart(e, "bottom")}>
+
this.handleResizeStart(e, "left")}>
+
this.handleResizeStart(e, "right")}>
+
this.handleResizeStart(e, "top-left")}>
+
this.handleResizeStart(e, "top-right")}>
+
this.handleResizeStart(e, "bottom-left")}>
+
this.handleResizeStart(e, "bottom-right")}>
+ ` + : null + }
+
@@ -189,22 +364,291 @@ export class ClawComputerPanel extends LitElement { } private handleClose = () => { - // Dispatch event to parent to close the panel this.dispatchEvent(new CustomEvent("close", { bubbles: true, composed: true })); }; + private handleDragStart = (e: MouseEvent) => { + if (this.isResizing) { + return; + } + e.preventDefault(); + e.stopPropagation(); + + // 先清理旧的监听器,防止重复添加 + this.cleanupDragListeners(); + + this.isDragging = true; + this.dragStart = { x: e.clientX, y: e.clientY }; + + const screen = this.shadowRoot?.querySelector(".screen") as HTMLDivElement | null; + if (screen) { + // 拖拽过程中,暂时禁用 screen 内部所有元素的 pointer-events + // 这样即使鼠标移动到 VNC 窗口内部,也不会被 canvas 捕获 + screen.classList.add("dragging"); + + const rect = screen.getBoundingClientRect(); + // 获取 claw-computer-panel 本身的尺寸 + const hostRect = this.getBoundingClientRect(); + + // 使用 offsetWidth/offsetHeight 获取实际尺寸 + const screenWidth = screen.offsetWidth; + const screenHeight = screen.offsetHeight; + const hostHeight = this.offsetHeight; + + this.initialRect = { + x: this.isFloating ? this.floatingRect.x : rect.left, + y: this.isFloating ? this.floatingRect.y : rect.top, + width: screenWidth, + height: screenHeight, + offsetY: this.dockedOffsetY, + offsetX: this.dockedOffsetX, + containerHeight: hostHeight, + containerTop: hostRect.top, + containerLeft: hostRect.left, + containerWidth: this.offsetWidth, + }; + + if (!this.isFloating) { + this.floatingRect = { + x: rect.left, + y: rect.top, + width: screenWidth, + height: screenHeight, + }; + } + } + + window.addEventListener("mousemove", this.handleDragMove, { capture: true, passive: false }); + window.addEventListener("mouseup", this.handleDragEnd, { capture: true }); + }; + + private handleDragMove = (e: MouseEvent) => { + if (!this.isDragging) { + return; + } + + // 安全检查:如果鼠标按钮不再按下,就停止拖拽 + if (e.buttons !== 1) { + this.cleanupDragListeners(); + return; + } + + e.preventDefault(); + e.stopPropagation(); + + const dx = e.clientX - this.dragStart.x; + const dy = e.clientY - this.dragStart.y; + + if (this.isFloating) { + let newX = this.initialRect.x + dx; + let newY = this.initialRect.y + dy; + + const windowWidth = window.innerWidth; + const windowHeight = window.innerHeight; + + // 限制悬浮窗口不超出屏幕边界 + newX = Math.max(0, Math.min(newX, windowWidth - this.initialRect.width)); + newY = Math.max(0, Math.min(newY, windowHeight - this.initialRect.height)); + + this.floatingRect = { + x: newX, + y: newY, + width: this.initialRect.width, + height: this.initialRect.height, + }; + } else { + // 每次都直接获取当前尺寸 + const hostHeight = this.offsetHeight; + const screen = this.shadowRoot?.querySelector(".screen") as HTMLDivElement | null; + const screenHeight = screen?.offsetHeight || 0; + + // 计算最高和最低不能超过多少 + // 当 screen 居中时,初始顶部位置是 (hostHeight - screenHeight) / 2 + const initialTop = (hostHeight - screenHeight) / 2; + + // 最高:screen 顶部 = host 顶部 → 偏移量 = 0 - initialTop = -initialTop + const maxUpOffset = -initialTop; + + const maxDownOffset = hostHeight - screenHeight - initialTop; + + let newOffsetY = this.initialRect.offsetY + dy; + + // 使用计算出来的边界限制 + newOffsetY = Math.max(maxUpOffset, Math.min(newOffsetY, maxDownOffset)); + + this.dockedOffsetY = newOffsetY; + + // 禁止左右移动 + this.dockedOffsetX = 0; + } + }; + + private cleanupDragListeners = () => { + this.isDragging = false; + + // 移除拖拽过程中添加的类,恢复 pointer-events + const screen = this.shadowRoot?.querySelector(".screen") as HTMLDivElement | null; + if (screen) { + screen.classList.remove("dragging"); + } + + try { + window.removeEventListener("mousemove", this.handleDragMove, { capture: true }); + window.removeEventListener("mouseup", this.handleDragEnd, { capture: true }); + } catch (e) { + console.error("Error removing drag event listeners:", e); + } + }; + + private handleDragEnd = () => { + this.cleanupDragListeners(); + }; + + private cleanupResizeListeners = () => { + this.isResizing = false; + try { + window.removeEventListener("mousemove", this.handleResizeMove); + window.removeEventListener("mouseup", this.handleResizeEnd); + } catch (e) { + console.error("Error removing resize event listeners:", e); + } + }; + + private handleResizeStart = (e: MouseEvent, edge: string) => { + e.preventDefault(); + e.stopPropagation(); + + // 先清理旧的监听器,防止重复添加 + this.cleanupResizeListeners(); + + this.isResizing = true; + this.resizeEdge = edge; + this.dragStart = { x: e.clientX, y: e.clientY }; + this.initialRect = { + x: this.floatingRect.x, + y: this.floatingRect.y, + width: this.floatingRect.width, + height: this.floatingRect.height, + offsetY: 0, + offsetX: 0, + containerHeight: 0, + containerTop: 0, + containerLeft: 0, + containerWidth: 0, + }; + this.aspectRatio = this.floatingRect.width / this.floatingRect.height; + + window.addEventListener("mousemove", this.handleResizeMove); + window.addEventListener("mouseup", this.handleResizeEnd); + }; + + private handleResizeMove = (e: MouseEvent) => { + if (!this.isResizing) { + return; + } + + // 安全检查:如果鼠标按钮不再按下,就停止调整大小 + if (e.buttons !== 1) { + this.cleanupResizeListeners(); + return; + } + + e.preventDefault(); + + const dx = e.clientX - this.dragStart.x; + const dy = e.clientY - this.dragStart.y; + + let { x, y, width, height } = this.initialRect; + + if (this.resizeEdge.includes("right")) { + width += dx; + if (!this.resizeEdge.includes("top") && !this.resizeEdge.includes("bottom")) { + height = width / this.aspectRatio; + } + } + if (this.resizeEdge.includes("left")) { + x += dx; + width -= dx; + if (!this.resizeEdge.includes("top") && !this.resizeEdge.includes("bottom")) { + height = width / this.aspectRatio; + } + } + if (this.resizeEdge.includes("bottom")) { + height += dy; + if (!this.resizeEdge.includes("left") && !this.resizeEdge.includes("right")) { + width = height * this.aspectRatio; + } + } + if (this.resizeEdge.includes("top")) { + y += dy; + height -= dy; + if (!this.resizeEdge.includes("left") && !this.resizeEdge.includes("right")) { + width = height * this.aspectRatio; + } + } + + // 对角调整时的比例保持 + if ( + (this.resizeEdge.includes("left") || this.resizeEdge.includes("right")) && + (this.resizeEdge.includes("top") || this.resizeEdge.includes("bottom")) + ) { + // 简单处理:基于宽度的变化来调整高度,或者基于高度调整宽度 + // 优先保持宽高比 + if (this.resizeEdge.includes("right")) { + height = width / this.aspectRatio; + } else { + height = width / this.aspectRatio; + } + // 如果是 top,需要重新计算 y + if (this.resizeEdge.includes("top")) { + y = this.initialRect.y + (this.initialRect.height - height); + } + } + + // 最小尺寸限制 + if (width < 200) { + width = 200; + height = width / this.aspectRatio; + if (this.resizeEdge.includes("left")) { + x = this.initialRect.x + (this.initialRect.width - width); + } + if (this.resizeEdge.includes("top")) { + y = this.initialRect.y + (this.initialRect.height - height); + } + } + if (height < 150) { + height = 150; + width = height * this.aspectRatio; + if (this.resizeEdge.includes("left")) { + x = this.initialRect.x + (this.initialRect.width - width); + } + if (this.resizeEdge.includes("top")) { + y = this.initialRect.y + (this.initialRect.height - height); + } + } + + this.floatingRect = { x, y, width, height }; + + if (this.rfb) { + requestAnimationFrame(() => this.rfb?.resize?.()); + } + }; + + private handleResizeEnd = () => { + this.cleanupResizeListeners(); + setTimeout(() => this.rfb?.resize?.(), 50); + }; + private connect = async () => { let url = this.vncUrl || `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/vnc`; - // Append target configuration if available if (this.vncTarget) { try { const urlObj = new URL(url); urlObj.searchParams.set("target", this.vncTarget); url = urlObj.toString(); } catch { - // Fallback for non-standard WebSocket URLs if URL parsing fails if (url.includes("?")) { url += `&target=${encodeURIComponent(this.vncTarget)}`; } else { @@ -218,11 +662,9 @@ export class ClawComputerPanel extends LitElement { } this.status = "正在連接..."; - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion let screen = this.screenRef.value; if (!screen) { - // Fallback: try to find the element via shadowRoot if ref failed screen = this.shadowRoot?.querySelector(".screen") as HTMLDivElement; } @@ -232,12 +674,10 @@ export class ClawComputerPanel extends LitElement { return; } - // Clear previous VNC canvas elements to prevent duplication const existingCanvases = screen.querySelectorAll("canvas"); existingCanvases.forEach((canvas) => canvas.remove()); try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any const Constructor = RFBClass as new ( target: HTMLElement, url: string, @@ -250,10 +690,10 @@ export class ClawComputerPanel extends LitElement { clipViewport: true, }); - // @ts-ignore - this.rfb.addEventListener("securityfailure", (e: CustomEvent) => { - console.error("VNC security failure:", e.detail); - this.status = `Security negotiation failed: ${e.detail.reason || "Unknown reason"}`; + this.rfb.addEventListener("securityfailure", (e: unknown) => { + const event = e as CustomEvent; + console.error("VNC security failure:", event.detail); + this.status = `Security negotiation failed: ${event.detail.reason || "Unknown reason"}`; }); if (this.rfb) { @@ -305,18 +745,16 @@ export class ClawComputerPanel extends LitElement { } }; - private handlePasswordKeydown = (e: KeyboardEvent) => { - if (e.key === "Enter") { - void this.connect(); - } + private handleWindowBlur = () => { + this.cleanupDragListeners(); + this.cleanupResizeListeners(); }; firstUpdated() { window.addEventListener("resize", this.handleResize); + window.addEventListener("blur", this.handleWindowBlur); - // Auto-connect if enabled and URL is configured if (this.enabled && this.vncUrl) { - // Use setTimeout to ensure DOM is fully ready and to allow UI to render first setTimeout(() => { void this.connect(); }, 100); @@ -326,6 +764,9 @@ export class ClawComputerPanel extends LitElement { disconnectedCallback() { super.disconnectedCallback(); window.removeEventListener("resize", this.handleResize); + window.removeEventListener("blur", this.handleWindowBlur); + this.cleanupDragListeners(); + this.cleanupResizeListeners(); this.disconnect(); } }