diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f438d0a2e3..16612308812 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -641,9 +641,18 @@ importers: ui: dependencies: + '@lit-labs/signals': + specifier: ^0.2.0 + version: 0.2.0 + '@lit/context': + specifier: ^1.1.6 + version: 1.1.6 '@noble/ed25519': specifier: 3.0.1 version: 3.0.1 + '@novnc/novnc': + specifier: ^1.6.0 + version: 1.6.0 dompurify: specifier: ^3.3.3 version: 3.3.3 @@ -654,6 +663,9 @@ importers: specifier: ^17.0.4 version: 17.0.4 devDependencies: + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 '@vitest/browser-playwright': specifier: 4.1.0 version: 4.1.0(playwright@1.58.2)(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.1.0) @@ -669,6 +681,9 @@ importers: vitest: specifier: 4.1.0 version: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(@vitest/browser-playwright@4.1.0)(jsdom@29.0.0(@noble/hashes@2.0.1))(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)) + ws: + specifier: ^8.19.0 + version: 8.19.0 packages: @@ -2068,6 +2083,9 @@ packages: resolution: {integrity: sha512-tlc/FcYIv5i8RYsl2iDil4A0gOihaas1R5jPcIC4Zw3GhjKsVilw90aHcVlhZPTBLGBzd379S+VcnsDjd9ChiA==} engines: {node: '>=12.4.0'} + '@novnc/novnc@1.6.0': + resolution: {integrity: sha512-CJrmdSe9Yt2ZbLsJpVFoVkEu0KICEvnr3njW25Nz0jodaiFJtg8AYLGZogRYy0/N5HUWkGUsCmegKXYBSqwygw==} + '@octokit/app@16.1.2': resolution: {integrity: sha512-8j7sEpUYVj18dxvh0KWj6W/l6uAiVRBl1JBDVRqH1VHKAO/G5eRVl4yEoYACjakWers1DjUkcCHyJNQK47JqyQ==} engines: {node: '>= 20'} @@ -8758,6 +8776,8 @@ snapshots: '@nolyfill/domexception@1.0.28': {} + '@novnc/novnc@1.6.0': {} + '@octokit/app@16.1.2': dependencies: '@octokit/auth-app': 8.2.0 diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index dd5a659dbc9..439fc9ba277 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -802,6 +802,28 @@ export function createGatewayHttpServer(opts: { rateLimiter, }), }, + { + name: "debug-image", + run: async () => { + if (requestPath === "/api/debug-image") { + try { + const fs = await import("node:fs"); + const img = fs.readFileSync( + "/home/fish/.openclaw/workspace/linux-desktop-control/images/desktop_screeshot_20260318_181311_annotated.png", + ); + res.statusCode = 200; + res.setHeader("Content-Type", "image/png"); + res.setHeader("Access-Control-Allow-Origin", "*"); + res.end(img); + } catch { + res.statusCode = 404; + res.end("Not Found"); + } + return true; + } + return false; + }, + }, { name: "sessions-kill", run: () => @@ -977,6 +999,58 @@ export function attachGatewayUpgradeHandler(opts: { if (scopedCanvas.rewrittenUrl) { req.url = scopedCanvas.rewrittenUrl; } + + const url = new URL(req.url ?? "/", "http://localhost"); + if (url.pathname === "/api/debug-vnc") { + const target = url.searchParams.get("target"); + let host = process.env.OPENCLAW_VNC_HOST || "localhost"; + let port = parseInt(process.env.OPENCLAW_VNC_PORT || "5900", 10); + if (target) { + if (target.includes(":")) { + const parts = target.split(":"); + host = parts[0] || host; + const p = parseInt(parts[1] || "", 10); + if (!isNaN(p)) { + port = p; + } + } else { + host = target; + } + } + + const net = await import("node:net"); + // Reuse the same wss for upgrading, but handle connection directly + // Or better yet, we can create an ad-hoc WebSocketServer just for this upgrade + // to avoid mixing with gateway clients. + const { WebSocketServer: VncWss } = await import("ws"); + const vncWss = new VncWss({ noServer: true, perMessageDeflate: false }); + vncWss.handleUpgrade(req, socket, head, (ws) => { + const tcpSocket = net.connect(port, host); + tcpSocket.on("data", (data) => { + if (ws.readyState === ws.OPEN) { + ws.send(data); + } + }); + ws.on("message", (data) => { + if (!tcpSocket.writable) { + return; + } + if (Buffer.isBuffer(data)) { + tcpSocket.write(data); + } else if (Array.isArray(data)) { + tcpSocket.write(Buffer.concat(data)); + } else { + tcpSocket.write(Buffer.from(data)); + } + }); + ws.on("close", () => tcpSocket.end()); + tcpSocket.on("close", () => ws.close()); + tcpSocket.on("error", () => ws.close()); + ws.on("error", () => tcpSocket.end()); + }); + return; + } + if (canvasHost) { const url = new URL(req.url ?? "/", "http://localhost"); if (url.pathname === CANVAS_WS_PATH) { diff --git a/test/fixtures/plugin-extension-import-boundary-inventory.json b/test/fixtures/plugin-extension-import-boundary-inventory.json index 0894fe0d5b5..ead171321f9 100644 --- a/test/fixtures/plugin-extension-import-boundary-inventory.json +++ b/test/fixtures/plugin-extension-import-boundary-inventory.json @@ -31,14 +31,6 @@ "resolvedPath": "extensions/imessage/runtime-api.js", "reason": "imports extension-owned file from src/plugins" }, - { - "file": "src/plugins/runtime/runtime-matrix.ts", - "line": 4, - "kind": "import", - "specifier": "../../../extensions/matrix/runtime-api.js", - "resolvedPath": "extensions/matrix/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, { "file": "src/plugins/runtime/runtime-slack-ops.runtime.ts", "line": 10, diff --git a/ui/package.json b/ui/package.json index 5d514f671cd..f1088b63b03 100644 --- a/ui/package.json +++ b/ui/package.json @@ -9,16 +9,21 @@ "test": "vitest run --config vitest.config.ts" }, "dependencies": { + "@lit-labs/signals": "^0.2.0", + "@lit/context": "^1.1.6", "@noble/ed25519": "3.0.1", + "@novnc/novnc": "^1.6.0", "dompurify": "^3.3.3", "lit": "^3.3.2", "marked": "^17.0.4" }, "devDependencies": { + "@types/ws": "^8.18.1", "@vitest/browser-playwright": "4.1.0", "jsdom": "^29.0.0", "playwright": "^1.58.2", "vite": "8.0.0", - "vitest": "4.1.0" + "vitest": "4.1.0", + "ws": "^8.19.0" } } diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index d27fb221582..c9760d08ce3 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -326,6 +326,26 @@ export function renderChatControls(state: AppViewState) { > ${renderCronFilterIcon(hiddenCronCount)} + `; } diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index c0535cd6c30..143b3b56632 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -380,6 +380,11 @@ export function renderApp(state: AppViewState) { ? rawDeliveryToSuggestions.filter((value) => isHttpUrl(value)) : rawDeliveryToSuggestions; + const gatewayHttpUrl = state.settings.gatewayUrl + .replace(/^ws:\/\//i, "http://") + .replace(/^wss:\/\//i, "https://"); + const debugImageUrl = `${gatewayHttpUrl}/api/debug-image`; + return html` ${renderCommandPalette({ open: state.paletteOpen, @@ -596,7 +601,7 @@ export function renderApp(state: AppViewState) { : nothing } ${ - state.tab === "config" + state.tab === "config" || state.tab === "chat" ? nothing : html`
@@ -638,7 +643,31 @@ export function renderApp(state: AppViewState) { overviewLogLines: state.overviewLogLines, showGatewayToken: state.overviewShowGatewayToken, showGatewayPassword: state.overviewShowGatewayPassword, - onSettingsChange: (next) => state.applySettings(next), + vncConfigDirty: state.vncConfigDirty, + onSettingsChange: (next) => { + // Only mark dirty if VNC fields changed + const vncChanged = + next.vncWsUrl !== state.settings.vncWsUrl || + next.vncPassword !== state.settings.vncPassword || + next.vncTarget !== state.settings.vncTarget; + + if (vncChanged) { + state.vncConfigDirty = true; + } + + // For VNC fields, we update local state but don't persist immediately + // This allows the Save button to be the trigger for persistence + // For other fields, we persist immediately as before + if (vncChanged) { + state.settings = next; + } else { + state.applySettings(next); + } + }, + onSaveVncConfig: () => { + state.applySettings(state.settings); + state.vncConfigDirty = false; + }, onPasswordChange: (next) => (state.password = next), onSessionKeyChange: (next) => { state.sessionKey = next; @@ -1341,125 +1370,201 @@ export function renderApp(state: AppViewState) { ${ state.tab === "chat" - ? renderChat({ - sessionKey: state.sessionKey, - onSessionKeyChange: (next) => { - state.sessionKey = next; - state.chatMessage = ""; - state.chatAttachments = []; - state.chatStream = null; - state.chatStreamStartedAt = null; - state.chatRunId = null; - state.chatQueue = []; - state.resetToolStream(); - state.resetChatScroll(); - state.applySettings({ - ...state.settings, - sessionKey: next, - lastActiveSessionKey: next, - }); - void state.loadAssistantIdentity(); - void loadChatHistory(state); - void refreshChatAvatar(state); - }, - thinkingLevel: state.chatThinkingLevel, - showThinking, - showToolCalls, - loading: state.chatLoading, - sending: state.chatSending, - compactionStatus: state.compactionStatus, - fallbackStatus: state.fallbackStatus, - assistantAvatarUrl: chatAvatarUrl, - messages: state.chatMessages, - toolMessages: state.chatToolMessages, - streamSegments: state.chatStreamSegments, - stream: state.chatStream, - streamStartedAt: state.chatStreamStartedAt, - draft: state.chatMessage, - queue: state.chatQueue, - connected: state.connected, - canSend: state.connected, - disabledReason: chatDisabledReason, - error: state.lastError, - sessions: state.sessionsResult, - focusMode: chatFocus, - onRefresh: () => { - state.resetToolStream(); - return Promise.all([loadChatHistory(state), refreshChatAvatar(state)]); - }, - onToggleFocusMode: () => { - if (state.onboarding) { - return; - } - state.applySettings({ - ...state.settings, - chatFocusMode: !state.settings.chatFocusMode, - }); - }, - onChatScroll: (event) => state.handleChatScroll(event), - getDraft: () => state.chatMessage, - onDraftChange: (next) => (state.chatMessage = next), - onRequestUpdate: requestHostUpdate, - attachments: state.chatAttachments, - onAttachmentsChange: (next) => (state.chatAttachments = next), - onSend: () => state.handleSendChat(), - canAbort: Boolean(state.chatRunId), - onAbort: () => void state.handleAbortChat(), - onQueueRemove: (id) => state.removeQueuedMessage(id), - onNewSession: () => state.handleSendChat("/new", { restoreDraft: true }), - onClearHistory: async () => { - if (!state.client || !state.connected) { - return; - } - try { - await state.client.request("sessions.reset", { key: state.sessionKey }); - state.chatMessages = []; - state.chatStream = null; - state.chatRunId = null; - await loadChatHistory(state); - } catch (err) { - state.lastError = String(err); - } - }, - agentsList: state.agentsList, - currentAgentId: resolvedAgentId ?? "main", - onAgentChange: (agentId: string) => { - state.sessionKey = buildAgentMainSessionKey({ agentId }); + ? html` +
+
+
+
+ ${renderChatSessionSelect(state)} +
+
+ ${state.lastError ? html`
${state.lastError}
` : nothing} + ${renderChatControls(state)} +
+
+ + + ${renderChat({ + sessionKey: state.sessionKey, + onSessionKeyChange: (next) => { + state.sessionKey = next; + state.chatMessage = ""; + state.chatAttachments = []; + state.chatStream = null; + state.chatStreamStartedAt = null; + state.chatRunId = null; + state.chatQueue = []; + state.resetToolStream(); + state.resetChatScroll(); + state.applySettings({ + ...state.settings, + sessionKey: next, + lastActiveSessionKey: next, + }); + void state.loadAssistantIdentity(); + void loadChatHistory(state); + void refreshChatAvatar(state); + }, + thinkingLevel: state.chatThinkingLevel, + showThinking, + showToolCalls, + loading: state.chatLoading, + sending: state.chatSending, + compactionStatus: state.compactionStatus, + fallbackStatus: state.fallbackStatus, + assistantAvatarUrl: chatAvatarUrl, + messages: state.chatMessages, + toolMessages: state.chatToolMessages, + streamSegments: state.chatStreamSegments, + stream: state.chatStream, + streamStartedAt: state.chatStreamStartedAt, + draft: state.chatMessage, + queue: state.chatQueue, + connected: state.connected, + canSend: state.connected, + disabledReason: chatDisabledReason, + error: state.lastError, + sessions: state.sessionsResult, + focusMode: chatFocus, + onRefresh: () => { + state.resetToolStream(); + return Promise.all([loadChatHistory(state), refreshChatAvatar(state)]); + }, + onToggleFocusMode: () => { + if (state.onboarding) { + return; + } + state.applySettings({ + ...state.settings, + chatFocusMode: !state.settings.chatFocusMode, + }); + }, + onChatScroll: (event) => state.handleChatScroll(event), + getDraft: () => state.chatMessage, + onDraftChange: (next) => (state.chatMessage = next), + onRequestUpdate: requestHostUpdate, + attachments: state.chatAttachments, + onAttachmentsChange: (next) => (state.chatAttachments = next), + onSend: () => state.handleSendChat(), + canAbort: Boolean(state.chatRunId), + onAbort: () => void state.handleAbortChat(), + onQueueRemove: (id) => state.removeQueuedMessage(id), + onNewSession: () => state.handleSendChat("/new", { restoreDraft: true }), + onClearHistory: async () => { + if (!state.client || !state.connected) { + return; + } + try { + await state.client.request("sessions.reset", { key: state.sessionKey }); state.chatMessages = []; state.chatStream = null; state.chatRunId = null; - state.applySettings({ - ...state.settings, - sessionKey: state.sessionKey, - lastActiveSessionKey: state.sessionKey, - }); - void loadChatHistory(state); - void state.loadAssistantIdentity(); - }, - onNavigateToAgent: () => { - state.agentsSelectedId = resolvedAgentId; - state.setTab("agents" as import("./navigation.ts").Tab); - }, - onSessionSelect: (key: string) => { - switchChatSession(state, key); - }, - showNewMessages: state.chatNewMessagesBelow && !state.chatManualRefreshInFlight, - onScrollToBottom: () => state.scrollToBottom(), - // Sidebar props for tool output viewing - sidebarOpen: state.sidebarOpen, - sidebarContent: state.sidebarContent, - sidebarError: state.sidebarError, - splitRatio: state.splitRatio, - onOpenSidebar: (content: string) => state.handleOpenSidebar(content), - onCloseSidebar: () => state.handleCloseSidebar(), - onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio), - assistantName: state.assistantName, - assistantAvatar: state.assistantAvatar, - basePath: state.basePath ?? "", - }) + await loadChatHistory(state); + } catch (err) { + state.lastError = String(err); + } + }, + agentsList: state.agentsList, + currentAgentId: resolvedAgentId ?? "main", + onAgentChange: (agentId: string) => { + state.sessionKey = buildAgentMainSessionKey({ agentId }); + state.chatMessages = []; + state.chatStream = null; + state.chatRunId = null; + state.applySettings({ + ...state.settings, + sessionKey: state.sessionKey, + lastActiveSessionKey: state.sessionKey, + }); + void loadChatHistory(state); + void state.loadAssistantIdentity(); + }, + onNavigateToAgent: () => { + state.agentsSelectedId = resolvedAgentId; + state.setTab("agents" as import("./navigation.ts").Tab); + }, + onSessionSelect: (key: string) => { + switchChatSession(state, key); + }, + showNewMessages: state.chatNewMessagesBelow && !state.chatManualRefreshInFlight, + onScrollToBottom: () => state.scrollToBottom(), + // Sidebar props for tool output viewing + sidebarOpen: state.sidebarOpen, + sidebarContent: state.sidebarContent, + sidebarError: state.sidebarError, + splitRatio: state.splitRatio, + onOpenSidebar: (content: string) => state.handleOpenSidebar(content), + onCloseSidebar: () => state.handleCloseSidebar(), + onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio), + assistantName: state.assistantName, + assistantAvatar: state.assistantAvatar, + basePath: state.basePath ?? "", + })} +
+ + { + if (e.detail.width !== undefined) { + state.setClawComputerWidth(e.detail.width); + } + }} + style=${state.showClawComputer ? "" : "display: none;"} + > +
+ ${ + state.activeClawTool === "images" + ? html` + state.setActiveClawTool(e.detail.tool)} + @close=${() => state.toggleClawComputer()} + @float=${() => state.setClawComputerWidth(0)} + @dock=${() => state.setClawComputerWidth(600)} + style="flex: 1; min-height: 0;" + > + ` + : html` + state.setActiveClawTool(e.detail.tool)} + @close=${() => state.toggleClawComputer()} + @float=${() => state.setClawComputerWidth(0)} + @dock=${() => state.setClawComputerWidth(600)} + style="flex: 1; min-height: 0;" + > + ` + } +
+
+ ` : nothing } + ${ state.tab === "config" ? renderConfig({ diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index df806794645..49a107e8839 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -303,6 +303,7 @@ export type AppViewState = { streamMode: boolean; overviewShowGatewayToken: boolean; overviewShowGatewayPassword: boolean; + vncConfigDirty: boolean; overviewLogLines: string[]; overviewLogCursor: number; client: GatewayBrowserClient | null; @@ -369,4 +370,10 @@ export type AppViewState = { handleOpenSidebar: (content: string) => void; handleCloseSidebar: () => void; handleSplitRatioChange: (ratio: number) => void; + showClawComputer: boolean; + toggleClawComputer: () => void; + clawComputerWidth: number; + activeClawTool: string; + setClawComputerWidth: (width: number) => void; + setActiveClawTool: (tool: string) => void; }; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index a07ed6376a6..204d8fbdb5d 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -88,6 +88,8 @@ import type { import { type ChatAttachment, type ChatQueueItem, type CronFormState } from "./ui-types.ts"; import { generateUUID } from "./uuid.ts"; import type { NostrProfileFormState } from "./views/channels.nostr-profile-form.ts"; +import "./components/claw-computer-panel.ts"; +import "./components/claw-image-panel.ts"; declare global { interface Window { @@ -175,6 +177,10 @@ export class OpenClawApp extends LitElement { @state() sidebarError: string | null = null; @state() splitRatio = this.settings.splitRatio; + @state() showClawComputer = false; + @state() clawComputerWidth = 600; + @state() activeClawTool = "vnc"; // vnc, browser, images + @state() nodesLoading = false; @state() nodes: Array> = []; @state() devicesLoading = false; @@ -724,6 +730,18 @@ export class OpenClawApp extends LitElement { this.applySettings({ ...this.settings, splitRatio: newRatio }); } + toggleClawComputer() { + this.showClawComputer = !this.showClawComputer; + } + + setClawComputerWidth(width: number) { + this.clawComputerWidth = width; + } + + setActiveClawTool(tool: string) { + this.activeClawTool = tool; + } + render() { return renderApp(this as unknown as AppViewState); } diff --git a/ui/src/ui/components/claw-computer-panel.ts b/ui/src/ui/components/claw-computer-panel.ts new file mode 100644 index 00000000000..31127932b6b --- /dev/null +++ b/ui/src/ui/components/claw-computer-panel.ts @@ -0,0 +1,1045 @@ +// @ts-ignore - noVNC types are not available +import RFB from "@novnc/novnc"; +import { LitElement, html, css } from "lit"; +import { customElement, state, property } from "lit/decorators.js"; +import { createRef, ref, Ref } from "lit/directives/ref.js"; + +// Compatible with both .default and non-.default versions +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const RFBClass = (RFB as any).default || RFB; + +// RFB instance type definition +interface RFBInstance { + disconnect(): void; + addEventListener(event: string, callback: (e: unknown) => void): void; + scaleViewport: boolean; + clipViewport: boolean; + resizeSession: boolean; + resize?(): void; +} + +@customElement("claw-computer-panel") +export class ClawComputerPanel extends LitElement { + @property() vncUrl = ""; + @property() vncTarget = ""; + @property() password = ""; + + @state() status = "等待連接..."; + @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 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; + // 记录拖拽开始时鼠标相对于拖拽句柄的位置(点 A) + private dragAnchor = { x: 0, y: 0 }; + // 拖拽过程中的临时悬浮状态 + private tempIsFloating = false; + // canvas 的比例(用于悬浮模式缩放时保持) + private canvasRatio = 16 / 9; + private resizeObserver: ResizeObserver | null = null; + private readonly TOOLBAR_SPACE = 50; + + @property({ type: String }) activeTool = "vnc"; // vnc, browser, images + + @property({ type: Boolean }) enabled = false; + + private setupResizeObserver() { + if (this.resizeObserver) { + return; + } + this.resizeObserver = new ResizeObserver(() => { + this.clampDockedOffset(); + }); + this.resizeObserver.observe(this); + } + + private cleanupResizeObserver() { + if (this.resizeObserver) { + this.resizeObserver.disconnect(); + this.resizeObserver = null; + } + } + + private clampDockedOffset() { + if (this.isFloating || !this.shadowRoot) { + return; + } + + const screenElement = this.shadowRoot.querySelector(".screen"); + if (!screenElement) { + return; + } + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + const screen = screenElement as HTMLElement; + + const hostHeight = this.offsetHeight; + const screenHeight = screen.offsetHeight; + + // 计算允许的最大垂直偏移量(上下边界) + // 默认居中时,screenTop = (hostHeight - screenHeight) / 2 + // maxUp = -screenTop (移动到最顶部) + // maxDown = screenTop (移动到最底部) + // 注意:这里的 hostHeight 实际上是 screen-container 的 content-box 高度(因为有 padding) + // 但 offsetHeight 返回的是包含 padding 的高度。 + // 不过,我们这里的逻辑主要是为了限制偏移量。 + + // 由于我们给容器加了 padding-top: 50px,Flex 居中是相对于剩余空间(Content Box)的。 + // 只要我们限制 screen 不超出 Content Box,就自然避开了工具栏。 + + // 获取容器的实际可用高度(减去 padding) + const style = getComputedStyle(this.shadowRoot.querySelector(".screen-container")!); + const paddingTop = parseFloat(style.paddingTop) || 0; + const contentHeight = hostHeight - paddingTop; + + // 计算在 Content Box 内的居中位置的顶部距离 + const centerTop = (contentHeight - screenHeight) / 2; + + const maxOffset = Math.max(0, centerTop); + + // 如果当前偏移量超出了允许范围,进行钳制 + if (Math.abs(this.dockedOffsetY) > maxOffset) { + this.dockedOffsetY = Math.max(-maxOffset, Math.min(maxOffset, this.dockedOffsetY)); + } + } + + updated(changedProperties: Map) { + if (changedProperties.has("enabled")) { + if (this.enabled) { + if (!this.isConnected) { + setTimeout(() => void this.connect(), 100); + } + } else { + this.disconnect(); + } + } + } + + static styles = css` + :host { + display: block; + height: 100%; + 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%; + height: 100%; + background: var(--bg-accent); + overflow: hidden; + position: relative; + display: flex; + align-items: center; + justify-content: center; + /* Reserve space for top toolbar */ + padding-top: 50px; + box-sizing: border-box; + } + + .screen { + width: auto; + height: auto; + max-width: 100%; + max-height: 100%; + display: flex; + align-items: center; + justify-content: center; + padding: 5.4px; + padding-top: 32.4px; + background: var(--vnc-window-bg); + border-radius: 6px; + box-shadow: + 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 { + position: absolute; + top: 10.8px; + left: 10.8px; + display: flex; + gap: 7.2px; + z-index: 25; + pointer-events: auto; + } + + .window-control { + width: 10.8px; + height: 10.8px; + border-radius: 50%; + cursor: pointer; + border: 1px solid rgba(0, 0, 0, 0.1); + transition: + transform 0.1s, + opacity 0.2s; + } + + .window-control:hover { + opacity: 0.8; + transform: scale(1.1); + } + + .window-control.maximize { + background-color: #27c93f; + border-color: #1aab29; + } + + .window-control.close { + background-color: #ff5f56; + border-color: #e0443e; + } + + .window-control.minimize { + background-color: #ffbd2e; + } + + .screen canvas { + max-width: 100% !important; + max-height: 100% !important; + width: auto !important; + height: auto !important; + outline: none; + display: block; + margin: auto !important; + border-radius: 0; + box-shadow: none; + pointer-events: auto !important; + } + + .status-overlay { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: var(--card); + padding: 16px 24px; + border-radius: 8px; + color: var(--text); + font-weight: 500; + pointer-events: none; + 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); + } + + /* Top Toolbar */ + .top-toolbar { + position: absolute; + top: 0; + left: 50%; + transform: translateX(-50%); + display: flex; + gap: 8px; + padding: 6px; + background: var(--card); + border: 1px solid var(--border); + border-radius: 999px; + z-index: 100; + pointer-events: auto; + } + + .toolbar-btn { + width: 32px; + height: 32px; + border-radius: 50%; + border: 1px solid transparent; + background: transparent; + color: var(--muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s ease; + } + + .toolbar-btn:hover { + background: var(--bg-hover); + color: var(--text); + } + + .toolbar-btn.active { + background: var(--accent); + color: white; + border-color: var(--accent); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + } + + .toolbar-btn svg { + width: 18px; + height: 18px; + stroke: currentColor; + fill: none; + stroke-width: 2px; + stroke-linecap: round; + stroke-linejoin: round; + } + + .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 displayFloating = this.isDragging ? this.tempIsFloating : this.isFloating; + const screenStyle = displayFloating + ? `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} +
+ + ${ + !displayFloating + ? html` +
+ + + +
+ ` + : null + } + +
+
+ ${ + displayFloating + ? 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 + } +
+
+
+
+
+
+
+
+ `; + } + + private handleClose = () => { + this.dispatchEvent(new CustomEvent("close", { bubbles: true, composed: true })); + }; + + private setActiveTool(tool: string) { + this.dispatchEvent( + new CustomEvent("tool-change", { detail: { tool }, 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 }; + this.tempIsFloating = this.isFloating; // 初始化临时状态 + + const screen = this.shadowRoot?.querySelector(".screen") as HTMLDivElement | null; + const dragHandle = this.shadowRoot?.querySelector(".drag-handle") as HTMLDivElement | null; + if (screen && dragHandle) { + // 拖拽过程中,暂时禁用 screen 内部所有元素的 pointer-events + // 这样即使鼠标移动到 VNC 窗口内部,也不会被 canvas 捕获 + screen.classList.add("dragging"); + + // 记录鼠标相对于拖拽句柄的位置(点 A) + const dragHandleRect = dragHandle.getBoundingClientRect(); + this.dragAnchor = { + x: e.clientX - dragHandleRect.left, + y: e.clientY - dragHandleRect.top, + }; + + 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, + }; + + // 打印尺寸信息,用于调试 + const canvas = this.shadowRoot?.querySelector(".screen canvas") as HTMLCanvasElement | null; + if (canvas) { + console.log("=== 切换到悬浮模式时的尺寸信息 ==="); + console.log("screen 宽度:", screenWidth); + console.log("screen 高度:", screenHeight); + console.log("screen 比例:", screenWidth / screenHeight); + console.log("canvas 宽度:", canvas.clientWidth); + console.log("canvas 高度:", canvas.clientHeight); + console.log("canvas 比例:", canvas.clientWidth / canvas.clientHeight); + } + } + } + + 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; + + // 获取我们自己的左边界 + const hostRect = this.getBoundingClientRect(); + + // 根据鼠标位置更新临时悬浮状态 + if (e.clientX < hostRect.left) { + // 鼠标在左侧,临时切换到悬浮模式 + if (!this.tempIsFloating) { + this.tempIsFloating = true; + // 计算新的悬浮窗口位置,保持鼠标相对于拖拽句柄的位置(点 A) + const newX = e.clientX - this.dragAnchor.x; + const newY = e.clientY - this.dragAnchor.y; + + // 更新 floatingRect + this.floatingRect = { + x: newX, + y: newY, + width: this.initialRect.width, + height: this.initialRect.height, + }; + + // 更新 initialRect,使其基于新的悬浮位置 + this.initialRect = { + ...this.initialRect, + x: newX, + y: newY, + }; + + // 更新 dragStart,使其从当前位置继续拖拽 + this.dragStart = { x: e.clientX, y: e.clientY }; + } + } else { + // 鼠标在右侧,临时切换回停靠模式 + if (this.tempIsFloating) { + this.tempIsFloating = false; + // 重置停靠模式的偏移量 + this.dockedOffsetY = this.initialRect.offsetY; + this.dockedOffsetX = 0; + // 更新 dragStart,使其从当前位置继续拖拽 + this.dragStart = { x: e.clientX, y: e.clientY }; + } + } + + if (this.tempIsFloating) { + 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; + + // 计算最高和最低不能超过多少 + // 获取容器的实际可用高度(减去 padding) + const container = this.shadowRoot?.querySelector(".screen-container"); + const style = container ? getComputedStyle(container) : null; + const paddingTop = style ? parseFloat(style.paddingTop) || 0 : 50; + const contentHeight = hostHeight - paddingTop; + + // 当 screen 居中时,初始顶部位置是 (contentHeight - screenHeight) / 2 + const centerTop = (contentHeight - screenHeight) / 2; + + // 允许的最上偏移量 (offset = -centerTop) + // 目标位置是 PADDING_TOP + const maxUpOffset = -centerTop; + + // 允许的最下偏移量 (offset = centerTop) + // 保持原来的逻辑,底部贴合容器底部 + const maxDownOffset = centerTop; + + 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 = (e: MouseEvent) => { + // 根据松手位置决定是否永久切换到悬浮模式 + const hostRect = this.getBoundingClientRect(); + const isDroppingInDockArea = e.clientX >= hostRect.left; + + if (!isDroppingInDockArea) { + // 在左侧(悬浮区域)松手 + if (!this.isFloating) { + // 原本是停靠,现在变悬浮 -> 触发 float + this.isFloating = true; + this.dispatchEvent(new CustomEvent("float", { bubbles: true, composed: true })); + } + } else { + // 在右侧(停靠区域)松手 + if (this.isFloating) { + // 原本是悬浮,现在变停靠 -> 触发 dock + this.isFloating = false; + this.dockedOffsetX = 0; + this.dispatchEvent(new CustomEvent("dock", { bubbles: true, composed: true })); + } else { + // 原本是停靠,现在还是停靠 -> 只是调整了垂直位置,或者是点击 + // 不需要触发 dock 事件,以免重置宽度 + this.isFloating = false; + this.dockedOffsetX = 0; + } + } + + 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 }; + + // 每次开始缩放时,获取最新的 canvas 比例 + const canvas = this.shadowRoot?.querySelector(".screen canvas") as HTMLCanvasElement | null; + if (canvas && canvas.clientWidth > 0 && canvas.clientHeight > 0) { + this.canvasRatio = canvas.clientWidth / canvas.clientHeight; + } + + 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; + + // 考虑到窗口有边框和标题栏,实际的 canvas 内容区域要减去这些 padding + // 左右 padding: 5.4px * 2 = 10.8px + // 上下 padding: 32.4px (标题栏) + 5.4px (底边) = 37.8px + const PADDING_X = 10.8; + const PADDING_Y = 37.8; + + let { x, y, width, height } = this.initialRect; + + // 1. 先计算出用户拖拽后的预期宽高 + if (this.resizeEdge.includes("right")) { + width += dx; + } + if (this.resizeEdge.includes("left")) { + x += dx; + width -= dx; + } + if (this.resizeEdge.includes("bottom")) { + height += dy; + } + if (this.resizeEdge.includes("top")) { + y += dy; + height -= dy; + } + + // 2. 根据 canvas 的比例强制修正窗口尺寸,确保窗口能仅仅贴合 VNC + let canvasW = width - PADDING_X; + let canvasH = height - PADDING_Y; + + if (this.resizeEdge === "top" || this.resizeEdge === "bottom") { + // 如果只拖拽上下边,以高度为基准计算宽度 + canvasW = canvasH * this.canvasRatio; + width = canvasW + PADDING_X; + if (this.resizeEdge.includes("left")) { + x = this.initialRect.x + (this.initialRect.width - width); + } + } else { + // 左右边或者对角线,统一以宽度为基准计算高度 + canvasH = canvasW / this.canvasRatio; + height = canvasH + PADDING_Y; + if (this.resizeEdge.includes("top")) { + y = this.initialRect.y + (this.initialRect.height - height); + } + } + + // 3. 最小尺寸限制 + if (width < 200) { + width = 200; + canvasW = width - PADDING_X; + canvasH = canvasW / this.canvasRatio; + height = canvasH + PADDING_Y; + 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); + } + } + // 注意:这里不再单独限制 height,因为 height 已经和 width 绑定了 + + 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}/api/debug-vnc`; + + if (this.vncTarget) { + try { + const urlObj = new URL(url); + urlObj.searchParams.set("target", this.vncTarget); + url = urlObj.toString(); + } catch { + if (url.includes("?")) { + url += `&target=${encodeURIComponent(this.vncTarget)}`; + } else { + url += `?target=${encodeURIComponent(this.vncTarget)}`; + } + } + } + + if (this.rfb) { + this.rfb.disconnect(); + } + + this.status = "正在連接..."; + let screen = this.screenRef.value; + + if (!screen) { + screen = this.shadowRoot?.querySelector(".screen") as HTMLDivElement; + } + + if (!screen) { + console.error("Screen element not found"); + this.status = "初始化失败:找不到屏幕元素"; + return; + } + + const existingCanvases = screen.querySelectorAll("canvas"); + existingCanvases.forEach((canvas) => canvas.remove()); + + try { + const Constructor = RFBClass as new ( + target: HTMLElement, + url: string, + options?: unknown, + ) => RFBInstance; + + this.rfb = new Constructor(screen, url, { + credentials: { password: this.password || undefined }, + resizeSession: true, + clipViewport: true, + }); + + 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) { + this.rfb.scaleViewport = this.isFitted; + } + + this.rfb?.addEventListener("connect", () => { + this.isConnected = true; + this.status = "已連線成功 ✓(改變視窗大小會自動適配)"; + setTimeout(() => this.rfb?.resize?.(), 100); + }); + + this.rfb?.addEventListener("disconnect", () => { + this.isConnected = false; + this.status = "連線中斷"; + this.rfb = null; + }); + } catch (error) { + console.error("Failed to create RFB instance:", error); + this.status = `连接失败: ${error as string}`; + } + }; + + private disconnect = () => { + if (this.rfb) { + this.rfb.disconnect(); + } + }; + + private setFitMode(fitted: boolean) { + this.isFitted = fitted; + if (this.rfb) { + this.rfb.scaleViewport = fitted; + this.rfb.clipViewport = true; + setTimeout(() => this.rfb?.resize?.(), 50); + } + } + + private handleResize = () => { + if (this.rfb && this.isConnected) { + setTimeout(() => this.rfb?.resize?.(), 80); + } + }; + + private toggleFullscreen = () => { + const container = this.shadowRoot?.querySelector(".screen-container"); + if (container) { + void (container as HTMLElement).requestFullscreen?.(); + } + }; + + private handleWindowBlur = () => { + this.cleanupDragListeners(); + this.cleanupResizeListeners(); + }; + + firstUpdated() { + window.addEventListener("resize", this.handleResize); + window.addEventListener("blur", this.handleWindowBlur); + this.setupResizeObserver(); + + if (this.enabled && this.vncUrl) { + setTimeout(() => { + void this.connect(); + }, 100); + } + } + + disconnectedCallback() { + super.disconnectedCallback(); + window.removeEventListener("resize", this.handleResize); + window.removeEventListener("blur", this.handleWindowBlur); + this.cleanupDragListeners(); + this.cleanupResizeListeners(); + this.cleanupResizeObserver(); + this.disconnect(); + } +} diff --git a/ui/src/ui/components/claw-image-panel.ts b/ui/src/ui/components/claw-image-panel.ts new file mode 100644 index 00000000000..0ad78bab6cd --- /dev/null +++ b/ui/src/ui/components/claw-image-panel.ts @@ -0,0 +1,905 @@ +import { LitElement, html, css } from "lit"; +import { customElement, state, property } from "lit/decorators.js"; +import { createRef, ref, Ref } from "lit/directives/ref.js"; + +@customElement("claw-image-panel") +export class ClawImagePanel extends LitElement { + // 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 screenRef: Ref = createRef(); + 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; + // 记录拖拽开始时鼠标相对于拖拽句柄的位置(点 A) + private dragAnchor = { x: 0, y: 0 }; + // 拖拽过程中的临时悬浮状态 + private tempIsFloating = false; + // canvas 的比例(用于悬浮模式缩放时保持) + private canvasRatio = 16 / 9; + private resizeObserver: ResizeObserver | null = null; + private readonly TOOLBAR_SPACE = 50; + + @property({ type: String }) activeTool = "images"; // vnc, browser, images + @property({ type: String }) imageUrl = ""; + + @property({ type: Boolean }) enabled = false; + + private setupResizeObserver() { + if (this.resizeObserver) { + return; + } + this.resizeObserver = new ResizeObserver(() => { + this.clampDockedOffset(); + }); + this.resizeObserver.observe(this); + } + + private cleanupResizeObserver() { + if (this.resizeObserver) { + this.resizeObserver.disconnect(); + this.resizeObserver = null; + } + } + + private clampDockedOffset() { + if (this.isFloating || !this.shadowRoot) { + return; + } + + const screenElement = this.shadowRoot.querySelector(".screen"); + if (!screenElement) { + return; + } + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + const screen = screenElement as HTMLElement; + + const hostHeight = this.offsetHeight; + const screenHeight = screen.offsetHeight; + + // 计算允许的最大垂直偏移量(上下边界) + // 默认居中时,screenTop = (hostHeight - screenHeight) / 2 + // maxUp = -screenTop (移动到最顶部) + // maxDown = screenTop (移动到最底部) + // 注意:这里的 hostHeight 实际上是 screen-container 的 content-box 高度(因为有 padding) + // 但 offsetHeight 返回的是包含 padding 的高度。 + // 不过,我们这里的逻辑主要是为了限制偏移量。 + + // 由于我们给容器加了 padding-top: 50px,Flex 居中是相对于剩余空间(Content Box)的。 + // 只要我们限制 screen 不超出 Content Box,就自然避开了工具栏。 + + // 获取容器的实际可用高度(减去 padding) + const style = getComputedStyle(this.shadowRoot.querySelector(".screen-container")!); + const paddingTop = parseFloat(style.paddingTop) || 0; + const contentHeight = hostHeight - paddingTop; + + // 计算在 Content Box 内的居中位置的顶部距离 + const centerTop = (contentHeight - screenHeight) / 2; + + const maxOffset = Math.max(0, centerTop); + + // 如果当前偏移量超出了允许范围,进行钳制 + if (Math.abs(this.dockedOffsetY) > maxOffset) { + this.dockedOffsetY = Math.max(-maxOffset, Math.min(maxOffset, this.dockedOffsetY)); + } + } + + updated(_changedProperties: Map) { + // No connection logic needed + } + + static styles = css` + :host { + display: block; + height: 100%; + 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%; + height: 100%; + background: var(--bg-accent); + overflow: hidden; + position: relative; + display: flex; + align-items: center; + justify-content: center; + /* Reserve space for top toolbar */ + padding-top: 50px; + box-sizing: border-box; + } + + .screen { + width: auto; + height: auto; + max-width: 100%; + max-height: 100%; + display: flex; + align-items: center; + justify-content: center; + padding: 5.4px; + padding-top: 32.4px; + background: var(--vnc-window-bg); + border-radius: 6px; + box-shadow: + 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 { + position: absolute; + top: 10.8px; + left: 10.8px; + display: flex; + gap: 7.2px; + z-index: 25; + pointer-events: auto; + } + + .window-control { + width: 10.8px; + height: 10.8px; + border-radius: 50%; + cursor: pointer; + border: 1px solid rgba(0, 0, 0, 0.1); + transition: + transform 0.1s, + opacity 0.2s; + } + + .window-control:hover { + opacity: 0.8; + transform: scale(1.1); + } + + .window-control.maximize { + background-color: #27c93f; + border-color: #1aab29; + } + + .window-control.close { + background-color: #ff5f56; + border-color: #e0443e; + } + + .window-control.minimize { + background-color: #ffbd2e; + } + + .screen canvas { + max-width: 100% !important; + max-height: 100% !important; + width: auto !important; + height: auto !important; + outline: none; + display: block; + margin: auto !important; + border-radius: 0; + box-shadow: none; + pointer-events: auto !important; + } + + .status-overlay { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: var(--card); + padding: 16px 24px; + border-radius: 8px; + color: var(--text); + font-weight: 500; + pointer-events: none; + 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); + } + + /* Top Toolbar */ + .top-toolbar { + position: absolute; + top: 0; + left: 50%; + transform: translateX(-50%); + display: flex; + gap: 8px; + padding: 6px; + background: var(--card); + border: 1px solid var(--border); + border-radius: 999px; + z-index: 100; + pointer-events: auto; + } + + .toolbar-btn { + width: 32px; + height: 32px; + border-radius: 50%; + border: 1px solid transparent; + background: transparent; + color: var(--muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s ease; + } + + .toolbar-btn:hover { + background: var(--bg-hover); + color: var(--text); + } + + .toolbar-btn.active { + background: var(--accent); + color: white; + border-color: var(--accent); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + } + + .toolbar-btn svg { + width: 18px; + height: 18px; + stroke: currentColor; + fill: none; + stroke-width: 2px; + stroke-linecap: round; + stroke-linejoin: round; + } + + .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 displayFloating = this.isDragging ? this.tempIsFloating : this.isFloating; + const screenStyle = displayFloating + ? `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` +
+
+ + ${ + !displayFloating + ? html` +
+ + + +
+ ` + : null + } + +
+ +
+ ${ + displayFloating + ? 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 + } +
+
+
+
+
+
+
+
+ `; + } + + private handleClose = () => { + this.dispatchEvent(new CustomEvent("close", { bubbles: true, composed: true })); + }; + + private setActiveTool(tool: string) { + this.dispatchEvent( + new CustomEvent("tool-change", { detail: { tool }, 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 }; + this.tempIsFloating = this.isFloating; // 初始化临时状态 + + const screen = this.shadowRoot?.querySelector(".screen") as HTMLDivElement | null; + const dragHandle = this.shadowRoot?.querySelector(".drag-handle") as HTMLDivElement | null; + if (screen && dragHandle) { + // 拖拽过程中,暂时禁用 screen 内部所有元素的 pointer-events + // 这样即使鼠标移动到 VNC 窗口内部,也不会被 canvas 捕获 + screen.classList.add("dragging"); + + // 记录鼠标相对于拖拽句柄的位置(点 A) + const dragHandleRect = dragHandle.getBoundingClientRect(); + this.dragAnchor = { + x: e.clientX - dragHandleRect.left, + y: e.clientY - dragHandleRect.top, + }; + + 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, + }; + + // 打印尺寸信息,用于调试 + const canvas = this.shadowRoot?.querySelector(".screen canvas") as HTMLCanvasElement | null; + if (canvas) { + console.log("=== 切换到悬浮模式时的尺寸信息 ==="); + console.log("screen 宽度:", screenWidth); + console.log("screen 高度:", screenHeight); + console.log("screen 比例:", screenWidth / screenHeight); + console.log("canvas 宽度:", canvas.clientWidth); + console.log("canvas 高度:", canvas.clientHeight); + console.log("canvas 比例:", canvas.clientWidth / canvas.clientHeight); + } + } + } + + 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; + + // 获取我们自己的左边界 + const hostRect = this.getBoundingClientRect(); + + // 根据鼠标位置更新临时悬浮状态 + if (e.clientX < hostRect.left) { + // 鼠标在左侧,临时切换到悬浮模式 + if (!this.tempIsFloating) { + this.tempIsFloating = true; + // 计算新的悬浮窗口位置,保持鼠标相对于拖拽句柄的位置(点 A) + const newX = e.clientX - this.dragAnchor.x; + const newY = e.clientY - this.dragAnchor.y; + + // 更新 floatingRect + this.floatingRect = { + x: newX, + y: newY, + width: this.initialRect.width, + height: this.initialRect.height, + }; + + // 更新 initialRect,使其基于新的悬浮位置 + this.initialRect = { + ...this.initialRect, + x: newX, + y: newY, + }; + + // 更新 dragStart,使其从当前位置继续拖拽 + this.dragStart = { x: e.clientX, y: e.clientY }; + } + } else { + // 鼠标在右侧,临时切换回停靠模式 + if (this.tempIsFloating) { + this.tempIsFloating = false; + // 重置停靠模式的偏移量 + this.dockedOffsetY = this.initialRect.offsetY; + this.dockedOffsetX = 0; + // 更新 dragStart,使其从当前位置继续拖拽 + this.dragStart = { x: e.clientX, y: e.clientY }; + } + } + + if (this.tempIsFloating) { + 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; + + // 计算最高和最低不能超过多少 + // 获取容器的实际可用高度(减去 padding) + const container = this.shadowRoot?.querySelector(".screen-container"); + const style = container ? getComputedStyle(container) : null; + const paddingTop = style ? parseFloat(style.paddingTop) || 0 : 50; + const contentHeight = hostHeight - paddingTop; + + // 当 screen 居中时,初始顶部位置是 (contentHeight - screenHeight) / 2 + const centerTop = (contentHeight - screenHeight) / 2; + + // 允许的最上偏移量 (offset = -centerTop) + // 目标位置是 PADDING_TOP + const maxUpOffset = -centerTop; + + // 允许的最下偏移量 (offset = centerTop) + // 保持原来的逻辑,底部贴合容器底部 + const maxDownOffset = centerTop; + + 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 = (e: MouseEvent) => { + // 根据松手位置决定是否永久切换到悬浮模式 + const hostRect = this.getBoundingClientRect(); + const isDroppingInDockArea = e.clientX >= hostRect.left; + + if (!isDroppingInDockArea) { + // 在左侧(悬浮区域)松手 + if (!this.isFloating) { + // 原本是停靠,现在变悬浮 -> 触发 float + this.isFloating = true; + this.dispatchEvent(new CustomEvent("float", { bubbles: true, composed: true })); + } + } else { + // 在右侧(停靠区域)松手 + if (this.isFloating) { + // 原本是悬浮,现在变停靠 -> 触发 dock + this.isFloating = false; + this.dockedOffsetX = 0; + this.dispatchEvent(new CustomEvent("dock", { bubbles: true, composed: true })); + } else { + // 原本是停靠,现在还是停靠 -> 只是调整了垂直位置,或者是点击 + // 不需要触发 dock 事件,以免重置宽度 + this.isFloating = false; + this.dockedOffsetX = 0; + } + } + + 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 }; + + // 每次开始缩放时,获取最新的 canvas 比例 + const canvas = this.shadowRoot?.querySelector(".screen canvas") as HTMLCanvasElement | null; + if (canvas && canvas.clientWidth > 0 && canvas.clientHeight > 0) { + this.canvasRatio = canvas.clientWidth / canvas.clientHeight; + } + + 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; + + // 考虑到窗口有边框和标题栏,实际的 canvas 内容区域要减去这些 padding + // 左右 padding: 5.4px * 2 = 10.8px + // 上下 padding: 32.4px (标题栏) + 5.4px (底边) = 37.8px + const PADDING_X = 10.8; + const PADDING_Y = 37.8; + + let { x, y, width, height } = this.initialRect; + + // 1. 先计算出用户拖拽后的预期宽高 + if (this.resizeEdge.includes("right")) { + width += dx; + } + if (this.resizeEdge.includes("left")) { + x += dx; + width -= dx; + } + if (this.resizeEdge.includes("bottom")) { + height += dy; + } + if (this.resizeEdge.includes("top")) { + y += dy; + height -= dy; + } + + // 2. 根据 canvas 的比例强制修正窗口尺寸,确保窗口能仅仅贴合 VNC + let canvasW = width - PADDING_X; + let canvasH = height - PADDING_Y; + + if (this.resizeEdge === "top" || this.resizeEdge === "bottom") { + // 如果只拖拽上下边,以高度为基准计算宽度 + canvasW = canvasH * this.canvasRatio; + width = canvasW + PADDING_X; + if (this.resizeEdge.includes("left")) { + x = this.initialRect.x + (this.initialRect.width - width); + } + } else { + // 左右边或者对角线,统一以宽度为基准计算高度 + canvasH = canvasW / this.canvasRatio; + height = canvasH + PADDING_Y; + if (this.resizeEdge.includes("top")) { + y = this.initialRect.y + (this.initialRect.height - height); + } + } + + // 3. 最小尺寸限制 + if (width < 200) { + width = 200; + canvasW = width - PADDING_X; + canvasH = canvasW / this.canvasRatio; + height = canvasH + PADDING_Y; + 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); + } + } + // 注意:这里不再单独限制 height,因为 height 已经和 width 绑定了 + + this.floatingRect = { x, y, width, height }; + }; + + private handleResizeEnd = () => { + this.cleanupResizeListeners(); + }; + + private handleResize = () => { + // No-op for image + }; + + private toggleFullscreen = () => { + const container = this.shadowRoot?.querySelector(".screen-container"); + if (container) { + void (container as HTMLElement).requestFullscreen?.(); + } + }; + + private handleWindowBlur = () => { + this.cleanupDragListeners(); + this.cleanupResizeListeners(); + }; + + firstUpdated() { + window.addEventListener("resize", this.handleResize); + window.addEventListener("blur", this.handleWindowBlur); + this.setupResizeObserver(); + } + + disconnectedCallback() { + super.disconnectedCallback(); + window.removeEventListener("resize", this.handleResize); + window.removeEventListener("blur", this.handleWindowBlur); + this.cleanupDragListeners(); + this.cleanupResizeListeners(); + this.cleanupResizeObserver(); + } +} diff --git a/ui/src/ui/components/resizable-divider.ts b/ui/src/ui/components/resizable-divider.ts index defec19e5cb..9868d437313 100644 --- a/ui/src/ui/components/resizable-divider.ts +++ b/ui/src/ui/components/resizable-divider.ts @@ -11,9 +11,16 @@ export class ResizableDivider extends LitElement { @property({ type: Number }) minRatio = 0.4; @property({ type: Number }) maxRatio = 0.7; + @property({ type: String }) mode: "ratio" | "pixels" = "ratio"; + @property({ type: String }) side: "left" | "right" = "left"; + @property({ type: Number }) initialWidth = 0; + @property({ type: Number }) minWidth = 0; + @property({ type: Number }) maxWidth = Infinity; + private isDragging = false; private startX = 0; private startRatio = 0; + private startWidth = 0; static styles = css` :host { @@ -60,8 +67,21 @@ export class ResizableDivider extends LitElement { this.isDragging = true; this.startX = e.clientX; this.startRatio = this.splitRatio; + this.startWidth = this.initialWidth; this.classList.add("dragging"); + // Add a global overlay to prevent iframe/other elements from capturing mouse events + const overlay = document.createElement("div"); + overlay.id = "resize-overlay"; + overlay.style.position = "fixed"; + overlay.style.top = "0"; + overlay.style.left = "0"; + overlay.style.width = "100%"; + overlay.style.height = "100%"; + overlay.style.zIndex = "9999"; + overlay.style.cursor = "col-resize"; + document.body.appendChild(overlay); + document.addEventListener("mousemove", this.handleMouseMove); document.addEventListener("mouseup", this.handleMouseUp); @@ -78,8 +98,29 @@ export class ResizableDivider extends LitElement { return; } - const containerWidth = container.getBoundingClientRect().width; const deltaX = e.clientX - this.startX; + + if (this.mode === "pixels") { + let newWidth = this.startWidth; + if (this.side === "left") { + newWidth += deltaX; + } else { + newWidth -= deltaX; + } + + newWidth = Math.max(this.minWidth, Math.min(this.maxWidth, newWidth)); + + this.dispatchEvent( + new CustomEvent("resize", { + detail: { width: newWidth }, + bubbles: true, + composed: true, + }), + ); + return; + } + + const containerWidth = container.getBoundingClientRect().width; const deltaRatio = deltaX / containerWidth; let newRatio = this.startRatio + deltaRatio; @@ -98,6 +139,11 @@ export class ResizableDivider extends LitElement { this.isDragging = false; this.classList.remove("dragging"); + const overlay = document.getElementById("resize-overlay"); + if (overlay) { + document.body.removeChild(overlay); + } + document.removeEventListener("mousemove", this.handleMouseMove); document.removeEventListener("mouseup", this.handleMouseUp); }; diff --git a/ui/src/ui/storage.node.test.ts b/ui/src/ui/storage.node.test.ts index 2155758fe97..11c5f1ff3fd 100644 --- a/ui/src/ui/storage.node.test.ts +++ b/ui/src/ui/storage.node.test.ts @@ -134,6 +134,8 @@ describe("loadSettings default gateway URL derivation", () => { navWidth: 220, navGroupsCollapsed: {}, borderRadius: 50, + vncTarget: "localhost:5900", + vncWsUrl: "wss://gateway.example:8443/api/debug-vnc", sessionsByGateway: { "wss://gateway.example:8443/openclaw": { sessionKey: "agent", @@ -250,6 +252,8 @@ describe("loadSettings default gateway URL derivation", () => { navWidth: 220, navGroupsCollapsed: {}, borderRadius: 50, + vncTarget: "localhost:5900", + vncWsUrl: "wss://gateway.example:8443/api/debug-vnc", }); expect(loadSettings()).toMatchObject({ gatewayUrl: gwUrl, @@ -269,6 +273,8 @@ describe("loadSettings default gateway URL derivation", () => { navWidth: 220, navGroupsCollapsed: {}, borderRadius: 50, + vncTarget: "localhost:5900", + vncWsUrl: "wss://gateway.example:8443/api/debug-vnc", sessionsByGateway: { [gwUrl]: { sessionKey: "main", diff --git a/ui/src/ui/storage.ts b/ui/src/ui/storage.ts index 20997b6fec3..77f268aa355 100644 --- a/ui/src/ui/storage.ts +++ b/ui/src/ui/storage.ts @@ -41,6 +41,9 @@ export type UiSettings = { navGroupsCollapsed: Record; // Which nav groups are collapsed borderRadius: number; // Corner roundness (0–100, default 50) locale?: string; + vncWsUrl?: string; + vncPassword?: string; + vncTarget?: string; }; function isViteDevPage(): boolean { @@ -191,6 +194,8 @@ export function loadSettings(): UiSettings { navCollapsed: false, navWidth: 220, navGroupsCollapsed: {}, + vncWsUrl: `${defaultUrl.replace(/\/$/, "")}/api/debug-vnc`, + vncTarget: "localhost:5900", borderRadius: 50, }; @@ -256,6 +261,9 @@ export function loadSettings(): UiSettings { ? parsed.borderRadius : defaults.borderRadius, locale: isSupportedLocale(parsed.locale) ? parsed.locale : undefined, + vncWsUrl: typeof parsed.vncWsUrl === "string" ? parsed.vncWsUrl : defaults.vncWsUrl, + vncPassword: typeof parsed.vncPassword === "string" ? parsed.vncPassword : undefined, + vncTarget: typeof parsed.vncTarget === "string" ? parsed.vncTarget : defaults.vncTarget, }; if ("token" in parsed) { persistSettings(settings); @@ -317,6 +325,9 @@ function persistSettings(next: UiSettings) { borderRadius: next.borderRadius, sessionsByGateway, ...(next.locale ? { locale: next.locale } : {}), + vncWsUrl: next.vncWsUrl, + vncPassword: next.vncPassword, + vncTarget: next.vncTarget, }; const serialized = JSON.stringify(persisted); try { diff --git a/ui/src/ui/types/novnc.d.ts b/ui/src/ui/types/novnc.d.ts new file mode 100644 index 00000000000..98524632afd --- /dev/null +++ b/ui/src/ui/types/novnc.d.ts @@ -0,0 +1,22 @@ +// novnc.d.ts +declare module "@novnc/novnc/lib/rfb.js" { + export default class RFB { + constructor(target: HTMLElement, url: string, options?: unknown); + addEventListener(event: string, callback: (e?: unknown) => void): void; + disconnect(): void; + scaleViewport: boolean; + clipViewport: boolean; + resize?(): void; + } +} + +declare module "@novnc/novnc" { + export default class RFB { + constructor(target: HTMLElement, url: string, options?: unknown); + addEventListener(event: string, callback: (e?: unknown) => void): void; + disconnect(): void; + scaleViewport: boolean; + clipViewport: boolean; + resize?(): void; + } +} diff --git a/ui/src/ui/views/overview.ts b/ui/src/ui/views/overview.ts index bb57874103e..62c35b32d40 100644 --- a/ui/src/ui/views/overview.ts +++ b/ui/src/ui/views/overview.ts @@ -47,7 +47,9 @@ export type OverviewProps = { overviewLogLines: string[]; showGatewayToken: boolean; showGatewayPassword: boolean; + vncConfigDirty?: boolean; onSettingsChange: (next: UiSettings) => void; + onSaveVncConfig?: () => void; onPasswordChange: (next: string) => void; onSessionKeyChange: (next: string) => void; onToggleGatewayTokenVisibility: () => void; @@ -304,24 +306,13 @@ export function renderOverview(props: OverviewProps) {
-
- - - ${ - isTrustedProxy ? t("overview.access.trustedProxy") : t("overview.access.connectHint") - } -
${ !props.connected ? html` -
- - +