diff --git a/canvas/src/components/Toolbar.tsx b/canvas/src/components/Toolbar.tsx index 63f4b6680..c71b7cfcf 100644 --- a/canvas/src/components/Toolbar.tsx +++ b/canvas/src/components/Toolbar.tsx @@ -224,12 +224,14 @@ export function Toolbar() { useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.key !== "?") return; - const tag = (e.target as HTMLElement).tagName; + const target = e.target as HTMLElement; + if (target.closest?.('[data-display-stream="true"]')) return; + const tag = target.tagName; const inInput = tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || - (e.target as HTMLElement).isContentEditable; + target.isContentEditable; if (inInput) return; // Don't fire when a modal/dialog is already mounted (canvas modals, // side panel, etc. use z-50 or above). diff --git a/canvas/src/components/canvas/__tests__/useKeyboardShortcuts.test.tsx b/canvas/src/components/canvas/__tests__/useKeyboardShortcuts.test.tsx index 9606180f5..7a94c7ce5 100644 --- a/canvas/src/components/canvas/__tests__/useKeyboardShortcuts.test.tsx +++ b/canvas/src/components/canvas/__tests__/useKeyboardShortcuts.test.tsx @@ -68,7 +68,11 @@ afterEach(() => { function ShortcutTestComponent() { useKeyboardShortcuts(); - return
; + return ( +
+
+
+ ); } function renderWithProvider() { @@ -78,6 +82,13 @@ function renderWithProvider() { // ─── Tests ─────────────────────────────────────────────────────────────────── describe("Esc — deselect / close context menu", () => { + it("does not handle keys targeted at the display stream", () => { + mockStoreState.contextMenu = { x: 100, y: 100, nodeId: "n1" }; + const { getByTestId } = renderWithProvider(); + fireEvent.keyDown(getByTestId("display-stream"), { key: "Escape" }); + expect(mockStoreState.closeContextMenu).not.toHaveBeenCalled(); + }); + it("closes the context menu when one is open", () => { mockStoreState.contextMenu = { x: 100, y: 100, nodeId: "n1" }; renderWithProvider(); diff --git a/canvas/src/components/canvas/useKeyboardShortcuts.ts b/canvas/src/components/canvas/useKeyboardShortcuts.ts index 2612f51c8..26077ace4 100644 --- a/canvas/src/components/canvas/useKeyboardShortcuts.ts +++ b/canvas/src/components/canvas/useKeyboardShortcuts.ts @@ -28,12 +28,14 @@ function hasChildren(nodeId: string, nodes: Node[]): boolean export function useKeyboardShortcuts() { useEffect(() => { const handler = (e: KeyboardEvent) => { - const tag = (e.target as HTMLElement).tagName; + const target = e.target as HTMLElement; + if (target.closest?.('[data-display-stream="true"]')) return; + const tag = target.tagName; const inInput = tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || - (e.target as HTMLElement).isContentEditable; + target.isContentEditable; if (e.key === "Escape") { const state = useCanvasStore.getState(); diff --git a/canvas/src/components/tabs/DisplayTab.tsx b/canvas/src/components/tabs/DisplayTab.tsx index 540f2e358..94f50730c 100644 --- a/canvas/src/components/tabs/DisplayTab.tsx +++ b/canvas/src/components/tabs/DisplayTab.tsx @@ -313,11 +313,21 @@ function DisplayControlBar({ function DesktopStream({ sessionUrl }: { sessionUrl: string }) { const containerRef = useRef(null); + const rfbRef = useRef(null); const [streamError, setStreamError] = useState(null); + const [clipboardStatus, setClipboardStatus] = useState(null); + const [remoteClipboardText, setRemoteClipboardText] = useState(""); useEffect(() => { let cancelled = false; let rfb: RFB | null = null; + let clipboardTimer: ReturnType | null = null; + + const setTemporaryClipboardStatus = (message: string) => { + setClipboardStatus(message); + if (clipboardTimer) clearTimeout(clipboardTimer); + clipboardTimer = setTimeout(() => setClipboardStatus(null), 2500); + }; async function connect() { setStreamError(null); @@ -328,9 +338,19 @@ function DesktopStream({ sessionUrl }: { sessionUrl: string }) { rfb = new mod.default(containerRef.current, stream.url, { wsProtocols: ["binary", `molecule-display-token.${stream.token}`], }); + rfbRef.current = rfb; rfb.scaleViewport = true; rfb.resizeSession = true; rfb.focusOnClick = true; + rfb.focus({ preventScroll: true }); + rfb.addEventListener("clipboard", (event: Event) => { + const text = (event as CustomEvent<{ text?: string }>).detail?.text ?? ""; + if (!text) return; + setRemoteClipboardText(text); + void navigator.clipboard?.writeText(text) + .then(() => setTemporaryClipboardStatus("Copied remote clipboard")) + .catch(() => setTemporaryClipboardStatus("Remote clipboard ready")); + }); rfb.addEventListener("disconnect", (event: Event) => { const detail = (event as CustomEvent<{ clean?: boolean }>).detail; if (!cancelled && !detail?.clean) setStreamError("Desktop stream disconnected."); @@ -343,13 +363,83 @@ function DesktopStream({ sessionUrl }: { sessionUrl: string }) { connect(); return () => { cancelled = true; + if (clipboardTimer) clearTimeout(clipboardTimer); + rfbRef.current = null; rfb?.disconnect(); }; }, [sessionUrl]); + useEffect(() => { + const onPaste = (event: ClipboardEvent) => { + if (!isDisplayEventTarget(containerRef.current, event.target)) return; + const text = event.clipboardData?.getData("text/plain") ?? ""; + if (!text) return; + event.preventDefault(); + rfbRef.current?.clipboardPasteFrom(text); + rfbRef.current?.focus({ preventScroll: true }); + setClipboardStatus("Pasted to desktop"); + }; + window.addEventListener("paste", onPaste, true); + return () => window.removeEventListener("paste", onPaste, true); + }, []); + + const pasteLocalClipboard = async () => { + try { + const text = await navigator.clipboard?.readText(); + if (!text) { + setClipboardStatus("Clipboard is empty"); + return; + } + rfbRef.current?.clipboardPasteFrom(text); + rfbRef.current?.focus({ preventScroll: true }); + setClipboardStatus("Pasted to desktop"); + } catch { + setClipboardStatus("Press Ctrl/Cmd+V while the desktop is focused"); + } + }; + + const copyRemoteClipboard = async () => { + if (!remoteClipboardText) { + setClipboardStatus("No remote clipboard yet"); + return; + } + try { + await navigator.clipboard.writeText(remoteClipboardText); + setClipboardStatus("Copied remote clipboard"); + } catch { + setClipboardStatus("Browser blocked clipboard copy"); + } + }; + return ( -
+
rfbRef.current?.focus({ preventScroll: true })} + >
+
+ {clipboardStatus && ( + + {clipboardStatus} + + )} + + +
{streamError && (
{streamError} @@ -359,6 +449,13 @@ function DesktopStream({ sessionUrl }: { sessionUrl: string }) { ); } +function isDisplayEventTarget(container: HTMLElement | null, target: EventTarget | null): boolean { + if (!container) return false; + if (target instanceof Node && container.contains(target)) return true; + const active = document.activeElement; + return active instanceof Node && container.contains(active); +} + function displayWebSocketConnection(sessionUrl: string): { url: string; token: string } { const url = new URL(sessionUrl, window.location.href); const token = new URLSearchParams(url.hash.replace(/^#/, "")).get("token") ?? ""; diff --git a/canvas/src/components/tabs/__tests__/DisplayTab.test.tsx b/canvas/src/components/tabs/__tests__/DisplayTab.test.tsx index a5735c9cc..afd84361c 100644 --- a/canvas/src/components/tabs/__tests__/DisplayTab.test.tsx +++ b/canvas/src/components/tabs/__tests__/DisplayTab.test.tsx @@ -2,10 +2,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; -const { mockGet, mockPost, mockRFBConstructor } = vi.hoisted(() => ({ +const { mockGet, mockPost, mockRFBConstructor, mockRFBClipboardPasteFrom, mockRFBFocus } = vi.hoisted(() => ({ mockGet: vi.fn(), mockPost: vi.fn(), mockRFBConstructor: vi.fn(), + mockRFBClipboardPasteFrom: vi.fn(), + mockRFBFocus: vi.fn(), })); vi.mock("@/lib/api", () => ({ @@ -30,6 +32,12 @@ vi.mock("@novnc/novnc", () => ({ this.options = options; mockRFBConstructor(target, url, options); } + clipboardPasteFrom(text: string) { + mockRFBClipboardPasteFrom(text); + } + focus(options?: FocusOptions) { + mockRFBFocus(options); + } disconnect() {} }, })); @@ -42,6 +50,8 @@ describe("DisplayTab", () => { mockGet.mockReset(); mockPost.mockReset(); mockRFBConstructor.mockReset(); + mockRFBClipboardPasteFrom.mockReset(); + mockRFBFocus.mockReset(); }); it("renders unavailable state for non-display workspaces", async () => { @@ -157,6 +167,43 @@ describe("DisplayTab", () => { expect(mockRFBConstructor.mock.calls[0][1]).not.toContain("token="); }); + it("forwards browser paste events into the noVNC clipboard", async () => { + mockGet + .mockResolvedValueOnce({ + available: true, + mode: "desktop-control", + protocol: "novnc", + width: 1920, + height: 1080, + }) + .mockResolvedValueOnce({ + controller: "none", + }); + mockPost.mockResolvedValueOnce({ + controller: "user", + controlled_by: "admin-token", + expires_at: "2026-05-23T08:48:27Z", + session_url: "/workspaces/ws-display/display/session/websockify#token=signed", + }); + + render(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Take control" })).toBeTruthy(); + }); + fireEvent.click(screen.getByRole("button", { name: "Take control" })); + + const desktop = await screen.findByTitle("Workspace desktop"); + fireEvent.paste(desktop, { + clipboardData: { + getData: (type: string) => (type === "text/plain" ? "Paste Me" : ""), + }, + }); + + expect(mockRFBClipboardPasteFrom).toHaveBeenCalledWith("Paste Me"); + expect(mockRFBFocus).toHaveBeenCalledWith({ preventScroll: true }); + }); + it("releases user display control", async () => { mockGet .mockResolvedValueOnce({ diff --git a/canvas/src/hooks/use-keyboard-shortcut.ts b/canvas/src/hooks/use-keyboard-shortcut.ts index 298f94b82..af70e8362 100644 --- a/canvas/src/hooks/use-keyboard-shortcut.ts +++ b/canvas/src/hooks/use-keyboard-shortcut.ts @@ -15,6 +15,8 @@ export function useKeyboardShortcut( if (!enabled) return; function handler(e: KeyboardEvent) { + const target = e.target as HTMLElement; + if (target.closest?.('[data-display-stream="true"]')) return; if (e.key !== key) return; if (meta && !e.metaKey) return; if (ctrl && !e.ctrlKey) return; diff --git a/canvas/src/types/novnc.d.ts b/canvas/src/types/novnc.d.ts index 3175ec772..1742332c6 100644 --- a/canvas/src/types/novnc.d.ts +++ b/canvas/src/types/novnc.d.ts @@ -4,6 +4,8 @@ declare module "@novnc/novnc" { resizeSession: boolean; focusOnClick: boolean; constructor(target: HTMLElement, url: string, options?: { wsProtocols?: string[]; [key: string]: unknown }); + clipboardPasteFrom(text: string): void; disconnect(): void; + focus(options?: FocusOptions): void; } }