@driftengine/native-host 4.0.0
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/LICENSE +202 -0
- package/NOTICE +29 -0
- package/README.md +58 -0
- package/dist/activation.d.ts +26 -0
- package/dist/activation.js +38 -0
- package/dist/alphaCopy.d.ts +36 -0
- package/dist/alphaCopy.js +178 -0
- package/dist/audio.d.ts +55 -0
- package/dist/audio.js +193 -0
- package/dist/audioParams.d.ts +25 -0
- package/dist/audioParams.js +128 -0
- package/dist/bridge.d.ts +70 -0
- package/dist/bridge.js +170 -0
- package/dist/canvas.d.ts +97 -0
- package/dist/canvas.js +197 -0
- package/dist/canvasStyle.d.ts +23 -0
- package/dist/canvasStyle.js +79 -0
- package/dist/device.d.ts +34 -0
- package/dist/device.js +144 -0
- package/dist/dialogs.d.ts +23 -0
- package/dist/dialogs.js +85 -0
- package/dist/domEvents.d.ts +37 -0
- package/dist/domEvents.js +145 -0
- package/dist/domTree.d.ts +34 -0
- package/dist/domTree.js +194 -0
- package/dist/dualRumble.d.ts +19 -0
- package/dist/dualRumble.js +47 -0
- package/dist/eventClasses.d.ts +12 -0
- package/dist/eventClasses.js +35 -0
- package/dist/files.d.ts +16 -0
- package/dist/files.js +59 -0
- package/dist/fullscreen.d.ts +33 -0
- package/dist/fullscreen.js +65 -0
- package/dist/game.d.ts +25 -0
- package/dist/game.js +38 -0
- package/dist/gamepads.d.ts +65 -0
- package/dist/gamepads.js +178 -0
- package/dist/globals.d.ts +24 -0
- package/dist/globals.js +37 -0
- package/dist/gpu.d.ts +26 -0
- package/dist/gpu.js +47 -0
- package/dist/images.d.ts +50 -0
- package/dist/images.js +111 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +21 -0
- package/dist/keyCodes.d.ts +30 -0
- package/dist/keyCodes.js +205 -0
- package/dist/keyEvents.d.ts +54 -0
- package/dist/keyEvents.js +117 -0
- package/dist/mediaQueries.d.ts +62 -0
- package/dist/mediaQueries.js +152 -0
- package/dist/mouseEvents.d.ts +96 -0
- package/dist/mouseEvents.js +287 -0
- package/dist/page.d.ts +118 -0
- package/dist/page.js +302 -0
- package/dist/pointerCapture.d.ts +32 -0
- package/dist/pointerCapture.js +69 -0
- package/dist/present.d.ts +27 -0
- package/dist/present.js +83 -0
- package/dist/rawPad.d.ts +44 -0
- package/dist/rawPad.js +65 -0
- package/dist/readback.d.ts +18 -0
- package/dist/readback.js +46 -0
- package/dist/runtime.d.ts +69 -0
- package/dist/runtime.js +103 -0
- package/dist/standardPad.d.ts +49 -0
- package/dist/standardPad.js +80 -0
- package/dist/textHost.d.ts +37 -0
- package/dist/textHost.js +43 -0
- package/dist/threads.d.ts +27 -0
- package/dist/threads.js +59 -0
- package/dist/tileFiles.d.ts +16 -0
- package/dist/tileFiles.js +36 -0
- package/dist/touchEvents.d.ts +67 -0
- package/dist/touchEvents.js +210 -0
- package/dist/uiEvents.d.ts +157 -0
- package/dist/uiEvents.js +206 -0
- package/dist/uiTouch.d.ts +59 -0
- package/dist/uiTouch.js +75 -0
- package/dist/window.d.ts +71 -0
- package/dist/window.js +164 -0
- package/dist/windowMode.d.ts +19 -0
- package/dist/windowMode.js +15 -0
- package/dist/windowTitle.d.ts +18 -0
- package/dist/windowTitle.js +33 -0
- package/dist/workerScope.mjs +48 -0
- package/licenses/GPL-3.0.txt +674 -0
- package/package.json +83 -0
- package/src/activation.ts +42 -0
- package/src/alphaCopy.ts +201 -0
- package/src/audio.ts +246 -0
- package/src/audioParams.ts +150 -0
- package/src/bridge.ts +230 -0
- package/src/canvas.ts +227 -0
- package/src/canvasStyle.ts +89 -0
- package/src/device.ts +167 -0
- package/src/dialogs.ts +98 -0
- package/src/domEvents.ts +170 -0
- package/src/domTree.ts +215 -0
- package/src/drs.d.ts +9 -0
- package/src/dualRumble.ts +58 -0
- package/src/eventClasses.ts +46 -0
- package/src/files.ts +61 -0
- package/src/fixtures/grey.jpg +0 -0
- package/src/fixtures/rgb.png +0 -0
- package/src/fixtures/tone.opus +0 -0
- package/src/fullscreen.ts +71 -0
- package/src/game.ts +53 -0
- package/src/gamepads.ts +195 -0
- package/src/globals.ts +39 -0
- package/src/gpu.ts +59 -0
- package/src/images.ts +148 -0
- package/src/index.ts +24 -0
- package/src/keyCodes.ts +203 -0
- package/src/keyEvents.ts +135 -0
- package/src/mediaQueries.ts +173 -0
- package/src/mouseEvents.ts +328 -0
- package/src/page.ts +337 -0
- package/src/pointerCapture.ts +73 -0
- package/src/present.ts +92 -0
- package/src/rawPad.ts +85 -0
- package/src/readback.ts +61 -0
- package/src/runtime.ts +162 -0
- package/src/standardPad.ts +126 -0
- package/src/textHost.ts +66 -0
- package/src/threads.ts +66 -0
- package/src/tileFiles.ts +39 -0
- package/src/touchEvents.ts +238 -0
- package/src/uiEvents.ts +268 -0
- package/src/uiTouch.ts +97 -0
- package/src/window.ts +192 -0
- package/src/windowMode.ts +24 -0
- package/src/windowTitle.ts +36 -0
- package/src/workerScope.mjs +48 -0
package/src/threads.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Real threads, as a page's `Worker`, with none of a page's conditions on shared memory.
|
|
3
|
+
*
|
|
4
|
+
* **A browser gives a page `SharedArrayBuffer` only when it is cross-origin isolated**, served with
|
|
5
|
+
* `Cross-Origin-Opener-Policy` and `Cross-Origin-Embedder-Policy` headers: a condition on every
|
|
6
|
+
* consumer's deployment, which is why the engine's pools are switches rather than defaults. Node
|
|
7
|
+
* has shared memory unconditionally, so on this host they run on real threads with nothing asked.
|
|
8
|
+
*
|
|
9
|
+
* **The engine is not told.** Its pools build their workers the way a page does, `new Worker(url)`,
|
|
10
|
+
* and hear from them through `onmessage` and `error` events; this is that `Worker` over
|
|
11
|
+
* `node:worker_threads`, each thread starting in `workerScope.mjs`, which gives the module a
|
|
12
|
+
* browser worker's scope. The core count is Node's own `navigator.hardwareConcurrency`, which is
|
|
13
|
+
* what the physics pool bounds itself by.
|
|
14
|
+
*
|
|
15
|
+
* What it gives up: a classic, non-module worker script, which a Node thread cannot load; the
|
|
16
|
+
* `name` and `credentials` options, which mean nothing without a page.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { Worker as NodeWorker } from 'node:worker_threads';
|
|
20
|
+
|
|
21
|
+
const SCOPE = new URL('./workerScope.mjs', import.meta.url);
|
|
22
|
+
|
|
23
|
+
export class HostWorker extends EventTarget {
|
|
24
|
+
onmessage: ((event: MessageEvent) => void) | null = null;
|
|
25
|
+
onerror: ((event: ErrorEvent) => void) | null = null;
|
|
26
|
+
private readonly thread: NodeWorker;
|
|
27
|
+
|
|
28
|
+
constructor(url: string | URL, _options?: WorkerOptions) {
|
|
29
|
+
super();
|
|
30
|
+
this.thread = new NodeWorker(SCOPE, { workerData: { url: String(url) } });
|
|
31
|
+
this.thread.on('message', (data: unknown) => {
|
|
32
|
+
const event = Object.assign(new Event('message'), { data }) as MessageEvent;
|
|
33
|
+
this.onmessage?.(event);
|
|
34
|
+
this.dispatchEvent(event);
|
|
35
|
+
});
|
|
36
|
+
this.thread.on('error', (error: Error) => {
|
|
37
|
+
const event = Object.assign(new Event('error'), {
|
|
38
|
+
message: error.message,
|
|
39
|
+
error,
|
|
40
|
+
}) as ErrorEvent;
|
|
41
|
+
this.onerror?.(event);
|
|
42
|
+
this.dispatchEvent(event);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
postMessage(value: unknown, transfer?: readonly Transferable[]): void {
|
|
47
|
+
this.thread.postMessage(value, transfer as never);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
terminate(): void {
|
|
51
|
+
void this.thread.terminate();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Answer `new Worker(url)` with a real thread, and hand back what takes it away again. */
|
|
56
|
+
export function installThreads(
|
|
57
|
+
scope: Record<string, unknown> = globalThis as Record<string, unknown>,
|
|
58
|
+
): () => void {
|
|
59
|
+
const had = 'Worker' in scope;
|
|
60
|
+
const before = scope['Worker'];
|
|
61
|
+
scope['Worker'] = HostWorker;
|
|
62
|
+
return () => {
|
|
63
|
+
if (had) scope['Worker'] = before;
|
|
64
|
+
else delete scope['Worker'];
|
|
65
|
+
};
|
|
66
|
+
}
|
package/src/tileFiles.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Texture tiles from a directory on disk: the texture streamer's `TileSource`, with no network.
|
|
3
|
+
*
|
|
4
|
+
* **A tile is a file named by its hash**, the 64-bit FNV-1a `hashTile` gives as sixteen hex
|
|
5
|
+
* characters, which is what the streamer asks for. A name that is anything else is answered
|
|
6
|
+
* `null` before the filesystem is touched, so a hash from a stale or hostile index cannot climb out
|
|
7
|
+
* of the directory.
|
|
8
|
+
*
|
|
9
|
+
* **A file that is not there is `null`, which the streamer remembers as missing** and stops asking
|
|
10
|
+
* for. One that is there and cannot be read is a deployment fault rather than an absence, so it is
|
|
11
|
+
* said, once a tile, and then counted as missing too: the streamer treats a refusal as an absence
|
|
12
|
+
* either way, and a world that never finishes loading with nothing said is what it exists to
|
|
13
|
+
* prevent.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { readFile } from 'node:fs/promises';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
|
|
19
|
+
import type { TileSource } from '@driftengine/texture';
|
|
20
|
+
|
|
21
|
+
const TILE_NAME = /^[0-9a-f]{16}$/;
|
|
22
|
+
|
|
23
|
+
export function tileFiles(directory: string): TileSource {
|
|
24
|
+
const said = new Set<string>();
|
|
25
|
+
return {
|
|
26
|
+
async fetch(hash: string): Promise<Uint8Array | null> {
|
|
27
|
+
if (!TILE_NAME.test(hash)) return null;
|
|
28
|
+
try {
|
|
29
|
+
return new Uint8Array(await readFile(join(directory, hash)));
|
|
30
|
+
} catch (cause) {
|
|
31
|
+
if ((cause as NodeJS.ErrnoException).code !== 'ENOENT' && !said.has(hash)) {
|
|
32
|
+
said.add(hash);
|
|
33
|
+
console.warn(`[driftengine] tile ${hash} is on disk and could not be read:`, cause);
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fingers, as a page hears them from Chrome: pointer events of type `touch`, touch events, and the
|
|
3
|
+
* mouse events and click Chrome makes of a tap.
|
|
4
|
+
*
|
|
5
|
+
* **What Chrome sends, measured 2026-09-19 in Chrome 151 over the DevTools protocol** on a page
|
|
6
|
+
* whose canvas filled it, is what this sends:
|
|
7
|
+
* - A finger down is `pointerover`, `pointerenter` at the document and the canvas, `pointerdown`,
|
|
8
|
+
* then `touchstart`. Fingers are pointers 2, 3 and on, never reused, the first down primary, and
|
|
9
|
+
* a touch's `identifier` is the lowest one free.
|
|
10
|
+
* - A finger is captured by the canvas when it lands, so `gotpointercapture` comes before its next
|
|
11
|
+
* event and `lostpointercapture` right after its `pointerup`, then `pointerout`, `pointerleave`
|
|
12
|
+
* at the canvas and the document, and `touchend`.
|
|
13
|
+
* - A `touchmove` within 15 px of where the finger landed is not sent, unless the `touchstart` was
|
|
14
|
+
* prevented; 13 px was held back and 17 px sent. A pointer move is always sent.
|
|
15
|
+
* - A tap — one finger, no further than 15 px, neither its `touchstart` nor its `touchend`
|
|
16
|
+
* prevented — is then a mouse: `mouseover` and `mouseenter` the first time, `mousemove`,
|
|
17
|
+
* `mousedown`, `mouseup`, and a `click` that is the finger's own pointer event. A second tap
|
|
18
|
+
* landing within 400 ms of the first's lift and 20 px of it counts two, with a `dblclick`: 340 ms
|
|
19
|
+
* counted and 380 did not, 20 px counted and 30 did not.
|
|
20
|
+
*
|
|
21
|
+
* **SDL makes a mouse of every finger**, and a finger of the mouse where asked; both are dropped,
|
|
22
|
+
* each already said by the other.
|
|
23
|
+
*
|
|
24
|
+
* What it gives up: SDL reports no contact size, so a finger is a pointer one pixel across; no
|
|
25
|
+
* `touchcancel`, which SDL never reports; and a `touchend` after a move is cancelable here, which
|
|
26
|
+
* Chrome's was not.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { NativeCanvas } from './canvas.ts';
|
|
30
|
+
import type { MouseWindow } from './mouseEvents.ts';
|
|
31
|
+
import type { HostPage } from './page.ts';
|
|
32
|
+
import { type ModifierInit, MouseEvent, type PointerInit, PointerEvent } from './uiEvents.ts';
|
|
33
|
+
import { Touch, TouchEvent } from './uiTouch.ts';
|
|
34
|
+
|
|
35
|
+
export interface SdlFinger {
|
|
36
|
+
readonly fingerId: number;
|
|
37
|
+
/** Across the window, from 0 to 1. */
|
|
38
|
+
readonly x: number;
|
|
39
|
+
readonly y: number;
|
|
40
|
+
readonly pressure: number;
|
|
41
|
+
/** A finger SDL made of the mouse. */
|
|
42
|
+
readonly mouse?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface Finger {
|
|
46
|
+
readonly identifier: number;
|
|
47
|
+
readonly pointerId: number;
|
|
48
|
+
readonly primary: boolean;
|
|
49
|
+
readonly landedAt: number;
|
|
50
|
+
readonly startX: number;
|
|
51
|
+
readonly startY: number;
|
|
52
|
+
x: number;
|
|
53
|
+
y: number;
|
|
54
|
+
pressure: number;
|
|
55
|
+
/** Past the slop: no longer a tap, and its moves are sent. */
|
|
56
|
+
travelled: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const SLOP_PX = 15;
|
|
60
|
+
const DOUBLE_TAP_MS = 400;
|
|
61
|
+
const DOUBLE_TAP_PX = 20;
|
|
62
|
+
|
|
63
|
+
export class TouchModel {
|
|
64
|
+
private readonly fingers = new Map<number, Finger>();
|
|
65
|
+
private nextPointer = 2;
|
|
66
|
+
/** Whether this run of touches may still be a tap: one finger, nothing prevented. */
|
|
67
|
+
private tappable = true;
|
|
68
|
+
private lastTap = { liftedAt: Number.NEGATIVE_INFINITY, x: 0, y: 0, count: 0 };
|
|
69
|
+
/** Whether the mouse Chrome makes of taps is over the canvas yet. */
|
|
70
|
+
private mouseOver = false;
|
|
71
|
+
|
|
72
|
+
constructor(
|
|
73
|
+
private readonly window: MouseWindow,
|
|
74
|
+
private readonly page: HostPage,
|
|
75
|
+
private readonly canvas: NativeCanvas,
|
|
76
|
+
private readonly modifiers: () => ModifierInit,
|
|
77
|
+
private readonly now: () => number,
|
|
78
|
+
) {}
|
|
79
|
+
|
|
80
|
+
down(event: SdlFinger): void {
|
|
81
|
+
if (event.mouse === true || this.fingers.has(event.fingerId)) return;
|
|
82
|
+
this.page.noteTouch();
|
|
83
|
+
const taken = new Set([...this.fingers.values()].map((f) => f.identifier));
|
|
84
|
+
let identifier = 0;
|
|
85
|
+
while (taken.has(identifier)) identifier += 1;
|
|
86
|
+
const primary = this.fingers.size === 0;
|
|
87
|
+
/* A second finger down makes the run a gesture of two, which is no tap. */
|
|
88
|
+
this.tappable = primary;
|
|
89
|
+
const x = event.x * this.window.width;
|
|
90
|
+
const y = event.y * this.window.height;
|
|
91
|
+
const finger: Finger = {
|
|
92
|
+
identifier,
|
|
93
|
+
pointerId: this.nextPointer,
|
|
94
|
+
primary,
|
|
95
|
+
landedAt: this.now(),
|
|
96
|
+
startX: x,
|
|
97
|
+
startY: y,
|
|
98
|
+
x,
|
|
99
|
+
y,
|
|
100
|
+
pressure: event.pressure,
|
|
101
|
+
travelled: false,
|
|
102
|
+
};
|
|
103
|
+
this.nextPointer += 1;
|
|
104
|
+
this.fingers.set(event.fingerId, finger);
|
|
105
|
+
const { capture, document } = this.page;
|
|
106
|
+
capture.add(finger.pointerId, () => this.fingers.get(event.fingerId) === finger);
|
|
107
|
+
const init = this.init(finger, 0, 1);
|
|
108
|
+
this.pointer('pointerover', init);
|
|
109
|
+
this.boundary('pointerenter', init, [document, this.canvas]);
|
|
110
|
+
this.pointer('pointerdown', init);
|
|
111
|
+
/* Held by the canvas it landed on, as a browser holds a finger, unless a listener chose. */
|
|
112
|
+
if (!capture.has(this.canvas, finger.pointerId)) capture.set(this.canvas, finger.pointerId);
|
|
113
|
+
if (!this.touch('touchstart', finger)) this.tappable = false;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
move(event: SdlFinger): void {
|
|
117
|
+
const finger = this.fingers.get(event.fingerId);
|
|
118
|
+
if (event.mouse === true || finger === undefined) return;
|
|
119
|
+
finger.x = event.x * this.window.width;
|
|
120
|
+
finger.y = event.y * this.window.height;
|
|
121
|
+
finger.pressure = event.pressure;
|
|
122
|
+
if (Math.hypot(finger.x - finger.startX, finger.y - finger.startY) > SLOP_PX) {
|
|
123
|
+
finger.travelled = true;
|
|
124
|
+
}
|
|
125
|
+
this.pointer('pointermove', this.init(finger, -1, 1));
|
|
126
|
+
/* A move still inside the slop is held back, unless the page took the touch at its start. */
|
|
127
|
+
if (finger.travelled || !this.tappable) this.touch('touchmove', finger);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
up(event: SdlFinger): void {
|
|
131
|
+
const finger = this.fingers.get(event.fingerId);
|
|
132
|
+
if (event.mouse === true || finger === undefined) return;
|
|
133
|
+
finger.x = event.x * this.window.width;
|
|
134
|
+
finger.y = event.y * this.window.height;
|
|
135
|
+
/* A finger lifted is a person's gesture, noted before its events so their listeners may use it. */
|
|
136
|
+
this.page.userActivation.notify();
|
|
137
|
+
/* The pointer's pressure is gone; the touch keeps the force it last had, as Chrome's did. */
|
|
138
|
+
const init: PointerInit = { ...this.init(finger, 0, 0), pressure: 0 };
|
|
139
|
+
this.pointer('pointerup', init);
|
|
140
|
+
this.page.capture.releaseImplicitly(finger.pointerId, init);
|
|
141
|
+
this.pointer('pointerout', init);
|
|
142
|
+
this.boundary('pointerleave', init, [this.canvas, this.page.document]);
|
|
143
|
+
this.fingers.delete(event.fingerId);
|
|
144
|
+
this.page.capture.remove(finger.pointerId);
|
|
145
|
+
const ended = this.touch('touchend', finger);
|
|
146
|
+
if (ended && this.tappable && !finger.travelled && this.fingers.size === 0) this.tap(finger);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The mouse Chrome makes of a tap, and its click. */
|
|
150
|
+
private tap(finger: Finger): void {
|
|
151
|
+
const last = this.lastTap;
|
|
152
|
+
const again =
|
|
153
|
+
finger.landedAt - last.liftedAt <= DOUBLE_TAP_MS &&
|
|
154
|
+
Math.hypot(finger.startX - last.x, finger.startY - last.y) <= DOUBLE_TAP_PX;
|
|
155
|
+
const count = again ? last.count + 1 : 1;
|
|
156
|
+
this.lastTap = { liftedAt: this.now(), x: finger.startX, y: finger.startY, count };
|
|
157
|
+
const at = { ...this.init(finger, -1, 0), clientX: finger.startX, clientY: finger.startY };
|
|
158
|
+
if (!this.mouseOver) {
|
|
159
|
+
this.mouseOver = true;
|
|
160
|
+
this.mouse('mouseover', at);
|
|
161
|
+
this.boundary('mouseenter', at, [this.page.document, this.canvas]);
|
|
162
|
+
}
|
|
163
|
+
this.mouse('mousemove', at);
|
|
164
|
+
const down = this.mouse('mousedown', { ...at, button: 0, buttons: 1, detail: count });
|
|
165
|
+
if (!down.defaultPrevented) this.canvas.focus();
|
|
166
|
+
this.mouse('mouseup', { ...at, button: 0, buttons: 0, detail: count });
|
|
167
|
+
this.canvas.dispatchEvent(
|
|
168
|
+
new PointerEvent('click', {
|
|
169
|
+
...at,
|
|
170
|
+
button: 0,
|
|
171
|
+
buttons: 0,
|
|
172
|
+
detail: count,
|
|
173
|
+
isPrimary: false,
|
|
174
|
+
bubbles: true,
|
|
175
|
+
cancelable: true,
|
|
176
|
+
}),
|
|
177
|
+
);
|
|
178
|
+
if (count === 2) this.mouse('dblclick', { ...at, button: 0, buttons: 0, detail: count });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private init(finger: Finger, button: number, buttons: number): PointerInit {
|
|
182
|
+
return {
|
|
183
|
+
clientX: finger.x,
|
|
184
|
+
clientY: finger.y,
|
|
185
|
+
screenX: this.window.x + finger.x,
|
|
186
|
+
screenY: this.window.y + finger.y,
|
|
187
|
+
button,
|
|
188
|
+
buttons,
|
|
189
|
+
pressure: finger.pressure,
|
|
190
|
+
pointerId: finger.pointerId,
|
|
191
|
+
pointerType: 'touch',
|
|
192
|
+
isPrimary: finger.primary,
|
|
193
|
+
...this.modifiers(),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private pointer(type: string, init: PointerInit): void {
|
|
198
|
+
this.page.capture.process(init.pointerId ?? 0, init);
|
|
199
|
+
this.canvas.dispatchEvent(new PointerEvent(type, { ...init, bubbles: true, cancelable: true }));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private mouse(type: string, init: PointerInit): MouseEvent {
|
|
203
|
+
const event = new MouseEvent(type, { ...init, bubbles: true, cancelable: true });
|
|
204
|
+
this.canvas.dispatchEvent(event);
|
|
205
|
+
return event;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** An enter or a leave, which does not bubble: sent to each node it crosses, in turn. */
|
|
209
|
+
private boundary(type: string, init: PointerInit, nodes: readonly EventTarget[]): void {
|
|
210
|
+
const Kind = type.startsWith('pointer') ? PointerEvent : MouseEvent;
|
|
211
|
+
for (const node of nodes) node.dispatchEvent(new Kind(type, { ...init, bubbles: false }));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** A touch event for `finger`, answering whether it went uncancelled. */
|
|
215
|
+
private touch(type: string, finger: Finger): boolean {
|
|
216
|
+
const touchOf = (f: Finger) =>
|
|
217
|
+
new Touch({
|
|
218
|
+
identifier: f.identifier,
|
|
219
|
+
target: this.canvas,
|
|
220
|
+
clientX: f.x,
|
|
221
|
+
clientY: f.y,
|
|
222
|
+
screenX: this.window.x + f.x,
|
|
223
|
+
screenY: this.window.y + f.y,
|
|
224
|
+
force: f.pressure,
|
|
225
|
+
});
|
|
226
|
+
const all = [...this.fingers.values()].map(touchOf);
|
|
227
|
+
const event = new TouchEvent(type, {
|
|
228
|
+
bubbles: true,
|
|
229
|
+
cancelable: true,
|
|
230
|
+
touches: all,
|
|
231
|
+
targetTouches: all,
|
|
232
|
+
changedTouches: [touchOf(finger)],
|
|
233
|
+
...this.modifiers(),
|
|
234
|
+
});
|
|
235
|
+
this.canvas.dispatchEvent(event);
|
|
236
|
+
return !event.defaultPrevented;
|
|
237
|
+
}
|
|
238
|
+
}
|
package/src/uiEvents.ts
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The browser's input events, for a runtime that has only `Event`: keys, the mouse, pointers, the
|
|
3
|
+
* wheel, touches, focus and pads, as classes with the members a browser's carry.
|
|
4
|
+
*
|
|
5
|
+
* **A page reads more of an event than the engine does.** The engine's input reads a key's `code`
|
|
6
|
+
* and a pointer's movement; a consumer's own code reads `getModifierState`, `detail` for a double
|
|
7
|
+
* click, the legacy `keyCode`, `pressure`, `offsetX`, or asks `instanceof PointerEvent`. An event
|
|
8
|
+
* assembled from a bare `Event` answers none of those, and the failure is a `TypeError` inside the
|
|
9
|
+
* consumer's handler or an `undefined` in its arithmetic. So each is its class, installed as the
|
|
10
|
+
* global a browser has, defaulting every member as the UI Events and Pointer Events specifications
|
|
11
|
+
* do.
|
|
12
|
+
*
|
|
13
|
+
* **Two of Chrome's own answers are kept where they differ from a first reading of those
|
|
14
|
+
* specifications**, both measured in Chrome 151 on 2026-09-19: a `MouseEvent` whose button did not
|
|
15
|
+
* change reports `button` 0 where a `PointerEvent` reports −1, and `which` is that raw button plus
|
|
16
|
+
* one, so 0 for a move either way.
|
|
17
|
+
*
|
|
18
|
+
* What it gives up: coordinates are the window's, which is the canvas's here, so `pageX` is
|
|
19
|
+
* `clientX` (nothing scrolls) and `offsetX` is measured from the target's box when it has one.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
type Modifier = 'AltGraph' | 'CapsLock' | 'NumLock';
|
|
23
|
+
|
|
24
|
+
export interface ModifierInit {
|
|
25
|
+
readonly ctrlKey?: boolean;
|
|
26
|
+
readonly shiftKey?: boolean;
|
|
27
|
+
readonly altKey?: boolean;
|
|
28
|
+
readonly metaKey?: boolean;
|
|
29
|
+
readonly modifierAltGraph?: boolean;
|
|
30
|
+
readonly modifierCapsLock?: boolean;
|
|
31
|
+
readonly modifierNumLock?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface UiInit extends EventInit {
|
|
35
|
+
readonly detail?: number;
|
|
36
|
+
readonly view?: unknown;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class UIEvent extends Event {
|
|
40
|
+
readonly detail: number;
|
|
41
|
+
readonly view: unknown;
|
|
42
|
+
constructor(type: string, init: UiInit = {}) {
|
|
43
|
+
super(type, init);
|
|
44
|
+
this.detail = init.detail ?? 0;
|
|
45
|
+
this.view = init.view ?? null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** `getModifierState`, which every event carrying modifiers answers the same way. */
|
|
50
|
+
function modifierState(init: ModifierInit, key: string): boolean {
|
|
51
|
+
if (key === 'Shift') return init.shiftKey === true;
|
|
52
|
+
if (key === 'Control') return init.ctrlKey === true;
|
|
53
|
+
if (key === 'Alt') return init.altKey === true;
|
|
54
|
+
if (key === 'Meta') return init.metaKey === true;
|
|
55
|
+
const locked: Record<Modifier, boolean | undefined> = {
|
|
56
|
+
AltGraph: init.modifierAltGraph,
|
|
57
|
+
CapsLock: init.modifierCapsLock,
|
|
58
|
+
NumLock: init.modifierNumLock,
|
|
59
|
+
};
|
|
60
|
+
return locked[key as Modifier] === true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface MouseInit extends UiInit, ModifierInit {
|
|
64
|
+
readonly screenX?: number;
|
|
65
|
+
readonly screenY?: number;
|
|
66
|
+
readonly clientX?: number;
|
|
67
|
+
readonly clientY?: number;
|
|
68
|
+
readonly movementX?: number;
|
|
69
|
+
readonly movementY?: number;
|
|
70
|
+
/** −1 where no button changed: a move, an enter, a leave. */
|
|
71
|
+
readonly button?: number;
|
|
72
|
+
readonly buttons?: number;
|
|
73
|
+
readonly relatedTarget?: EventTarget | null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export class MouseEvent extends UIEvent {
|
|
77
|
+
readonly screenX: number;
|
|
78
|
+
readonly screenY: number;
|
|
79
|
+
readonly clientX: number;
|
|
80
|
+
readonly clientY: number;
|
|
81
|
+
readonly movementX: number;
|
|
82
|
+
readonly movementY: number;
|
|
83
|
+
readonly ctrlKey: boolean;
|
|
84
|
+
readonly shiftKey: boolean;
|
|
85
|
+
readonly altKey: boolean;
|
|
86
|
+
readonly metaKey: boolean;
|
|
87
|
+
readonly buttons: number;
|
|
88
|
+
readonly relatedTarget: EventTarget | null;
|
|
89
|
+
protected readonly rawButton: number;
|
|
90
|
+
private readonly modifiers: ModifierInit;
|
|
91
|
+
|
|
92
|
+
constructor(type: string, init: MouseInit = {}) {
|
|
93
|
+
super(type, init);
|
|
94
|
+
this.screenX = init.screenX ?? 0;
|
|
95
|
+
this.screenY = init.screenY ?? 0;
|
|
96
|
+
this.clientX = init.clientX ?? 0;
|
|
97
|
+
this.clientY = init.clientY ?? 0;
|
|
98
|
+
this.movementX = init.movementX ?? 0;
|
|
99
|
+
this.movementY = init.movementY ?? 0;
|
|
100
|
+
this.ctrlKey = init.ctrlKey === true;
|
|
101
|
+
this.shiftKey = init.shiftKey === true;
|
|
102
|
+
this.altKey = init.altKey === true;
|
|
103
|
+
this.metaKey = init.metaKey === true;
|
|
104
|
+
this.rawButton = init.button ?? 0;
|
|
105
|
+
this.buttons = init.buttons ?? 0;
|
|
106
|
+
this.relatedTarget = init.relatedTarget ?? null;
|
|
107
|
+
this.modifiers = init;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
get button(): number {
|
|
111
|
+
return Math.max(this.rawButton, 0);
|
|
112
|
+
}
|
|
113
|
+
get which(): number {
|
|
114
|
+
return this.rawButton + 1;
|
|
115
|
+
}
|
|
116
|
+
get x(): number {
|
|
117
|
+
return this.clientX;
|
|
118
|
+
}
|
|
119
|
+
get y(): number {
|
|
120
|
+
return this.clientY;
|
|
121
|
+
}
|
|
122
|
+
get pageX(): number {
|
|
123
|
+
return this.clientX;
|
|
124
|
+
}
|
|
125
|
+
get pageY(): number {
|
|
126
|
+
return this.clientY;
|
|
127
|
+
}
|
|
128
|
+
get offsetX(): number {
|
|
129
|
+
return this.clientX - boxOf(this.target).left;
|
|
130
|
+
}
|
|
131
|
+
get offsetY(): number {
|
|
132
|
+
return this.clientY - boxOf(this.target).top;
|
|
133
|
+
}
|
|
134
|
+
getModifierState(key: string): boolean {
|
|
135
|
+
return modifierState(this.modifiers, key);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function boxOf(target: EventTarget | null): { left: number; top: number } {
|
|
140
|
+
const box = (target as { getBoundingClientRect?: () => DOMRect } | null)?.getBoundingClientRect;
|
|
141
|
+
return typeof box === 'function' ? box.call(target) : { left: 0, top: 0 };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface WheelInit extends MouseInit {
|
|
145
|
+
readonly deltaX?: number;
|
|
146
|
+
readonly deltaY?: number;
|
|
147
|
+
readonly deltaZ?: number;
|
|
148
|
+
readonly deltaMode?: number;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export class WheelEvent extends MouseEvent {
|
|
152
|
+
static readonly DOM_DELTA_PIXEL = 0;
|
|
153
|
+
static readonly DOM_DELTA_LINE = 1;
|
|
154
|
+
static readonly DOM_DELTA_PAGE = 2;
|
|
155
|
+
readonly deltaX: number;
|
|
156
|
+
readonly deltaY: number;
|
|
157
|
+
readonly deltaZ: number;
|
|
158
|
+
readonly deltaMode: number;
|
|
159
|
+
constructor(type: string, init: WheelInit = {}) {
|
|
160
|
+
super(type, init);
|
|
161
|
+
this.deltaX = init.deltaX ?? 0;
|
|
162
|
+
this.deltaY = init.deltaY ?? 0;
|
|
163
|
+
this.deltaZ = init.deltaZ ?? 0;
|
|
164
|
+
this.deltaMode = init.deltaMode ?? 0;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export interface PointerInit extends MouseInit {
|
|
169
|
+
readonly pointerId?: number;
|
|
170
|
+
readonly width?: number;
|
|
171
|
+
readonly height?: number;
|
|
172
|
+
readonly pressure?: number;
|
|
173
|
+
readonly pointerType?: string;
|
|
174
|
+
readonly isPrimary?: boolean;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export class PointerEvent extends MouseEvent {
|
|
178
|
+
readonly pointerId: number;
|
|
179
|
+
readonly width: number;
|
|
180
|
+
readonly height: number;
|
|
181
|
+
readonly pressure: number;
|
|
182
|
+
readonly tangentialPressure = 0;
|
|
183
|
+
readonly tiltX = 0;
|
|
184
|
+
readonly tiltY = 0;
|
|
185
|
+
readonly twist = 0;
|
|
186
|
+
readonly altitudeAngle = Math.PI / 2;
|
|
187
|
+
readonly azimuthAngle = 0;
|
|
188
|
+
readonly pointerType: string;
|
|
189
|
+
readonly isPrimary: boolean;
|
|
190
|
+
constructor(type: string, init: PointerInit = {}) {
|
|
191
|
+
super(type, init);
|
|
192
|
+
this.pointerId = init.pointerId ?? 0;
|
|
193
|
+
this.width = init.width ?? 1;
|
|
194
|
+
this.height = init.height ?? 1;
|
|
195
|
+
this.pressure = init.pressure ?? 0;
|
|
196
|
+
this.pointerType = init.pointerType ?? '';
|
|
197
|
+
this.isPrimary = init.isPrimary === true;
|
|
198
|
+
}
|
|
199
|
+
override get button(): number {
|
|
200
|
+
return this.rawButton;
|
|
201
|
+
}
|
|
202
|
+
/** A move stands for itself: this host coalesces nothing, so there is one event in it. */
|
|
203
|
+
getCoalescedEvents(): PointerEvent[] {
|
|
204
|
+
return this.type === 'pointermove' ? [this] : [];
|
|
205
|
+
}
|
|
206
|
+
getPredictedEvents(): PointerEvent[] {
|
|
207
|
+
return [];
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export interface KeyInit extends UiInit, ModifierInit {
|
|
212
|
+
readonly key?: string;
|
|
213
|
+
readonly code?: string;
|
|
214
|
+
readonly location?: number;
|
|
215
|
+
readonly repeat?: boolean;
|
|
216
|
+
readonly isComposing?: boolean;
|
|
217
|
+
readonly keyCode?: number;
|
|
218
|
+
readonly charCode?: number;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export class KeyboardEvent extends UIEvent {
|
|
222
|
+
static readonly DOM_KEY_LOCATION_STANDARD = 0;
|
|
223
|
+
static readonly DOM_KEY_LOCATION_LEFT = 1;
|
|
224
|
+
static readonly DOM_KEY_LOCATION_RIGHT = 2;
|
|
225
|
+
static readonly DOM_KEY_LOCATION_NUMPAD = 3;
|
|
226
|
+
readonly key: string;
|
|
227
|
+
readonly code: string;
|
|
228
|
+
readonly location: number;
|
|
229
|
+
readonly repeat: boolean;
|
|
230
|
+
readonly isComposing: boolean;
|
|
231
|
+
readonly ctrlKey: boolean;
|
|
232
|
+
readonly shiftKey: boolean;
|
|
233
|
+
readonly altKey: boolean;
|
|
234
|
+
readonly metaKey: boolean;
|
|
235
|
+
/** The legacy pair: a key's code on `keydown` and `keyup`, a character's on `keypress`. */
|
|
236
|
+
readonly keyCode: number;
|
|
237
|
+
readonly charCode: number;
|
|
238
|
+
private readonly modifiers: ModifierInit;
|
|
239
|
+
constructor(type: string, init: KeyInit = {}) {
|
|
240
|
+
super(type, init);
|
|
241
|
+
this.key = init.key ?? '';
|
|
242
|
+
this.code = init.code ?? '';
|
|
243
|
+
this.location = init.location ?? 0;
|
|
244
|
+
this.repeat = init.repeat === true;
|
|
245
|
+
this.isComposing = init.isComposing === true;
|
|
246
|
+
this.ctrlKey = init.ctrlKey === true;
|
|
247
|
+
this.shiftKey = init.shiftKey === true;
|
|
248
|
+
this.altKey = init.altKey === true;
|
|
249
|
+
this.metaKey = init.metaKey === true;
|
|
250
|
+
this.keyCode = init.keyCode ?? 0;
|
|
251
|
+
this.charCode = init.charCode ?? 0;
|
|
252
|
+
this.modifiers = init;
|
|
253
|
+
}
|
|
254
|
+
get which(): number {
|
|
255
|
+
return this.keyCode;
|
|
256
|
+
}
|
|
257
|
+
getModifierState(key: string): boolean {
|
|
258
|
+
return modifierState(this.modifiers, key);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export class FocusEvent extends UIEvent {
|
|
263
|
+
readonly relatedTarget: EventTarget | null;
|
|
264
|
+
constructor(type: string, init: UiInit & { relatedTarget?: EventTarget | null } = {}) {
|
|
265
|
+
super(type, init);
|
|
266
|
+
this.relatedTarget = init.relatedTarget ?? null;
|
|
267
|
+
}
|
|
268
|
+
}
|