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");