@omercnet/paseo-shared-browser 1.2.1 → 1.2.2-next.237.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +312 -25
- package/REMOTE_BROWSER_RESEARCH.md +395 -0
- package/STREAMING_DESIGN.md +191 -0
- package/client/browser-auto-capture-density.test.ts +102 -0
- package/client/browser-auto-capture-density.ts +30 -0
- package/client/browser-canvas-input.test.ts +198 -0
- package/client/browser-canvas-input.ts +202 -0
- package/client/browser-canvas-keyboard-web.test.ts +113 -0
- package/client/browser-canvas-keyboard-web.ts +230 -0
- package/client/browser-canvas-keyboard.test.ts +123 -0
- package/client/browser-canvas-keyboard.ts +126 -0
- package/client/browser-canvas-layout.test.ts +109 -0
- package/client/browser-canvas-layout.ts +51 -0
- package/client/browser-canvas-viewport-web.test.ts +77 -0
- package/client/browser-canvas-viewport-web.ts +63 -0
- package/client/browser-canvas-viewport.tsx +152 -0
- package/client/browser-capture-cadence.test.ts +9 -0
- package/client/browser-capture-cadence.ts +6 -0
- package/client/browser-capture-density-controls.tsx +75 -0
- package/client/browser-chrome.tsx +234 -0
- package/client/browser-client-presence.ts +184 -0
- package/client/browser-compose-controls.test.tsx +275 -0
- package/client/browser-compose-controls.tsx +174 -0
- package/client/browser-control-button.tsx +109 -0
- package/client/browser-control-dialog.tsx +71 -0
- package/client/browser-control-request.test.ts +27 -0
- package/client/browser-control-request.ts +57 -0
- package/client/browser-control-tooltip-web.ts +10 -0
- package/client/browser-dialog-body.tsx +22 -0
- package/client/browser-emulation-mode.test.ts +75 -0
- package/client/browser-emulation-mode.ts +98 -0
- package/client/browser-fill-viewport.test.ts +45 -0
- package/client/browser-fill-viewport.ts +28 -0
- package/client/browser-frame-buffer.test.ts +157 -0
- package/client/browser-frame-buffer.ts +148 -0
- package/client/browser-frame-image.tsx +62 -0
- package/client/browser-image-load.test.ts +23 -0
- package/client/browser-image-load.ts +26 -0
- package/client/browser-input-queue.test.ts +1201 -0
- package/client/browser-input-queue.ts +505 -0
- package/client/browser-menu-placement.test.ts +90 -0
- package/client/browser-menu-placement.ts +78 -0
- package/client/browser-panel-styles.ts +384 -0
- package/client/browser-quality-controls.test.ts +26 -0
- package/client/browser-quality-controls.tsx +139 -0
- package/client/browser-resolution-picker.tsx +189 -0
- package/client/browser-tabs.tsx +117 -0
- package/client/browser-toolbar-menu-web.test.ts +185 -0
- package/client/browser-toolbar-menu-web.ts +159 -0
- package/client/browser-toolbar-menu.tsx +255 -0
- package/client/browser-toolbar-submenu.tsx +93 -0
- package/client/browser-video-bytes.test.ts +44 -0
- package/client/browser-video-decoder.test.ts +473 -0
- package/client/browser-video-decoder.ts +311 -0
- package/client/browser-video-surface.tsx +21 -0
- package/client/browser-video-visibility.test.ts +45 -0
- package/client/browser-viewer-recovery.test.ts +111 -0
- package/client/browser-viewer-recovery.ts +78 -0
- package/client/browser.tsx +1642 -1569
- package/client/favorite-star.tsx +23 -0
- package/client/frame-lifecycle.ts +62 -59
- package/client/use-browser-auto-capture-density.test.ts +104 -0
- package/client/use-browser-auto-capture-density.ts +43 -0
- package/client/use-browser-canvas-input.ts +370 -0
- package/client/use-browser-capture-density.ts +87 -0
- package/client/use-browser-emulation-mode.ts +56 -0
- package/client/use-browser-fill-viewport.test.ts +108 -0
- package/client/use-browser-fill-viewport.ts +62 -0
- package/client/use-browser-frame-buffer.ts +66 -0
- package/client/use-browser-image-capture.ts +139 -0
- package/client/use-browser-scoped-mutation.test.ts +118 -0
- package/client/use-browser-scoped-mutation.ts +59 -0
- package/client/use-browser-video.test.ts +991 -0
- package/client/use-browser-video.ts +474 -0
- package/client/use-browser-viewer-recovery.ts +26 -0
- package/client/use-resolution-favorites.ts +90 -0
- package/client/web.test.ts +125 -0
- package/client/web.ts +405 -0
- package/index.client.tsx +0 -10
- package/index.server.ts +32 -0
- package/npm-shrinkwrap.json +1834 -0
- package/package.json +13 -8
- package/paseo-plugin.json +3 -1
- package/scripts/prepare-dependencies.mjs +1 -1
- package/scripts/prepare-runtime.mjs +1 -1
- package/server/agent-browser-runtime.ts +1598 -314
- package/server/browser-cursor.test.ts +64 -0
- package/server/browser-cursor.ts +69 -0
- package/server/browser-frame-context.ts +22 -0
- package/server/browser-frame-epoch.test.ts +95 -0
- package/server/browser-frame-rejection.ts +17 -0
- package/server/browser-gesture.ts +120 -0
- package/server/browser-operation-diagnostics.test.ts +79 -0
- package/server/browser-operation-diagnostics.ts +103 -0
- package/server/browser-policy.ts +1313 -151
- package/server/browser-video-input-authority.test.ts +208 -0
- package/server/browser-video-policy.test.ts +564 -0
- package/server/browser-video-socket.test.ts +368 -0
- package/server/browser.ts +151 -16
- package/server/capture-transport-policy.test.ts +105 -0
- package/server/capture-transport-policy.ts +154 -0
- package/server/capture-transport-runtime.test.ts +213 -0
- package/server/daemon-launch-chain.test.ts +70 -0
- package/server/daemon-shutdown.test.ts +289 -0
- package/server/discrete-input-freshness.test.ts +364 -0
- package/server/discrete-input-native-lifetime.test.ts +150 -0
- package/server/display-loss-recovery.test.ts +294 -0
- package/server/emulation-restoration.test.ts +262 -0
- package/server/input-cleanup.ts +23 -0
- package/server/input-generation.test.ts +31 -0
- package/server/input-generation.ts +36 -0
- package/server/jpeg-capture-demand.test.ts +76 -0
- package/server/jpeg-capture-demand.ts +98 -0
- package/server/jpeg-frame.ts +29 -0
- package/server/keyboard-input.test.ts +61 -0
- package/server/keyboard-input.ts +104 -0
- package/server/live-input-policy.test.ts +958 -0
- package/server/live-input-runtime.test.ts +732 -0
- package/server/live-input-supervisor.test.ts +147 -0
- package/server/mcp-entry.ts +44 -2
- package/server/native-video-backpressure.test.ts +228 -0
- package/server/native-video-buffer.test.ts +141 -0
- package/server/native-video-buffer.ts +114 -0
- package/server/native-video-capture.test.ts +599 -0
- package/server/native-video-capture.ts +497 -0
- package/server/native-video-extension-source.test.ts +201 -0
- package/server/native-video-extension-source.ts +364 -0
- package/server/native-video-extension.test.ts +41 -0
- package/server/native-video-extension.ts +83 -0
- package/server/native-video-packet.ts +9 -0
- package/server/native-video-settings.test.ts +245 -0
- package/server/navigation-budget.ts +8 -0
- package/server/navigation-commit.ts +125 -0
- package/server/navigation-policy.test.ts +153 -0
- package/server/process-identity.ts +58 -0
- package/server/reattach-held-input.test.ts +143 -0
- package/server/runtime-frame-reuse.test.ts +295 -0
- package/server/runtime-jpeg-demand.test.ts +200 -0
- package/server/runtime-jpeg-shutdown.test.ts +210 -0
- package/server/runtime-jpeg-source-order.test.ts +201 -0
- package/server/runtime-launch-environment.test.ts +111 -0
- package/server/runtime-navigation.test.ts +327 -0
- package/server/runtime-owner-display.test.ts +152 -0
- package/server/runtime-owner.ts +305 -31
- package/server/runtime-page-state.test.ts +249 -0
- package/server/runtime-protocol.ts +31 -1
- package/server/runtime-target-retention.test.ts +181 -0
- package/server/runtime-video-opt-in.test.ts +82 -0
- package/server/runtime-video.test.ts +444 -0
- package/server/screencast-source-time.ts +46 -0
- package/server/stale-wheel-admission.test.ts +136 -0
- package/server/supervisor-client.ts +28 -4
- package/server/supervisor-heartbeat.test.ts +80 -0
- package/server/supervisor.ts +553 -55
- package/server/video-source-recovery.test.ts +20 -0
- package/server/video-source-recovery.ts +64 -0
- package/server/viewer-capture-lifetime.ts +51 -0
- package/server/virtual-display.test.ts +184 -0
- package/server/virtual-display.ts +236 -0
- package/shared/browser-display-preferences.ts +46 -0
- package/shared/browser-video.ts +80 -0
- package/shared/browser.ts +286 -65
- package/shared/capture-density.ts +16 -0
- package/shared/capture-settings.ts +17 -0
- package/shared/device-presets.ts +90 -0
- package/shared/resolution-menu.test.ts +120 -0
- package/shared/resolution-menu.ts +72 -0
- package/shared/video-settings.test.ts +38 -0
- package/shared/video-settings.ts +34 -0
- package/shared/viewport-limits.ts +5 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { getAutomaticCaptureDensity } from "./browser-auto-capture-density";
|
|
3
|
+
|
|
4
|
+
describe("automatic capture density", () => {
|
|
5
|
+
it("sharpens the reported phone layout without enlarging CSS dimensions", () => {
|
|
6
|
+
const viewport = { width: 411, height: 653 };
|
|
7
|
+
expect(getAutomaticCaptureDensity(viewport, viewport, 3, "fit")).toBe(2);
|
|
8
|
+
expect(getAutomaticCaptureDensity(viewport, viewport, 1, "fit")).toBe(1);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("accounts for both high DPI screens and enlarged low DPI presentations", () => {
|
|
12
|
+
const viewport = { width: 800, height: 600 };
|
|
13
|
+
expect(getAutomaticCaptureDensity(viewport, viewport, 1.5, "fit")).toBe(2);
|
|
14
|
+
expect(getAutomaticCaptureDensity(viewport, { width: 1200, height: 900 }, 1, "fit")).toBe(2);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("does not double density for fractional layout rounding alone", () => {
|
|
18
|
+
expect(
|
|
19
|
+
getAutomaticCaptureDensity(
|
|
20
|
+
{ width: 411, height: 653 },
|
|
21
|
+
{ width: 411.1, height: 653.1 },
|
|
22
|
+
1,
|
|
23
|
+
"fit",
|
|
24
|
+
),
|
|
25
|
+
).toBe(1);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("avoids unnecessary density when a large page is shrunk on a phone", () => {
|
|
29
|
+
expect(
|
|
30
|
+
getAutomaticCaptureDensity(
|
|
31
|
+
{ width: 1920, height: 1080 },
|
|
32
|
+
{ width: 411, height: 653 },
|
|
33
|
+
3,
|
|
34
|
+
"fit",
|
|
35
|
+
),
|
|
36
|
+
).toBe(1);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("uses the contained frame rather than letterboxed container height", () => {
|
|
40
|
+
expect(
|
|
41
|
+
getAutomaticCaptureDensity(
|
|
42
|
+
{ width: 1000, height: 500 },
|
|
43
|
+
{ width: 400, height: 2000 },
|
|
44
|
+
2,
|
|
45
|
+
"fit",
|
|
46
|
+
),
|
|
47
|
+
).toBe(1);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("uses CSS dimensions for Actual size even when the panel clips the page", () => {
|
|
51
|
+
expect(
|
|
52
|
+
getAutomaticCaptureDensity(
|
|
53
|
+
{ width: 1280, height: 800 },
|
|
54
|
+
{ width: 400, height: 600 },
|
|
55
|
+
2,
|
|
56
|
+
"actual",
|
|
57
|
+
),
|
|
58
|
+
).toBe(2);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("reduces density at the physical capture limit without substituting a layout size", () => {
|
|
62
|
+
expect(
|
|
63
|
+
getAutomaticCaptureDensity(
|
|
64
|
+
{ width: 1920, height: 1920 },
|
|
65
|
+
{ width: 1920, height: 1920 },
|
|
66
|
+
3,
|
|
67
|
+
"fit",
|
|
68
|
+
),
|
|
69
|
+
).toBe(2);
|
|
70
|
+
expect(
|
|
71
|
+
getAutomaticCaptureDensity(
|
|
72
|
+
{ width: 1921, height: 700 },
|
|
73
|
+
{ width: 1921, height: 700 },
|
|
74
|
+
3,
|
|
75
|
+
"fit",
|
|
76
|
+
),
|
|
77
|
+
).toBe(1);
|
|
78
|
+
expect(
|
|
79
|
+
getAutomaticCaptureDensity(
|
|
80
|
+
{ width: 3840, height: 3840 },
|
|
81
|
+
{ width: 3840, height: 3840 },
|
|
82
|
+
3,
|
|
83
|
+
"fit",
|
|
84
|
+
),
|
|
85
|
+
).toBe(1);
|
|
86
|
+
expect(
|
|
87
|
+
getAutomaticCaptureDensity(
|
|
88
|
+
{ width: 3841, height: 700 },
|
|
89
|
+
{ width: 3841, height: 700 },
|
|
90
|
+
3,
|
|
91
|
+
"fit",
|
|
92
|
+
),
|
|
93
|
+
).toBeNull();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("waits for usable geometry and screen metrics", () => {
|
|
97
|
+
const size = { width: 411, height: 653 };
|
|
98
|
+
expect(getAutomaticCaptureDensity(size, { width: 0, height: 0 }, 3, "fit")).toBeNull();
|
|
99
|
+
expect(getAutomaticCaptureDensity(size, size, Number.NaN, "fit")).toBeNull();
|
|
100
|
+
expect(getAutomaticCaptureDensity(size, size, 0, "fit")).toBeNull();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Select capture detail from actual displayed size, independently of page layout. */
|
|
2
|
+
import { type CaptureDensity, canUseCaptureDensity } from "../shared/capture-density";
|
|
3
|
+
import { type BrowserCanvasDisplayMode, getBrowserCanvasLayout } from "./browser-canvas-layout";
|
|
4
|
+
|
|
5
|
+
export type CaptureDensityMode = "auto" | CaptureDensity;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Choose the smallest supported density that covers the display's physical
|
|
9
|
+
* pixels, or the highest available density when 2x cannot fully cover them.
|
|
10
|
+
* Fit accounts for letterboxing; Actual uses CSS size even when clipped.
|
|
11
|
+
* Unmeasured geometry returns null so it cannot trigger speculative writes.
|
|
12
|
+
*/
|
|
13
|
+
export function getAutomaticCaptureDensity(
|
|
14
|
+
viewport: { width: number; height: number },
|
|
15
|
+
container: { width: number; height: number },
|
|
16
|
+
pixelRatio: number,
|
|
17
|
+
mode: BrowserCanvasDisplayMode,
|
|
18
|
+
): CaptureDensity | null {
|
|
19
|
+
if (!Number.isFinite(pixelRatio) || pixelRatio <= 0) return null;
|
|
20
|
+
const layout = getBrowserCanvasLayout(container, viewport, viewport, mode);
|
|
21
|
+
if (!layout) return null;
|
|
22
|
+
const requiredDensity =
|
|
23
|
+
Math.max(layout.frameRect.width / viewport.width, layout.frameRect.height / viewport.height) *
|
|
24
|
+
pixelRatio;
|
|
25
|
+
// Fractional panel measurements can differ from rounded CSS layout by one
|
|
26
|
+
// physical pixel. That alone should not double source resolution on a 1x screen.
|
|
27
|
+
const roundingTolerance = 1 / Math.max(viewport.width, viewport.height);
|
|
28
|
+
if (requiredDensity > 1 + roundingTolerance && canUseCaptureDensity(viewport, 2)) return 2;
|
|
29
|
+
return canUseCaptureDensity(viewport, 1) ? 1 : null;
|
|
30
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { BrowserGestureEvent } from "../shared/browser";
|
|
3
|
+
import { createBrowserCanvasInput } from "./browser-canvas-input";
|
|
4
|
+
|
|
5
|
+
const point = { x: 40, y: 20, width: 400, height: 300 };
|
|
6
|
+
function fixture() {
|
|
7
|
+
const events: BrowserGestureEvent[] = [];
|
|
8
|
+
let ended = 0;
|
|
9
|
+
const input = createBrowserCanvasInput({
|
|
10
|
+
enabled: () => true,
|
|
11
|
+
viewport: () => ({ width: 800, height: 600 }),
|
|
12
|
+
enqueue: (event) => {
|
|
13
|
+
events.push(event);
|
|
14
|
+
return true;
|
|
15
|
+
},
|
|
16
|
+
finish: () => {
|
|
17
|
+
ended += 1;
|
|
18
|
+
},
|
|
19
|
+
cancel: () => {},
|
|
20
|
+
onPoint: () => {},
|
|
21
|
+
onActivity: () => {},
|
|
22
|
+
});
|
|
23
|
+
return { input, events, ended: () => ended };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("natural canvas input", () => {
|
|
27
|
+
it("quarantines a refused touch start until all physical contacts are released", () => {
|
|
28
|
+
const enqueue = vi.fn().mockReturnValueOnce(false).mockReturnValue(true);
|
|
29
|
+
const activity = vi.fn();
|
|
30
|
+
const cancel = vi.fn();
|
|
31
|
+
const input = createBrowserCanvasInput({
|
|
32
|
+
enabled: () => true,
|
|
33
|
+
viewport: () => ({ width: 800, height: 600 }),
|
|
34
|
+
enqueue,
|
|
35
|
+
finish: vi.fn(),
|
|
36
|
+
cancel,
|
|
37
|
+
onPoint: vi.fn(),
|
|
38
|
+
onActivity: activity,
|
|
39
|
+
});
|
|
40
|
+
const first = { ...point, id: 7 };
|
|
41
|
+
const second = { ...point, id: 9 };
|
|
42
|
+
expect(input.touch("start", [first])).toBe(false);
|
|
43
|
+
expect(input.isTouchQuarantined()).toBe(true);
|
|
44
|
+
expect(activity).not.toHaveBeenCalledWith(true);
|
|
45
|
+
// A fresh decoded frame must not turn this held contact into an orphan move.
|
|
46
|
+
input.touch("move", [{ ...first, x: 60 }]);
|
|
47
|
+
input.touch("start", [first, second]);
|
|
48
|
+
input.touch("end", [second]);
|
|
49
|
+
expect(enqueue).toHaveBeenCalledTimes(1);
|
|
50
|
+
input.touch("end", []);
|
|
51
|
+
expect(input.isTouchQuarantined()).toBe(false);
|
|
52
|
+
expect(input.touch("start", [first])).toBe(true);
|
|
53
|
+
expect(enqueue).toHaveBeenLastCalledWith({ kind: "touch", type: "start", points: [first] });
|
|
54
|
+
expect(cancel).toHaveBeenCalledOnce();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("does not restore held touch state after a synchronous queue failure resets the model", () => {
|
|
58
|
+
const events: BrowserGestureEvent[] = [];
|
|
59
|
+
let reject = false;
|
|
60
|
+
const input = createBrowserCanvasInput({
|
|
61
|
+
enabled: () => true,
|
|
62
|
+
viewport: () => ({ width: 800, height: 600 }),
|
|
63
|
+
enqueue: (event) => {
|
|
64
|
+
events.push(event);
|
|
65
|
+
if (reject) {
|
|
66
|
+
input.reset();
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
return true;
|
|
70
|
+
},
|
|
71
|
+
finish: vi.fn(),
|
|
72
|
+
cancel: vi.fn(),
|
|
73
|
+
onPoint: vi.fn(),
|
|
74
|
+
onActivity: vi.fn(),
|
|
75
|
+
});
|
|
76
|
+
const first = { ...point, id: 7 };
|
|
77
|
+
input.touch("start", [first]);
|
|
78
|
+
reject = true;
|
|
79
|
+
expect(input.touch("move", [{ ...first, x: 60 }])).toBe(false);
|
|
80
|
+
expect(input.isTouchQuarantined()).toBe(true);
|
|
81
|
+
reject = false;
|
|
82
|
+
input.touch("move", [{ ...first, x: 100 }]);
|
|
83
|
+
expect(events).toHaveLength(2);
|
|
84
|
+
input.touch("end", []);
|
|
85
|
+
input.touch("start", [first]);
|
|
86
|
+
expect(events.at(-1)).toEqual({ kind: "touch", type: "start", points: [first] });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("allows a new physical touch after a refused final release", () => {
|
|
90
|
+
const enqueue = vi
|
|
91
|
+
.fn()
|
|
92
|
+
.mockReturnValueOnce(true)
|
|
93
|
+
.mockReturnValueOnce(false)
|
|
94
|
+
.mockReturnValue(true);
|
|
95
|
+
const input = createBrowserCanvasInput({
|
|
96
|
+
enabled: () => true,
|
|
97
|
+
viewport: () => ({ width: 800, height: 600 }),
|
|
98
|
+
enqueue,
|
|
99
|
+
finish: vi.fn(),
|
|
100
|
+
cancel: vi.fn(),
|
|
101
|
+
onPoint: vi.fn(),
|
|
102
|
+
onActivity: vi.fn(),
|
|
103
|
+
});
|
|
104
|
+
const contact = { ...point, id: 7 };
|
|
105
|
+
input.touch("start", [contact]);
|
|
106
|
+
expect(input.touch("end", [])).toBe(false);
|
|
107
|
+
expect(input.isTouchQuarantined()).toBe(false);
|
|
108
|
+
expect(input.touch("start", [contact])).toBe(true);
|
|
109
|
+
expect(enqueue).toHaveBeenLastCalledWith({ kind: "touch", type: "start", points: [contact] });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("keeps actual right, middle and double-click edges without a mode override", () => {
|
|
113
|
+
const f = fixture();
|
|
114
|
+
for (const [button, clickCount] of [
|
|
115
|
+
["left", 1],
|
|
116
|
+
["left", 2],
|
|
117
|
+
["right", 1],
|
|
118
|
+
["middle", 1],
|
|
119
|
+
] as const) {
|
|
120
|
+
f.input.mouseDown(point, button, clickCount);
|
|
121
|
+
f.input.mouseUp(point, button, clickCount);
|
|
122
|
+
}
|
|
123
|
+
expect(
|
|
124
|
+
f.events.map((event) =>
|
|
125
|
+
event.kind === "down" || event.kind === "up"
|
|
126
|
+
? [event.kind, event.button, event.clickCount]
|
|
127
|
+
: null,
|
|
128
|
+
),
|
|
129
|
+
).toEqual([
|
|
130
|
+
["down", "left", 1],
|
|
131
|
+
["up", "left", 1],
|
|
132
|
+
["down", "left", 2],
|
|
133
|
+
["up", "left", 2],
|
|
134
|
+
["down", "right", 1],
|
|
135
|
+
["up", "right", 1],
|
|
136
|
+
["down", "middle", 1],
|
|
137
|
+
["up", "middle", 1],
|
|
138
|
+
]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("forwards held mouse motion before release rather than synthesizing a final drag", () => {
|
|
142
|
+
const f = fixture();
|
|
143
|
+
f.input.mouseDown(point, "left", 1);
|
|
144
|
+
f.input.mouseMove({ ...point, x: 60 });
|
|
145
|
+
f.input.mouseMove({ ...point, x: 100 });
|
|
146
|
+
expect(f.ended()).toBe(0);
|
|
147
|
+
expect(f.events.map((event) => event.kind)).toEqual(["down", "move", "move"]);
|
|
148
|
+
f.input.mouseUp({ ...point, x: 100 }, "left", 1);
|
|
149
|
+
expect(f.ended()).toBe(1);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("always forwards genuine touch contacts, including held press and pinch partial release", () => {
|
|
153
|
+
const f = fixture();
|
|
154
|
+
const first = { ...point, id: 7 };
|
|
155
|
+
const second = { ...point, x: 200, id: 9 };
|
|
156
|
+
f.input.touch("start", [first]);
|
|
157
|
+
expect(f.events).toEqual([{ kind: "touch", type: "start", points: [first] }]);
|
|
158
|
+
expect(f.ended()).toBe(0);
|
|
159
|
+
f.input.touch("start", [first, second]);
|
|
160
|
+
f.input.touch("move", [
|
|
161
|
+
{ ...first, x: 20 },
|
|
162
|
+
{ ...second, x: 240 },
|
|
163
|
+
]);
|
|
164
|
+
f.input.touch("end", [second]);
|
|
165
|
+
f.input.touch("end", []);
|
|
166
|
+
expect(
|
|
167
|
+
f.events.map((event) =>
|
|
168
|
+
event.kind === "touch"
|
|
169
|
+
? [event.type, event.points.map((contact) => contact.id)]
|
|
170
|
+
: event.kind,
|
|
171
|
+
),
|
|
172
|
+
).toEqual([
|
|
173
|
+
["start", [7]],
|
|
174
|
+
["start", [7, 9]],
|
|
175
|
+
["move", [7, 9]],
|
|
176
|
+
["move", [9]],
|
|
177
|
+
["end", []],
|
|
178
|
+
]);
|
|
179
|
+
expect(f.ended()).toBe(1);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("preserves genuine pointer modifier snapshots without fabricating held keys", () => {
|
|
184
|
+
const f = fixture();
|
|
185
|
+
f.input.mouseDown(point, "left", 1, 10);
|
|
186
|
+
f.input.mouseMove(point, 2);
|
|
187
|
+
f.input.mouseUp(point, "left", 1, 0);
|
|
188
|
+
f.input.wheel(point, 0, 5000, 4);
|
|
189
|
+
expect(
|
|
190
|
+
f.events.slice(0, 3).map((event) => ("modifiers" in event ? event.modifiers : undefined)),
|
|
191
|
+
).toEqual([10, 2, 0]);
|
|
192
|
+
expect(f.events.slice(3).every((event) => event.kind === "scroll" && event.modifiers === 4)).toBe(
|
|
193
|
+
true,
|
|
194
|
+
);
|
|
195
|
+
expect(f.events.some((event) => event.kind === "key")).toBe(false);
|
|
196
|
+
f.input.mouseMove(point); // Native callers preserve existing held-key inference.
|
|
197
|
+
expect(f.events.at(-1)).not.toHaveProperty("modifiers");
|
|
198
|
+
});
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform-neutral canvas gestures. Coordinate mapping belongs to the actual
|
|
3
|
+
* displayed image rectangle, not capture pixels (including sharp mobile images).
|
|
4
|
+
* Touch is always genuine multi-touch on every remote preset. Mouse buttons and
|
|
5
|
+
* click counts come from physical events; surrounding Paseo chrome is untouched.
|
|
6
|
+
*/
|
|
7
|
+
import type { BrowserGestureEvent } from "../shared/browser";
|
|
8
|
+
import type { BrowserGesturePoint, BrowserTouchPoint } from "./browser-input-queue";
|
|
9
|
+
|
|
10
|
+
export interface CanvasInputOptions {
|
|
11
|
+
enabled(): boolean;
|
|
12
|
+
viewport(): { width: number; height: number } | null;
|
|
13
|
+
enqueue(event: BrowserGestureEvent): boolean;
|
|
14
|
+
finish(): void;
|
|
15
|
+
cancel(): void;
|
|
16
|
+
onPoint(point: BrowserGesturePoint): void;
|
|
17
|
+
onActivity(active: boolean): void;
|
|
18
|
+
}
|
|
19
|
+
const MAX_SCROLL_DELTA = 4_000;
|
|
20
|
+
|
|
21
|
+
/** Preserve wheel distance by splitting oversized deltas instead of clipping it. */
|
|
22
|
+
function scroll(
|
|
23
|
+
options: CanvasInputOptions,
|
|
24
|
+
point: BrowserGesturePoint,
|
|
25
|
+
deltaX: number,
|
|
26
|
+
deltaY: number,
|
|
27
|
+
modifiers?: number,
|
|
28
|
+
) {
|
|
29
|
+
let remainingX = deltaX;
|
|
30
|
+
let remainingY = deltaY;
|
|
31
|
+
while (Math.abs(remainingX) > 0.001 || Math.abs(remainingY) > 0.001) {
|
|
32
|
+
const x = Math.max(-MAX_SCROLL_DELTA, Math.min(MAX_SCROLL_DELTA, remainingX));
|
|
33
|
+
const y = Math.max(-MAX_SCROLL_DELTA, Math.min(MAX_SCROLL_DELTA, remainingY));
|
|
34
|
+
if (
|
|
35
|
+
!options.enqueue({
|
|
36
|
+
kind: "scroll",
|
|
37
|
+
point,
|
|
38
|
+
deltaX: x,
|
|
39
|
+
deltaY: y,
|
|
40
|
+
...(modifiers === undefined ? {} : { modifiers }),
|
|
41
|
+
})
|
|
42
|
+
)
|
|
43
|
+
return;
|
|
44
|
+
remainingX -= x;
|
|
45
|
+
remainingY -= y;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Consume normalized real pointer/touch events with stable touch identities. */
|
|
50
|
+
export function createBrowserCanvasInput(options: CanvasInputOptions) {
|
|
51
|
+
const mouseButtons = new Set<"left" | "right" | "middle">();
|
|
52
|
+
let activeTouchIds = new Set<number>();
|
|
53
|
+
let touchBlockedUntilRelease = false;
|
|
54
|
+
|
|
55
|
+
// Quarantine cancelled held contacts until actual liftoff. A move or added
|
|
56
|
+
// finger cannot invent a new press on a replacement page or controller.
|
|
57
|
+
const reset = () => {
|
|
58
|
+
if (activeTouchIds.size) touchBlockedUntilRelease = true;
|
|
59
|
+
mouseButtons.clear();
|
|
60
|
+
activeTouchIds.clear();
|
|
61
|
+
options.onActivity(false);
|
|
62
|
+
};
|
|
63
|
+
const cancel = () => {
|
|
64
|
+
reset();
|
|
65
|
+
options.cancel();
|
|
66
|
+
};
|
|
67
|
+
const mouseMove = (point: BrowserGesturePoint, modifiers?: number) => {
|
|
68
|
+
if (!options.enabled()) return false;
|
|
69
|
+
options.onPoint(point);
|
|
70
|
+
if (!mouseButtons.size) options.onActivity(false);
|
|
71
|
+
return options.enqueue({
|
|
72
|
+
kind: "move",
|
|
73
|
+
point,
|
|
74
|
+
...(modifiers === undefined ? {} : { modifiers }),
|
|
75
|
+
});
|
|
76
|
+
};
|
|
77
|
+
const mouseDown = (
|
|
78
|
+
point: BrowserGesturePoint,
|
|
79
|
+
button: "left" | "right" | "middle",
|
|
80
|
+
clickCount: 1 | 2,
|
|
81
|
+
modifiers?: number,
|
|
82
|
+
) => {
|
|
83
|
+
if (!options.enabled()) return false;
|
|
84
|
+
options.onPoint(point);
|
|
85
|
+
const accepted = options.enqueue({
|
|
86
|
+
kind: "down",
|
|
87
|
+
point,
|
|
88
|
+
button,
|
|
89
|
+
clickCount,
|
|
90
|
+
...(modifiers === undefined ? {} : { modifiers }),
|
|
91
|
+
});
|
|
92
|
+
if (accepted) {
|
|
93
|
+
mouseButtons.add(button);
|
|
94
|
+
options.onActivity(true);
|
|
95
|
+
}
|
|
96
|
+
return accepted;
|
|
97
|
+
};
|
|
98
|
+
const mouseUp = (
|
|
99
|
+
point: BrowserGesturePoint,
|
|
100
|
+
button: "left" | "right" | "middle",
|
|
101
|
+
clickCount: 1 | 2,
|
|
102
|
+
modifiers?: number,
|
|
103
|
+
) => {
|
|
104
|
+
if (!mouseButtons.has(button)) return false;
|
|
105
|
+
options.onPoint(point);
|
|
106
|
+
const accepted = options.enqueue({
|
|
107
|
+
kind: "up",
|
|
108
|
+
point,
|
|
109
|
+
button,
|
|
110
|
+
clickCount,
|
|
111
|
+
...(modifiers === undefined ? {} : { modifiers }),
|
|
112
|
+
});
|
|
113
|
+
mouseButtons.delete(button);
|
|
114
|
+
if (!mouseButtons.size) {
|
|
115
|
+
options.onActivity(false);
|
|
116
|
+
options.finish();
|
|
117
|
+
}
|
|
118
|
+
return accepted;
|
|
119
|
+
};
|
|
120
|
+
const wheel = (
|
|
121
|
+
point: BrowserGesturePoint,
|
|
122
|
+
deltaX: number,
|
|
123
|
+
deltaY: number,
|
|
124
|
+
modifiers?: number,
|
|
125
|
+
) => {
|
|
126
|
+
if (!options.enabled()) return false;
|
|
127
|
+
const viewport = options.viewport();
|
|
128
|
+
if (!viewport) return false;
|
|
129
|
+
options.onPoint(point);
|
|
130
|
+
options.onActivity(true);
|
|
131
|
+
scroll(
|
|
132
|
+
options,
|
|
133
|
+
point,
|
|
134
|
+
(deltaX * viewport.width) / point.width,
|
|
135
|
+
(deltaY * viewport.height) / point.height,
|
|
136
|
+
modifiers,
|
|
137
|
+
);
|
|
138
|
+
return true;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const touch = (phase: "start" | "move" | "end" | "cancel", points: BrowserTouchPoint[]) => {
|
|
142
|
+
if (phase === "cancel") {
|
|
143
|
+
cancel();
|
|
144
|
+
// Native touchCancel terminates the physical contact session itself.
|
|
145
|
+
touchBlockedUntilRelease = false;
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
if (touchBlockedUntilRelease) {
|
|
149
|
+
if (points.length === 0) touchBlockedUntilRelease = false;
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
if (!activeTouchIds.size && phase !== "start") return true;
|
|
153
|
+
if (!options.enabled()) {
|
|
154
|
+
cancel();
|
|
155
|
+
touchBlockedUntilRelease = points.length > 0;
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
if (points.length > 5 || new Set(points.map((point) => point.id)).size !== points.length) {
|
|
159
|
+
cancel();
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
const hasNewPoint = points.some((point) => !activeTouchIds.has(point.id));
|
|
163
|
+
if (hasNewPoint && phase !== "start") {
|
|
164
|
+
cancel();
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
const type = points.length === 0 ? "end" : hasNewPoint ? "start" : "move";
|
|
168
|
+
if (points.length === 0 && activeTouchIds.size === 0) return false;
|
|
169
|
+
const accepted = options.enqueue({ kind: "touch", type, points });
|
|
170
|
+
if (!accepted) {
|
|
171
|
+
// An unavailable decoded frame or synchronous queue failure did not admit
|
|
172
|
+
// these contacts. Never let a later move invent their missing start, or
|
|
173
|
+
// overwrite a reset performed by the queue's error callback.
|
|
174
|
+
cancel();
|
|
175
|
+
touchBlockedUntilRelease = points.length > 0;
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
activeTouchIds = new Set(points.map((point) => point.id));
|
|
179
|
+
options.onActivity(points.length > 0);
|
|
180
|
+
if (points.length === 0) options.finish();
|
|
181
|
+
if (points[0]) options.onPoint(points[0]);
|
|
182
|
+
return accepted;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
mouseMove,
|
|
187
|
+
mouseDown,
|
|
188
|
+
mouseUp,
|
|
189
|
+
wheel,
|
|
190
|
+
touch,
|
|
191
|
+
cancel,
|
|
192
|
+
reset,
|
|
193
|
+
isHoldingMouse: () => mouseButtons.size > 0,
|
|
194
|
+
isTouchQuarantined: () => touchBlockedUntilRelease,
|
|
195
|
+
leave: () => {
|
|
196
|
+
if (!mouseButtons.size && !activeTouchIds.size) {
|
|
197
|
+
options.enqueue({ kind: "leave" });
|
|
198
|
+
options.finish();
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { CanvasKeyboardEvent } from "./browser-canvas-keyboard";
|
|
3
|
+
import { bindBrowserCanvasKeyboard, type KeyboardCanvasNode } from "./browser-canvas-keyboard-web";
|
|
4
|
+
|
|
5
|
+
type Listener = (event: never) => void;
|
|
6
|
+
|
|
7
|
+
/** Minimal DOM: only listener registration and focus, enough to drive the real adapter. */
|
|
8
|
+
function fakeDom() {
|
|
9
|
+
const registry = new Map<string, Set<Listener>>();
|
|
10
|
+
const target = (name: string) => ({
|
|
11
|
+
add(type: string, listener: Listener) {
|
|
12
|
+
const key = `${name}:${type}`;
|
|
13
|
+
registry.set(key, (registry.get(key) ?? new Set()).add(listener));
|
|
14
|
+
},
|
|
15
|
+
remove(type: string, listener: Listener) {
|
|
16
|
+
registry.get(`${name}:${type}`)?.delete(listener);
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
const nodeTarget = target("node");
|
|
20
|
+
const sinkTarget = target("sink");
|
|
21
|
+
const docTarget = target("doc");
|
|
22
|
+
const winTarget = target("win");
|
|
23
|
+
const document = {
|
|
24
|
+
activeElement: null as unknown,
|
|
25
|
+
hidden: false,
|
|
26
|
+
createElement: () => sink,
|
|
27
|
+
addEventListener: docTarget.add,
|
|
28
|
+
removeEventListener: docTarget.remove,
|
|
29
|
+
defaultView: { addEventListener: winTarget.add, removeEventListener: winTarget.remove },
|
|
30
|
+
};
|
|
31
|
+
const sink = {
|
|
32
|
+
tabIndex: 0,
|
|
33
|
+
value: "",
|
|
34
|
+
style: {},
|
|
35
|
+
setAttribute() {},
|
|
36
|
+
focus() {
|
|
37
|
+
document.activeElement = sink;
|
|
38
|
+
},
|
|
39
|
+
remove() {},
|
|
40
|
+
addEventListener: sinkTarget.add,
|
|
41
|
+
removeEventListener: sinkTarget.remove,
|
|
42
|
+
};
|
|
43
|
+
const node = {
|
|
44
|
+
...sink,
|
|
45
|
+
appendChild() {},
|
|
46
|
+
ownerDocument: document,
|
|
47
|
+
addEventListener: nodeTarget.add,
|
|
48
|
+
removeEventListener: nodeTarget.remove,
|
|
49
|
+
} as unknown as KeyboardCanvasNode;
|
|
50
|
+
sink.focus();
|
|
51
|
+
const fire = (name: string, type: string, event: object = {}) => {
|
|
52
|
+
for (const listener of registry.get(`${name}:${type}`) ?? [])
|
|
53
|
+
(listener as (event: object) => void)({
|
|
54
|
+
preventDefault() {},
|
|
55
|
+
stopPropagation() {},
|
|
56
|
+
...event,
|
|
57
|
+
});
|
|
58
|
+
};
|
|
59
|
+
return { node, document, fire };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const key = {
|
|
63
|
+
key: "a",
|
|
64
|
+
code: "KeyA",
|
|
65
|
+
altKey: false,
|
|
66
|
+
ctrlKey: false,
|
|
67
|
+
metaKey: false,
|
|
68
|
+
shiftKey: false,
|
|
69
|
+
repeat: false,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
function bind() {
|
|
73
|
+
const dom = fakeDom();
|
|
74
|
+
const events: CanvasKeyboardEvent[] = [];
|
|
75
|
+
let cancels = 0;
|
|
76
|
+
const binding = bindBrowserCanvasKeyboard(dom.node, {
|
|
77
|
+
enabled: () => true,
|
|
78
|
+
enqueue: (event) => {
|
|
79
|
+
events.push(event);
|
|
80
|
+
return true;
|
|
81
|
+
},
|
|
82
|
+
finish: () => {},
|
|
83
|
+
cancel: () => {
|
|
84
|
+
cancels += 1;
|
|
85
|
+
},
|
|
86
|
+
onError: () => {},
|
|
87
|
+
});
|
|
88
|
+
return { ...dom, events, binding, cancels: () => cancels };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
describe("web canvas keyboard adapter cancellation", () => {
|
|
92
|
+
it.each([
|
|
93
|
+
["sink blur", (dom: ReturnType<typeof bind>) => dom.fire("sink", "blur")],
|
|
94
|
+
["window blur", (dom: ReturnType<typeof bind>) => dom.fire("win", "blur")],
|
|
95
|
+
[
|
|
96
|
+
"page hidden",
|
|
97
|
+
(dom: ReturnType<typeof bind>) => {
|
|
98
|
+
dom.document.hidden = true;
|
|
99
|
+
dom.fire("doc", "visibilitychange");
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
])("%s cancels the held key and drops its late release", (_name, lose) => {
|
|
103
|
+
const dom = bind();
|
|
104
|
+
dom.fire("sink", "keydown", key);
|
|
105
|
+
expect(dom.events.map((event) => event.kind)).toEqual(["key"]);
|
|
106
|
+
lose(dom);
|
|
107
|
+
expect(dom.cancels()).toBe(1);
|
|
108
|
+
dom.document.hidden = false;
|
|
109
|
+
dom.fire("doc", "keyup", key);
|
|
110
|
+
dom.fire("sink", "keydown", { ...key, repeat: true });
|
|
111
|
+
expect(dom.events).toHaveLength(1);
|
|
112
|
+
});
|
|
113
|
+
});
|