effect-browser 0.2.0-beta.8 → 0.3.0-beta.5
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 +705 -1166
- package/dist/Browser-CM65Uo7f.mjs +437 -0
- package/dist/Browser-CM65Uo7f.mjs.map +1 -0
- package/dist/Browser.d.mts +115 -3
- package/dist/Browser.mjs +2 -22
- package/dist/BrowserError-DXXIA6Rk.d.mts +135 -0
- package/dist/BrowserError.d.mts +2 -0
- package/dist/BrowserError.mjs +202 -0
- package/dist/BrowserError.mjs.map +1 -0
- package/dist/BrowserEvent-DzdNdvoh.d.mts +277 -0
- package/dist/BrowserEvent.d.mts +2 -0
- package/dist/BrowserEvent.mjs +295 -0
- package/dist/BrowserEvent.mjs.map +1 -0
- package/dist/Cdp.d.mts +32 -0
- package/dist/Cdp.mjs +61 -0
- package/dist/Cdp.mjs.map +1 -0
- package/dist/Change-DNvztNLu.mjs +138 -0
- package/dist/Change-DNvztNLu.mjs.map +1 -0
- package/dist/Change-DVP2IJWm.d.mts +71 -0
- package/dist/Change.d.mts +2 -0
- package/dist/Change.mjs +2 -0
- package/dist/Chromium.d.mts +29 -81
- package/dist/Chromium.mjs +57 -392
- package/dist/Chromium.mjs.map +1 -1
- package/dist/Expect.d.mts +62 -0
- package/dist/Expect.mjs +114 -0
- package/dist/Expect.mjs.map +1 -0
- package/dist/Frame-BERmf_gM.d.mts +117 -0
- package/dist/Frame.d.mts +2 -0
- package/dist/Frame.mjs +123 -0
- package/dist/Frame.mjs.map +1 -0
- package/dist/Moment.d.mts +2 -0
- package/dist/Moment.mjs +288 -0
- package/dist/Moment.mjs.map +1 -0
- package/dist/Motion.d.mts +40 -0
- package/dist/Motion.mjs +122 -0
- package/dist/Motion.mjs.map +1 -0
- package/dist/Page-D4281dIi.mjs +618 -0
- package/dist/Page-D4281dIi.mjs.map +1 -0
- package/dist/Page-DYTdVhYa.d.mts +593 -0
- package/dist/Page.d.mts +2 -0
- package/dist/Page.mjs +2 -0
- package/dist/Plan-CNNOU1Tx.d.mts +112 -0
- package/dist/Plan-Dtrb-8im.mjs +311 -0
- package/dist/Plan-Dtrb-8im.mjs.map +1 -0
- package/dist/Plan.d.mts +2 -3
- package/dist/Plan.mjs +2 -2
- package/dist/Policy.d.mts +76 -0
- package/dist/Policy.mjs +237 -0
- package/dist/Policy.mjs.map +1 -0
- package/dist/Presentation.d.mts +48 -0
- package/dist/Presentation.mjs +117 -0
- package/dist/Presentation.mjs.map +1 -0
- package/dist/Snapshot-B34hZ4Jl.d.mts +68 -0
- package/dist/Snapshot.d.mts +2 -0
- package/dist/Snapshot.mjs +79 -0
- package/dist/Snapshot.mjs.map +1 -0
- package/dist/Stage.d.mts +54 -0
- package/dist/Stage.mjs +133 -0
- package/dist/Stage.mjs.map +1 -0
- package/dist/Supervisor-BtEG93gR.mjs +420 -0
- package/dist/Supervisor-BtEG93gR.mjs.map +1 -0
- package/dist/Supervisor-Im6-wbg5.d.mts +230 -0
- package/dist/Supervisor.d.mts +2 -0
- package/dist/Supervisor.mjs +2 -0
- package/dist/context-DAYjATgC.mjs +137 -0
- package/dist/context-DAYjATgC.mjs.map +1 -0
- package/dist/index.d.mts +17 -14
- package/dist/index.mjs +18 -13
- package/dist/match.inpage-xOe9LNO0.mjs +48 -0
- package/dist/match.inpage-xOe9LNO0.mjs.map +1 -0
- package/dist/page-Banfm_zc.mjs +5097 -0
- package/dist/page-Banfm_zc.mjs.map +1 -0
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/dist/url-CcL0K7S0.mjs +262 -0
- package/dist/url-CcL0K7S0.mjs.map +1 -0
- package/package.json +96 -61
- package/src/Browser.ts +625 -0
- package/src/BrowserError.ts +225 -0
- package/src/BrowserEvent.ts +294 -0
- package/src/Cdp.ts +101 -0
- package/src/Change.ts +88 -0
- package/src/Chromium.ts +102 -0
- package/src/Expect.ts +180 -0
- package/src/Frame.ts +130 -0
- package/src/Moment.ts +479 -0
- package/src/Motion.ts +166 -0
- package/src/Page.ts +520 -0
- package/src/Plan.ts +350 -0
- package/src/Policy.ts +325 -0
- package/src/Presentation.ts +195 -0
- package/src/Snapshot.ts +89 -0
- package/src/Stage.ts +223 -0
- package/src/Supervisor.ts +353 -0
- package/src/index.ts +20 -0
- package/src/internal/input/actions.ts +607 -0
- package/src/internal/input/dispatch.ts +161 -0
- package/src/internal/input/edit.inpage.ts +162 -0
- package/src/internal/input/evidence.inpage.ts +164 -0
- package/src/internal/input/guard.inpage.ts +605 -0
- package/src/internal/input/guard.ts +183 -0
- package/src/internal/input/keyboard.ts +112 -0
- package/src/internal/input/keys.ts +127 -0
- package/src/internal/input/perform.ts +322 -0
- package/src/internal/input/pointer.ts +153 -0
- package/src/internal/input/replies.ts +159 -0
- package/src/internal/input/settle.ts +50 -0
- package/src/internal/input/style.ts +186 -0
- package/src/internal/input/targets.inpage.ts +293 -0
- package/src/internal/input/targets.ts +148 -0
- package/src/internal/page/bridge.ts +386 -0
- package/src/internal/page/context.ts +302 -0
- package/src/internal/page/lane.ts +301 -0
- package/src/internal/page/navigation.ts +183 -0
- package/src/internal/page/openings.ts +87 -0
- package/src/internal/page/page.ts +137 -0
- package/src/internal/page/url.ts +100 -0
- package/src/internal/page/viewport.ts +43 -0
- package/src/internal/pictures/capture.ts +778 -0
- package/src/internal/pictures/clock.ts +141 -0
- package/src/internal/pictures/pictures.ts +520 -0
- package/src/internal/pictures/transport.ts +242 -0
- package/src/internal/reading/choose.ts +100 -0
- package/src/internal/reading/context.inpage.ts +201 -0
- package/src/internal/reading/controls.inpage.ts +158 -0
- package/src/internal/reading/expect.inpage.ts +70 -0
- package/src/internal/reading/match.inpage.ts +92 -0
- package/src/internal/reading/names.inpage.ts +306 -0
- package/src/internal/reading/outline.inpage.ts +260 -0
- package/src/internal/reading/reading.ts +223 -0
- package/src/internal/reading/ready.inpage.ts +154 -0
- package/src/internal/reading/ready.ts +105 -0
- package/src/internal/reading/subjects.inpage.ts +188 -0
- package/src/internal/reading/text.inpage.ts +135 -0
- package/src/internal/reading/walk.inpage.ts +220 -0
- package/src/internal/supervisor/lifecycle.ts +265 -0
- package/src/internal/timeline/changes.inpage.ts +216 -0
- package/src/internal/timeline/changes.ts +100 -0
- package/src/internal/timeline/events.ts +93 -0
- package/src/internal/timeline/fold.inpage.ts +123 -0
- package/src/internal/timeline/history.inpage.ts +229 -0
- package/src/internal/timeline/marks.inpage.ts +207 -0
- package/src/internal/timeline/record.inpage.ts +133 -0
- package/src/internal/timeline/sight.inpage.ts +122 -0
- package/src/internal/timeline/window.ts +96 -0
- package/src/internal/timeline/words.ts +65 -0
- package/dist/Bootstrap-B9UUXsvF.d.mts +0 -183
- package/dist/Bootstrap-CeQOWBy6.mjs +0 -189
- package/dist/Bootstrap-CeQOWBy6.mjs.map +0 -1
- package/dist/Bootstrap.d.mts +0 -3
- package/dist/Bootstrap.mjs +0 -2
- package/dist/Browser-n-bKgBNN.d.mts +0 -372
- package/dist/Browser.mjs.map +0 -1
- package/dist/BrowserData-CPIIIHol.d.mts +0 -585
- package/dist/BrowserData.d.mts +0 -3
- package/dist/BrowserData.mjs +0 -695
- package/dist/BrowserData.mjs.map +0 -1
- package/dist/BrowserRuntime-CFe9uUTv.d.mts +0 -148
- package/dist/BrowserRuntime-HJ1yYlFS.mjs +0 -6741
- package/dist/BrowserRuntime-HJ1yYlFS.mjs.map +0 -1
- package/dist/BrowserRuntime.d.mts +0 -3
- package/dist/BrowserRuntime.mjs +0 -3
- package/dist/Capture.d.mts +0 -56
- package/dist/Capture.mjs +0 -678
- package/dist/Capture.mjs.map +0 -1
- package/dist/CaptureData-CYriZHX1.mjs +0 -201
- package/dist/CaptureData-CYriZHX1.mjs.map +0 -1
- package/dist/CaptureData.d.mts +0 -219
- package/dist/CaptureData.mjs +0 -2
- package/dist/Errors.d.mts +0 -158
- package/dist/Errors.mjs +0 -207
- package/dist/Errors.mjs.map +0 -1
- package/dist/Images-CaaPNeed.mjs +0 -76
- package/dist/Images-CaaPNeed.mjs.map +0 -1
- package/dist/OperationOptions-DAeRS6O9.mjs +0 -55
- package/dist/OperationOptions-DAeRS6O9.mjs.map +0 -1
- package/dist/PageControl.d.mts +0 -15
- package/dist/PageControl.mjs +0 -34
- package/dist/PageControl.mjs.map +0 -1
- package/dist/PageRegistry-B3q9DS3g.mjs +0 -164
- package/dist/PageRegistry-B3q9DS3g.mjs.map +0 -1
- package/dist/Plan-D8qQ48Ic.mjs +0 -512
- package/dist/Plan-D8qQ48Ic.mjs.map +0 -1
- package/dist/PlanData-B668RxbQ.d.mts +0 -4831
- package/dist/PlanData.d.mts +0 -3
- package/dist/PlanData.mjs +0 -268
- package/dist/PlanData.mjs.map +0 -1
- package/dist/PlanInput-BJcPPncs.mjs +0 -75
- package/dist/PlanInput-BJcPPncs.mjs.map +0 -1
- package/dist/PublicSession-Bs61Eal8.mjs +0 -5601
- package/dist/PublicSession-Bs61Eal8.mjs.map +0 -1
- package/dist/Testing.d.mts +0 -479
- package/dist/Testing.mjs +0 -2147
- package/dist/Testing.mjs.map +0 -1
- package/dist/Timeline-fV22Xk-S.d.mts +0 -29
- package/dist/Timeline.d.mts +0 -4
- package/dist/Timeline.mjs +0 -20
- package/dist/Timeline.mjs.map +0 -1
- package/dist/TimelineData-BsOn-YGZ.d.mts +0 -3818
- package/dist/TimelineData.d.mts +0 -3
- package/dist/TimelineData.mjs +0 -482
- package/dist/TimelineData.mjs.map +0 -1
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How input is sent: plainly, as fast as the page takes it, or performed for viewers, with a
|
|
3
|
+
* pointer that glides, keys typed at a person's pace, held buttons and wheels in bursts. A page's
|
|
4
|
+
* actions take one, and the input reaches the same controls either way; only its timing, and
|
|
5
|
+
* what viewers see of it, differ. A presenter builds the performed one.
|
|
6
|
+
*/
|
|
7
|
+
import { Effect, type MutableRef, type Option, Random } from "effect";
|
|
8
|
+
|
|
9
|
+
import type * as Motion from "../../Motion.ts";
|
|
10
|
+
import type { Point } from "../../Page.ts";
|
|
11
|
+
import * as Keys from "./keys.ts";
|
|
12
|
+
|
|
13
|
+
export interface TypingEvent {
|
|
14
|
+
/** From the start of the typing. */
|
|
15
|
+
readonly afterMillis: number;
|
|
16
|
+
readonly phase: "down" | "up" | "insert";
|
|
17
|
+
readonly key: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface WheelStep {
|
|
21
|
+
readonly dx: number;
|
|
22
|
+
readonly dy: number;
|
|
23
|
+
/** From the first step. */
|
|
24
|
+
readonly afterMillis: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface Style {
|
|
28
|
+
/**
|
|
29
|
+
* Whether viewers watch the input: then the pointer clicks into a field before typing, finds
|
|
30
|
+
* what lies under it as it scrolls, and scrolls to a target out of view with the wheel.
|
|
31
|
+
*/
|
|
32
|
+
readonly shown: boolean;
|
|
33
|
+
/** Where viewers last saw the pointer, which the next glide starts from; else the page's own. */
|
|
34
|
+
readonly pointer: MutableRef.MutableRef<Option.Option<Point>> | undefined;
|
|
35
|
+
/** The pointer's path to `to`, ending exactly there. */
|
|
36
|
+
readonly glide: (
|
|
37
|
+
from: Point,
|
|
38
|
+
to: Point,
|
|
39
|
+
dragging: boolean,
|
|
40
|
+
) => Effect.Effect<ReadonlyArray<Motion.Sample>>;
|
|
41
|
+
/** How long a button stays down when a click does not say. */
|
|
42
|
+
readonly hold: Effect.Effect<number>;
|
|
43
|
+
/** The key events that type `text`, or one insertion of it under a guard, timed from the start. */
|
|
44
|
+
readonly typing: (text: string, guarded: boolean) => Effect.Effect<ReadonlyArray<TypingEvent>>;
|
|
45
|
+
/** The pause between repeated presses of the same keys. */
|
|
46
|
+
readonly keyGap: Effect.Effect<number>;
|
|
47
|
+
/** A wheel scroll as the wheel turns: in one step, or in notches and bursts. */
|
|
48
|
+
readonly wheel: (dx: number, dy: number) => Effect.Effect<ReadonlyArray<WheelStep>>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Each key as it comes, with no pause; a drag moves in eight short steps so its target sees it. */
|
|
52
|
+
export const plain: Style = {
|
|
53
|
+
shown: false,
|
|
54
|
+
pointer: undefined,
|
|
55
|
+
glide: (from, to, dragging) =>
|
|
56
|
+
Effect.succeed(
|
|
57
|
+
dragging
|
|
58
|
+
? Array.from({ length: 8 }, (_, index) => ({
|
|
59
|
+
x: index === 7 ? to.x : from.x + ((to.x - from.x) * (index + 1)) / 8,
|
|
60
|
+
y: index === 7 ? to.y : from.y + ((to.y - from.y) * (index + 1)) / 8,
|
|
61
|
+
afterMillis: (index + 1) * 8,
|
|
62
|
+
}))
|
|
63
|
+
: [{ ...to, afterMillis: 0 }],
|
|
64
|
+
),
|
|
65
|
+
hold: Effect.succeed(0),
|
|
66
|
+
typing: (text, guarded) =>
|
|
67
|
+
Effect.succeed(
|
|
68
|
+
guarded && text !== ""
|
|
69
|
+
? [{ afterMillis: 0, phase: "insert", key: text }]
|
|
70
|
+
: [...text].flatMap((key): ReadonlyArray<TypingEvent> =>
|
|
71
|
+
Keys.description(key) === undefined
|
|
72
|
+
? [{ afterMillis: 0, phase: "insert", key }]
|
|
73
|
+
: [
|
|
74
|
+
{ afterMillis: 0, phase: "down", key },
|
|
75
|
+
{ afterMillis: 0, phase: "up", key },
|
|
76
|
+
],
|
|
77
|
+
),
|
|
78
|
+
),
|
|
79
|
+
keyGap: Effect.succeed(0),
|
|
80
|
+
wheel: (dx, dy) => Effect.succeed([{ dx, dy, afterMillis: 0 }]),
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// A right-skewed draw, clamped so that cleanup and deadlines stay predictable; 1 - U keeps the
|
|
84
|
+
// logarithm defined when Random returns zero.
|
|
85
|
+
const lognormal = Effect.fnUntraced(function* (
|
|
86
|
+
median: number,
|
|
87
|
+
sigma: number,
|
|
88
|
+
minimum: number,
|
|
89
|
+
maximum: number,
|
|
90
|
+
) {
|
|
91
|
+
const radius = Math.sqrt(-2 * Math.log(1 - (yield* Random.next)));
|
|
92
|
+
const normal = radius * Math.cos(2 * Math.PI * (yield* Random.next));
|
|
93
|
+
|
|
94
|
+
return Math.max(minimum, Math.min(maximum, median * Math.exp(sigma * normal)));
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
/** A pause around `median`, as the research measured reactions: at most four times it. */
|
|
98
|
+
export const pause = (median: number, sigma: number) =>
|
|
99
|
+
median <= 0 ? Effect.succeed(0) : lognormal(median, sigma, 0, median * 4);
|
|
100
|
+
|
|
101
|
+
// A mouse press about 80 ms long on average, a key about 110 ms, each now and then longer: their
|
|
102
|
+
// medians are the means times exp(-sigma^2 / 2).
|
|
103
|
+
const pressHold = lognormal(75.2, 0.35, 35, 200);
|
|
104
|
+
const keyHold = lognormal(107.8, 0.2, 60, 220);
|
|
105
|
+
const eventOrder = { up: 0, insert: 1, down: 2 };
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Plan the whole text before any key goes, so holds overlap without waiting on the network. A
|
|
109
|
+
* physical key is never held twice, as with A and a, or ! and 1. The first key waits for the hand
|
|
110
|
+
* to reach the keys, each later one follows the last by a gap drawn around `gap`, and a word's
|
|
111
|
+
* first key comes 60 ms later.
|
|
112
|
+
*/
|
|
113
|
+
const typing = Effect.fnUntraced(function* (
|
|
114
|
+
text: string,
|
|
115
|
+
gap: number,
|
|
116
|
+
): Effect.fn.Return<ReadonlyArray<TypingEvent>> {
|
|
117
|
+
const events: Array<TypingEvent> = [];
|
|
118
|
+
const releases = new Map<string, number>();
|
|
119
|
+
let due = text === "" ? 0 : yield* Random.nextBetween(150, 400);
|
|
120
|
+
let previous = "";
|
|
121
|
+
|
|
122
|
+
for (const character of text) {
|
|
123
|
+
if (/\s/u.test(previous) && /[\p{L}\p{N}]/u.test(character)) due += 60;
|
|
124
|
+
const description = Keys.description(character);
|
|
125
|
+
|
|
126
|
+
if (description === undefined)
|
|
127
|
+
events.push({ afterMillis: due, phase: "insert", key: character });
|
|
128
|
+
else {
|
|
129
|
+
due = Math.max(due, (releases.get(description.code) ?? -1) + 1);
|
|
130
|
+
const released = due + (yield* keyHold);
|
|
131
|
+
|
|
132
|
+
events.push(
|
|
133
|
+
{ afterMillis: due, phase: "down", key: character },
|
|
134
|
+
{ afterMillis: released, phase: "up", key: character },
|
|
135
|
+
);
|
|
136
|
+
releases.set(description.code, released);
|
|
137
|
+
}
|
|
138
|
+
due += yield* Random.nextBetween(gap / 2, (gap * 3) / 2);
|
|
139
|
+
previous = character;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return events.toSorted(
|
|
143
|
+
(left, right) =>
|
|
144
|
+
left.afterMillis - right.afterMillis || eventOrder[left.phase] - eventOrder[right.phase],
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
/** A word is five characters; the gap leaves room for each word's later start. */
|
|
149
|
+
export const keyGap = (wordsPerMinute: number) => 12_000 / wordsPerMinute - 10;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The wheel in 100 px notches, 30 to 140 ms apart, in bursts of at most nine, with 0.6 to 2.6 s
|
|
153
|
+
* between bursts, as people scroll.
|
|
154
|
+
*/
|
|
155
|
+
const bursts = Effect.fnUntraced(function* (dx: number, dy: number) {
|
|
156
|
+
const notches = Math.max(1, Math.ceil(Math.hypot(dx, dy) / 100));
|
|
157
|
+
const steps: Array<WheelStep> = [];
|
|
158
|
+
let due = 0;
|
|
159
|
+
|
|
160
|
+
for (let index = 0; index < notches; index++) {
|
|
161
|
+
if (index > 0)
|
|
162
|
+
due += yield* index % 9 === 0 ? Random.nextBetween(600, 2600) : Random.nextBetween(30, 140);
|
|
163
|
+
steps.push({ dx: dx / notches, dy: dy / notches, afterMillis: due });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return steps;
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
/** Input performed for viewers, with this pointer and pace. */
|
|
170
|
+
export const performed = (options: {
|
|
171
|
+
readonly pointer: MutableRef.MutableRef<Option.Option<Point>>;
|
|
172
|
+
readonly motion: Motion.Service;
|
|
173
|
+
readonly wordsPerMinute: number;
|
|
174
|
+
}): Style => {
|
|
175
|
+
const gap = keyGap(options.wordsPerMinute);
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
shown: true,
|
|
179
|
+
pointer: options.pointer,
|
|
180
|
+
glide: (from, to) => options.motion.plan(from, to),
|
|
181
|
+
hold: pressHold,
|
|
182
|
+
typing: (text) => typing(text, gap),
|
|
183
|
+
keyGap: Random.nextBetween(gap / 2, (gap * 3) / 2),
|
|
184
|
+
wheel: bursts,
|
|
185
|
+
};
|
|
186
|
+
};
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In the page: where input lands. It resolves a ref or a point to the control under it, in
|
|
3
|
+
* top-document viewport pixels, with the subject's context, and plans a wheel scroll that brings a
|
|
4
|
+
* ref into view. See `reading/names.inpage.ts` for what a page-side part may use.
|
|
5
|
+
*/
|
|
6
|
+
import { Schema } from "effect";
|
|
7
|
+
|
|
8
|
+
import { Box, SubjectContext } from "../../BrowserEvent.ts";
|
|
9
|
+
import type { ContextReader } from "../reading/context.inpage.ts";
|
|
10
|
+
import type { Names } from "../reading/names.inpage.ts";
|
|
11
|
+
import type { Walk } from "../reading/walk.inpage.ts";
|
|
12
|
+
|
|
13
|
+
export type ResolvedPoint = typeof ResolvedPointSchema.Type;
|
|
14
|
+
export type PointResult = typeof PointResultSchema.Type;
|
|
15
|
+
|
|
16
|
+
export const targets = (names: Names, walked: Walk, placing: ContextReader) => {
|
|
17
|
+
const { describe, isDisabled, lookup, nameOf, parentOf, roleOf } = names;
|
|
18
|
+
const { boxOf, controlOf, hitAt } = walked;
|
|
19
|
+
const { contextOf } = placing;
|
|
20
|
+
|
|
21
|
+
/** Whether `node` is `ancestor` or inside it, across shadow roots and same-origin frames. */
|
|
22
|
+
const within = (ancestor: Element, node: Element): boolean => {
|
|
23
|
+
for (let current: Element | null = node; current !== null;) {
|
|
24
|
+
if (current === ancestor) return true;
|
|
25
|
+
current = parentOf(current) ?? current.ownerDocument.defaultView?.frameElement ?? null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return false;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// A press at a point reaches the element when the top-document hit is the element, inside it,
|
|
32
|
+
// or one of its containers (whose activation the element's own facts already cover).
|
|
33
|
+
const receives = (element: Element, hit: Element): boolean =>
|
|
34
|
+
within(element, hit) || within(hit, element);
|
|
35
|
+
|
|
36
|
+
// HTML links, SVG links and image-map areas all navigate; SVG keeps lowercase tag names.
|
|
37
|
+
const hrefAttribute = (element: Element): string | null =>
|
|
38
|
+
element.localName === "a" || element.localName === "area"
|
|
39
|
+
? (element.getAttribute("href") ??
|
|
40
|
+
element.getAttributeNS("http://www.w3.org/1999/xlink", "href"))
|
|
41
|
+
: null;
|
|
42
|
+
|
|
43
|
+
const linkOf = (element: Element): Element | undefined => {
|
|
44
|
+
for (let node: Element | null = element; node !== null; node = parentOf(node))
|
|
45
|
+
if (hrefAttribute(node) !== null) return node;
|
|
46
|
+
|
|
47
|
+
return undefined;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const details = (
|
|
51
|
+
element: Element,
|
|
52
|
+
hit: Element,
|
|
53
|
+
x: number,
|
|
54
|
+
y: number,
|
|
55
|
+
): Omit<ResolvedPoint, "context"> => {
|
|
56
|
+
const link = linkOf(element);
|
|
57
|
+
let href: string | undefined;
|
|
58
|
+
|
|
59
|
+
if (link !== undefined) {
|
|
60
|
+
try {
|
|
61
|
+
href = new URL(hrefAttribute(link) ?? "", link.ownerDocument.baseURI).href;
|
|
62
|
+
} catch {
|
|
63
|
+
href = undefined;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const role = roleOf(element);
|
|
67
|
+
const view = hit.ownerDocument.defaultView ?? window;
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
x,
|
|
71
|
+
y,
|
|
72
|
+
element: describe(element),
|
|
73
|
+
tag: element.tagName.toLowerCase(),
|
|
74
|
+
role,
|
|
75
|
+
name: nameOf(element, role),
|
|
76
|
+
cursor: view.getComputedStyle(hit).cursor,
|
|
77
|
+
...(href === undefined ? {} : { href }),
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const point = (
|
|
82
|
+
target: string | { readonly x: number; readonly y: number },
|
|
83
|
+
scroll = true,
|
|
84
|
+
): PointResult => {
|
|
85
|
+
if (typeof target !== "string") {
|
|
86
|
+
const { x, y } = target;
|
|
87
|
+
|
|
88
|
+
if (
|
|
89
|
+
!Number.isFinite(x) ||
|
|
90
|
+
!Number.isFinite(y) ||
|
|
91
|
+
x < 0 ||
|
|
92
|
+
y < 0 ||
|
|
93
|
+
x >= window.innerWidth ||
|
|
94
|
+
y >= window.innerHeight
|
|
95
|
+
)
|
|
96
|
+
return { error: "outside", detail: `(${x}, ${y}) is outside the viewport` };
|
|
97
|
+
const hit = hitAt(document, x, y);
|
|
98
|
+
|
|
99
|
+
if (hit === null) return { error: "offscreen", detail: `nothing is painted at (${x}, ${y})` };
|
|
100
|
+
const control = controlOf(hit);
|
|
101
|
+
|
|
102
|
+
return { ...details(control, hit, x, y), context: contextOf(control), box: boxOf(control) };
|
|
103
|
+
}
|
|
104
|
+
const ref = target;
|
|
105
|
+
const element = lookup(ref);
|
|
106
|
+
|
|
107
|
+
if (element === undefined)
|
|
108
|
+
return { error: "stale", detail: `${ref} is not on the page any more` };
|
|
109
|
+
if (isDisabled(element)) return { error: "disabled", detail: `${ref} is disabled` };
|
|
110
|
+
const rect = element.getBoundingClientRect();
|
|
111
|
+
|
|
112
|
+
if (rect.width === 0 && rect.height === 0)
|
|
113
|
+
return { error: "hidden", detail: `${ref} has no size on the page` };
|
|
114
|
+
|
|
115
|
+
// Input arrives in top-document viewport pixels, so geometry and occlusion are measured
|
|
116
|
+
// there: a frame's own viewport cannot say whether the frame is scrolled away or covered.
|
|
117
|
+
let box = { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom };
|
|
118
|
+
let visible = { ...box };
|
|
119
|
+
let outside = false;
|
|
120
|
+
|
|
121
|
+
for (let inner: Element = element; ;) {
|
|
122
|
+
const view = inner.ownerDocument.defaultView ?? window;
|
|
123
|
+
|
|
124
|
+
outside ||=
|
|
125
|
+
box.left < 0 || box.top < 0 || box.right > view.innerWidth || box.bottom > view.innerHeight;
|
|
126
|
+
visible = {
|
|
127
|
+
left: Math.max(visible.left, 0),
|
|
128
|
+
top: Math.max(visible.top, 0),
|
|
129
|
+
right: Math.min(visible.right, view.innerWidth),
|
|
130
|
+
bottom: Math.min(visible.bottom, view.innerHeight),
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// A field can be inside the viewport and still clipped by a nested scrolling panel.
|
|
134
|
+
for (let ancestor = parentOf(inner); ancestor !== null; ancestor = parentOf(ancestor)) {
|
|
135
|
+
const style = view.getComputedStyle(ancestor);
|
|
136
|
+
const clip = ancestor.getBoundingClientRect();
|
|
137
|
+
|
|
138
|
+
if (/(auto|scroll|hidden|clip)/.test(style.overflowX)) {
|
|
139
|
+
visible.left = Math.max(visible.left, clip.left + ancestor.clientLeft);
|
|
140
|
+
visible.right = Math.min(
|
|
141
|
+
visible.right,
|
|
142
|
+
clip.left + ancestor.clientLeft + ancestor.clientWidth,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
if (/(auto|scroll|hidden|clip)/.test(style.overflowY)) {
|
|
146
|
+
visible.top = Math.max(visible.top, clip.top + ancestor.clientTop);
|
|
147
|
+
visible.bottom = Math.min(
|
|
148
|
+
visible.bottom,
|
|
149
|
+
clip.top + ancestor.clientTop + ancestor.clientHeight,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const frame = view.frameElement;
|
|
155
|
+
|
|
156
|
+
if (frame === null) break;
|
|
157
|
+
const frameBox = frame.getBoundingClientRect();
|
|
158
|
+
const dx = frameBox.left + frame.clientLeft;
|
|
159
|
+
const dy = frameBox.top + frame.clientTop;
|
|
160
|
+
|
|
161
|
+
box = {
|
|
162
|
+
left: box.left + dx,
|
|
163
|
+
top: box.top + dy,
|
|
164
|
+
right: box.right + dx,
|
|
165
|
+
bottom: box.bottom + dy,
|
|
166
|
+
};
|
|
167
|
+
visible = {
|
|
168
|
+
left: Math.max(visible.left + dx, dx),
|
|
169
|
+
top: Math.max(visible.top + dy, dy),
|
|
170
|
+
right: Math.min(visible.right + dx, dx + frame.clientWidth),
|
|
171
|
+
bottom: Math.min(visible.bottom + dy, dy + frame.clientHeight),
|
|
172
|
+
};
|
|
173
|
+
inner = frame;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (outside || visible.right <= visible.left || visible.bottom <= visible.top) {
|
|
177
|
+
if (scroll) {
|
|
178
|
+
element.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
|
|
179
|
+
|
|
180
|
+
return point(target, false);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
error: "offscreen",
|
|
185
|
+
detail: outside
|
|
186
|
+
? ref + " is outside the viewport"
|
|
187
|
+
: `${ref} could not be scrolled into view`,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
const x = (visible.left + visible.right) / 2;
|
|
191
|
+
const y = (visible.top + visible.bottom) / 2;
|
|
192
|
+
const hit = hitAt(document, x, y);
|
|
193
|
+
|
|
194
|
+
if (hit !== null && !receives(element, hit))
|
|
195
|
+
return { error: "covered", detail: `${ref} is covered by ${describe(hit)}` };
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
...details(element, hit ?? element, Math.round(x), Math.round(y)),
|
|
199
|
+
context: contextOf(element),
|
|
200
|
+
};
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
// Suggest one visible wheel origin. Unsupported frame geometry and fully clipped panels use
|
|
204
|
+
// the bounded instant fallback; guessing their coordinates could scroll an unrelated control.
|
|
205
|
+
const scrollPlan = (ref: string) => {
|
|
206
|
+
const element = lookup(ref);
|
|
207
|
+
|
|
208
|
+
if (element === undefined || element.ownerDocument !== document) return null;
|
|
209
|
+
const target = element.getBoundingClientRect();
|
|
210
|
+
const centerX = (target.left + target.right) / 2;
|
|
211
|
+
const centerY = (target.top + target.bottom) / 2;
|
|
212
|
+
|
|
213
|
+
for (let ancestor = parentOf(element); ancestor !== null; ancestor = parentOf(ancestor)) {
|
|
214
|
+
const style = window.getComputedStyle(ancestor);
|
|
215
|
+
|
|
216
|
+
const horizontal =
|
|
217
|
+
/^(auto|scroll)$/.test(style.overflowX) && ancestor.scrollWidth > ancestor.clientWidth;
|
|
218
|
+
|
|
219
|
+
const vertical =
|
|
220
|
+
/^(auto|scroll)$/.test(style.overflowY) && ancestor.scrollHeight > ancestor.clientHeight;
|
|
221
|
+
|
|
222
|
+
if (!horizontal && !vertical) continue;
|
|
223
|
+
const box = ancestor.getBoundingClientRect();
|
|
224
|
+
const left = Math.max(0, box.left + ancestor.clientLeft);
|
|
225
|
+
const top = Math.max(0, box.top + ancestor.clientTop);
|
|
226
|
+
|
|
227
|
+
const right = Math.min(
|
|
228
|
+
window.innerWidth,
|
|
229
|
+
box.left + ancestor.clientLeft + ancestor.clientWidth,
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
const bottom = Math.min(
|
|
233
|
+
window.innerHeight,
|
|
234
|
+
box.top + ancestor.clientTop + ancestor.clientHeight,
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
if (right <= left || bottom <= top) continue;
|
|
238
|
+
const x = (left + right) / 2;
|
|
239
|
+
const y = (top + bottom) / 2;
|
|
240
|
+
const dx = horizontal && (target.left < left || target.right > right) ? centerX - x : 0;
|
|
241
|
+
const dy = vertical && (target.top < top || target.bottom > bottom) ? centerY - y : 0;
|
|
242
|
+
|
|
243
|
+
if (dx === 0 && dy === 0) continue;
|
|
244
|
+
const hit = hitAt(document, x, y);
|
|
245
|
+
|
|
246
|
+
if (hit !== null && (hit === ancestor || ancestor.contains(hit))) return { x, y, dx, dy };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const dx =
|
|
250
|
+
target.left < 0 || target.right > window.innerWidth ? centerX - window.innerWidth / 2 : 0;
|
|
251
|
+
|
|
252
|
+
const dy =
|
|
253
|
+
target.top < 0 || target.bottom > window.innerHeight ? centerY - window.innerHeight / 2 : 0;
|
|
254
|
+
|
|
255
|
+
return dx === 0 && dy === 0
|
|
256
|
+
? null
|
|
257
|
+
: { x: window.innerWidth / 2, y: window.innerHeight / 2, dx, dy };
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
return { controlOf, details, hitAt, hrefAttribute, linkOf, point, receives, scrollPlan, within };
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
export type Targets = ReturnType<typeof targets>;
|
|
264
|
+
|
|
265
|
+
const ResolvedPointSchema = Schema.Struct({
|
|
266
|
+
x: Schema.Finite,
|
|
267
|
+
y: Schema.Finite,
|
|
268
|
+
element: Schema.String,
|
|
269
|
+
tag: Schema.String,
|
|
270
|
+
role: Schema.NullOr(Schema.String),
|
|
271
|
+
name: Schema.String,
|
|
272
|
+
context: SubjectContext,
|
|
273
|
+
box: Schema.optional(Box),
|
|
274
|
+
cursor: Schema.String,
|
|
275
|
+
href: Schema.optional(Schema.String),
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
export const PointResultSchema = Schema.Union([
|
|
279
|
+
ResolvedPointSchema,
|
|
280
|
+
Schema.Struct({
|
|
281
|
+
error: Schema.Literals(["stale", "hidden", "offscreen", "disabled", "covered", "outside"]),
|
|
282
|
+
detail: Schema.String,
|
|
283
|
+
}),
|
|
284
|
+
]);
|
|
285
|
+
|
|
286
|
+
export const ScrollPlanSchema = Schema.NullOr(
|
|
287
|
+
Schema.Struct({
|
|
288
|
+
x: Schema.Finite,
|
|
289
|
+
y: Schema.Finite,
|
|
290
|
+
dx: Schema.Finite,
|
|
291
|
+
dy: Schema.Finite,
|
|
292
|
+
}),
|
|
293
|
+
);
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where an action's input goes: a ref or a point resolved to a control and a viewport point,
|
|
3
|
+
* scrolled into view first when it is outside the viewport: at once, or with the wheel where
|
|
4
|
+
* viewers watch.
|
|
5
|
+
*/
|
|
6
|
+
import { Effect } from "effect";
|
|
7
|
+
|
|
8
|
+
import { InvalidRequest, NotActionable, StaleRef } from "../../BrowserError.ts";
|
|
9
|
+
import { ResolvedTarget, type Target } from "../../Page.ts";
|
|
10
|
+
import { type Bridge, scriptCall } from "../page/bridge.ts";
|
|
11
|
+
import { decodeWith, failWith } from "../page/context.ts";
|
|
12
|
+
import type { Viewport } from "../page/viewport.ts";
|
|
13
|
+
import type { Dispatch } from "./dispatch.ts";
|
|
14
|
+
import type { Approval, Guard } from "./guard.ts";
|
|
15
|
+
import type { InputMarks } from "./perform.ts";
|
|
16
|
+
import type { Pointer } from "./pointer.ts";
|
|
17
|
+
import type { Settle } from "./settle.ts";
|
|
18
|
+
import * as Script from "./targets.inpage.ts";
|
|
19
|
+
|
|
20
|
+
const pointFailure = (
|
|
21
|
+
operation: string,
|
|
22
|
+
target: Target,
|
|
23
|
+
result: Extract<Script.PointResult, { readonly error: string }>,
|
|
24
|
+
) =>
|
|
25
|
+
failWith(
|
|
26
|
+
operation,
|
|
27
|
+
result.error === "stale" && typeof target === "string"
|
|
28
|
+
? new StaleRef({ ref: target })
|
|
29
|
+
: result.error === "outside"
|
|
30
|
+
? new InvalidRequest({ detail: result.detail })
|
|
31
|
+
: new NotActionable({ detail: result.detail }),
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const resolvedTarget = (result: Script.ResolvedPoint): ResolvedTarget =>
|
|
35
|
+
new ResolvedTarget({
|
|
36
|
+
point: { x: result.x, y: result.y },
|
|
37
|
+
element: result.element,
|
|
38
|
+
tag: result.tag,
|
|
39
|
+
role: result.role,
|
|
40
|
+
name: result.name,
|
|
41
|
+
context: result.context,
|
|
42
|
+
box: result.box,
|
|
43
|
+
cursor: result.cursor,
|
|
44
|
+
...(result.href === undefined ? {} : { href: result.href }),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export const make = (
|
|
48
|
+
bridge: Bridge,
|
|
49
|
+
viewport: Viewport,
|
|
50
|
+
guard: Guard,
|
|
51
|
+
pointer: Pointer,
|
|
52
|
+
dispatch: Dispatch,
|
|
53
|
+
settling: Settle,
|
|
54
|
+
) => {
|
|
55
|
+
const { evaluate } = bridge;
|
|
56
|
+
const { mutate } = guard;
|
|
57
|
+
const { moveTo, wheel } = pointer;
|
|
58
|
+
const { flush } = dispatch;
|
|
59
|
+
|
|
60
|
+
const readPoint = (
|
|
61
|
+
operation: string,
|
|
62
|
+
target: Target,
|
|
63
|
+
approval: Approval | undefined,
|
|
64
|
+
scroll: boolean,
|
|
65
|
+
) =>
|
|
66
|
+
(approval === undefined
|
|
67
|
+
? evaluate(operation, scriptCall("point", target, scroll))
|
|
68
|
+
: mutate(operation, scriptCall("point", target, scroll), approval)
|
|
69
|
+
).pipe(Effect.flatMap(decodeWith(operation, Script.PointResultSchema)));
|
|
70
|
+
|
|
71
|
+
const resolve = (operation: string, target: Target, approval?: Approval, scroll = true) => {
|
|
72
|
+
if (typeof target === "string" && !/^e\d+$/.test(target))
|
|
73
|
+
return failWith(
|
|
74
|
+
operation,
|
|
75
|
+
new InvalidRequest({ detail: target + " is not a ref; refs look like e12" }),
|
|
76
|
+
);
|
|
77
|
+
if (typeof target !== "string" && (!Number.isFinite(target.x) || !Number.isFinite(target.y)))
|
|
78
|
+
return failWith(
|
|
79
|
+
operation,
|
|
80
|
+
new InvalidRequest({ detail: "point coordinates must be finite" }),
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
return readPoint(operation, target, approval, scroll).pipe(
|
|
84
|
+
Effect.flatMap((result) =>
|
|
85
|
+
"error" in result
|
|
86
|
+
? pointFailure(operation, target, result)
|
|
87
|
+
: Effect.succeed(resolvedTarget(result)),
|
|
88
|
+
),
|
|
89
|
+
);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const targetFor = Effect.fnUntraced(function* (
|
|
93
|
+
operation: string,
|
|
94
|
+
target: Target,
|
|
95
|
+
approval: Approval | undefined,
|
|
96
|
+
marks: InputMarks,
|
|
97
|
+
) {
|
|
98
|
+
if (!marks.style.shown || typeof target !== "string")
|
|
99
|
+
return yield* resolve(operation, target, approval);
|
|
100
|
+
|
|
101
|
+
// Inspection and policy approval happen before this point. Every retry is geometry only;
|
|
102
|
+
// scrolling never obtains a fresh approval or silently follows a replacement document.
|
|
103
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
104
|
+
const result = yield* readPoint(operation, target, approval, false);
|
|
105
|
+
|
|
106
|
+
if (!("error" in result)) return resolvedTarget(result);
|
|
107
|
+
if (result.error !== "offscreen") return yield* pointFailure(operation, target, result);
|
|
108
|
+
|
|
109
|
+
const plan = yield* mutate(operation, scriptCall("scrollPlan", target), approval).pipe(
|
|
110
|
+
Effect.flatMap(decodeWith(operation, Script.ScrollPlanSchema)),
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
if (plan === null) break;
|
|
114
|
+
const size = yield* viewport.viewportFor(operation);
|
|
115
|
+
const point = { x: plan.x, y: plan.y };
|
|
116
|
+
|
|
117
|
+
yield* marks.at(point);
|
|
118
|
+
yield* moveTo(operation, marks, point);
|
|
119
|
+
const before = settling.mark();
|
|
120
|
+
|
|
121
|
+
yield* wheel(
|
|
122
|
+
operation,
|
|
123
|
+
marks,
|
|
124
|
+
point,
|
|
125
|
+
Math.max(-size.width * 0.9, Math.min(size.width * 0.9, plan.dx)),
|
|
126
|
+
Math.max(-size.height * 0.9, Math.min(size.height * 0.9, plan.dy)),
|
|
127
|
+
);
|
|
128
|
+
yield* flush(operation, marks.input);
|
|
129
|
+
yield* settling.settle(operation, before);
|
|
130
|
+
if (approval !== undefined) yield* approval.check();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Wheels may be prevented or the target may need unsupported nested/frame geometry. One
|
|
134
|
+
// explicit fallback preserves reachability without a distance-dependent protocol loop.
|
|
135
|
+
yield* marks.touched;
|
|
136
|
+
const before = settling.mark();
|
|
137
|
+
|
|
138
|
+
yield* resolve(operation, target, approval);
|
|
139
|
+
yield* settling.settle(operation, before);
|
|
140
|
+
if (approval !== undefined) yield* approval.check();
|
|
141
|
+
|
|
142
|
+
return yield* resolve(operation, target, approval, false);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
return { resolve, targetFor };
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
export type Targets = ReturnType<typeof make>;
|