diff --git a/apps/web/src/browser/ServerBrowserSurface.browser.test.tsx b/apps/web/src/browser/ServerBrowserSurface.browser.test.tsx index 39f151674b648..47cfb623dc7f9 100644 --- a/apps/web/src/browser/ServerBrowserSurface.browser.test.tsx +++ b/apps/web/src/browser/ServerBrowserSurface.browser.test.tsx @@ -1,102 +1,201 @@ -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { EnvironmentId } from "@t3tools/contracts"; import type { + PreviewStreamControl, PreviewStreamEvents, PreviewStreamInput, } from "@t3tools/client-runtime/preview/server-browser-stream"; - -import "~/index.css"; import { describe, expect, it, vi } from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; -import { render } from "../../test/render"; +import { act, render } from "../../test/render"; const mocks = vi.hoisted(() => ({ - send: vi.fn<(input: PreviewStreamInput) => void>(), - connect: vi.fn(), - events: null as PreviewStreamEvents | null, + createClient: vi.fn<(target: unknown, handlers: PreviewStreamEvents) => unknown>(), + sent: [] as PreviewStreamInput[], + access: { url: "ws://preview.test", token: "test" }, painted: null as (() => void) | null, })); vi.mock(import("@t3tools/client-runtime/preview/server-browser-stream"), async (original) => ({ ...(await original()), + createPreviewStreamClient: mocks.createClient as never, createPreviewFramePainter: (_canvas, onPainted) => { mocks.painted = onPainted ?? null; return { paint: () => undefined, stop: () => undefined }; }, - createPreviewStreamClient: (_options, events) => { - mocks.events = events; - mocks.connect(); - return { send: mocks.send, stop: () => undefined } as never; - }, })); vi.mock(import("~/state/previewStream"), async (original) => ({ ...(await original()), - usePreviewStreamAccess: () => ({}) as never, + usePreviewStreamAccess: () => mocks.access as never, })); import { ServerBrowserSurface } from "./ServerBrowserSurface"; -async function renderSurface() { - mocks.send.mockReset(); - mocks.connect.mockReset(); +function setup() { + mocks.sent.length = 0; + mocks.painted = null; + mocks.createClient.mockReset().mockImplementation(() => ({ + send: (input: PreviewStreamInput) => mocks.sent.push(input), + stop: () => undefined, + })); +} + +async function renderSurface(controller: PreviewStreamControl["controller"]) { const container = document.createElement("div"); - container.style.cssText = "width: 400px; height: 300px;"; + container.style.cssText = "width: 400px; height: 300px; display: flex;"; await using stack = new AsyncDisposableStack(); - stack.use( + const screen = stack.use( await render( , { container }, ), ); - const canvas = container.querySelector("canvas"); - if (!canvas) throw new Error("The surface did not render its canvas."); - // The stream connects once the surface has measured its size. - await vi.waitFor(() => expect(mocks.connect).toHaveBeenCalled()); - return Object.assign(stack.move(), { canvas }); + await vi.waitFor(() => expect(mocks.createClient).toHaveBeenCalled()); + const handlers = mocks.createClient.mock.calls.at(-1)![1]; + await act(() => + handlers.onControl?.({ canOperate: true, controller, generation: 1, dialog: null }), + ); + mocks.sent.length = 0; + const input = container.querySelector("textarea")!; + const canvas = container.querySelector("canvas")!; + stack.move(); + return { screen, input, canvas }; } +const keys = () => mocks.sent.filter((input) => input.type === "key"); + +describe("ServerBrowserSurface clipboard commands", () => { + it("sends a native Copy or Cut menu command to the page once", async () => { + setup(); + const { screen, input, canvas } = await renderSurface("you"); + await using _screen = screen; + // Chromium runs a script Copy or Cut only during a user gesture, as a menu click is. + await userEvent.click(canvas); + input.focus(); + mocks.sent.length = 0; + + // Like Electron's webContents.copy(): the browser's Copy command, with no key. + expect(document.execCommand("copy")).toBe(true); + expect(keys()).toEqual([ + { type: "key", action: "down", key: "c", code: "KeyC", keyCode: 67, modifiers: 2 }, + { type: "key", action: "up", key: "c", code: "KeyC", keyCode: 67, modifiers: 2 }, + ]); + + mocks.sent.length = 0; + expect(document.execCommand("cut")).toBe(true); + expect(input.value).toBe("\u200b"); + expect(keys()).toEqual([ + { type: "key", action: "down", key: "x", code: "KeyX", keyCode: 88, modifiers: 2 }, + { type: "key", action: "up", key: "x", code: "KeyX", keyCode: 88, modifiers: 2 }, + ]); + }); + + it("sends a keyboard Copy or Cut once and leaves menu Copy working after any shortcut", async () => { + setup(); + const { screen, input, canvas } = await renderSurface("you"); + await using _screen = screen; + await userEvent.click(canvas); + input.focus(); + const prevented: boolean[] = []; + const onKeyDown = (event: KeyboardEvent) => { + if (/^[cx]$/i.test(event.key)) prevented.push(event.defaultPrevented); + }; + window.addEventListener("keydown", onKeyDown); + using _listener = { [Symbol.dispose]: () => window.removeEventListener("keydown", onKeyDown) }; + + // An unprevented copy or cut key also makes the browser fire copy or cut: here Chromium's + // Ctrl key binding, on macOS Electron's menu after the Meta keyup. + for (const [shortcut, cancels] of [ + ["{Control>}x{/Control}", true], + ["{Control>}c{/Control}", true], + ["{Meta>}{x>}{/Meta}{/x}", true], + ["{Control>}{Shift>}c{/Shift}{/Control}", false], + ["{Control>}{Alt>}x{/Alt}{/Control}", false], + ] as const) { + mocks.sent.length = 0; + prevented.length = 0; + await userEvent.keyboard(shortcut); + expect(prevented, shortcut).toEqual([cancels]); + expect(keys().filter((key) => /^[cx]$/i.test(key.key)).length, shortcut).toBe(2); + expect(input.value).toBe("\u200b"); + + mocks.sent.length = 0; + expect(document.execCommand("copy")).toBe(true); + expect(keys(), shortcut).toEqual([ + { type: "key", action: "down", key: "c", code: "KeyC", keyCode: 67, modifiers: 2 }, + { type: "key", action: "up", key: "c", code: "KeyC", keyCode: 67, modifiers: 2 }, + ]); + } + }); + + it("sends nothing when another viewer controls the page", async () => { + setup(); + const { screen, input } = await renderSurface("another-viewer"); + await using _screen = screen; + + expect( + input.dispatchEvent(new ClipboardEvent("copy", { bubbles: true, cancelable: true })), + ).toBe(false); + expect( + input.dispatchEvent(new ClipboardEvent("cut", { bubbles: true, cancelable: true })), + ).toBe(false); + expect(mocks.sent).toEqual([]); + }); +}); + const mouseUp = (canvas: HTMLCanvasElement, button: number) => { const event = new MouseEvent("mouseup", { button, bubbles: true, cancelable: true }); canvas.dispatchEvent(event); return event; }; -describe("ServerBrowserSurface", () => { +describe("ServerBrowserSurface history", () => { it("routes mouse thumb buttons to the preview's history instead of the app", async () => { - await using surface = await renderSurface(); - const { canvas } = surface; + setup(); + const { screen, input } = await renderSurface("you"); + await using _screen = screen; + const canvas = input.closest("[data-server-browser-surface]")!.querySelector("canvas")!; expect(mouseUp(canvas, 3).defaultPrevented).toBe(true); expect(mouseUp(canvas, 4).defaultPrevented).toBe(true); - expect(mocks.send.mock.calls).toEqual([ - [{ type: "history", delta: -1 }], - [{ type: "history", delta: 1 }], + expect(mocks.sent).toEqual([ + { type: "history", delta: -1 }, + { type: "history", delta: 1 }, ]); }); it("leaves other mouse buttons alone", async () => { - await using surface = await renderSurface(); - const { canvas } = surface; + setup(); + const { screen, input } = await renderSurface("you"); + await using _screen = screen; + const canvas = input.closest("[data-server-browser-surface]")!.querySelector("canvas")!; expect(mouseUp(canvas, 0).defaultPrevented).toBe(false); - expect(mocks.send).not.toHaveBeenCalled(); + expect(mocks.sent).toEqual([]); }); it("does not forward thumb-button presses to the page", async () => { - await using surface = await renderSurface(); - const { canvas } = surface; + setup(); + const { screen, input } = await renderSurface("you"); + await using _screen = screen; + const canvas = input.closest("[data-server-browser-surface]")!.querySelector("canvas")!; canvas.width = 400; canvas.height = 300; // Synthetic pointers have no active pointer id to capture. canvas.setPointerCapture = () => undefined; - mocks.painted?.(); - mocks.events?.onViewport({ width: 400, height: 300 }); - mocks.events?.onControl?.({ canOperate: true, controller: "you", generation: 1, dialog: null }); + const handlers = mocks.createClient.mock.calls.at(-1)![1]; + await act(() => { + mocks.painted?.(); + handlers.onViewport({ width: 400, height: 300 }); + }); const { left, top } = canvas.getBoundingClientRect(); const press = (type: "pointerdown" | "pointerup", button: number) => canvas.dispatchEvent( @@ -109,7 +208,7 @@ describe("ServerBrowserSurface", () => { bubbles: true, }), ); - const mouseInputs = () => mocks.send.mock.calls.filter(([input]) => input.type === "mouse"); + const mouseInputs = () => mocks.sent.filter((input) => input.type === "mouse"); for (const button of [3, 4]) { press("pointerdown", button); diff --git a/apps/web/src/browser/ServerBrowserSurface.tsx b/apps/web/src/browser/ServerBrowserSurface.tsx index 3b14dcb5f3c78..e32e8f76f1a9d 100644 --- a/apps/web/src/browser/ServerBrowserSurface.tsx +++ b/apps/web/src/browser/ServerBrowserSurface.tsx @@ -20,6 +20,7 @@ import type { PreviewViewportSetting, } from "@t3tools/contracts"; import { + type ClipboardEvent, type FormEvent, type KeyboardEvent, type MouseEvent, @@ -57,6 +58,9 @@ const PROBE_REUSE_MS = 10_000; const INPUT_SENTINEL = "\u200b"; const BACKSPACE = { key: "Backspace", code: "Backspace", keyCode: 8 } as const; const DELETE = { key: "Delete", code: "Delete", keyCode: 46 } as const; +const COPY = { key: "c", code: "KeyC", keyCode: 67 } as const; +const CUT = { key: "x", code: "KeyX", keyCode: 88 } as const; +const CTRL = 2; const MAX_UNAUTHORIZED_REFUSALS = 3; const TAP_SLOP_PX = 8; const MULTI_CLICK_MS = 500; @@ -649,9 +653,20 @@ export function ServerBrowserSurface(props: { textarea.setSelectionRange(INPUT_SENTINEL.length, INPUT_SENTINEL.length); }; - const sendKeyPress = (key: typeof BACKSPACE | typeof DELETE) => { - send({ type: "key", action: "down", ...key, modifiers: 0 }); - send({ type: "key", action: "up", ...key, modifiers: 0 }); + const sendKeyPress = ( + key: typeof BACKSPACE | typeof DELETE | typeof COPY | typeof CUT, + modifiers = 0, + ) => { + send({ type: "key", action: "down", ...key, modifiers }); + send({ type: "key", action: "up", ...key, modifiers }); + }; + + // Edit menu Copy and Cut fire no key, so send the shortcut the server copies on. + const handleClipboard = (key: typeof COPY | typeof CUT, event: ClipboardEvent) => { + // Keep the sentinel off the clipboard. + event.preventDefault(); + if (controlRef.current?.controller !== "you") return; + sendKeyPress(key, CTRL); }; const handleKey = (action: "down" | "up", event: KeyboardEvent) => { @@ -696,6 +711,15 @@ export function ServerBrowserSurface(props: { action === "down" && !shortcut && (text !== undefined || event.key === "Backspace" || event.key === "Delete"); + // The key copies or cuts in the page, so stop the browser's own copy or cut repeating it. + if ( + event.ctrlKey !== event.metaKey && + !event.altKey && + !event.shiftKey && + ["c", "x"].includes(event.key.toLowerCase()) + ) { + event.preventDefault(); + } if (shortcut) return; event.preventDefault(); event.stopPropagation(); @@ -790,9 +814,8 @@ export function ServerBrowserSurface(props: { if (text) send({ type: "text", text }); resetInput(event.currentTarget); }} - // Keep the sentinel off the clipboard. - onCopy={(event) => event.preventDefault()} - onCut={(event) => event.preventDefault()} + onCopy={(event) => handleClipboard(COPY, event)} + onCut={(event) => handleClipboard(CUT, event)} onPaste={(event) => { event.preventDefault(); const text = event.clipboardData.getData("text/plain");