buninu-browser 0.1.0 → 0.2.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.
Files changed (64) hide show
  1. package/AGENTS.md +17 -0
  2. package/LICENSES/brotli.txt +19 -0
  3. package/README.md +41 -7
  4. package/THIRD_PARTY_NOTICES.md +26 -4
  5. package/buninu-browser.js +10 -2
  6. package/lib/audio/pcm.js +16 -0
  7. package/lib/audio/piano.js +109 -0
  8. package/lib/audio/pulse-client.js +413 -0
  9. package/lib/cdp-server.js +28 -7
  10. package/lib/document-types.js +308 -0
  11. package/lib/download-directory.js +28 -0
  12. package/lib/download.js +63 -0
  13. package/lib/happy-dom/selector-matcher.js +83 -0
  14. package/lib/headless-shell.js +165 -26
  15. package/lib/input/hit-test.js +57 -8
  16. package/lib/layout/text-layout.js +290 -41
  17. package/lib/network/network-service.js +177 -40
  18. package/lib/network/resolver.js +16 -0
  19. package/lib/paint/display-list.js +25 -10
  20. package/lib/render-tree/index.js +58 -0
  21. package/lib/renderer/bindings.js +543 -55
  22. package/lib/renderer/flags.js +8 -5
  23. package/lib/renderer/host.js +103 -7
  24. package/lib/renderer/main.js +35 -3
  25. package/lib/renderer/module-transform.js +30 -23
  26. package/lib/renderer/page-realm.js +228 -24
  27. package/lib/renderer/page-renderer.js +1264 -221
  28. package/lib/screenshot-cli.js +61 -0
  29. package/lib/style/computed-style.js +558 -229
  30. package/lib/style/selector-keys.js +302 -0
  31. package/lib/style/selector-traits.js +240 -0
  32. package/lib/user-agent.js +12 -0
  33. package/package.json +1 -1
  34. package/patches/happy-dom/0003-defer-script-events-to-renderer.patch +40 -0
  35. package/patches/happy-dom/0004-insert-adjacent-html-in-order.patch +19 -0
  36. package/patches/happy-dom/0005-invalidate-matches-on-structure-changes.patch +43 -0
  37. package/patches/happy-dom/README.md +12 -0
  38. package/patches/ses/0001-allow-regexp-own-tostring.patch +13 -0
  39. package/patches/ses/README.md +16 -0
  40. package/test/computed-style.test.js +113 -1
  41. package/test/document-types.test.js +111 -0
  42. package/test/download.test.js +192 -0
  43. package/test/fixtures/tone.mp3 +0 -0
  44. package/test/flex.test.js +11 -0
  45. package/test/happy-dom-matches.test.js +25 -0
  46. package/test/headless-shell.test.js +56 -0
  47. package/test/hit-test.test.js +37 -0
  48. package/test/log.test.js +4 -1
  49. package/test/network-service.test.js +67 -0
  50. package/test/page-scripts.test.js +437 -29
  51. package/test/piano.test.js +65 -0
  52. package/test/positioning.test.js +11 -0
  53. package/test/pulse-client.test.js +130 -0
  54. package/test/render-tree.test.js +36 -1
  55. package/test/resolver.test.js +12 -1
  56. package/test/screenshot-cli.test.js +19 -0
  57. package/test/selector-keys.test.js +39 -0
  58. package/test/selector-traits.test.js +19 -0
  59. package/test/text-layout.test.js +91 -1
  60. package/test/user-agent.test.js +42 -0
  61. package/vendor/happy-dom/packages/happy-dom/src/nodes/element/Element.ts +3 -6
  62. package/vendor/happy-dom/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts +2 -20
  63. package/vendor/happy-dom/packages/happy-dom/src/query-selector/QuerySelector.ts +15 -3
  64. package/vendor/ses/dist/ses.mjs +2 -0
@@ -11,10 +11,13 @@
11
11
  * the renderer sandbox, for every page.
12
12
  */
13
13
  import { CdpServer } from "./cdp-server.js";
