buninu-browser 0.2.0 → 0.2.2
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/AGENTS.md +4 -0
- package/README.md +74 -9
- package/bunfig.toml +1 -0
- package/buninu-browser.js +4 -0
- package/index.js +5 -0
- package/lib/audio/piano.js +24 -1
- package/lib/audio/pulse-client.js +120 -12
- package/lib/cdp-server.js +19 -12
- package/lib/happy-dom/parser.js +24 -4
- package/lib/happy-dom/selector-matcher.js +83 -0
- package/lib/headless-shell.js +120 -28
- package/lib/input/hit-test.js +25 -7
- package/lib/layout/text-layout.js +365 -54
- package/lib/log.js +5 -2
- package/lib/media/byte-pipe.js +82 -0
- package/lib/media/position-audio.js +42 -0
- package/lib/network/network-service.js +326 -46
- package/lib/paint/display-list.js +322 -21
- package/lib/paint/gradient.js +11 -5
- package/lib/render-tree/index.js +62 -2
- package/lib/render-tree/svg.js +2 -1
- package/lib/renderer/bindings.js +989 -72
- package/lib/renderer/canvas-2d.js +92 -0
- package/lib/renderer/host.js +159 -9
- package/lib/renderer/interfaces.js +215 -0
- package/lib/renderer/main.js +160 -12
- package/lib/renderer/media-source.js +819 -0
- package/lib/renderer/messaging.js +411 -0
- package/lib/renderer/native-functions.js +113 -0
- package/lib/renderer/page-realm.js +1407 -200
- package/lib/renderer/page-renderer.js +1899 -394
- package/lib/renderer/rejections.js +31 -0
- package/lib/renderer/rewrite-worker.js +22 -0
- package/lib/renderer/script-rewrite.js +805 -57
- package/lib/renderer/trusted-types.js +129 -0
- package/lib/renderer/workers.js +327 -0
- package/lib/screenshot-cli.js +2 -0
- package/lib/style/computed-style.js +463 -242
- package/lib/style/selector-keys.js +302 -0
- package/lib/style/selector-traits.js +240 -0
- package/lib/user-agent.js +14 -2
- package/package.json +4 -1
- package/patches/happy-dom/0002-use-bun-platform-adapters.patch +139 -19
- package/patches/happy-dom/0006-parse-css-escapes-in-selectors.patch +115 -0
- package/patches/happy-dom/README.md +26 -0
- package/patches/ses/0002-embedder-native-functions-source-text-sloppy-code.patch +106 -0
- package/patches/ses/0003-cache-sloppy-evaluators.patch +47 -0
- package/patches/ses/README.md +4 -0
- package/test/canvas-2d.test.js +55 -0
- package/test/compositor.test.js +3 -1
- package/test/computed-style.test.js +98 -1
- package/test/display-list.test.js +74 -1
- package/test/download.test.js +35 -0
- package/test/fixtures/long-video.mp4 +0 -0
- package/test/fixtures/media-sync.js +57 -0
- package/test/happy-dom-platform-adapters.test.js +25 -0
- package/test/headless-shell.test.js +29 -0
- package/test/hit-test.test.js +25 -1
- package/test/log.test.js +4 -1
- package/test/media-source.test.js +156 -0
- package/test/media-sync.test.js +12 -0
- package/test/mobile.test.js +2 -2
- package/test/navigation.test.js +61 -0
- package/test/network-service.test.js +81 -0
- package/test/page-scripts.test.js +1094 -7
- package/test/position-audio.test.js +67 -0
- package/test/positioning.test.js +95 -1
- package/test/pulse-client.test.js +69 -0
- package/test/render-tree.test.js +22 -1
- package/test/renderer-host.test.js +5 -2
- package/test/screenshot-cli.test.js +25 -1
- package/test/script-rewrite.test.js +111 -6
- package/test/selector-keys.test.js +39 -0
- package/test/selector-traits.test.js +19 -0
- package/test/setup.js +5 -0
- package/test/text-layout.test.js +69 -1
- package/test/user-agent.test.js +60 -4
- package/vendor/happy-dom/packages/happy-dom/src/nodes/html-image-element/HTMLImageElement.ts +52 -22
- package/vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorParser.ts +18 -17
- package/vendor/ses/dist/ses.mjs +52 -6
package/lib/headless-shell.js
CHANGED
|
@@ -17,7 +17,7 @@ import { installLog, logFileFromEnv, writeLog } from "./log.js";
|
|
|
17
17
|
import { NetworkService } from "./network/network-service.js";
|
|
18
18
|
import { RendererHost } from "./renderer/host.js";
|
|
19
19
|
import { MOBILE_FLAG, MOBILE_LAYOUT_WIDTH, MOBILE_USER_AGENT } from "./renderer/flags.js";
|
|
20
|
-
import { DESKTOP_USER_AGENT } from "./user-agent.js";
|
|
20
|
+
import { DESKTOP_USER_AGENT, WEBKIT_USER_AGENT, webkitFromArgs } from "./user-agent.js";
|
|
21
21
|
|
|
22
22
|
/** Opt-in phone presentation: mobile user agent and touch media features. */
|
|
23
23
|
export const MOBILE = "--mobile";
|
|
@@ -37,15 +37,23 @@ export async function runHeadlessShell(args = Bun.argv.slice(2)) {
|
|
|
37
37
|
const mobile = args.includes(MOBILE);
|
|
38
38
|
if (mobile) console.error(`buninu-browser: ${MOBILE}: mobile user agent and touch media features; user agent overrides are ignored`);
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
+ `ses ${layerStatus(browser.sandbox?.ses)}`);
|
|
44
|
-
|
|
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, webkit: webkitFromArgs(args), waitForRenderer: false });
|
|
45
43
|
const portArgument = args.find((argument) => argument.startsWith("--remote-debugging-port="));
|
|
46
44
|
const requestedPort = Number(portArgument?.slice("--remote-debugging-port=".length) ?? 9222);
|
|
47
45
|
const server = CdpServer.create(browser.context).listen(requestedPort, "127.0.0.1");
|
|
48
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)}`);
|
|
49
57
|
|
|
50
58
|
const shutdown = () => {
|
|
51
59
|
server.stop(true);
|
|
@@ -67,13 +75,21 @@ export async function runHeadlessShell(args = Bun.argv.slice(2)) {
|
|
|
67
75
|
* not wait for CanvasKit and fonts to load. Site isolation is by origin
|
|
68
76
|
* because no Public Suffix List is bundled to compute sites.
|
|
69
77
|
*
|
|
70
|
-
*
|
|
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, mobile?: boolean, webkit?: boolean }} options
|
|
84
|
+
* WebKit identity defaults on; webkit: false selects Chromium. Mobile takes priority.
|
|
71
85
|
*/
|
|
72
86
|
export async function createBrowser({
|
|
73
87
|
allowHostJs = false, spareRenderer = true, network = new NetworkService(), rendererTimeout = 30_000, mobile = false,
|
|
88
|
+
waitForRenderer = true, webkit = true,
|
|
74
89
|
} = {}) {
|
|
75
|
-
//
|
|
76
|
-
|
|
90
|
+
// Explicit presentation profiles fix the identity for requests and scripts.
|
|
91
|
+
const initialUserAgent = mobile ? MOBILE_USER_AGENT : webkit ? WEBKIT_USER_AGENT : DESKTOP_USER_AGENT;
|
|
92
|
+
network.userAgent = initialUserAgent;
|
|
77
93
|
const history = [{ url: "about:blank", x: 0, y: 0 }];
|
|
78
94
|
let historyIndex = 0;
|
|
79
95
|
let currentUrl = "about:blank";
|
|
@@ -83,9 +99,14 @@ export async function createBrowser({
|
|
|
83
99
|
let currentKey = null;
|
|
84
100
|
let spare = null;
|
|
85
101
|
let closed = false;
|
|
102
|
+
// Settles when the first renderer shows about:blank (assigned below).
|
|
103
|
+
let rendererReady = null;
|
|
86
104
|
// Renderer-independent state replayed into every new renderer.
|
|
87
105
|
const viewport = { width: 800, height: 600, deviceScaleFactor: 1 };
|
|
88
|
-
let userAgent = null;
|
|
106
|
+
let userAgent = webkit ? initialUserAgent : null;
|
|
107
|
+
// Page.addScriptToEvaluateOnNewDocument: source by identifier, run in every new document before its scripts.
|
|
108
|
+
const initScripts = new Map();
|
|
109
|
+
let nextInitScript = 1;
|
|
89
110
|
// Listeners for frame and lifecycle notifications (the CDP server).
|
|
90
111
|
const frameListeners = new Set();
|
|
91
112
|
const lifecycleListeners = new Set();
|
|
@@ -146,13 +167,13 @@ export async function createBrowser({
|
|
|
146
167
|
await load(currentUrl, invoke);
|
|
147
168
|
},
|
|
148
169
|
// Only the renderer showing the current page may navigate it.
|
|
149
|
-
onNavigate: (url, downloadName, fragmentFrom) => {
|
|
170
|
+
onNavigate: (url, downloadName, fragmentFrom, post = null) => {
|
|
150
171
|
if (host !== current) return;
|
|
151
172
|
if (fragmentFrom) {
|
|
152
173
|
recordFragment(url, fragmentFrom);
|
|
153
174
|
return;
|
|
154
175
|
}
|
|
155
|
-
navigate(url, { downloadName }).catch((error) => console.error(`buninu-browser: navigation failed: ${error.message}`));
|
|
176
|
+
navigate(url, { downloadName, post }).catch((error) => console.error(`buninu-browser: navigation failed: ${error.message}`));
|
|
156
177
|
},
|
|
157
178
|
onDownload: ({ url, name, data }) => {
|
|
158
179
|
if (host !== current) return;
|
|
@@ -190,6 +211,7 @@ export async function createBrowser({
|
|
|
190
211
|
async function replayState(invoke) {
|
|
191
212
|
await invoke("resize", ...resizeArguments());
|
|
192
213
|
if (userAgent) await invoke("setUserAgent", userAgent);
|
|
214
|
+
if (initScripts.size) await invoke("setInitScripts", [...initScripts.values()]);
|
|
193
215
|
}
|
|
194
216
|
|
|
195
217
|
function prepareSpare() {
|
|
@@ -216,14 +238,28 @@ export async function createBrowser({
|
|
|
216
238
|
return current;
|
|
217
239
|
}
|
|
218
240
|
|
|
219
|
-
const call = (method, ...params) =>
|
|
241
|
+
const call = async (method, ...params) => {
|
|
242
|
+
await rendererReady;
|
|
243
|
+
return current.call(method, ...params);
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Applies viewport or user agent state the renderer is told about. Before
|
|
248
|
+
* the first renderer is up there is nothing to wait for: it starts with the
|
|
249
|
+
* state (replayState), and the latest state is applied again once it is.
|
|
250
|
+
*/
|
|
251
|
+
function applyState(method, args) {
|
|
252
|
+
if (current) return current.call(method, ...args());
|
|
253
|
+
rendererReady.then(() => current?.call(method, ...args())).catch(() => {});
|
|
254
|
+
return undefined;
|
|
255
|
+
}
|
|
220
256
|
|
|
221
257
|
/**
|
|
222
258
|
* Fetches a top-level document, then commits it in the renderer for its
|
|
223
259
|
* final origin. `invoke` pins the renderer (used when restoring a
|
|
224
260
|
* restarted renderer).
|
|
225
261
|
*/
|
|
226
|
-
async function load(url, invoke = null, downloadName = null) {
|
|
262
|
+
async function load(url, invoke = null, downloadName = null, post = null) {
|
|
227
263
|
const started = performance.now();
|
|
228
264
|
if (downloadName !== null && resource && withoutHash(url) === withoutHash(resource.url)) {
|
|
229
265
|
return download({ url: resource.url, body: resource.body, attributeName: downloadName, totalBytes: resource.body.byteLength });
|
|
@@ -237,7 +273,7 @@ export async function createBrowser({
|
|
|
237
273
|
try {
|
|
238
274
|
const target = new URL(url);
|
|
239
275
|
const response = ["http:", "https:"].includes(target.protocol)
|
|
240
|
-
? await network.navigate(target.href, { onResponse: (raw, finalUrl) => {
|
|
276
|
+
? await network.navigate(target.href, { post, onResponse: (raw, finalUrl) => {
|
|
241
277
|
if (!raw.ok || (downloadName === null && !isAttachment(raw.headers.get("content-disposition")))) return null;
|
|
242
278
|
return download({ url: finalUrl, body: raw.body, disposition: raw.headers.get("content-disposition") ?? "",
|
|
243
279
|
attributeName: downloadName ?? "", totalBytes: Number(raw.headers.get("content-length")) || 0 });
|
|
@@ -266,6 +302,7 @@ export async function createBrowser({
|
|
|
266
302
|
const fetched = performance.now();
|
|
267
303
|
writeLog("navigation", `${url}: ${document.error ?? `HTTP ${document.status ?? 200}`}`
|
|
268
304
|
+ `${document.url === url ? "" : ` -> ${document.url}`} in ${Math.round(fetched - started)}ms`);
|
|
305
|
+
if (!invoke) await rendererReady;
|
|
269
306
|
const host = invoke ? current : await rendererFor(document.url);
|
|
270
307
|
const generation = await (invoke ?? ((method, ...params) => host.call(method, ...params)))("loadDocument", document);
|
|
271
308
|
committed = { host, generation };
|
|
@@ -275,14 +312,19 @@ export async function createBrowser({
|
|
|
275
312
|
|
|
276
313
|
// Screenshots and scrolling go to the compositor thread, so they are
|
|
277
314
|
// answered even while the renderer's main thread is busy.
|
|
278
|
-
const composite = (method, ...params) =>
|
|
315
|
+
const composite = async (method, ...params) => {
|
|
316
|
+
await rendererReady;
|
|
317
|
+
return current.composite(method, ...params);
|
|
318
|
+
};
|
|
279
319
|
|
|
280
320
|
async function rememberScroll() {
|
|
321
|
+
// Before the first renderer is up nothing has been shown or scrolled.
|
|
322
|
+
if (!current) return;
|
|
281
323
|
const offset = await composite("scrollOffset");
|
|
282
324
|
Object.assign(history[historyIndex], { x: offset.x, y: offset.y });
|
|
283
325
|
}
|
|
284
326
|
|
|
285
|
-
async function navigate(url, { downloadName = null } = {}) {
|
|
327
|
+
async function navigate(url, { downloadName = null, post = null } = {}) {
|
|
286
328
|
const resolved = new URL(url, currentUrl).href;
|
|
287
329
|
// A javascript: URL runs in the current document; history does not change.
|
|
288
330
|
if (resolved.startsWith("javascript:")) {
|
|
@@ -290,15 +332,20 @@ export async function createBrowser({
|
|
|
290
332
|
return;
|
|
291
333
|
}
|
|
292
334
|
await rememberScroll();
|
|
293
|
-
|
|
335
|
+
let shown = resolved;
|
|
336
|
+
if (!post && downloadName === null && withoutHash(resolved) === withoutHash(currentUrl)) {
|
|
294
337
|
currentUrl = resolved;
|
|
295
338
|
} else {
|
|
296
|
-
const result = await load(resolved, null, downloadName);
|
|
339
|
+
const result = await load(resolved, null, downloadName, post);
|
|
297
340
|
if (result?.downloaded) return null;
|
|
341
|
+
// The document is at the URL redirects ended on; the request's fragment
|
|
342
|
+
// carries over to it unless it has its own (Fetch §4.4, HTML §7.4.6).
|
|
343
|
+
shown = withFragment(currentUrl, resolved);
|
|
344
|
+
currentUrl = shown;
|
|
298
345
|
}
|
|
299
|
-
await call("showFragment",
|
|
346
|
+
await call("showFragment", shown);
|
|
300
347
|
history.splice(historyIndex + 1);
|
|
301
|
-
history.push({ url:
|
|
348
|
+
history.push({ url: shown, x: 0, y: 0 });
|
|
302
349
|
historyIndex = history.length - 1;
|
|
303
350
|
}
|
|
304
351
|
|
|
@@ -323,6 +370,7 @@ export async function createBrowser({
|
|
|
323
370
|
|
|
324
371
|
const context = {
|
|
325
372
|
navigate,
|
|
373
|
+
get userAgent() { return userAgent ?? initialUserAgent; },
|
|
326
374
|
setDownloadBehavior({ behavior = "allow", downloadPath = DEFAULT_DOWNLOAD_DIR, eventsEnabled = false } = {}) {
|
|
327
375
|
if (!["allow", "allowAndName", "default", "deny"].includes(behavior)) throw new TypeError(`Unsupported download behavior ${behavior}`);
|
|
328
376
|
downloadBehavior = { behavior, downloadPath, eventsEnabled: Boolean(eventsEnabled) };
|
|
@@ -336,14 +384,24 @@ export async function createBrowser({
|
|
|
336
384
|
goForward: () => traverseHistory(historyIndex + 1),
|
|
337
385
|
resize(width, height, deviceScaleFactor = 1) {
|
|
338
386
|
Object.assign(viewport, { width, height, deviceScaleFactor });
|
|
339
|
-
return
|
|
387
|
+
return applyState("resize", resizeArguments);
|
|
340
388
|
},
|
|
341
389
|
setUserAgent(value) {
|
|
342
|
-
//
|
|
343
|
-
if (mobile) return undefined;
|
|
390
|
+
// A client's default override must not replace an explicit CLI profile.
|
|
391
|
+
if (mobile || webkit) return undefined;
|
|
344
392
|
userAgent = typeof value === "string" && value ? value : null;
|
|
345
393
|
network.userAgent = userAgent;
|
|
346
|
-
return userAgent ?
|
|
394
|
+
return userAgent ? applyState("setUserAgent", () => [userAgent]) : undefined;
|
|
395
|
+
},
|
|
396
|
+
addInitScript(source) {
|
|
397
|
+
const identifier = String(nextInitScript++);
|
|
398
|
+
initScripts.set(identifier, String(source));
|
|
399
|
+
applyState("setInitScripts", () => [[...initScripts.values()]]);
|
|
400
|
+
return identifier;
|
|
401
|
+
},
|
|
402
|
+
removeInitScript(identifier) {
|
|
403
|
+
if (!initScripts.delete(String(identifier))) return;
|
|
404
|
+
applyState("setInitScripts", () => [[...initScripts.values()]]);
|
|
347
405
|
},
|
|
348
406
|
screenshot: (options = {}) => composite("screenshot", {
|
|
349
407
|
format: options.format === "jpeg" || options.format === "webp" ? options.format : "png",
|
|
@@ -366,7 +424,20 @@ export async function createBrowser({
|
|
|
366
424
|
? { url: resource.url, contentType: resource.contentType, body: resource.body }
|
|
367
425
|
: null;
|
|
368
426
|
},
|
|
369
|
-
|
|
427
|
+
/**
|
|
428
|
+
* Scrolls by a wheel delta. With the pointer's viewport position, the
|
|
429
|
+
* scroll container under it (an element, an iframe's document) scrolls
|
|
430
|
+
* first; the page scrolls when nothing there takes it.
|
|
431
|
+
*/
|
|
432
|
+
async scroll(deltaX, deltaY, at = null) {
|
|
433
|
+
const scale = pageScale();
|
|
434
|
+
let left = { x: deltaX / scale, y: deltaY / scale };
|
|
435
|
+
if (at && Number.isFinite(at.x) && Number.isFinite(at.y)) {
|
|
436
|
+
left = await call("wheel", at.x / scale, at.y / scale, left.x, left.y) ?? left;
|
|
437
|
+
if (!left.x && !left.y) return undefined;
|
|
438
|
+
}
|
|
439
|
+
return composite("scroll", left.x, left.y);
|
|
440
|
+
},
|
|
370
441
|
press: (key, options) => call("press", key, options),
|
|
371
442
|
type: (text) => call("type", text),
|
|
372
443
|
scrollOffset: () => composite("scrollOffset"),
|
|
@@ -401,12 +472,21 @@ export async function createBrowser({
|
|
|
401
472
|
},
|
|
402
473
|
};
|
|
403
474
|
|
|
404
|
-
|
|
405
|
-
|
|
475
|
+
// The spare starts with the first renderer rather than after it, ready
|
|
476
|
+
// for the first navigation to an origin.
|
|
477
|
+
rendererReady = (async () => {
|
|
478
|
+
const first = rendererFor("about:blank");
|
|
479
|
+
prepareSpare();
|
|
480
|
+
await first;
|
|
481
|
+
await current.call("loadDocument", { url: "about:blank" });
|
|
482
|
+
})();
|
|
483
|
+
if (waitForRenderer) await rendererReady;
|
|
484
|
+
else rendererReady.catch(() => {}); // the caller awaits `ready`
|
|
406
485
|
|
|
407
486
|
return {
|
|
408
487
|
context,
|
|
409
488
|
network,
|
|
489
|
+
ready: rendererReady,
|
|
410
490
|
get sandbox() {
|
|
411
491
|
return current?.sandbox ?? null;
|
|
412
492
|
},
|
|
@@ -449,6 +529,18 @@ async function localDocument(url) {
|
|
|
449
529
|
};
|
|
450
530
|
}
|
|
451
531
|
|
|
532
|
+
/** `url`, with the fragment of `requested` when it has none of its own. */
|
|
533
|
+
function withFragment(url, requested) {
|
|
534
|
+
try {
|
|
535
|
+
const target = new URL(url);
|
|
536
|
+
const hash = new URL(requested).hash;
|
|
537
|
+
if (!target.hash && hash) target.hash = hash;
|
|
538
|
+
return target.href;
|
|
539
|
+
} catch {
|
|
540
|
+
return url;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
452
544
|
function withoutHash(href) {
|
|
453
545
|
const copy = new URL(href);
|
|
454
546
|
copy.hash = "";
|
package/lib/input/hit-test.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
import { hitTarget } from "../paint/display-list.js";
|
|
2
|
+
|
|
3
|
+
// An iframe takes clicks for its own document (the renderer forwards them).
|
|
4
|
+
const INTERACTIVE_TAGS = new Set(["A", "AUDIO", "BUTTON", "IFRAME", "INPUT", "LABEL", "SELECT", "SUMMARY", "TEXTAREA", "VIDEO"]);
|
|
5
|
+
// Form controls, labels and iframes are hit over their whole box, not only
|
|
6
|
+
// where text is drawn (an unchecked checkbox or an empty field has none; a
|
|
7
|
+
// block label is clickable beside its text).
|
|
8
|
+
const CONTROL_TAGS = new Set(["AUDIO", "BUTTON", "IFRAME", "INPUT", "LABEL", "SELECT", "TEXTAREA"]);
|
|
6
9
|
|
|
7
10
|
export function interactiveRegions(tree, layout, scroll = { x: 0, y: 0 }, viewport = {}) {
|
|
8
11
|
const regions = [];
|
|
@@ -71,6 +74,18 @@ export function hitTest(tree, layout, x, y, scroll, viewport) {
|
|
|
71
74
|
return deepest.sort((left, right) => left.area - right.area)[0] ?? null;
|
|
72
75
|
}
|
|
73
76
|
|
|
77
|
+
/**
|
|
78
|
+
* The element a pointer event at a viewport point targets: the topmost one
|
|
79
|
+
* painted there (its own box or its text), skipping those that take no
|
|
80
|
+
* pointer events. Null when the point hits nothing.
|
|
81
|
+
*/
|
|
82
|
+
export function elementAtPoint(tree, displayList, x, y, scroll) {
|
|
83
|
+
const hittable = hitTestable(tree);
|
|
84
|
+
const nodeId = hitTarget(displayList, x, y, scroll, (id) => !hittable(tree.nodesById.get(id)));
|
|
85
|
+
const domNode = nodeId == null ? null : tree.nodesById.get(nodeId)?.domNode;
|
|
86
|
+
return domNode?.nodeType === 1 ? domNode : domNode?.parentElement ?? null;
|
|
87
|
+
}
|
|
88
|
+
|
|
74
89
|
/** Returns the content-coordinate bounds contributed by an element's text descendants. */
|
|
75
90
|
export function elementBounds(tree, layout, element) {
|
|
76
91
|
const rects = [];
|
|
@@ -81,11 +96,14 @@ export function elementBounds(tree, layout, element) {
|
|
|
81
96
|
? renderNode.domNode
|
|
82
97
|
: renderNode?.domNode?.parentElement;
|
|
83
98
|
if (!owner || (owner !== element && !element.contains(owner))) continue;
|
|
99
|
+
// A replaced element (an image) spans its own box, not its line's height.
|
|
100
|
+
const replaced = run.type === "image" && run.y != null;
|
|
101
|
+
const top = replaced ? run.y : fragment.y;
|
|
84
102
|
rects.push({
|
|
85
103
|
left: run.x,
|
|
86
|
-
top
|
|
104
|
+
top,
|
|
87
105
|
right: run.x + run.width,
|
|
88
|
-
bottom:
|
|
106
|
+
bottom: top + (replaced ? run.height : fragment.height),
|
|
89
107
|
});
|
|
90
108
|
}
|
|
91
109
|
}
|