@omercnet/paseo-shared-browser 1.2.2-next.232.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/package.json +9 -5
- package/paseo-plugin.json +3 -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,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Focused web keyboard/IME bridge. Its invisible editable sink gives the browser
|
|
3
|
+
* a real composition target without modifying image children. Only controlled
|
|
4
|
+
* canvas focus consumes host keys; blur releases the original remote channel.
|
|
5
|
+
* Browser-reserved shortcuts are outside JavaScript's interception guarantees.
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
type CanvasKeyboardEvent,
|
|
9
|
+
type CanvasKeyEnvelope,
|
|
10
|
+
createBrowserCanvasKeyboard,
|
|
11
|
+
} from "./browser-canvas-keyboard";
|
|
12
|
+
|
|
13
|
+
interface KeyboardDomEvent extends CanvasKeyEnvelope {
|
|
14
|
+
data?: string | null;
|
|
15
|
+
inputType?: string;
|
|
16
|
+
clipboardData?: { getData(type: string): string } | null;
|
|
17
|
+
preventDefault(): void;
|
|
18
|
+
stopPropagation(): void;
|
|
19
|
+
}
|
|
20
|
+
interface KeyboardElement {
|
|
21
|
+
tabIndex: number;
|
|
22
|
+
value: string;
|
|
23
|
+
style: Record<string, string>;
|
|
24
|
+
setAttribute(name: string, value: string): void;
|
|
25
|
+
focus(options?: { preventScroll: boolean }): void;
|
|
26
|
+
remove(): void;
|
|
27
|
+
addEventListener(
|
|
28
|
+
type: string,
|
|
29
|
+
listener: (event: KeyboardDomEvent) => void,
|
|
30
|
+
options?: { capture?: boolean },
|
|
31
|
+
): void;
|
|
32
|
+
removeEventListener(
|
|
33
|
+
type: string,
|
|
34
|
+
listener: (event: KeyboardDomEvent) => void,
|
|
35
|
+
options?: { capture?: boolean },
|
|
36
|
+
): void;
|
|
37
|
+
}
|
|
38
|
+
export interface KeyboardCanvasNode extends KeyboardElement {
|
|
39
|
+
appendChild(child: KeyboardElement): void;
|
|
40
|
+
ownerDocument: {
|
|
41
|
+
activeElement: unknown;
|
|
42
|
+
hidden: boolean;
|
|
43
|
+
createElement(tag: "textarea"): KeyboardElement;
|
|
44
|
+
addEventListener(type: string, listener: (event: KeyboardDomEvent) => void): void;
|
|
45
|
+
removeEventListener(type: string, listener: (event: KeyboardDomEvent) => void): void;
|
|
46
|
+
defaultView: {
|
|
47
|
+
addEventListener(type: string, listener: () => void): void;
|
|
48
|
+
removeEventListener(type: string, listener: () => void): void;
|
|
49
|
+
} | null;
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
interface BindingOptions {
|
|
53
|
+
enabled(): boolean;
|
|
54
|
+
enqueue(event: CanvasKeyboardEvent): boolean;
|
|
55
|
+
finish(): void;
|
|
56
|
+
cancel(): void;
|
|
57
|
+
onError(error: Error): void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** DOM-only binding; callers must dispose it and reset on route/control changes. */
|
|
61
|
+
export function bindBrowserCanvasKeyboard(node: KeyboardCanvasNode, options: BindingOptions) {
|
|
62
|
+
const document = node.ownerDocument;
|
|
63
|
+
const previousTabIndex = node.tabIndex;
|
|
64
|
+
const sink = document.createElement("textarea");
|
|
65
|
+
sink.tabIndex = -1;
|
|
66
|
+
sink.setAttribute("aria-label", "Type in the shared browser");
|
|
67
|
+
sink.setAttribute("autocomplete", "off");
|
|
68
|
+
sink.setAttribute("autocapitalize", "off");
|
|
69
|
+
sink.setAttribute("spellcheck", "false");
|
|
70
|
+
Object.assign(sink.style, {
|
|
71
|
+
position: "absolute",
|
|
72
|
+
width: "1px",
|
|
73
|
+
height: "1px",
|
|
74
|
+
opacity: "0",
|
|
75
|
+
left: "0",
|
|
76
|
+
top: "0",
|
|
77
|
+
padding: "0",
|
|
78
|
+
border: "0",
|
|
79
|
+
pointerEvents: "none",
|
|
80
|
+
});
|
|
81
|
+
node.tabIndex = 0;
|
|
82
|
+
node.appendChild(sink);
|
|
83
|
+
let composing = false;
|
|
84
|
+
let compositionAdmitted = false;
|
|
85
|
+
let altTextPending: string | null = null;
|
|
86
|
+
const focused = () => document.activeElement === sink || document.activeElement === node;
|
|
87
|
+
const keyboard = createBrowserCanvasKeyboard({
|
|
88
|
+
...options,
|
|
89
|
+
enabled: () => options.enabled() && focused() && !document.hidden,
|
|
90
|
+
});
|
|
91
|
+
const consume = (event: KeyboardDomEvent) => {
|
|
92
|
+
event.preventDefault();
|
|
93
|
+
event.stopPropagation();
|
|
94
|
+
};
|
|
95
|
+
const reset = () => {
|
|
96
|
+
keyboard.reset();
|
|
97
|
+
compositionAdmitted = false;
|
|
98
|
+
composing = false;
|
|
99
|
+
altTextPending = null;
|
|
100
|
+
sink.value = "";
|
|
101
|
+
};
|
|
102
|
+
const cancel = () => {
|
|
103
|
+
reset();
|
|
104
|
+
options.cancel();
|
|
105
|
+
};
|
|
106
|
+
const focus = () => {
|
|
107
|
+
if (options.enabled() && !document.hidden) {
|
|
108
|
+
sink.focus({ preventScroll: true });
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
const down = (event: KeyboardDomEvent) => {
|
|
112
|
+
if (!focused()) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
// Local paste supplies clipboard bytes separately. Sending Ctrl/Meta+V would
|
|
116
|
+
// additionally paste an unrelated remote clipboard and duplicate the action.
|
|
117
|
+
const pasteShortcut =
|
|
118
|
+
!event.altKey &&
|
|
119
|
+
(((event.ctrlKey || event.metaKey) && event.code === "KeyV") ||
|
|
120
|
+
(event.shiftKey && !event.ctrlKey && !event.metaKey && event.code === "Insert"));
|
|
121
|
+
if (pasteShortcut) {
|
|
122
|
+
if (options.enabled()) {
|
|
123
|
+
event.stopPropagation();
|
|
124
|
+
}
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (keyboard.key("down", event)) {
|
|
128
|
+
const altPrintable =
|
|
129
|
+
event.altKey &&
|
|
130
|
+
!event.ctrlKey &&
|
|
131
|
+
!event.metaKey &&
|
|
132
|
+
!event.getModifierState?.("AltGraph") &&
|
|
133
|
+
Array.from(event.key).length === 1;
|
|
134
|
+
altTextPending = altPrintable ? event.key : null;
|
|
135
|
+
if (altTextPending) {
|
|
136
|
+
// Option layouts can produce text with Alt held. Only the browser's
|
|
137
|
+
// ensuing insertion event qualifies it; an Alt shortcut may emit none.
|
|
138
|
+
event.stopPropagation();
|
|
139
|
+
} else {
|
|
140
|
+
consume(event);
|
|
141
|
+
}
|
|
142
|
+
} else if (options.enabled() && (composing || event.isComposing)) {
|
|
143
|
+
event.stopPropagation();
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
const up = (event: KeyboardDomEvent) => {
|
|
147
|
+
// Document observes releases after focus loss without consuming Paseo input.
|
|
148
|
+
if (keyboard.key("up", event) && focused()) {
|
|
149
|
+
consume(event);
|
|
150
|
+
}
|
|
151
|
+
altTextPending = null;
|
|
152
|
+
};
|
|
153
|
+
const compositionStart = () => {
|
|
154
|
+
altTextPending = null;
|
|
155
|
+
composing = true;
|
|
156
|
+
compositionAdmitted = options.enabled() && focused() && !document.hidden;
|
|
157
|
+
};
|
|
158
|
+
const commitText = (value: string) => {
|
|
159
|
+
if (value.length > 16_000) {
|
|
160
|
+
options.onError(new Error("Text is too long. Send up to 16,000 characters at once."));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
keyboard.text(value);
|
|
164
|
+
};
|
|
165
|
+
const compositionEnd = (event: KeyboardDomEvent) => {
|
|
166
|
+
const accepted = compositionAdmitted && options.enabled() && focused();
|
|
167
|
+
composing = false;
|
|
168
|
+
compositionAdmitted = false;
|
|
169
|
+
if (accepted) {
|
|
170
|
+
commitText(event.data ?? "");
|
|
171
|
+
}
|
|
172
|
+
sink.value = "";
|
|
173
|
+
};
|
|
174
|
+
const beforeInput = (event: KeyboardDomEvent) => {
|
|
175
|
+
if (!composing && options.enabled() && focused()) {
|
|
176
|
+
consume(event);
|
|
177
|
+
if (altTextPending && event.inputType === "insertText" && event.data === altTextPending) {
|
|
178
|
+
commitText(event.data);
|
|
179
|
+
}
|
|
180
|
+
altTextPending = null;
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
const paste = (event: KeyboardDomEvent) => {
|
|
184
|
+
if (!options.enabled() || !focused()) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
consume(event);
|
|
188
|
+
altTextPending = null;
|
|
189
|
+
commitText(event.clipboardData?.getData("text/plain") ?? "");
|
|
190
|
+
sink.value = "";
|
|
191
|
+
};
|
|
192
|
+
const visibility = () => {
|
|
193
|
+
if (document.hidden) {
|
|
194
|
+
cancel();
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
const listeners: Array<[KeyboardElement, string, (event: KeyboardDomEvent) => void]> = [
|
|
198
|
+
[node, "pointerdown", focus],
|
|
199
|
+
[node, "mousedown", focus],
|
|
200
|
+
[node, "touchstart", focus],
|
|
201
|
+
[node, "focus", focus],
|
|
202
|
+
[sink, "keydown", down],
|
|
203
|
+
[node, "keydown", down],
|
|
204
|
+
[sink, "compositionstart", compositionStart],
|
|
205
|
+
[sink, "compositionend", compositionEnd],
|
|
206
|
+
[sink, "beforeinput", beforeInput],
|
|
207
|
+
[sink, "paste", paste],
|
|
208
|
+
[sink, "blur", cancel],
|
|
209
|
+
];
|
|
210
|
+
for (const [element, type, listener] of listeners) {
|
|
211
|
+
element.addEventListener(type, listener);
|
|
212
|
+
}
|
|
213
|
+
document.addEventListener("keyup", up);
|
|
214
|
+
document.addEventListener("visibilitychange", visibility);
|
|
215
|
+
document.defaultView?.addEventListener("blur", cancel);
|
|
216
|
+
return {
|
|
217
|
+
reset,
|
|
218
|
+
dispose() {
|
|
219
|
+
cancel();
|
|
220
|
+
for (const [element, type, listener] of listeners) {
|
|
221
|
+
element.removeEventListener(type, listener);
|
|
222
|
+
}
|
|
223
|
+
document.removeEventListener("keyup", up);
|
|
224
|
+
document.removeEventListener("visibilitychange", visibility);
|
|
225
|
+
document.defaultView?.removeEventListener("blur", cancel);
|
|
226
|
+
node.tabIndex = previousTabIndex;
|
|
227
|
+
sink.remove();
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
type CanvasKeyboardEvent,
|
|
4
|
+
type CanvasKeyEnvelope,
|
|
5
|
+
canvasKeyModifiers,
|
|
6
|
+
createBrowserCanvasKeyboard,
|
|
7
|
+
} from "./browser-canvas-keyboard";
|
|
8
|
+
|
|
9
|
+
const envelope = (overrides: Partial<CanvasKeyEnvelope> = {}): CanvasKeyEnvelope => ({
|
|
10
|
+
key: "a",
|
|
11
|
+
code: "KeyA",
|
|
12
|
+
altKey: false,
|
|
13
|
+
ctrlKey: false,
|
|
14
|
+
metaKey: false,
|
|
15
|
+
shiftKey: false,
|
|
16
|
+
repeat: false,
|
|
17
|
+
...overrides,
|
|
18
|
+
});
|
|
19
|
+
function fixture() {
|
|
20
|
+
const events: CanvasKeyboardEvent[] = [];
|
|
21
|
+
let enabled = true;
|
|
22
|
+
let finishes = 0;
|
|
23
|
+
const input = createBrowserCanvasKeyboard({
|
|
24
|
+
enabled: () => enabled,
|
|
25
|
+
enqueue: (event) => {
|
|
26
|
+
events.push(event);
|
|
27
|
+
return true;
|
|
28
|
+
},
|
|
29
|
+
finish: () => {
|
|
30
|
+
finishes += 1;
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
return {
|
|
34
|
+
input,
|
|
35
|
+
events,
|
|
36
|
+
disable: () => {
|
|
37
|
+
enabled = false;
|
|
38
|
+
},
|
|
39
|
+
enable: () => {
|
|
40
|
+
enabled = true;
|
|
41
|
+
},
|
|
42
|
+
finishes: () => finishes,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
describe("physical canvas keyboard", () => {
|
|
46
|
+
it("commits qualified AltGraph text once but never converts Ctrl+Alt shortcuts", () => {
|
|
47
|
+
const f = fixture();
|
|
48
|
+
const euro = envelope({
|
|
49
|
+
key: "€",
|
|
50
|
+
code: "KeyE",
|
|
51
|
+
ctrlKey: true,
|
|
52
|
+
altKey: true,
|
|
53
|
+
getModifierState: (value) => value === "AltGraph",
|
|
54
|
+
});
|
|
55
|
+
f.input.key("down", euro);
|
|
56
|
+
f.input.key("up", euro);
|
|
57
|
+
f.input.key("down", envelope({ key: "e", code: "KeyE", ctrlKey: true, altKey: true }));
|
|
58
|
+
expect(f.events.filter((event) => event.kind === "text")).toEqual([
|
|
59
|
+
{ kind: "text", text: "€" },
|
|
60
|
+
]);
|
|
61
|
+
expect(f.events.filter((event) => event.kind === "key")).toHaveLength(3);
|
|
62
|
+
expect(
|
|
63
|
+
f.events.filter((event) => event.kind === "key").every((event) => event.text === undefined),
|
|
64
|
+
).toBe(true);
|
|
65
|
+
const separate = fixture();
|
|
66
|
+
separate.input.key(
|
|
67
|
+
"down",
|
|
68
|
+
envelope({ key: "€", getModifierState: (value) => value === "AltGraph" }),
|
|
69
|
+
);
|
|
70
|
+
expect(separate.events).toEqual([
|
|
71
|
+
{ kind: "key", type: "down", key: "€", code: "KeyA", modifiers: 0, repeat: false, text: "€" },
|
|
72
|
+
]);
|
|
73
|
+
});
|
|
74
|
+
it("preserves Unicode text, physical repeat/code and all modifier bits", () => {
|
|
75
|
+
const f = fixture();
|
|
76
|
+
f.input.key("down", envelope({ key: "é" }));
|
|
77
|
+
f.input.key("down", envelope({ key: "é", repeat: true }));
|
|
78
|
+
f.input.key("up", envelope({ key: "é", repeat: true }));
|
|
79
|
+
expect(f.events).toEqual([
|
|
80
|
+
{ kind: "key", type: "down", key: "é", code: "KeyA", modifiers: 0, repeat: false, text: "é" },
|
|
81
|
+
{ kind: "key", type: "down", key: "é", code: "KeyA", modifiers: 0, repeat: true, text: "é" },
|
|
82
|
+
{ kind: "key", type: "up", key: "é", code: "KeyA", modifiers: 0, repeat: false },
|
|
83
|
+
]);
|
|
84
|
+
expect(
|
|
85
|
+
canvasKeyModifiers(envelope({ altKey: true, ctrlKey: true, metaKey: true, shiftKey: true })),
|
|
86
|
+
).toBe(15);
|
|
87
|
+
f.input.key("down", envelope({ ctrlKey: true }));
|
|
88
|
+
expect(f.events.at(-1)).not.toHaveProperty("text");
|
|
89
|
+
});
|
|
90
|
+
it("never starts from a cancelled repeat or a repeat that began observe-only", () => {
|
|
91
|
+
const f = fixture();
|
|
92
|
+
f.input.key("down", envelope());
|
|
93
|
+
f.input.reset();
|
|
94
|
+
expect(f.input.key("down", envelope({ repeat: true }))).toBe(false);
|
|
95
|
+
expect(f.input.key("up", envelope())).toBe(false);
|
|
96
|
+
f.disable();
|
|
97
|
+
f.input.key("down", envelope());
|
|
98
|
+
f.enable();
|
|
99
|
+
expect(f.input.key("down", envelope({ repeat: true }))).toBe(false);
|
|
100
|
+
expect(f.input.key("up", envelope())).toBe(false);
|
|
101
|
+
expect(f.input.key("down", envelope())).toBe(true);
|
|
102
|
+
expect(f.events).toHaveLength(2);
|
|
103
|
+
});
|
|
104
|
+
it("does not type composition intermediates; commits bounded text separately", () => {
|
|
105
|
+
const f = fixture();
|
|
106
|
+
expect(f.input.key("down", envelope({ isComposing: true }))).toBe(false);
|
|
107
|
+
expect(f.input.key("down", envelope({ key: "Dead" }))).toBe(false);
|
|
108
|
+
expect(f.input.key("down", envelope({ keyCode: 229 }))).toBe(false);
|
|
109
|
+
expect(f.input.text("日本語🙂")).toBe(true);
|
|
110
|
+
expect(f.input.text("x".repeat(16_001))).toBe(false);
|
|
111
|
+
expect(f.events).toEqual([{ kind: "text", text: "日本語🙂" }]);
|
|
112
|
+
});
|
|
113
|
+
it("keeps modifier holds through other keys and finishes only after last keyup", () => {
|
|
114
|
+
const f = fixture();
|
|
115
|
+
const ctrl = envelope({ key: "Control", code: "ControlLeft", ctrlKey: true });
|
|
116
|
+
f.input.key("down", ctrl);
|
|
117
|
+
f.input.key("down", envelope({ ctrlKey: true }));
|
|
118
|
+
f.input.key("up", envelope({ ctrlKey: true }));
|
|
119
|
+
expect(f.finishes()).toBe(0);
|
|
120
|
+
f.input.key("up", { ...ctrl, ctrlKey: false });
|
|
121
|
+
expect(f.finishes()).toBe(1);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize physical canvas keys without inventing presses after control loss.
|
|
3
|
+
* IME and clipboard commits use text insertion; ordinary typing retains real
|
|
4
|
+
* key/code/repeat/modifier events. The DOM adapter owns focus and composition.
|
|
5
|
+
*/
|
|
6
|
+
import type { BrowserGestureEvent } from "../shared/browser";
|
|
7
|
+
|
|
8
|
+
export interface CanvasKeyEnvelope {
|
|
9
|
+
key: string;
|
|
10
|
+
code: string;
|
|
11
|
+
altKey: boolean;
|
|
12
|
+
ctrlKey: boolean;
|
|
13
|
+
metaKey: boolean;
|
|
14
|
+
shiftKey: boolean;
|
|
15
|
+
repeat: boolean;
|
|
16
|
+
isComposing?: boolean;
|
|
17
|
+
keyCode?: number;
|
|
18
|
+
getModifierState?(key: string): boolean;
|
|
19
|
+
}
|
|
20
|
+
export type CanvasKeyboardEvent = Extract<BrowserGestureEvent, { kind: "key" | "text" }>;
|
|
21
|
+
interface KeyboardOptions {
|
|
22
|
+
enabled(): boolean;
|
|
23
|
+
enqueue(event: CanvasKeyboardEvent): boolean;
|
|
24
|
+
finish(): void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** CDP modifier bits preserve the actual host combination, including Shift. */
|
|
28
|
+
export function canvasKeyModifiers(event: CanvasKeyEnvelope): number {
|
|
29
|
+
return (
|
|
30
|
+
(event.altKey ? 1 : 0) |
|
|
31
|
+
(event.ctrlKey ? 2 : 0) |
|
|
32
|
+
(event.metaKey ? 4 : 0) |
|
|
33
|
+
(event.shiftKey ? 8 : 0)
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A cancelled held key's repeat/up cannot begin input on a replacement page. */
|
|
38
|
+
export function createBrowserCanvasKeyboard(options: KeyboardOptions) {
|
|
39
|
+
const admitted = new Set<string>();
|
|
40
|
+
const blocked = new Set<string>();
|
|
41
|
+
const identity = (event: CanvasKeyEnvelope) => event.code || event.key;
|
|
42
|
+
const reset = () => {
|
|
43
|
+
for (const key of admitted) blocked.add(key);
|
|
44
|
+
admitted.clear();
|
|
45
|
+
};
|
|
46
|
+
const key = (type: "down" | "up", event: CanvasKeyEnvelope): boolean => {
|
|
47
|
+
const id = identity(event);
|
|
48
|
+
if (type === "up") {
|
|
49
|
+
blocked.delete(id);
|
|
50
|
+
if (!admitted.delete(id)) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
} else {
|
|
54
|
+
if (!options.enabled()) {
|
|
55
|
+
blocked.add(id);
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
if (
|
|
59
|
+
event.isComposing ||
|
|
60
|
+
event.key === "Dead" ||
|
|
61
|
+
event.key === "Process" ||
|
|
62
|
+
event.keyCode === 229
|
|
63
|
+
) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
// A non-repeat is an authentic new physical edge after a missed blur keyup.
|
|
67
|
+
if (!event.repeat) {
|
|
68
|
+
blocked.delete(id);
|
|
69
|
+
}
|
|
70
|
+
if (blocked.has(id) || (event.repeat && !admitted.has(id))) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (
|
|
75
|
+
!options.enabled() ||
|
|
76
|
+
!event.key ||
|
|
77
|
+
event.key.length > 64 ||
|
|
78
|
+
!/^[A-Za-z][A-Za-z0-9]{0,63}$/.test(event.code)
|
|
79
|
+
) {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
const modifiers = canvasKeyModifiers(event);
|
|
83
|
+
const printable =
|
|
84
|
+
type === "down" &&
|
|
85
|
+
!event.altKey &&
|
|
86
|
+
!event.ctrlKey &&
|
|
87
|
+
!event.metaKey &&
|
|
88
|
+
Array.from(event.key).length === 1;
|
|
89
|
+
const accepted = options.enqueue({
|
|
90
|
+
kind: "key",
|
|
91
|
+
type,
|
|
92
|
+
key: event.key,
|
|
93
|
+
code: event.code,
|
|
94
|
+
modifiers,
|
|
95
|
+
repeat: type === "down" && event.repeat,
|
|
96
|
+
...(printable ? { text: event.key } : {}),
|
|
97
|
+
});
|
|
98
|
+
if (accepted && type === "down") {
|
|
99
|
+
admitted.add(id);
|
|
100
|
+
// AltGraph can report Ctrl+Alt on international layouts. The real host
|
|
101
|
+
// qualifier permits one text commit without rewriting a genuine shortcut.
|
|
102
|
+
if (
|
|
103
|
+
event.getModifierState?.("AltGraph") &&
|
|
104
|
+
(modifiers & 7) !== 0 &&
|
|
105
|
+
Array.from(event.key).length === 1
|
|
106
|
+
) {
|
|
107
|
+
options.enqueue({ kind: "text", text: event.key });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (accepted && type === "up" && !admitted.size) {
|
|
111
|
+
options.finish();
|
|
112
|
+
}
|
|
113
|
+
return accepted;
|
|
114
|
+
};
|
|
115
|
+
const text = (value: string): boolean => {
|
|
116
|
+
if (!options.enabled() || !value || value.length > 16_000) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
const accepted = options.enqueue({ kind: "text", text: value });
|
|
120
|
+
if (accepted && !admitted.size) {
|
|
121
|
+
options.finish();
|
|
122
|
+
}
|
|
123
|
+
return accepted;
|
|
124
|
+
};
|
|
125
|
+
return { key, text, reset };
|
|
126
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { containedRect } from "../shared/browser";
|
|
3
|
+
import { getBrowserCanvasLayout } from "./browser-canvas-layout";
|
|
4
|
+
|
|
5
|
+
describe("local browser canvas geometry", () => {
|
|
6
|
+
it("defaults to Fit and exactly preserves containedRect's letterboxing", () => {
|
|
7
|
+
const container = { width: 1000, height: 700 };
|
|
8
|
+
const frame = { width: 824, height: 1678 };
|
|
9
|
+
expect(getBrowserCanvasLayout(container, frame, { width: 412, height: 839 })).toEqual({
|
|
10
|
+
contentSize: container,
|
|
11
|
+
frameRect: containedRect(container, frame),
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("displays sharp Pixel capture at canonical CSS size rather than twice its size", () => {
|
|
16
|
+
expect(
|
|
17
|
+
getBrowserCanvasLayout(
|
|
18
|
+
{ width: 300, height: 600 },
|
|
19
|
+
{ width: 824, height: 1678 },
|
|
20
|
+
{ width: 412, height: 839 },
|
|
21
|
+
"actual",
|
|
22
|
+
),
|
|
23
|
+
).toEqual({
|
|
24
|
+
contentSize: { width: 412, height: 839 },
|
|
25
|
+
frameRect: { x: 0, y: 0, width: 412, height: 839 },
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("keeps Actual size unchanged at DPR1,2 or3", () => {
|
|
30
|
+
const viewport = { width: 1280, height: 800 };
|
|
31
|
+
for (const scale of [1, 2, 3]) {
|
|
32
|
+
expect(
|
|
33
|
+
getBrowserCanvasLayout(
|
|
34
|
+
{ width: 600, height: 400 },
|
|
35
|
+
{ width: viewport.width * scale, height: viewport.height * scale },
|
|
36
|
+
viewport,
|
|
37
|
+
"actual",
|
|
38
|
+
),
|
|
39
|
+
).toEqual({
|
|
40
|
+
contentSize: viewport,
|
|
41
|
+
frameRect: { x: 0, y: 0, ...viewport },
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("lets the scroll host center a smaller Actual frame without an outer-sized spacer", () => {
|
|
47
|
+
expect(
|
|
48
|
+
getBrowserCanvasLayout(
|
|
49
|
+
{ width: 1000, height: 1000 },
|
|
50
|
+
{ width: 824, height: 1678 },
|
|
51
|
+
{ width: 412, height: 839 },
|
|
52
|
+
"actual",
|
|
53
|
+
),
|
|
54
|
+
).toEqual({
|
|
55
|
+
contentSize: { width: 412, height: 839 },
|
|
56
|
+
frameRect: { x: 0, y: 0, width: 412, height: 839 },
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("uses exact frame extents on both axes so scrollbar allocation cannot create unused overflow", () => {
|
|
61
|
+
expect(
|
|
62
|
+
getBrowserCanvasLayout(
|
|
63
|
+
{ width: 1000, height: 500 },
|
|
64
|
+
{ width: 824, height: 1678 },
|
|
65
|
+
{ width: 412, height: 839 },
|
|
66
|
+
"actual",
|
|
67
|
+
),
|
|
68
|
+
).toEqual({
|
|
69
|
+
contentSize: { width: 412, height: 839 },
|
|
70
|
+
frameRect: { x: 0, y: 0, width: 412, height: 839 },
|
|
71
|
+
});
|
|
72
|
+
expect(
|
|
73
|
+
getBrowserCanvasLayout(
|
|
74
|
+
{ width: 300, height: 1000 },
|
|
75
|
+
{ width: 824, height: 1678 },
|
|
76
|
+
{ width: 412, height: 839 },
|
|
77
|
+
"actual",
|
|
78
|
+
),
|
|
79
|
+
).toEqual({
|
|
80
|
+
contentSize: { width: 412, height: 839 },
|
|
81
|
+
frameRect: { x: 0, y: 0, width: 412, height: 839 },
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("rejects invalid/unmeasured dimensions without publishing NaN overlay coordinates", () => {
|
|
86
|
+
const valid = { width: 600, height: 400 };
|
|
87
|
+
for (const invalid of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
|
|
88
|
+
for (const axis of ["width", "height"] as const) {
|
|
89
|
+
const size = { ...valid, [axis]: invalid };
|
|
90
|
+
expect(getBrowserCanvasLayout(size, valid, valid)).toBeNull();
|
|
91
|
+
expect(getBrowserCanvasLayout(valid, size, valid)).toBeNull();
|
|
92
|
+
expect(getBrowserCanvasLayout(valid, valid, size, "actual")).toBeNull();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("does not require resolved remote CSS size before the existing Fit geometry can render", () => {
|
|
98
|
+
expect(
|
|
99
|
+
getBrowserCanvasLayout(
|
|
100
|
+
{ width: 800, height: 600 },
|
|
101
|
+
{ width: 1280, height: 800 },
|
|
102
|
+
{ width: 0, height: 0 },
|
|
103
|
+
),
|
|
104
|
+
).toEqual({
|
|
105
|
+
contentSize: { width: 800, height: 600 },
|
|
106
|
+
frameRect: containedRect({ width: 800, height: 600 }, { width: 1280, height: 800 }),
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local viewer geometry only. Frame pixels determine Fit's aspect ratio; Actual
|
|
3
|
+
* size uses the remote viewport's CSS dimensions, independently of capture DPR.
|
|
4
|
+
* Neither mode changes the remote viewport or its frame/input authority.
|
|
5
|
+
*/
|
|
6
|
+
import { containedRect, type FrameRect, type FrameSize } from "../shared/browser";
|
|
7
|
+
|
|
8
|
+
export type BrowserCanvasDisplayMode = "fit" | "actual";
|
|
9
|
+
|
|
10
|
+
export interface BrowserCanvasLayout {
|
|
11
|
+
/** Frame wrapper size; Actual's scroll host centers it within the available inner viewport. */
|
|
12
|
+
contentSize: FrameSize;
|
|
13
|
+
/** Position and displayed size inside that extent, also used by the input overlay. */
|
|
14
|
+
frameRect: FrameRect;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Calculate displayed geometry without treating sharp capture pixels as CSS
|
|
19
|
+
* dimensions. Invalid/unmeasured sizes return null so no frame or input overlay
|
|
20
|
+
* receives non-finite coordinates. Fit preserves containedRect's exact geometry.
|
|
21
|
+
*/
|
|
22
|
+
export function getBrowserCanvasLayout(
|
|
23
|
+
containerSize: FrameSize,
|
|
24
|
+
framePixelSize: FrameSize,
|
|
25
|
+
viewportCssSize: FrameSize,
|
|
26
|
+
mode: BrowserCanvasDisplayMode = "fit",
|
|
27
|
+
): BrowserCanvasLayout | null {
|
|
28
|
+
if (!validSize(containerSize) || !validSize(framePixelSize)) return null;
|
|
29
|
+
|
|
30
|
+
if (mode === "fit") {
|
|
31
|
+
const frameRect = containedRect(containerSize, framePixelSize);
|
|
32
|
+
return frameRect ? { contentSize: { ...containerSize }, frameRect } : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!validSize(viewportCssSize)) return null;
|
|
36
|
+
return {
|
|
37
|
+
contentSize: { ...viewportCssSize },
|
|
38
|
+
frameRect: {
|
|
39
|
+
x: 0,
|
|
40
|
+
y: 0,
|
|
41
|
+
width: viewportCssSize.width,
|
|
42
|
+
height: viewportCssSize.height,
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function validSize(size: FrameSize): boolean {
|
|
48
|
+
return (
|
|
49
|
+
Number.isFinite(size.width) && size.width > 0 && Number.isFinite(size.height) && size.height > 0
|
|
50
|
+
);
|
|
51
|
+
}
|