14
+ import { DEFAULT_DOWNLOAD_DIR, isAttachment, saveDownload, suggestedFilename } from "./download.js";
15
+ import { audioDemoDocument, displayDocument } from "./document-types.js";
14
16
  import { installLog, logFileFromEnv, writeLog } from "./log.js";
15
17
  import { NetworkService } from "./network/network-service.js";
16
18
  import { RendererHost } from "./renderer/host.js";
17
19
  import { MOBILE_FLAG, MOBILE_LAYOUT_WIDTH, MOBILE_USER_AGENT } from "./renderer/flags.js";
20
+ import { DESKTOP_USER_AGENT } from "./user-agent.js";
18
21
 
19
22
  /** Opt-in phone presentation: mobile user agent and touch media features. */
20
23
  export const MOBILE = "--mobile";
@@ -34,15 +37,23 @@ export async function runHeadlessShell(args = Bun.argv.slice(2)) {
34
37
  const mobile = args.includes(MOBILE);
35
38
  if (mobile) console.error(`buninu-browser: ${MOBILE}: mobile user agent and touch media features; user agent overrides are ignored`);
36
39
 
37
- const browser = await createBrowser({ allowHostJs, mobile });
38
- const layerStatus = (layer) => layer?.installed ? "on" : `off (${layer?.reason})`;
39
- console.error(`buninu-browser: renderer sandbox: seccomp ${layerStatus(browser.sandbox?.seccomp)}, `
40
- + `ses ${layerStatus(browser.sandbox?.ses)}`);
41
-
40
+ // The endpoint opens while the first renderer starts: a client's first
41
+ // navigation fetches its document meanwhile.
42
+ const browser = await createBrowser({ allowHostJs, mobile, waitForRenderer: false });
42
43
  const portArgument = args.find((argument) => argument.startsWith("--remote-debugging-port="));
43
44
  const requestedPort = Number(portArgument?.slice("--remote-debugging-port=".length) ?? 9222);
44
45
  const server = CdpServer.create(browser.context).listen(requestedPort, "127.0.0.1");
45
46
  console.error(`DevTools listening on ws://127.0.0.1:${server.port}/devtools/browser/cdp-server`);
47
+ try {
48
+ await browser.ready;
49
+ } catch (error) {
50
+ server.stop(true);
51
+ browser.close();
52
+ throw error;
53
+ }
54
+ const layerStatus = (layer) => layer?.installed ? "on" : `off (${layer?.reason})`;
55
+ console.error(`buninu-browser: renderer sandbox: seccomp ${layerStatus(browser.sandbox?.seccomp)}, `
56
+ + `ses ${layerStatus(browser.sandbox?.ses)}`);
46
57
 
47
58
  const shutdown = () => {
48
59
  server.stop(true);
@@ -64,26 +75,38 @@ export async function runHeadlessShell(args = Bun.argv.slice(2)) {
64
75
  * not wait for CanvasKit and fonts to load. Site isolation is by origin
65
76
  * because no Public Suffix List is bundled to compute sites.
66
77
  *
67
- * @param {{ allowHostJs?: boolean, spareRenderer?: boolean, network?: NetworkService, rendererTimeout?: number }} options
78
+ * With `waitForRenderer: false` it returns before the first renderer is up
79
+ * (`ready` settles then): navigations fetch their documents meanwhile, and
80
+ * anything that needs a renderer waits for it.
81
+ *
82
+ * @param {{ allowHostJs?: boolean, spareRenderer?: boolean, network?: NetworkService, rendererTimeout?: number,
83
+ * waitForRenderer?: boolean }} options
68
84
  */
69
85
  export async function createBrowser({
70
86
  allowHostJs = false, spareRenderer = true, network = new NetworkService(), rendererTimeout = 30_000, mobile = false,
87
+ waitForRenderer = true,
71
88
  } = {}) {
72
89
  // --mobile fixes the user agent for page requests and navigator.userAgent.
73
- if (mobile) network.userAgent = MOBILE_USER_AGENT;
90
+ network.userAgent = mobile ? MOBILE_USER_AGENT : DESKTOP_USER_AGENT;
74
91
  const history = [{ url: "about:blank", x: 0, y: 0 }];
75
92
  let historyIndex = 0;
76
93
  let currentUrl = "about:blank";
94
+ // The current document's response as fetched (raw bytes), for downloads.
95
+ let resource = null;
77
96
  let current = null;
78
97
  let currentKey = null;
79
98
  let spare = null;
80
99
  let closed = false;
100
+ // Settles when the first renderer shows about:blank (assigned below).
101
+ let rendererReady = null;
81
102
  // Renderer-independent state replayed into every new renderer.
82
103
  const viewport = { width: 800, height: 600, deviceScaleFactor: 1 };
83
104
  let userAgent = null;
84
105
  // Listeners for frame and lifecycle notifications (the CDP server).
85
106
  const frameListeners = new Set();
86
107
  const lifecycleListeners = new Set();
108
+ const downloadListeners = new Set();
109
+ let downloadBehavior = { behavior: "allow", downloadPath: DEFAULT_DOWNLOAD_DIR, eventsEnabled: false };
87
110
  // The committed document: its renderer and load generation. Lifecycle
88
111
  // messages can arrive before loadDocument returns, so each host records
89
112
  // what every generation has reached.
@@ -104,6 +127,30 @@ export async function createBrowser({
104
127
  }
105
128
  }
106
129
 
130
+ async function download({ url, body, disposition = "", attributeName = "", totalBytes = 0 }) {
131
+ const guid = crypto.randomUUID();
132
+ const filename = suggestedFilename(url, disposition, attributeName);
133
+ if (downloadBehavior.eventsEnabled) notify(downloadListeners, "willBegin", { guid, url, suggestedFilename: filename });
134
+ if (downloadBehavior.behavior === "deny") {
135
+ if (downloadBehavior.eventsEnabled) notify(downloadListeners, "progress", { guid, state: "canceled", receivedBytes: 0, totalBytes });
136
+ return { downloaded: true };
137
+ }
138
+ try {
139
+ if (downloadBehavior.eventsEnabled) notify(downloadListeners, "progress", { guid, state: "inProgress", receivedBytes: 0, totalBytes });
140
+ const saved = await saveDownload({ url, body, disposition, attributeName,
141
+ filename: downloadBehavior.behavior === "allowAndName" ? guid : null, dir: downloadBehavior.downloadPath });
142
+ if (downloadBehavior.eventsEnabled) notify(downloadListeners, "progress", {
143
+ guid, state: "completed", receivedBytes: saved.bytes, totalBytes: totalBytes || saved.bytes, filePath: saved.path,
144
+ });
145
+ writeLog("download", `${url} -> ${saved.path}`);
146
+ return { downloaded: true, path: saved.path };
147
+ } catch (error) {
148
+ if (downloadBehavior.eventsEnabled) notify(downloadListeners, "progress", { guid, state: "canceled", receivedBytes: 0, totalBytes });
149
+ console.error(`buninu-browser: download failed: ${error?.message ?? error}`);
150
+ return { downloaded: true, error };
151
+ }
152
+ }
153
+
107
154
  function createHost() {
108
155
  const host = new RendererHost({
109
156
  network,
@@ -115,9 +162,17 @@ export async function createBrowser({
115
162
  await load(currentUrl, invoke);
116
163
  },
117
164
  // Only the renderer showing the current page may navigate it.
118
- onNavigate: (url) => {
165
+ onNavigate: (url, downloadName, fragmentFrom) => {
166
+ if (host !== current) return;
167
+ if (fragmentFrom) {
168
+ recordFragment(url, fragmentFrom);
169
+ return;
170
+ }
171
+ navigate(url, { downloadName }).catch((error) => console.error(`buninu-browser: navigation failed: ${error.message}`));
172
+ },
173
+ onDownload: ({ url, name, data }) => {
119
174
  if (host !== current) return;
120
- navigate(url).catch((error) => console.error(`buninu-browser: navigation failed: ${error.message}`));
175
+ void download({ url, body: data, attributeName: name, totalBytes: data.byteLength });
121
176
  },
122
177
  onFrame: () => {
123
178
  if (host === current) notify(frameListeners);
@@ -177,29 +232,57 @@ export async function createBrowser({
177
232
  return current;
178
233
  }
179
234
 
180
- const call = (method, ...params) => current.call(method, ...params);
235
+ const call = async (method, ...params) => {
236
+ await rendererReady;
237
+ return current.call(method, ...params);
238
+ };
239
+
240
+ /**
241
+ * Applies viewport or user agent state the renderer is told about. Before
242
+ * the first renderer is up there is nothing to wait for: it starts with the
243
+ * state (replayState), and the latest state is applied again once it is.
244
+ */
245
+ function applyState(method, args) {
246
+ if (current) return current.call(method, ...args());
247
+ rendererReady.then(() => current?.call(method, ...args())).catch(() => {});
248
+ return undefined;
249
+ }
181
250
 
182
251
  /**
183
252
  * Fetches a top-level document, then commits it in the renderer for its
184
253
  * final origin. `invoke` pins the renderer (used when restoring a
185
254
  * restarted renderer).
186
255
  */
187
- async function load(url, invoke = null) {
256
+ async function load(url, invoke = null, downloadName = null) {
188
257
  const started = performance.now();
189
- currentUrl = url;
258
+ if (downloadName !== null && resource && withoutHash(url) === withoutHash(resource.url)) {
259
+ return download({ url: resource.url, body: resource.body, attributeName: downloadName, totalBytes: resource.body.byteLength });
260
+ }
190
261
  let document;
191
262
  if (url === "about:blank") {
192
263
  document = { url };
264
+ } else if (url === "about:audio") {
265
+ document = { url, ...audioDemoDocument() };
193
266
  } else {
194
267
  try {
195
268
  const target = new URL(url);
196
269
  const response = ["http:", "https:"].includes(target.protocol)
197
- ? await network.navigate(target.href)
270
+ ? await network.navigate(target.href, { onResponse: (raw, finalUrl) => {
271
+ if (!raw.ok || (downloadName === null && !isAttachment(raw.headers.get("content-disposition")))) return null;
272
+ return download({ url: finalUrl, body: raw.body, disposition: raw.headers.get("content-disposition") ?? "",
273
+ attributeName: downloadName ?? "", totalBytes: Number(raw.headers.get("content-length")) || 0 });
274
+ } })
198
275
  : await localDocument(target);
276
+ if (response.downloaded) return response;
277
+ if (downloadName !== null) return download({ url: response.url, body: response.body,
278
+ attributeName: downloadName, totalBytes: response.body.byteLength });
279
+ resource = { url: response.url, contentType: response.contentType ?? "", body: response.body };
280
+ // Non-HTML responses (images, JSON, TOML, text...) are shown through a generated page.
281
+ const shown = displayDocument({ url: response.url, contentType: response.contentType, body: response.body });
199
282
  document = {
200
283
  url: response.url,
201
- source: new TextDecoder().decode(response.body),
202
- contentType: response.contentType || "text/html",
284
+ source: shown.source,
285
+ contentType: shown.contentType,
203
286
  status: response.status,
204
287
  statusText: response.statusText,
205
288
  cookie: network.documentCookie(response.url),
@@ -208,10 +291,12 @@ export async function createBrowser({
208
291
  document = { url, error: String(error) };
209
292
  }
210
293
  }
294
+ if (document.url === "about:blank" || document.url === "about:audio" || document.error) resource = null;
211
295
  currentUrl = document.url;
212
296
  const fetched = performance.now();
213
297
  writeLog("navigation", `${url}: ${document.error ?? `HTTP ${document.status ?? 200}`}`
214
298
  + `${document.url === url ? "" : ` -> ${document.url}`} in ${Math.round(fetched - started)}ms`);
299
+ if (!invoke) await rendererReady;
215
300
  const host = invoke ? current : await rendererFor(document.url);
216
301
  const generation = await (invoke ?? ((method, ...params) => host.call(method, ...params)))("loadDocument", document);
217
302
  committed = { host, generation };
@@ -221,14 +306,19 @@ export async function createBrowser({
221
306
 
222
307
  // Screenshots and scrolling go to the compositor thread, so they are
223
308
  // answered even while the renderer's main thread is busy.
224
- const composite = (method, ...params) => current.composite(method, ...params);
309
+ const composite = async (method, ...params) => {
310
+ await rendererReady;
311
+ return current.composite(method, ...params);
312
+ };
225
313
 
226
314
  async function rememberScroll() {
315
+ // Before the first renderer is up nothing has been shown or scrolled.
316
+ if (!current) return;
227
317
  const offset = await composite("scrollOffset");
228
318
  Object.assign(history[historyIndex], { x: offset.x, y: offset.y });
229
319
  }
230
320
 
231
- async function navigate(url) {
321
+ async function navigate(url, { downloadName = null } = {}) {
232
322
  const resolved = new URL(url, currentUrl).href;
233
323
  // A javascript: URL runs in the current document; history does not change.
234
324
  if (resolved.startsWith("javascript:")) {
@@ -236,10 +326,11 @@ export async function createBrowser({
236
326
  return;
237
327
  }
238
328
  await rememberScroll();
239
- if (withoutHash(resolved) === withoutHash(currentUrl)) {
329
+ if (downloadName === null && withoutHash(resolved) === withoutHash(currentUrl)) {
240
330
  currentUrl = resolved;
241
331
  } else {
242
- await load(resolved);
332
+ const result = await load(resolved, null, downloadName);
333
+ if (result?.downloaded) return null;
243
334
  }
244
335
  await call("showFragment", resolved);
245
336
  history.splice(historyIndex + 1);
@@ -247,6 +338,16 @@ export async function createBrowser({
247
338
  historyIndex = history.length - 1;
248
339
  }
249
340
 
341
+ /** The renderer already navigated to a fragment of its document; only history changes. */
342
+ function recordFragment(url, from) {
343
+ if (withoutHash(url) !== withoutHash(currentUrl)) return;
344
+ Object.assign(history[historyIndex], { x: from.x, y: from.y });
345
+ currentUrl = url;
346
+ history.splice(historyIndex + 1);
347
+ history.push({ url, x: 0, y: 0 });
348
+ historyIndex = history.length - 1;
349
+ }
350
+
250
351
  async function traverseHistory(index) {
251
352
  if (index < 0 || index >= history.length || index === historyIndex) return;
252
353
  await rememberScroll();
@@ -258,25 +359,49 @@ export async function createBrowser({
258
359
 
259
360
  const context = {
260
361
  navigate,
362
+ setDownloadBehavior({ behavior = "allow", downloadPath = DEFAULT_DOWNLOAD_DIR, eventsEnabled = false } = {}) {
363
+ if (!["allow", "allowAndName", "default", "deny"].includes(behavior)) throw new TypeError(`Unsupported download behavior ${behavior}`);
364
+ downloadBehavior = { behavior, downloadPath, eventsEnabled: Boolean(eventsEnabled) };
365
+ },
366
+ onDownload(listener) {
367
+ downloadListeners.add(listener);
368
+ return () => downloadListeners.delete(listener);
369
+ },
261
370
  reload: () => load(currentUrl),
262
371
  goBack: () => traverseHistory(historyIndex - 1),
263
372
  goForward: () => traverseHistory(historyIndex + 1),
264
373
  resize(width, height, deviceScaleFactor = 1) {
265
374
  Object.assign(viewport, { width, height, deviceScaleFactor });
266
- return call("resize", ...resizeArguments());
375
+ return applyState("resize", resizeArguments);
267
376
  },
268
377
  setUserAgent(value) {
269
378
  // With --mobile the phone user agent stays (casty, for one, overrides it with a desktop one).
270
379
  if (mobile) return undefined;
271
380
  userAgent = typeof value === "string" && value ? value : null;
272
381
  network.userAgent = userAgent;
273
- return userAgent ? call("setUserAgent", userAgent) : undefined;
382
+ return userAgent ? applyState("setUserAgent", () => [userAgent]) : undefined;
274
383
  },
275
384
  screenshot: (options = {}) => composite("screenshot", {
276
385
  format: options.format === "jpeg" || options.format === "webp" ? options.format : "png",
277
386
  quality: Number.isFinite(options.quality) ? options.quality : 80,
278
387
  }),
279
388
  title: () => call("title"),
389
+ /**
390
+ * The document's response body as it arrived (CDP Page.getResourceContent),
391
+ * for the current document's URL with or without its fragment; else null.
392
+ */
393
+ resourceContent(url = currentUrl) {
394
+ if (!resource) return null;
395
+ let wanted;
396
+ try {
397
+ wanted = withoutHash(String(url));
398
+ } catch {
399
+ return null;
400
+ }
401
+ return wanted === withoutHash(resource.url) || wanted === withoutHash(currentUrl)
402
+ ? { url: resource.url, contentType: resource.contentType, body: resource.body }
403
+ : null;
404
+ },
280
405
  scroll: (deltaX, deltaY) => composite("scroll", deltaX / pageScale(), deltaY / pageScale()),
281
406
  press: (key, options) => call("press", key, options),
282
407
  type: (text) => call("type", text),
@@ -296,10 +421,15 @@ export async function createBrowser({
296
421
  return [...reachedLifecycle(committed.host, committed.generation)];
297
422
  },
298
423
  async click(x, y) {
299
- const href = await call("click", x / pageScale(), y / pageScale());
424
+ const clicked = await call("click", x / pageScale(), y / pageScale());
425
+ const href = typeof clicked === "string" ? clicked : clicked?.url;
300
426
  if (typeof href !== "string") return null;
301
- await navigate(href);
302
- return { url: href, title: await call("title") };
427
+ if (clicked.fragmentFrom) {
428
+ recordFragment(href, clicked.fragmentFrom);
429
+ return { url: href, title: await call("title") };
430
+ }
431
+ const result = await navigate(href, { downloadName: typeof clicked === "object" ? clicked.download ?? null : null });
432
+ return result === null ? null : { url: href, title: await call("title") };
303
433
  },
304
434
  evaluate: (expression) => call("evaluate", String(expression)),
305
435
  cdp() {
@@ -307,12 +437,21 @@ export async function createBrowser({
307
437
  },
308
438
  };
309
439
 
310
- await rendererFor("about:blank");
311
- await current.call("loadDocument", { url: "about:blank" });
440
+ // The spare starts with the first renderer rather than after it, ready
441
+ // for the first navigation to an origin.
442
+ rendererReady = (async () => {
443
+ const first = rendererFor("about:blank");
444
+ prepareSpare();
445
+ await first;
446
+ await current.call("loadDocument", { url: "about:blank" });
447
+ })();
448
+ if (waitForRenderer) await rendererReady;
449
+ else rendererReady.catch(() => {}); // the caller awaits `ready`
312
450
 
313
451
  return {
314
452
  context,
315
453
  network,
454
+ ready: rendererReady,
316
455
  get sandbox() {
317
456
  return current?.sandbox ?? null;
318
457
  },
@@ -1,14 +1,19 @@
1
- const INTERACTIVE_TAGS = new Set(["A", "BUTTON", "INPUT", "SELECT", "SUMMARY", "TEXTAREA", "VIDEO"]);
1
+ const INTERACTIVE_TAGS = new Set(["A", "AUDIO", "BUTTON", "INPUT", "LABEL", "SELECT", "SUMMARY", "TEXTAREA", "VIDEO"]);
2
+ // Form controls and labels are hit over their whole box, not only where text
3
+ // is drawn (an unchecked checkbox or an empty field has none; a block label
4
+ // is clickable beside its text).
5
+ const CONTROL_TAGS = new Set(["AUDIO", "BUTTON", "INPUT", "LABEL", "SELECT", "TEXTAREA"]);
2
6
 
3
7
  export function interactiveRegions(tree, layout, scroll = { x: 0, y: 0 }, viewport = {}) {
4
8
  const regions = [];
5
9
  const byElement = new Map();
10
+ const hittable = hitTestable(tree);
6
11
 
7
12
  for (const fragment of layout.fragments) {
8
13
  for (const run of fragment.runs) {
9
14
  const renderNode = tree.nodesById.get(run.nodeId);
10
15
  const element = closestInteractive(renderNode?.domNode);
11
- if (!element) continue;
16
+ if (!element || !hittable(renderNode)) continue;
12
17
  const top = fragment.y - scroll.y;
13
18
  const left = run.x - scroll.x;
14
19
  const right = left + run.width;
@@ -27,19 +32,43 @@ export function interactiveRegions(tree, layout, scroll = { x: 0, y: 0 }, viewpo
27
32
  }
28
33
  }
29
34
 
35
+ for (const box of layout.boxes ?? []) {
36
+ const renderNode = tree.nodesById.get(box.nodeId);
37
+ const element = renderNode?.domNode;
38
+ if (!element || element.nodeType !== 1 || !CONTROL_TAGS.has(element.tagName) || !hittable(renderNode)) continue;
39
+ const top = box.y - scroll.y;
40
+ const left = box.x - scroll.x;
41
+ const right = left + box.width;
42
+ const bottom = top + box.height;
43
+ if (box.width <= 0 || box.height <= 0 || right <= 0 || bottom <= 0
44
+ || left >= (viewport.width ?? Infinity) || top >= (viewport.height ?? Infinity)) {
45
+ continue;
46
+ }
47
+ const rect = Object.freeze({ left, top, right, bottom, width: box.width, height: box.height });
48
+ let region = byElement.get(element);
49
+ if (!region) {
50
+ region = { element, rects: [] };
51
+ byElement.set(element, region);
52
+ regions.push(region);
53
+ }
54
+ region.rects.push(rect);
55
+ }
56
+
30
57
  return regions.map((region) => Object.freeze({
31
58
  element: region.element,
32
59
  rects: Object.freeze(region.rects),
33
60
  }));
34
61
  }
35
62
 
63
+ /** The innermost interactive element under a point (a checkbox before its label). */
36
64
  export function hitTest(tree, layout, x, y, scroll, viewport) {
37
- for (const region of interactiveRegions(tree, layout, scroll, viewport)) {
38
- if (region.rects.some((rect) => x >= rect.left && x < rect.right && y >= rect.top && y < rect.bottom)) {
39
- return region;
40
- }
41
- }
42
- return null;
65
+ const hits = interactiveRegions(tree, layout, scroll, viewport).map((region) => ({
66
+ ...region,
67
+ area: Math.min(...region.rects.filter((rect) => x >= rect.left && x < rect.right && y >= rect.top && y < rect.bottom)
68
+ .map((rect) => rect.width * rect.height)),
69
+ })).filter((region) => Number.isFinite(region.area));
70
+ const deepest = hits.filter((region) => !hits.some((other) => other !== region && region.element.contains(other.element)));
71
+ return deepest.sort((left, right) => left.area - right.area)[0] ?? null;
43
72
  }
44
73
 
45
74
  /** Returns the content-coordinate bounds contributed by an element's text descendants. */
@@ -68,6 +97,26 @@ export function elementBounds(tree, layout, element) {
68
97
  return Object.freeze({ left, top, right, bottom, width: right - left, height: bottom - top });
69
98
  }
70
99
 
100
+ /**
101
+ * Whether a render node takes clicks: not when its element is
102
+ * visibility: hidden or pointer-events: none (both inherited, so a visible
103
+ * or pointer-events: auto descendant still does). Text uses its element's style.
104
+ */
105
+ function hitTestable(tree) {
106
+ const styles = new Map();
107
+ for (const node of tree.nodesById.values()) {
108
+ if (node.domNode?.nodeType === 1 && node.style && !node.pseudo && !styles.has(node.domNode)) {
109
+ styles.set(node.domNode, node.style);
110
+ }
111
+ }
112
+ return (renderNode) => {
113
+ const domNode = renderNode?.domNode;
114
+ const element = domNode?.nodeType === 1 ? domNode : domNode?.parentElement;
115
+ const style = (renderNode?.pseudo ? renderNode.style : null) ?? styles.get(element);
116
+ return !style || (style.visibility !== "hidden" && style.pointerEvents !== "none");
117
+ };
118
+ }
119
+
71
120
  function closestInteractive(node) {
72
121
  for (let current = node?.nodeType === 1 ? node : node?.parentElement; current; current = current.parentElement) {
73
122
  if (INTERACTIVE_TAGS.has(current.tagName) || current.hasAttribute("onclick") || current.hasAttribute("tabindex")) {