buninu-browser 0.2.3 → 0.2.4
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/lib/cdp-server.js +47 -14
- package/lib/happy-dom/document-base.js +33 -0
- package/lib/happy-dom/parser.js +15 -6
- package/lib/happy-dom/selector-matcher.js +2 -2
- package/lib/happy-dom/selector-pseudo-adapter.js +19 -0
- package/lib/happy-dom/svg-attribute-adapter.js +50 -0
- package/lib/headless-shell.js +9 -6
- package/lib/input/hit-test.js +67 -19
- package/lib/layout/text-layout.js +213 -91
- package/lib/media/byte-pipe.js +2 -2
- package/lib/media/position-audio.js +10 -1
- package/lib/media/source-snapshots.js +126 -0
- package/lib/media/video-keyframe.js +17 -4
- package/lib/media/webm-stream.js +106 -0
- package/lib/network/network-service.js +268 -120
- package/lib/paint/display-list.js +8 -28
- package/lib/paint/frame-cache.js +25 -0
- package/lib/paint/frame.js +42 -13
- package/lib/paint/paragraph-text.js +24 -0
- package/lib/paint/raster-readback.js +41 -0
- package/lib/paint/rounded-rect.js +82 -0
- package/lib/paint/shared-frame-pool.js +105 -0
- package/lib/render-tree/index.js +38 -6
- package/lib/render-tree/svg.js +15 -7
- package/lib/renderer/bindings.js +233 -80
- package/lib/renderer/broadcast-channel.js +76 -0
- package/lib/renderer/canvas-2d.js +257 -14
- package/lib/renderer/canvas-text.js +120 -0
- package/lib/renderer/compositor.js +13 -11
- package/lib/renderer/custom-element-reactions.js +25 -1
- package/lib/renderer/font-loading.js +227 -0
- package/lib/renderer/host.js +28 -4
- package/lib/renderer/image-resources.js +65 -0
- package/lib/renderer/interfaces.js +28 -0
- package/lib/renderer/layout-observers.js +236 -0
- package/lib/renderer/main.js +30 -4
- package/lib/renderer/media-source.js +126 -55
- package/lib/renderer/page-realm.js +270 -98
- package/lib/renderer/page-renderer.js +647 -235
- package/lib/renderer/prepared-script-cache.js +50 -0
- package/lib/renderer/rendered-text.js +108 -0
- package/lib/renderer/rewrite-worker.js +4 -3
- package/lib/renderer/script-rewrite.js +13 -2
- package/lib/renderer/scroll-into-view.js +28 -0
- package/lib/renderer/slot-assignment.js +60 -0
- package/lib/renderer/video-presentation.js +23 -4
- package/lib/renderer/workers.js +2 -2
- package/lib/style/computed-style.js +611 -104
- package/lib/style/selector-traits.js +49 -5
- package/package.json +1 -1
- package/test/broadcast-channel.test.js +26 -0
- package/test/canvas-2d.test.js +83 -2
- package/test/canvas-paint.test.js +9 -0
- package/test/canvas-text.test.js +41 -0
- package/test/cdp-stream.test.js +56 -0
- package/test/computed-style-binding.test.js +151 -0
- package/test/computed-style.test.js +258 -1
- package/test/display-list.test.js +12 -0
- package/test/document-base.test.js +87 -0
- package/test/dom-platform.test.js +461 -0
- package/test/fetch-lifecycle.test.js +161 -0
- package/test/fixtures/canvas-paint.js +117 -0
- package/test/fixtures/canvas-text.js +42 -0
- package/test/fixtures/image-lifetime.js +90 -0
- package/test/fixtures/media-seek-snapshot.js +77 -0
- package/test/fixtures/mse-audio-chirp.webm +0 -0
- package/test/fixtures/mse-switch-large.mp4 +0 -0
- package/test/fixtures/mse-switch-small.mp4 +0 -0
- package/test/fixtures/renderer-memory.js +110 -0
- package/test/fixtures/summary-style.js +28 -0
- package/test/font-loading.test.js +97 -0
- package/test/grid.test.js +58 -0
- package/test/hit-test.test.js +21 -1
- package/test/image-lifetime.test.js +12 -0
- package/test/image-resources.test.js +94 -0
- package/test/local-media.test.js +84 -0
- package/test/media-seek-snapshot.test.js +11 -0
- package/test/media-source-config.test.js +98 -0
- package/test/media-source-webm-accounting.test.js +98 -0
- package/test/media-source-webm.test.js +108 -0
- package/test/navigation.test.js +1 -0
- package/test/network-service.test.js +388 -0
- package/test/page-scripts.test.js +1 -1
- package/test/paint-frame.test.js +103 -0
- package/test/paint-paragraph-cache.test.js +87 -0
- package/test/position-audio.test.js +24 -0
- package/test/positioning.test.js +22 -0
- package/test/prepared-script-cache.test.js +45 -0
- package/test/pulse-client.test.js +10 -3
- package/test/raster-readback.test.js +40 -0
- package/test/render-tree.test.js +28 -0
- package/test/renderer-memory.test.js +11 -0
- package/test/selector-traits.test.js +24 -0
- package/test/shared-frame-pool.test.js +63 -0
- package/test/style-sheets.test.js +51 -0
- package/test/svg.test.js +29 -0
- package/test/text-layout.test.js +106 -0
- package/test/video-keyframe.test.js +61 -0
- package/test/video-presentation.test.js +69 -0
- package/tools/browser-lab.js +78 -0
- package/tools/browser-soak.js +75 -0
- package/tools/network-reference.js +49 -0
- package/tools/process-memory.js +38 -0
- package/tools/script-benchmark.js +25 -0
- package/ai-debug.log +0 -5537
|
@@ -23,22 +23,30 @@
|
|
|
23
23
|
* after the load event.
|
|
24
24
|
*/
|
|
25
25
|
import CanvasKitInit from "../../usr/lib/canvaskit/canvaskit.js";
|
|
26
|
-
import { setCanvasKit } from "./canvas-2d.js";
|
|
26
|
+
import { setCanvasKit, canvasResource, observeDocumentCanvases } from "./canvas-2d.js";
|
|
27
|
+
import { createCanvasTextRenderer, parseCanvasFont } from "./canvas-text.js";
|
|
27
28
|
import { DESKTOP_USER_AGENT } from "../user-agent.js";
|
|
28
29
|
import { parseHTMLDocument } from "../happy-dom/parser.js";
|
|
29
30
|
import { LayoutEngine } from "../layout/text-layout.js";
|
|
30
31
|
import { ScrollViewport } from "../layout/scroll-viewport.js";
|
|
31
|
-
import {
|
|
32
|
+
import { scrollAlignmentDelta } from "./scroll-into-view.js";
|
|
33
|
+
import { elementAtPoint, elementBounds, elementBox, hitTest, interactiveRegions, renderNodeAtPoint } from "../input/hit-test.js";
|
|
32
34
|
import { beginImageLoad, finishImageLoad } from "./image-loading.js";
|
|
35
|
+
import { createImageResourceLoader, imageCrossOrigin, imageRequestKey } from "./image-resources.js";
|
|
36
|
+
import { DocumentFonts } from "./font-loading.js";
|
|
33
37
|
import { dispatchPointerMove } from "./pointer-move.js";
|
|
34
38
|
import { keyboardInput } from "./keyboard-input.js";
|
|
35
39
|
import { playbackEvent } from "./media-playback-events.js";
|
|
36
40
|
import { RenderTreeBuilder, renderTreeText, sameLayoutInput } from "../render-tree/index.js";
|
|
37
|
-
import { absolutizeStyleSheetUrls, matchSelectors, StyleEngine, topmostConnected } from "../style/computed-style.js";
|
|
41
|
+
import { absolutizeStyleSheetUrls, extractFontFaces, matchSelectors, mediaQueryMatches, StyleEngine, topmostConnected } from "../style/computed-style.js";
|
|
38
42
|
import { compileSelector, matchesCompiled } from "../happy-dom/selector-matcher.js";
|
|
39
43
|
import { elementFeatures, sheetTraits } from "../style/selector-traits.js";
|
|
40
44
|
import { buildDisplayList, visibleItems } from "../paint/display-list.js";
|
|
41
|
-
import { backgroundBytes, cropViewport,
|
|
45
|
+
import { backgroundBytes, cropViewport, encodeImageBase64 } from "../paint/frame.js";
|
|
46
|
+
import { FrameCaptureCache } from "../paint/frame-cache.js";
|
|
47
|
+
import { RasterReadback } from "../paint/raster-readback.js";
|
|
48
|
+
import { insetRadii, roundedRect } from "../paint/rounded-rect.js";
|
|
49
|
+
import { appendParagraphText } from "../paint/paragraph-text.js";
|
|
42
50
|
import { CLASSIC_SCRIPT_TYPES, createPageRealm } from "./page-realm.js";
|
|
43
51
|
import { originOf } from "./messaging.js";
|
|
44
52
|
import { MOBILE_USER_AGENT } from "./flags.js";
|
|
@@ -143,6 +151,11 @@ export function loadGraphics() {
|
|
|
143
151
|
const fontCollection = CanvasKit.FontCollection.Make();
|
|
144
152
|
fontCollection.setDefaultFontManager(fontProvider);
|
|
145
153
|
fontCollection.enableFontFallback();
|
|
154
|
+
const builtinTypefaces = [...loadedFamilies.values()].map(family => ({
|
|
155
|
+
family, typeface: fontProvider.matchFamilyStyle(family, {
|
|
156
|
+
weight: 400, width: 5, slant: CanvasKit.FontSlant.Upright,
|
|
157
|
+
}),
|
|
158
|
+
})).filter(entry => entry.typeface);
|
|
146
159
|
|
|
147
160
|
const fontFamilies = uiFontNames.map((name) => (fontCatalogue.fonts[name].families ?? [name])[0]);
|
|
148
161
|
const sansSerifFamily = fontFamilies[0];
|
|
@@ -158,7 +171,7 @@ export function loadGraphics() {
|
|
|
158
171
|
]);
|
|
159
172
|
return loadedGraphics = {
|
|
160
173
|
CanvasKit, loadedFamilies, fontCollection, fontFamilies, sansSerifFamily, monospaceFamily, monospaceBoldFamily,
|
|
161
|
-
genericFamilies,
|
|
174
|
+
genericFamilies, builtinTypefaces,
|
|
162
175
|
};
|
|
163
176
|
})();
|
|
164
177
|
}
|
|
@@ -231,38 +244,47 @@ function buildPageRenderer({
|
|
|
231
244
|
// An iframe's document (see syncFrames): frames go to the embedding page,
|
|
232
245
|
// which needs only the viewport, so nothing is painted ahead.
|
|
233
246
|
embedded = false,
|
|
247
|
+
allocateFramePixels = null,
|
|
234
248
|
// This document's window handle, made by the embedding page (see windowHandle).
|
|
235
249
|
frameWindow = null,
|
|
236
250
|
}, {
|
|
237
251
|
CanvasKit, loadedFamilies, fontCollection, fontFamilies, sansSerifFamily, monospaceFamily, monospaceBoldFamily,
|
|
238
|
-
genericFamilies,
|
|
252
|
+
genericFamilies, builtinTypefaces,
|
|
239
253
|
}) {
|
|
254
|
+
let documentFonts = null;
|
|
255
|
+
let fontSheets = null;
|
|
256
|
+
let fontEnvironment = "";
|
|
257
|
+
let fontDefinitions = [];
|
|
258
|
+
let fontDefinitionKey = "[]";
|
|
240
259
|
const resolvedFamilies = new Map();
|
|
241
260
|
/** First available family from a computed font-family list, then the UI fallbacks. */
|
|
242
|
-
function resolveFontFamilies(families = ["sans-serif"], weight = 400) {
|
|
261
|
+
function resolveFontFamilies(families = ["sans-serif"], weight = 400, fontStyle = "normal", point = null, stretch = 100) {
|
|
243
262
|
const bold = weight >= 600;
|
|
244
|
-
const key = `${
|
|
245
|
-
let resolved = resolvedFamilies.get(key);
|
|
263
|
+
const key = `${weight}/${fontStyle}/${stretch}\n${families.join("\n")}`;
|
|
264
|
+
let resolved = point === null && resolvedFamilies.get(key);
|
|
246
265
|
if (!resolved) {
|
|
247
|
-
let primary =
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
266
|
+
let primary = [];
|
|
267
|
+
for (const family of families) {
|
|
268
|
+
const web = documentFonts?.resolve(family, weight, fontStyle, point, stretch);
|
|
269
|
+
if (web?.length) { primary.push(...web); continue; }
|
|
270
|
+
const builtin = loadedFamilies.get(family.toLowerCase()) ?? genericFamilies.get(family.toLowerCase());
|
|
271
|
+
if (builtin) { primary.push(bold && builtin === monospaceFamily ? monospaceBoldFamily : builtin); break; }
|
|
272
|
+
}
|
|
273
|
+
if (!primary.length) primary = [sansSerifFamily];
|
|
274
|
+
resolved = [...primary, ...fontFamilies.filter((family) => !primary.includes(family))];
|
|
275
|
+
if (point === null) resolvedFamilies.set(key, resolved);
|
|
253
276
|
}
|
|
254
277
|
return resolved;
|
|
255
278
|
}
|
|
256
|
-
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
257
|
-
const emojiPresentation = /^\p{Emoji_Presentation}/u;
|
|
258
279
|
const renderTreeBuilder = new RenderTreeBuilder();
|
|
259
280
|
const layoutEngine = new LayoutEngine({ measureText, fontMetrics });
|
|
260
|
-
const styleEngine = new StyleEngine();
|
|
281
|
+
const styleEngine = new StyleEngine({ measureText });
|
|
261
282
|
// Incremental restyle: what may have changed style since the last
|
|
262
283
|
// computation. DOM mutations come from a MutationObserver; state that is
|
|
263
284
|
// not in the DOM tree (checked, value, focus) is marked by the code that
|
|
264
285
|
// changes it. `full` asks for a complete restyle.
|
|
265
|
-
const styleChanges = { full: true, roots: new Set(), rootFeatures: new Map(), records: [], observer: null,
|
|
286
|
+
const styleChanges = { full: true, roots: new Set(), rootFeatures: new Map(), records: [], observer: null,
|
|
287
|
+
activeElement: null, focusAncestors: new Set() };
|
|
266
288
|
let selectorTraits = { sibling: false, has: null, sheets: null };
|
|
267
289
|
let lastStyleParts = "";
|
|
268
290
|
let laidOutSheets = null;
|
|
@@ -306,6 +328,7 @@ function buildPageRenderer({
|
|
|
306
328
|
deviceScaleFactor: 1,
|
|
307
329
|
pageScale: 1,
|
|
308
330
|
needsPaint: true,
|
|
331
|
+
resourcePaintDirty: false,
|
|
309
332
|
loading: false,
|
|
310
333
|
hints: [],
|
|
311
334
|
hintsVisible: false,
|
|
@@ -316,6 +339,7 @@ function buildPageRenderer({
|
|
|
316
339
|
backgroundColor: "rgba(255, 255, 255, 1)",
|
|
317
340
|
resources: new WeakMap(),
|
|
318
341
|
decodedImages: [],
|
|
342
|
+
imageElements: new Set(),
|
|
319
343
|
imagesLoaded: null,
|
|
320
344
|
// CSS background images by absolute URL: { resource, loaded } (see queueBackgroundImages).
|
|
321
345
|
backgrounds: new Map(),
|
|
@@ -341,15 +365,58 @@ function buildPageRenderer({
|
|
|
341
365
|
if (typeof response.documentCookie === "string") page.cookie = response.documentCookie;
|
|
342
366
|
return response;
|
|
343
367
|
}
|
|
368
|
+
const loadImageResource = createImageResourceLoader(CanvasKit, fetchResource, pageFetch);
|
|
369
|
+
const renderCanvasText = createCanvasTextRenderer(CanvasKit, makeParagraph);
|
|
370
|
+
|
|
371
|
+
function resetDocumentFonts() {
|
|
372
|
+
documentFonts?.dispose();
|
|
373
|
+
fontSheets = null;
|
|
374
|
+
fontDefinitions = [];
|
|
375
|
+
if (fontDefinitionKey !== "[]") clearFontCaches();
|
|
376
|
+
fontEnvironment = "";
|
|
377
|
+
fontDefinitionKey = "[]";
|
|
378
|
+
documentFonts = new DocumentFonts({
|
|
379
|
+
CanvasKit, builtins: builtinTypefaces, defaultCollection: fontCollection, fetch: pageFetch,
|
|
380
|
+
availableFamilies: new Set([...loadedFamilies.keys(), ...genericFamilies.keys()]),
|
|
381
|
+
changed() {
|
|
382
|
+
if (disposedRenderer) return;
|
|
383
|
+
clearFontCaches();
|
|
384
|
+
// Font metrics also affect CSS ch units and normal line-height.
|
|
385
|
+
styleChanges.full = true;
|
|
386
|
+
page.dirty = true;
|
|
387
|
+
page.resourceLayoutDirty = true;
|
|
388
|
+
page.needsPaint = true;
|
|
389
|
+
scheduleFrame();
|
|
390
|
+
},
|
|
391
|
+
error: error => trace?.(`font failed: ${error?.message ?? error}`),
|
|
392
|
+
});
|
|
393
|
+
}
|
|
344
394
|
|
|
345
|
-
function
|
|
395
|
+
function clearFontCaches() {
|
|
396
|
+
resolvedFamilies.clear();
|
|
397
|
+
measuredWidths.clear();
|
|
398
|
+
measuredCount = 0;
|
|
399
|
+
fontKeys = new WeakMap();
|
|
400
|
+
fontMetricsCache.clear();
|
|
401
|
+
clearPaintParagraphs();
|
|
402
|
+
rasterized.list = null;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function queueFontLoads() {
|
|
406
|
+
if (page.renderTree && fontDefinitions.length) documentFonts.update(fontDefinitions,
|
|
407
|
+
[...page.renderTree.nodesById.values()].filter(node => node.type === "text"));
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function makeParagraph(text, computedStyle = null, fontSize = computedStyle?.fontSize ?? 16, scale = 1, foregroundPaint = null) {
|
|
346
411
|
const color = canvasColor(CanvasKit, computedStyle?.color ?? "rgba(0, 0, 0, 1)");
|
|
347
412
|
const weight = computedStyle?.fontWeight ?? 400;
|
|
348
|
-
const
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
413
|
+
const fontStyle = computedStyle?.fontStyle ?? "normal";
|
|
414
|
+
const stretch = computedStyle?.fontStretch ?? 100;
|
|
415
|
+
const widths = [50, 62.5, 75, 87.5, 100, 112.5, 125, 150, 200];
|
|
416
|
+
const width = widths.reduce((best, value, index) => Math.abs(value - stretch) < Math.abs(widths[best] - stretch) ? index : best, 4) + 1;
|
|
417
|
+
const slant = fontStyle === "italic" ? CanvasKit.FontSlant.Italic
|
|
418
|
+
: fontStyle === "oblique" ? CanvasKit.FontSlant.Oblique : CanvasKit.FontSlant.Upright;
|
|
419
|
+
const families = resolveFontFamilies(computedStyle?.fontFamily, weight, fontStyle, null, stretch);
|
|
353
420
|
// text-decoration lines, drawn by SkParagraph under/over/through the glyphs.
|
|
354
421
|
const decoration = computedStyle?.decoration ? {
|
|
355
422
|
decoration: computedStyle.decoration.lines.reduce((mask, line) => mask | ({
|
|
@@ -363,30 +430,60 @@ function buildPageRenderer({
|
|
|
363
430
|
color,
|
|
364
431
|
fontFamilies: families,
|
|
365
432
|
fontSize: Math.max(1, Math.round(fontSize * scale)),
|
|
366
|
-
fontStyle: { weight },
|
|
433
|
+
fontStyle: { weight, slant, width },
|
|
367
434
|
// Roboto is a variable font; static fonts ignore the axis and match by weight.
|
|
368
|
-
fontVariations: [{ axis: "wght", value: weight }],
|
|
435
|
+
fontVariations: [{ axis: "wght", value: weight }, { axis: "wdth", value: stretch }],
|
|
369
436
|
...decoration,
|
|
370
437
|
},
|
|
371
438
|
maxLines: 1,
|
|
439
|
+
...(computedStyle?.direction ? { textDirection: computedStyle.direction === "rtl" ? CanvasKit.TextDirection.RTL : CanvasKit.TextDirection.LTR } : {}),
|
|
372
440
|
});
|
|
373
|
-
const builder = CanvasKit.ParagraphBuilder.MakeFromFontCollection(style, fontCollection);
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
441
|
+
const builder = CanvasKit.ParagraphBuilder.MakeFromFontCollection(style, documentFonts?.collection ?? fontCollection);
|
|
442
|
+
const backgroundPaint = foregroundPaint ? new CanvasKit.Paint() : null;
|
|
443
|
+
if (backgroundPaint) backgroundPaint.setColor(CanvasKit.TRANSPARENT);
|
|
444
|
+
const pushTextStyle = foregroundPaint
|
|
445
|
+
? value => builder.pushPaintStyle(value, foregroundPaint, backgroundPaint) : value => builder.pushStyle(value);
|
|
446
|
+
const textStyle = (fontFamilies) => CanvasKit.TextStyle({ color, ...decoration, fontFamilies,
|
|
447
|
+
fontSize: Math.max(1, Math.round(fontSize * scale)), fontStyle: { weight, slant, width },
|
|
448
|
+
fontVariations: [{ axis: "wght", value: weight }, { axis: "wdth", value: stretch }] });
|
|
449
|
+
function append(text, fonts) {
|
|
450
|
+
let emojiStyle;
|
|
451
|
+
const writer = foregroundPaint ? { addText: value => builder.addText(value), pushStyle: pushTextStyle, pop: () => builder.pop() } : builder;
|
|
452
|
+
appendParagraphText(writer, text, () => emojiStyle ??= textStyle([
|
|
453
|
+
...fonts.filter(name => name.includes("Emoji")), ...fonts.filter(name => !name.includes("Emoji")),
|
|
454
|
+
]));
|
|
387
455
|
}
|
|
456
|
+
if (foregroundPaint) pushTextStyle(textStyle(families));
|
|
457
|
+
if (documentFonts?.hasUnicodeRanges && text) {
|
|
458
|
+
// A downloaded subset may physically contain more glyphs than its CSS
|
|
459
|
+
// unicode-range permits. Apply the range when choosing native families,
|
|
460
|
+
// preserving whole adjacent runs for shaping instead of per-letter calls.
|
|
461
|
+
const choices = new Map();
|
|
462
|
+
let run = "", previous = null, previousKey = "";
|
|
463
|
+
const flush = () => {
|
|
464
|
+
if (!run) return;
|
|
465
|
+
pushTextStyle(textStyle(previous));
|
|
466
|
+
append(run, previous);
|
|
467
|
+
builder.pop();
|
|
468
|
+
};
|
|
469
|
+
for (const character of text) {
|
|
470
|
+
const point = character.codePointAt(0);
|
|
471
|
+
let choice = choices.get(point);
|
|
472
|
+
if (!choice) {
|
|
473
|
+
const fonts = resolveFontFamilies(computedStyle?.fontFamily, weight, fontStyle, point, stretch);
|
|
474
|
+
choices.set(point, choice = { fonts, key: fonts.join("\n") });
|
|
475
|
+
}
|
|
476
|
+
if (run && choice.key !== previousKey) { flush(); run = ""; }
|
|
477
|
+
previous = choice.fonts;
|
|
478
|
+
previousKey = choice.key;
|
|
479
|
+
run += character;
|
|
480
|
+
}
|
|
481
|
+
flush();
|
|
482
|
+
} else append(text, families);
|
|
483
|
+
if (foregroundPaint) builder.pop();
|
|
388
484
|
const paragraph = builder.build();
|
|
389
485
|
builder.delete();
|
|
486
|
+
backgroundPaint?.delete();
|
|
390
487
|
return paragraph;
|
|
391
488
|
}
|
|
392
489
|
|
|
@@ -420,22 +517,24 @@ function buildPageRenderer({
|
|
|
420
517
|
}
|
|
421
518
|
|
|
422
519
|
// Computed styles are immutable and shared, so each one's key is computed once.
|
|
423
|
-
|
|
520
|
+
let fontKeys = new WeakMap();
|
|
424
521
|
function fontKeyOf(computedStyle) {
|
|
425
522
|
if (computedStyle && typeof computedStyle === "object") {
|
|
426
523
|
const cached = fontKeys.get(computedStyle);
|
|
427
524
|
if (cached) return cached;
|
|
428
525
|
}
|
|
429
526
|
const weight = computedStyle?.fontWeight ?? 400;
|
|
430
|
-
const
|
|
431
|
-
|
|
527
|
+
const fontStyle = computedStyle?.fontStyle ?? "normal";
|
|
528
|
+
const stretch = computedStyle?.fontStretch ?? 100;
|
|
529
|
+
const key = `${Math.max(1, Math.round(computedStyle?.fontSize ?? 16))}/${weight}/${fontStyle}/${stretch}/${
|
|
530
|
+
resolveFontFamilies(computedStyle?.fontFamily, weight, fontStyle, null, stretch).join(",")}`;
|
|
432
531
|
if (computedStyle && typeof computedStyle === "object") fontKeys.set(computedStyle, key);
|
|
433
532
|
return key;
|
|
434
533
|
}
|
|
435
534
|
|
|
436
535
|
const fontMetricsCache = new Map();
|
|
437
536
|
function fontMetrics(computedStyle = null) {
|
|
438
|
-
const key =
|
|
537
|
+
const key = fontKeyOf(computedStyle);
|
|
439
538
|
let metrics = fontMetricsCache.get(key);
|
|
440
539
|
if (!metrics) {
|
|
441
540
|
const paragraph = makeParagraph("Hg", computedStyle);
|
|
@@ -452,9 +551,10 @@ function buildPageRenderer({
|
|
|
452
551
|
// thread scrolls on its own (keys, fragments, script, a new document).
|
|
453
552
|
let scrollEpoch = 0;
|
|
454
553
|
let syncedScroll = { x: 0, y: 0 };
|
|
554
|
+
let syncedViewport = { width: 0, height: 0 };
|
|
455
555
|
let tileSerial = 0;
|
|
456
556
|
let lastTile = null;
|
|
457
|
-
|
|
557
|
+
const captures = new FrameCaptureCache();
|
|
458
558
|
let prepaintTimer = null;
|
|
459
559
|
let frameTimer = null;
|
|
460
560
|
let lastRebuildMs = 0;
|
|
@@ -469,6 +569,67 @@ function buildPageRenderer({
|
|
|
469
569
|
let rasterSurface = null;
|
|
470
570
|
let rasterSurfaceWidth = 0;
|
|
471
571
|
let rasterSurfaceHeight = 0;
|
|
572
|
+
const rasterReadback = new RasterReadback(CanvasKit, { shared: Boolean(onFrame), allocate: allocateFramePixels });
|
|
573
|
+
// A scroll or unrelated DOM update does not change text shaping. Keep a
|
|
574
|
+
// bounded set of visible paragraphs keyed by their complete shaping input.
|
|
575
|
+
// Both limits matter: a page may contain many unique or very long strings.
|
|
576
|
+
const paintedParagraphs = new Map();
|
|
577
|
+
let paragraphKeys = new WeakMap();
|
|
578
|
+
let paintedCharacters = 0;
|
|
579
|
+
const MAX_PAINT_PARAGRAPHS = 256;
|
|
580
|
+
const MAX_PAINT_CHARACTERS = 64_000;
|
|
581
|
+
|
|
582
|
+
function releasePaintParagraph(key, entry) {
|
|
583
|
+
entry.paragraph.delete();
|
|
584
|
+
paintedParagraphs.delete(key);
|
|
585
|
+
paintedCharacters -= entry.characters;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function clearPaintParagraphs() {
|
|
589
|
+
renderCanvasText.clear();
|
|
590
|
+
for (const entry of paintedParagraphs.values()) entry.paragraph.delete();
|
|
591
|
+
paintedParagraphs.clear();
|
|
592
|
+
paragraphKeys = new WeakMap();
|
|
593
|
+
paintedCharacters = 0;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function paragraphForPaint(item, scale, width) {
|
|
597
|
+
const retained = item.text.length <= 4096;
|
|
598
|
+
let cachedKey = retained && paragraphKeys.get(item);
|
|
599
|
+
if (retained && (!cachedKey || cachedKey.scale !== scale || cachedKey.width !== width)) {
|
|
600
|
+
cachedKey = { scale, width, key: JSON.stringify([item.text, item.color, item.font,
|
|
601
|
+
item.decoration, scale, width]) };
|
|
602
|
+
paragraphKeys.set(item, cachedKey);
|
|
603
|
+
}
|
|
604
|
+
const key = cachedKey?.key;
|
|
605
|
+
let entry = retained && paintedParagraphs.get(key);
|
|
606
|
+
if (entry) {
|
|
607
|
+
paintedParagraphs.delete(key);
|
|
608
|
+
paintedParagraphs.set(key, entry);
|
|
609
|
+
return entry;
|
|
610
|
+
}
|
|
611
|
+
const paragraph = makeParagraph(item.text, {
|
|
612
|
+
color: item.color, fontSize: item.font.size, fontWeight: item.font.weight, fontFamily: item.font.family,
|
|
613
|
+
fontStyle: item.font.style, fontStretch: item.font.stretch,
|
|
614
|
+
decoration: item.decoration,
|
|
615
|
+
}, undefined, scale);
|
|
616
|
+
try {
|
|
617
|
+
paragraph.layout(width);
|
|
618
|
+
} catch (error) {
|
|
619
|
+
paragraph.delete();
|
|
620
|
+
throw error;
|
|
621
|
+
}
|
|
622
|
+
entry = { paragraph, retained, characters: item.text.length };
|
|
623
|
+
if (entry.retained) {
|
|
624
|
+
paintedParagraphs.set(key, entry);
|
|
625
|
+
paintedCharacters += item.text.length;
|
|
626
|
+
while (paintedParagraphs.size > MAX_PAINT_PARAGRAPHS || paintedCharacters > MAX_PAINT_CHARACTERS) {
|
|
627
|
+
const [oldKey, oldEntry] = paintedParagraphs.entries().next().value;
|
|
628
|
+
releasePaintParagraph(oldKey, oldEntry);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
return entry;
|
|
632
|
+
}
|
|
472
633
|
|
|
473
634
|
/** Hint overlay contents, as a comparable key. */
|
|
474
635
|
function overlayKey() {
|
|
@@ -485,6 +646,8 @@ function buildPageRenderer({
|
|
|
485
646
|
if (page.displayList?.items.some((item) => item.fixed)
|
|
486
647
|
&& (rasterized.scrollX !== viewport.x || rasterized.scrollY !== viewport.y)) return false;
|
|
487
648
|
if (rasterized.list === page.displayList) return true;
|
|
649
|
+
if (rasterized.list?.images.length !== page.displayList?.images.length
|
|
650
|
+
|| page.displayList?.images.some((resource, index) => resource !== rasterized.list.images[index])) return false;
|
|
488
651
|
if (!rasterized.list || !page.displayList || !Bun.deepEquals(rasterized.list.items, page.displayList.items)) return false;
|
|
489
652
|
rasterized.list = page.displayList;
|
|
490
653
|
return true;
|
|
@@ -492,6 +655,12 @@ function buildPageRenderer({
|
|
|
492
655
|
|
|
493
656
|
/** Rasterizes the viewport and hands it to the compositor. */
|
|
494
657
|
function render() {
|
|
658
|
+
if (disposedRenderer) return;
|
|
659
|
+
if (page.resourcePaintDirty) {
|
|
660
|
+
page.displayList = paintList();
|
|
661
|
+
page.resourcePaintDirty = false;
|
|
662
|
+
}
|
|
663
|
+
page.realm?.layoutChanged(page.layoutVersion);
|
|
495
664
|
if (onVideo) for (const state of page.media.values()) readPresentedFrame(state);
|
|
496
665
|
const width = Math.max(1, Math.floor(page.width));
|
|
497
666
|
const height = Math.max(1, Math.floor(page.height));
|
|
@@ -499,15 +668,27 @@ function buildPageRenderer({
|
|
|
499
668
|
if (lastTile && tileCovers(lastTile, region) && sameRasterInput()) {
|
|
500
669
|
page.needsPaint = false;
|
|
501
670
|
// Only this thread's scroll position changed: tell the compositor.
|
|
502
|
-
if (viewport.x !== syncedScroll.x || viewport.y !== syncedScroll.y
|
|
671
|
+
if (viewport.x !== syncedScroll.x || viewport.y !== syncedScroll.y
|
|
672
|
+
|| page.width !== syncedViewport.width || page.height !== syncedViewport.height) publish(lastTile);
|
|
503
673
|
} else {
|
|
504
|
-
|
|
674
|
+
// Keep a previously prepainted region during ordinary page updates.
|
|
675
|
+
// Shrinking to the viewport and expanding again 30ms later used to
|
|
676
|
+
// rasterize each animation twice and repeatedly resize native surfaces.
|
|
677
|
+
const retained = !page.loading && lastTile && tileCovers(lastTile, region)
|
|
678
|
+
&& lastTile.height <= height * 2.5;
|
|
679
|
+
publish(rasterTile(retained ? { x: lastTile.x, y: lastTile.y, width: lastTile.width, height: lastTile.height } : region));
|
|
505
680
|
}
|
|
506
681
|
schedulePrepaint();
|
|
507
682
|
}
|
|
508
683
|
|
|
509
684
|
/** Rasterizes a page rectangle (CSS pixels) into RGBA pixels. */
|
|
510
685
|
function rasterTile(region) {
|
|
686
|
+
// Prepaint can run before a queued canvas/image repaint. Refresh native
|
|
687
|
+
// snapshots here too: the old display list may hold a released image.
|
|
688
|
+
if (page.resourcePaintDirty) {
|
|
689
|
+
page.displayList = paintList();
|
|
690
|
+
page.resourcePaintDirty = false;
|
|
691
|
+
}
|
|
511
692
|
const scale = page.deviceScaleFactor;
|
|
512
693
|
const pixelWidth = Math.max(1, Math.round(region.width * scale));
|
|
513
694
|
const pixelHeight = Math.max(1, Math.round(region.height * scale));
|
|
@@ -526,14 +707,9 @@ function buildPageRenderer({
|
|
|
526
707
|
drawHints(canvas, region);
|
|
527
708
|
surface.flush();
|
|
528
709
|
const image = surface.makeImageSnapshot();
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
colorType: CanvasKit.ColorType.RGBA_8888,
|
|
533
|
-
alphaType: CanvasKit.AlphaType.Unpremul,
|
|
534
|
-
colorSpace: CanvasKit.ColorSpace.SRGB,
|
|
535
|
-
});
|
|
536
|
-
image.delete();
|
|
710
|
+
let pixels;
|
|
711
|
+
try { pixels = rasterReadback.read(image, pixelWidth, pixelHeight); }
|
|
712
|
+
finally { image.delete(); }
|
|
537
713
|
const videoLayer = makeVideoLayer(canvas, surface, region, scale, pixelWidth, pixelHeight);
|
|
538
714
|
page.needsPaint = false;
|
|
539
715
|
rasterized = {
|
|
@@ -557,6 +733,7 @@ function buildPageRenderer({
|
|
|
557
733
|
function publish(tile) {
|
|
558
734
|
lastTile = tile;
|
|
559
735
|
tileSerial++;
|
|
736
|
+
syncedViewport = { width: page.width, height: page.height };
|
|
560
737
|
if (viewport.x !== syncedScroll.x || viewport.y !== syncedScroll.y) {
|
|
561
738
|
scrollEpoch++;
|
|
562
739
|
syncedScroll = { x: viewport.x, y: viewport.y };
|
|
@@ -587,7 +764,7 @@ function buildPageRenderer({
|
|
|
587
764
|
|
|
588
765
|
/** After a paint or a compositor scroll, rasterize around the viewport once this thread is idle. */
|
|
589
766
|
function schedulePrepaint() {
|
|
590
|
-
if (!onFrame || embedded) return;
|
|
767
|
+
if (disposedRenderer || !onFrame || embedded) return;
|
|
591
768
|
hostClearTimeout(prepaintTimer);
|
|
592
769
|
prepaintTimer = hostSetTimeout(() => {
|
|
593
770
|
prepaintTimer = null;
|
|
@@ -606,7 +783,7 @@ function buildPageRenderer({
|
|
|
606
783
|
|
|
607
784
|
/** A rendering opportunity for DOM changes nobody has asked to see yet. */
|
|
608
785
|
function scheduleFrame() {
|
|
609
|
-
if (!onFrame || frameTimer || page.loading) return;
|
|
786
|
+
if (disposedRenderer || !onFrame || frameTimer || page.loading) return;
|
|
610
787
|
frameTimer = hostSetTimeout(() => {
|
|
611
788
|
frameTimer = null;
|
|
612
789
|
refresh();
|
|
@@ -616,15 +793,14 @@ function buildPageRenderer({
|
|
|
616
793
|
}, Math.max(50, lastRebuildMs * 2));
|
|
617
794
|
}
|
|
618
795
|
|
|
619
|
-
/** The viewport as
|
|
620
|
-
async function viewportPng() {
|
|
796
|
+
/** The viewport as an encoded image (base64), cut from the latest tile. */
|
|
797
|
+
async function viewportPng({ format = "png", quality = 80, optimizeForSpeed = false } = {}) {
|
|
621
798
|
if (!lastTile) return "";
|
|
622
|
-
const key = `${tileSerial}:${viewport.x}:${viewport.y}`;
|
|
623
|
-
|
|
799
|
+
const key = `${tileSerial}:${viewport.x}:${viewport.y}:${page.width}:${page.height}:${format}:${quality}:${optimizeForSpeed}`;
|
|
800
|
+
return captures.get(key, () => {
|
|
624
801
|
const { pixels, width, height } = cropViewport(lastTile, viewport.x, viewport.y, page.width, page.height);
|
|
625
|
-
|
|
626
|
-
}
|
|
627
|
-
return encoded.png;
|
|
802
|
+
return encodeImageBase64(pixels, width, height, { format, quality, optimizeForSpeed });
|
|
803
|
+
});
|
|
628
804
|
}
|
|
629
805
|
|
|
630
806
|
/** Rasterizes display items intersecting the viewport onto a CanvasKit canvas. */
|
|
@@ -637,11 +813,12 @@ function buildPageRenderer({
|
|
|
637
813
|
pixel(rect.x - view.x), pixel(rect.y - view.y), pixel(rect.width), pixel(rect.height),
|
|
638
814
|
);
|
|
639
815
|
const deviceRRect = (rect, radii) => radii && (radii.x > 0 || radii.y > 0)
|
|
640
|
-
?
|
|
816
|
+
? roundedRect(deviceRect(rect), radii, scale)
|
|
641
817
|
: null;
|
|
642
818
|
// Returns a paint plus a release function that also frees its shader.
|
|
643
819
|
const fillPaint = (item) => {
|
|
644
820
|
const paint = new CanvasKit.Paint();
|
|
821
|
+
if (item.radii && (item.radii.x > 0 || item.radii.y > 0)) paint.setAntiAlias(true);
|
|
645
822
|
const shader = item.gradient ? gradientShader(item.gradient, view, scale) : null;
|
|
646
823
|
if (shader) paint.setShader(shader);
|
|
647
824
|
else paint.setColor(canvasColor(CanvasKit, item.color));
|
|
@@ -831,25 +1008,23 @@ function buildPageRenderer({
|
|
|
831
1008
|
} else if (item.op === "drawSvg") {
|
|
832
1009
|
drawSvg(item);
|
|
833
1010
|
} else if (item.op === "drawText") {
|
|
834
|
-
const
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
canvas.drawParagraph(paragraph, pixel(item.x - view.x), pixel(item.y - view.y));
|
|
1011
|
+
const entry = paragraphForPaint(item, scale, pixel(Math.max(1, view.width + view.x - item.x)));
|
|
1012
|
+
try {
|
|
1013
|
+
if (item.gradient) {
|
|
1014
|
+
// background-clip:text: draw glyphs, then keep the gradient only where they are.
|
|
1015
|
+
canvas.saveLayer(null, deviceRect(item.bounds));
|
|
1016
|
+
canvas.drawParagraph(entry.paragraph, pixel(item.x - view.x), pixel(item.y - view.y));
|
|
1017
|
+
const paint = fillPaint(item);
|
|
1018
|
+
paint.setBlendMode(CanvasKit.BlendMode.SrcIn);
|
|
1019
|
+
canvas.drawRect(deviceRect(item.bounds), paint);
|
|
1020
|
+
paint.release();
|
|
1021
|
+
canvas.restore();
|
|
1022
|
+
} else {
|
|
1023
|
+
canvas.drawParagraph(entry.paragraph, pixel(item.x - view.x), pixel(item.y - view.y));
|
|
1024
|
+
}
|
|
1025
|
+
} finally {
|
|
1026
|
+
if (!entry.retained) entry.paragraph.delete();
|
|
851
1027
|
}
|
|
852
|
-
paragraph.delete();
|
|
853
1028
|
}
|
|
854
1029
|
}
|
|
855
1030
|
}
|
|
@@ -866,13 +1041,9 @@ function buildPageRenderer({
|
|
|
866
1041
|
const Y0 = (item.rect.y - view.y) * scale;
|
|
867
1042
|
const X1 = X0 + item.rect.width * scale;
|
|
868
1043
|
const Y1 = Y0 + item.rect.height * scale;
|
|
869
|
-
const rx = item.radii.x * scale;
|
|
870
|
-
const ry = item.radii.y * scale;
|
|
871
1044
|
const [x0, y0, x1, y1] = [X0 + left, Y0 + top, Math.max(X0 + left, X1 - right), Math.max(Y0 + top, Y1 - bottom)];
|
|
872
|
-
const outer =
|
|
873
|
-
const inner =
|
|
874
|
-
Math.max(0, rx - left), Math.max(0, ry - top), Math.max(0, rx - right), Math.max(0, ry - top),
|
|
875
|
-
Math.max(0, rx - right), Math.max(0, ry - bottom), Math.max(0, rx - left), Math.max(0, ry - bottom));
|
|
1045
|
+
const outer = roundedRect([X0, Y0, X1, Y1], item.radii, scale);
|
|
1046
|
+
const inner = roundedRect([x0, y0, x1, y1], insetRadii(item.radii, item.widths), scale);
|
|
876
1047
|
const sides = [
|
|
877
1048
|
[X0, Y0, X1, Y0, x1, y0, x0, y0],
|
|
878
1049
|
[X1, Y0, X1, Y1, x1, y1, x1, y0],
|
|
@@ -1029,6 +1200,29 @@ function buildPageRenderer({
|
|
|
1029
1200
|
}
|
|
1030
1201
|
}
|
|
1031
1202
|
|
|
1203
|
+
function nativeControlState(element) {
|
|
1204
|
+
return [element.value, element.checked, element.indeterminate, element.selected, element.selectedIndex];
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
/** HTML activation can change control properties without attribute records
|
|
1208
|
+
* (including a different radio in the same group). Compare these few native
|
|
1209
|
+
* states instead of restyling the entire document for every pointer click.
|
|
1210
|
+
*/
|
|
1211
|
+
function controlStatesBeforeActivation() {
|
|
1212
|
+
return new Map(Array.from(page.document.querySelectorAll("input,select,textarea,option"),
|
|
1213
|
+
element => [element,nativeControlState(element)]));
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
function invalidateActivatedControls(previous) {
|
|
1217
|
+
for (const [element,before] of previous) {
|
|
1218
|
+
const after = nativeControlState(element);
|
|
1219
|
+
if (before.some((value,index) => value !== after[index])) {
|
|
1220
|
+
markStyleDirty(element);
|
|
1221
|
+
page.dirty = true;
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1032
1226
|
function observeStyleChanges(document) {
|
|
1033
1227
|
styleChanges.observer?.disconnect();
|
|
1034
1228
|
styleChanges.records = [];
|
|
@@ -1036,6 +1230,7 @@ function buildPageRenderer({
|
|
|
1036
1230
|
styleChanges.rootFeatures = new Map();
|
|
1037
1231
|
styleChanges.full = "new document";
|
|
1038
1232
|
styleChanges.activeElement = null;
|
|
1233
|
+
styleChanges.focusAncestors = new Set();
|
|
1039
1234
|
const observer = new page.window.MutationObserver((records) => { styleChanges.records.push(...records); });
|
|
1040
1235
|
observer.observe(document, { subtree: true, childList: true, attributes: true, attributeOldValue: true, characterData: true });
|
|
1041
1236
|
styleChanges.observer = observer;
|
|
@@ -1060,6 +1255,7 @@ function buildPageRenderer({
|
|
|
1060
1255
|
descendantFeatures: union("descendantFeatures"), siblingFeatures: union("siblingFeatures"),
|
|
1061
1256
|
descendantStateCompounds: parts.flatMap((part) => part.descendantStateCompounds),
|
|
1062
1257
|
siblingTargetCompounds: [...union("siblingTargetCompounds")],
|
|
1258
|
+
observedAttributes: union("observedAttributes"),
|
|
1063
1259
|
forward: any("forward"), firstChild: any("firstChild"), backward: any("backward"), lastChild: any("lastChild"),
|
|
1064
1260
|
defined: any("defined"), target: any("target"),
|
|
1065
1261
|
};
|
|
@@ -1189,11 +1385,12 @@ function buildPageRenderer({
|
|
|
1189
1385
|
let treeDirty = false;
|
|
1190
1386
|
let full = styleChanges.full;
|
|
1191
1387
|
const active = page.document.activeElement;
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1388
|
+
const previousActive = styleChanges.activeElement;
|
|
1389
|
+
const previousFocusAncestors = styleChanges.focusAncestors;
|
|
1390
|
+
const focusAncestors = new Set();
|
|
1391
|
+
for (let element = active; element; element = element.parentElement) focusAncestors.add(element);
|
|
1392
|
+
styleChanges.activeElement = active;
|
|
1393
|
+
styleChanges.focusAncestors = focusAncestors;
|
|
1197
1394
|
// Elements whose subtree restyles (`roots`), and elements whose own
|
|
1198
1395
|
// matches may change but whose subtree restyles only if their style does
|
|
1199
1396
|
// (`self`, decided by the style engine).
|
|
@@ -1221,7 +1418,8 @@ function buildPageRenderer({
|
|
|
1221
1418
|
if (!states.length) return false;
|
|
1222
1419
|
const available = new Set(elementFeatures(element));
|
|
1223
1420
|
return traits.descendantStateCompounds.some((entry) => (states.includes(":state") || entry.states.some((state) => states.includes(state)))
|
|
1224
|
-
&& entry.required.every((feature) => available.has(feature))
|
|
1421
|
+
&& entry.required.every((feature) => available.has(feature))
|
|
1422
|
+
&& entry.alternatives.every(group => group.some(required => required.every(feature => available.has(feature)))));
|
|
1225
1423
|
};
|
|
1226
1424
|
/** An element whose features changed: itself, and wider where non-subject compounds use them. */
|
|
1227
1425
|
const changed = (element, features) => {
|
|
@@ -1238,22 +1436,38 @@ function buildPageRenderer({
|
|
|
1238
1436
|
if (matchesChanged(structuralState, element)) roots.add(element);
|
|
1239
1437
|
};
|
|
1240
1438
|
if (!full && !hasState) full = "first restyle";
|
|
1241
|
-
//
|
|
1242
|
-
//
|
|
1243
|
-
|
|
1244
|
-
|
|
1439
|
+
// Only the explicitly marked roots need converting to feature changes;
|
|
1440
|
+
// roots added by relational/focus invalidation below must remain roots.
|
|
1441
|
+
const markedRoots = [...roots];
|
|
1442
|
+
roots.clear();
|
|
1443
|
+
for (const element of markedRoots) {
|
|
1245
1444
|
if (!full) changed(element, styleChanges.rootFeatures.get(element) ?? [":state"]);
|
|
1246
1445
|
}
|
|
1446
|
+
if (!full) {
|
|
1447
|
+
// Focus affects the two subjects, their :focus-within ancestor paths,
|
|
1448
|
+
// and selectors related to those nodes. Keep the previous path because
|
|
1449
|
+
// the old focused element may have been removed or reparented already.
|
|
1450
|
+
if (active !== previousActive) {
|
|
1451
|
+
if (previousActive) changed(previousActive, [":focus", ":focus-visible"]);
|
|
1452
|
+
if (active) changed(active, [":focus", ":focus-visible"]);
|
|
1453
|
+
}
|
|
1454
|
+
for (const element of previousFocusAncestors) if (!focusAncestors.has(element)) changed(element, [":focus-within"]);
|
|
1455
|
+
for (const element of focusAncestors) if (!previousFocusAncestors.has(element)) changed(element, [":focus-within"]);
|
|
1456
|
+
}
|
|
1247
1457
|
for (const record of records) {
|
|
1248
1458
|
if (full) break;
|
|
1249
1459
|
const target = record.target;
|
|
1250
1460
|
if (record.type === "attributes") {
|
|
1251
1461
|
if (target.nodeType !== 1) continue;
|
|
1252
1462
|
const name = record.attributeName;
|
|
1463
|
+
// Happy DOM reports null for an empty attribute's previous value on
|
|
1464
|
+
// removal. Null therefore cannot prove that the attribute is unchanged.
|
|
1465
|
+
if (record.oldValue !== null && record.oldValue === target.getAttribute(name)) continue;
|
|
1253
1466
|
// data/ARIA/title changes have no direct render-tree representation;
|
|
1254
1467
|
// selectors that observe them are handled by the style diff below.
|
|
1255
1468
|
// Other attributes may alter controls, replaced elements or visibility.
|
|
1256
1469
|
if (!name.startsWith("data-") && !name.startsWith("aria-") && name !== "title") treeDirty = true;
|
|
1470
|
+
else if (!traits.observedAttributes.has(name.toLowerCase())) continue;
|
|
1257
1471
|
const features = ["[" + name];
|
|
1258
1472
|
if (name === "class") {
|
|
1259
1473
|
const before = new Set(String(record.oldValue ?? "").split(/\s+/).filter(Boolean));
|
|
@@ -1266,6 +1480,12 @@ function buildPageRenderer({
|
|
|
1266
1480
|
if (id) features.push("#" + id);
|
|
1267
1481
|
}
|
|
1268
1482
|
changed(target, features);
|
|
1483
|
+
// The UA disclosure marker depends on its parent's open state even
|
|
1484
|
+
// when no author selector observes [open].
|
|
1485
|
+
if (name === "open" && target.tagName === "DETAILS") {
|
|
1486
|
+
const summary = [...target.children].find(element => element.tagName === "SUMMARY");
|
|
1487
|
+
if (summary) self.add(summary);
|
|
1488
|
+
}
|
|
1269
1489
|
} else if (record.type === "childList") {
|
|
1270
1490
|
treeDirty = true;
|
|
1271
1491
|
if (target.nodeType !== 1) { full = `children of ${target.nodeName}`; break; }
|
|
@@ -1280,6 +1500,11 @@ function buildPageRenderer({
|
|
|
1280
1500
|
}
|
|
1281
1501
|
}
|
|
1282
1502
|
self.add(target);
|
|
1503
|
+
// Adding, removing or moving a summary can change which one receives
|
|
1504
|
+
// the UA list-item display and disclosure marker.
|
|
1505
|
+
if (target.tagName === "DETAILS") {
|
|
1506
|
+
for (const child of target.children) if (child.tagName === "SUMMARY") self.add(child);
|
|
1507
|
+
}
|
|
1283
1508
|
hasChanges(traits, target, add, hasChecked);
|
|
1284
1509
|
let next = record.nextSibling;
|
|
1285
1510
|
while (next && next.nodeType !== 1) next = next.nextSibling;
|
|
@@ -1318,16 +1543,26 @@ function buildPageRenderer({
|
|
|
1318
1543
|
}
|
|
1319
1544
|
|
|
1320
1545
|
function replaceParsedDocument(parsed, styleSheets = [], imageResources = emptyImageResources(), linkSheets = null) {
|
|
1546
|
+
if (page.document) loadImageResource.cancelDocument(page.document);
|
|
1547
|
+
rasterReadback.dispose();
|
|
1548
|
+
clearPaintParagraphs();
|
|
1549
|
+
resetDocumentFonts();
|
|
1321
1550
|
disposeFrames();
|
|
1322
1551
|
closePiano();
|
|
1323
1552
|
disposeVideos();
|
|
1324
1553
|
page.realm?.dispose();
|
|
1325
1554
|
page.realm = null;
|
|
1326
|
-
page.window?.happyDOM?.
|
|
1555
|
+
page.window?.happyDOM?.close?.();
|
|
1327
1556
|
for (const image of page.decodedImages) image.delete();
|
|
1328
1557
|
disposeBackgrounds();
|
|
1329
1558
|
page.window = parsed.window;
|
|
1330
1559
|
page.document = parsed.document;
|
|
1560
|
+
observeDocumentCanvases(parsed.document, (node) => {
|
|
1561
|
+
if (disposedRenderer || node.ownerDocument !== page.document || !node.isConnected) return;
|
|
1562
|
+
page.resourcePaintDirty = true;
|
|
1563
|
+
page.needsPaint = true;
|
|
1564
|
+
scheduleFrame();
|
|
1565
|
+
});
|
|
1331
1566
|
observeStyleChanges(parsed.document);
|
|
1332
1567
|
page.title = parsed.document.title;
|
|
1333
1568
|
page.styleSheets = styleSheets;
|
|
@@ -1335,6 +1570,8 @@ function buildPageRenderer({
|
|
|
1335
1570
|
page.resources = imageResources.map;
|
|
1336
1571
|
initializeVideos(parsed.document, page.resources);
|
|
1337
1572
|
page.decodedImages = imageResources.decoded;
|
|
1573
|
+
page.imageElements = new Set(imageResources.entries.map(entry => entry.element));
|
|
1574
|
+
page.resourcePaintDirty = false;
|
|
1338
1575
|
page.imagesLoaded = null;
|
|
1339
1576
|
imageRequests = new WeakMap();
|
|
1340
1577
|
page.elementScroll = new Map();
|
|
@@ -1355,6 +1592,20 @@ function buildPageRenderer({
|
|
|
1355
1592
|
const ms = (from, to) => Math.round(to - from);
|
|
1356
1593
|
const sheets = currentStyleSheets();
|
|
1357
1594
|
const environment = { width: page.width, height: page.height, mobile };
|
|
1595
|
+
const documentBaseURL = page.document.baseURI || page.url;
|
|
1596
|
+
const environmentKey = `${page.width}/${page.height}/${mobile}/${documentBaseURL}`;
|
|
1597
|
+
if (fontSheets !== sheets || fontEnvironment !== environmentKey) {
|
|
1598
|
+
fontSheets = sheets;
|
|
1599
|
+
fontEnvironment = environmentKey;
|
|
1600
|
+
fontDefinitions = sheets.flatMap(sheet => /@font-face\b/i.test(sheet) ? extractFontFaces(sheet, documentBaseURL, environment) : []);
|
|
1601
|
+
documentFonts.setFaces(fontDefinitions);
|
|
1602
|
+
const key = JSON.stringify(fontDefinitions);
|
|
1603
|
+
if (key !== fontDefinitionKey) {
|
|
1604
|
+
fontDefinitionKey = key;
|
|
1605
|
+
clearFontCaches();
|
|
1606
|
+
page.resourceLayoutDirty = true;
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1358
1609
|
const changes = takeStyleChanges(sheets); // drained even when unused, so records do not pile up
|
|
1359
1610
|
const taken = hostPerformance.now();
|
|
1360
1611
|
// A rendering opportunity can be scheduled by lifecycle work which did
|
|
@@ -1404,17 +1655,19 @@ function buildPageRenderer({
|
|
|
1404
1655
|
measureText, fontMetrics, width: page.width, viewportHeight: page.height, x: 0, y: 0,
|
|
1405
1656
|
});
|
|
1406
1657
|
page.displayList = paintList();
|
|
1658
|
+
page.resourcePaintDirty = false;
|
|
1407
1659
|
// The content may now reach less far than a container was scrolled.
|
|
1408
1660
|
if (clampElementScroll()) page.displayList = paintList();
|
|
1409
1661
|
page.resourceLayoutDirty = false;
|
|
1410
1662
|
laidOutSheets = sheets;
|
|
1411
1663
|
setContentSize();
|
|
1664
|
+
queueFontLoads();
|
|
1412
1665
|
return { style: Math.round(styled - started), layout: Math.round(hostPerformance.now() - styled) };
|
|
1413
1666
|
}
|
|
1414
1667
|
|
|
1415
1668
|
/** BUNINU_VERIFY_STYLE: compare an incremental restyle with a full one, element by element. */
|
|
1416
1669
|
function verifyIncrementalStyle(sheets, environment) {
|
|
1417
|
-
const reference = new StyleEngine().compute(page.document, sheets, environment);
|
|
1670
|
+
const reference = new StyleEngine({ measureText }).compute(page.document, sheets, environment);
|
|
1418
1671
|
const serialize = (style) => JSON.stringify(style, (key, value) => typeof value === "bigint" ? String(value) : value);
|
|
1419
1672
|
let mismatches = 0;
|
|
1420
1673
|
for (const element of page.document.querySelectorAll("*")) {
|
|
@@ -1450,13 +1703,14 @@ function buildPageRenderer({
|
|
|
1450
1703
|
staleImages = queueImageResources();
|
|
1451
1704
|
}
|
|
1452
1705
|
const timing = styleAndLayout();
|
|
1453
|
-
|
|
1454
|
-
const
|
|
1455
|
-
|
|
1456
|
-
image.delete();
|
|
1706
|
+
if (staleImages.length) {
|
|
1707
|
+
const stale = new Set(staleImages);
|
|
1708
|
+
page.decodedImages = page.decodedImages.filter(image => !stale.has(image));
|
|
1709
|
+
for (const image of stale) image.delete();
|
|
1457
1710
|
}
|
|
1458
1711
|
if (!timing.skipped) page.needsPaint = true;
|
|
1459
1712
|
syncFrames();
|
|
1713
|
+
page.realm?.layoutChanged(page.layoutVersion);
|
|
1460
1714
|
if (!timing.skipped) queueBackgroundImages();
|
|
1461
1715
|
lastRebuildMs = hostPerformance.now() - started;
|
|
1462
1716
|
trace?.(`${page.url}: restyle and relayout (${reason}) ${Math.round(lastRebuildMs)}ms`
|
|
@@ -1475,6 +1729,7 @@ function buildPageRenderer({
|
|
|
1475
1729
|
* page); the rest of the load continues, see `whenLoaded`.
|
|
1476
1730
|
*/
|
|
1477
1731
|
function loadDocument(document) {
|
|
1732
|
+
if (disposedRenderer) return Promise.reject(new Error("Renderer has been disposed"));
|
|
1478
1733
|
const generation = ++loadGeneration;
|
|
1479
1734
|
let commit;
|
|
1480
1735
|
const committed = new Promise((resolve, reject) => {
|
|
@@ -1494,7 +1749,7 @@ function buildPageRenderer({
|
|
|
1494
1749
|
}
|
|
1495
1750
|
|
|
1496
1751
|
async function runLoad({
|
|
1497
|
-
url, source = "", contentType = "text/html", status = 200, statusText = "", error = null, cookie = "",
|
|
1752
|
+
url, source = "", contentType = "text/html", status = 200, statusText = "", error = null, cookie = "", referrer = "",
|
|
1498
1753
|
}, generation, commit) {
|
|
1499
1754
|
const current = () => generation === loadGeneration;
|
|
1500
1755
|
const finish = () => {
|
|
@@ -1538,7 +1793,7 @@ function buildPageRenderer({
|
|
|
1538
1793
|
phaseStart = now;
|
|
1539
1794
|
} : () => {};
|
|
1540
1795
|
const isHtml = contentType.toLowerCase().includes("html");
|
|
1541
|
-
const parsed = parseHTMLDocument(isHtml ? source : "<body></body>", url);
|
|
1796
|
+
const parsed = parseHTMLDocument(isHtml ? source : "<body></body>", url, referrer);
|
|
1542
1797
|
phase("parse");
|
|
1543
1798
|
// Stylesheets block rendering and scripts; images do not (only the
|
|
1544
1799
|
// window load event waits for them, see whenImagesLoaded): one slow image
|
|
@@ -1605,6 +1860,9 @@ function buildPageRenderer({
|
|
|
1605
1860
|
refresh();
|
|
1606
1861
|
phase("style, layout and raster after scripts");
|
|
1607
1862
|
} else {
|
|
1863
|
+
await documentFonts.ready();
|
|
1864
|
+
if (!current()) return;
|
|
1865
|
+
refresh();
|
|
1608
1866
|
finish();
|
|
1609
1867
|
}
|
|
1610
1868
|
}
|
|
@@ -1620,7 +1878,7 @@ function buildPageRenderer({
|
|
|
1620
1878
|
const preloaded = new Map();
|
|
1621
1879
|
const preload = (href) => {
|
|
1622
1880
|
let target;
|
|
1623
|
-
try { target = new URL(href, documentUrl); } catch { return; }
|
|
1881
|
+
try { target = new URL(href, document.baseURI || documentUrl); } catch { return; }
|
|
1624
1882
|
if (!["http:", "https:"].includes(target.protocol) || preloaded.has(target.href)) return;
|
|
1625
1883
|
const response = fetchResource(target.href, "script");
|
|
1626
1884
|
response.catch(() => {}); // reported when the script runs
|
|
@@ -1636,7 +1894,8 @@ function buildPageRenderer({
|
|
|
1636
1894
|
|
|
1637
1895
|
/** Frees what a superseded load fetched. */
|
|
1638
1896
|
async function discard(images, parsed = null) {
|
|
1639
|
-
parsed?.
|
|
1897
|
+
if (parsed?.document) loadImageResource.cancelDocument(parsed.document);
|
|
1898
|
+
parsed?.window?.happyDOM?.close?.();
|
|
1640
1899
|
const loaded = await images.catch(() => emptyImageResources());
|
|
1641
1900
|
for (const image of loaded.decoded) image.delete();
|
|
1642
1901
|
}
|
|
@@ -1651,7 +1910,7 @@ function buildPageRenderer({
|
|
|
1651
1910
|
// queueImageResources must not fetch these again.
|
|
1652
1911
|
for (const element of document.querySelectorAll("img")) {
|
|
1653
1912
|
const src = element.getAttribute("src");
|
|
1654
|
-
if (src) imageRequests.set(element, src);
|
|
1913
|
+
if (src) imageRequests.set(element, imageRequestKey(src, imageCrossOrigin(element)));
|
|
1655
1914
|
}
|
|
1656
1915
|
page.imagesLoaded = images.then((loaded) => {
|
|
1657
1916
|
if (generation !== loadGeneration || document !== page.document) {
|
|
@@ -1659,12 +1918,13 @@ function buildPageRenderer({
|
|
|
1659
1918
|
return;
|
|
1660
1919
|
}
|
|
1661
1920
|
for (const { element, src, resource } of loaded.entries) {
|
|
1662
|
-
if (element.getAttribute("src") !== src || page.resources.get(element)) {
|
|
1921
|
+
if (element.getAttribute("src") !== src || imageCrossOrigin(element) !== resource.crossOrigin || page.resources.get(element)) {
|
|
1663
1922
|
resource.image.delete();
|
|
1664
1923
|
continue;
|
|
1665
1924
|
}
|
|
1666
1925
|
page.resources.set(element, resource);
|
|
1667
1926
|
page.decodedImages.push(resource.image);
|
|
1927
|
+
page.imageElements.add(element);
|
|
1668
1928
|
finishImageLoad(element, src, resource, type => element.dispatchEvent(new page.window.Event(type)));
|
|
1669
1929
|
}
|
|
1670
1930
|
page.dirty = true;
|
|
@@ -1675,18 +1935,44 @@ function buildPageRenderer({
|
|
|
1675
1935
|
|
|
1676
1936
|
function inlineStyleSheets(document) {
|
|
1677
1937
|
return [...document.querySelectorAll("style")]
|
|
1678
|
-
.filter((element) =>
|
|
1938
|
+
.filter((element) => isCssStyleSheet(element) && styleSheetMatches(element))
|
|
1679
1939
|
.map((element) => element.textContent ?? "");
|
|
1680
1940
|
}
|
|
1681
1941
|
|
|
1942
|
+
function isCssStyleSheet(element) {
|
|
1943
|
+
const type = element.getAttribute("type")?.trim().toLowerCase();
|
|
1944
|
+
return (!type || type === "text/css") && !element.closest("noscript");
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
function styleSheetMatches(element) {
|
|
1948
|
+
return mediaQueryMatches(element.getAttribute("media") ?? "", { width: page.width, height: page.height, mobile })
|
|
1949
|
+
&& !(element.tagName === "LINK" && element.hasAttribute("disabled"));
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1682
1952
|
/** Brings style, layout and raster up to date; returns whether it repainted. */
|
|
1683
1953
|
function refresh() {
|
|
1954
|
+
if (disposedRenderer) return false;
|
|
1684
1955
|
if (page.dirty) rebuildRenderTree();
|
|
1685
1956
|
if (!page.needsPaint) return false;
|
|
1686
1957
|
render();
|
|
1687
1958
|
return true;
|
|
1688
1959
|
}
|
|
1689
1960
|
|
|
1961
|
+
function isDisclosureControl(element) {
|
|
1962
|
+
if (element?.tagName === "DETAILS") return ![...element.children].some(child => child.tagName === "SUMMARY");
|
|
1963
|
+
return element?.tagName === "SUMMARY" && element.parentElement?.tagName === "DETAILS"
|
|
1964
|
+
&& [...element.parentElement.children].find(child => child.tagName === "SUMMARY") === element;
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
function activateDisclosure(element) {
|
|
1968
|
+
const prevented = page.realm.dispatch(element, new page.window.MouseEvent("click", {
|
|
1969
|
+
bubbles: true, cancelable: true, composed: true, detail: 0,
|
|
1970
|
+
}));
|
|
1971
|
+
// An explicit summary is activated by the DOM's click default action.
|
|
1972
|
+
// The generated label has no DOM summary, so its owner toggles here.
|
|
1973
|
+
if (!prevented && element.tagName === "DETAILS") element.open = !element.open;
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1690
1976
|
function editFocusedText(text, backspace = false) {
|
|
1691
1977
|
const element = page.document.activeElement;
|
|
1692
1978
|
if (!element || !["INPUT", "TEXTAREA"].includes(element.tagName) || element.disabled || element.readOnly) return;
|
|
@@ -1765,6 +2051,31 @@ function buildPageRenderer({
|
|
|
1765
2051
|
page.dirty = true;
|
|
1766
2052
|
scheduleFrame();
|
|
1767
2053
|
},
|
|
2054
|
+
activationState: controlStatesBeforeActivation,
|
|
2055
|
+
activationChanged(previous) {
|
|
2056
|
+
invalidateActivatedControls(previous);
|
|
2057
|
+
if (page.dirty) scheduleFrame();
|
|
2058
|
+
},
|
|
2059
|
+
canvasImage(element) {
|
|
2060
|
+
if (!element) return null;
|
|
2061
|
+
if (element.tagName === "CANVAS") return canvasResource(element);
|
|
2062
|
+
const resource = page.resources.get(element);
|
|
2063
|
+
if (!resource?.image) return null;
|
|
2064
|
+
const url = resource.url ?? element.src ?? "";
|
|
2065
|
+
const originClean = resource.originClean ?? (url.startsWith("data:") || originOf(url) === originOf(page.url));
|
|
2066
|
+
return { ...resource, originClean };
|
|
2067
|
+
},
|
|
2068
|
+
canvasFont(element, value) {
|
|
2069
|
+
return parseCanvasFont(value, styleEngine.get(element)?.fontSize ?? 16,
|
|
2070
|
+
styleEngine.get(page.document.documentElement)?.fontSize ?? 16);
|
|
2071
|
+
},
|
|
2072
|
+
canvasText(element, request) {
|
|
2073
|
+
const style = { ...request.settings.parsedFont,
|
|
2074
|
+
direction: request.settings.direction === "inherit" ? styleEngine.get(element)?.direction ?? "ltr" : request.settings.direction };
|
|
2075
|
+
documentFonts.update(fontDefinitions, [{ text: String(request.args[0]), style }]);
|
|
2076
|
+
return renderCanvasText({ ...request, style,
|
|
2077
|
+
settings: { ...request.settings, direction: style.direction } });
|
|
2078
|
+
},
|
|
1768
2079
|
mediaState,
|
|
1769
2080
|
playMedia,
|
|
1770
2081
|
pauseMedia,
|
|
@@ -1788,7 +2099,7 @@ function buildPageRenderer({
|
|
|
1788
2099
|
scheduleFrame();
|
|
1789
2100
|
}
|
|
1790
2101
|
},
|
|
1791
|
-
whenImagesLoaded: () => Promise.all([page.imagesLoaded, ...[...page.backgrounds.values()].map((entry) => entry.loaded)]),
|
|
2102
|
+
whenImagesLoaded: () => Promise.all([page.imagesLoaded, documentFonts.ready(), ...[...page.backgrounds.values()].map((entry) => entry.loaded)]),
|
|
1792
2103
|
afterTask() {},
|
|
1793
2104
|
boundsOf(element) {
|
|
1794
2105
|
if (page.dirty) rebuildRenderTree("script read geometry");
|
|
@@ -1820,9 +2131,9 @@ function buildPageRenderer({
|
|
|
1820
2131
|
if (page.dirty) rebuildRenderTree("script scrolled");
|
|
1821
2132
|
setElementScroll(element, x, y);
|
|
1822
2133
|
},
|
|
1823
|
-
scrollIntoView(element) {
|
|
2134
|
+
scrollIntoView(element, options) {
|
|
1824
2135
|
if (page.dirty) rebuildRenderTree("script scrolled");
|
|
1825
|
-
revealElement(element);
|
|
2136
|
+
revealElement(element, options);
|
|
1826
2137
|
},
|
|
1827
2138
|
computedStyle(element) {
|
|
1828
2139
|
if (!element) return null;
|
|
@@ -1845,7 +2156,7 @@ function buildPageRenderer({
|
|
|
1845
2156
|
},
|
|
1846
2157
|
navigate: (url) => navigateTo(String(url)),
|
|
1847
2158
|
activateLink(anchor) {
|
|
1848
|
-
const href = new URL(anchor.getAttribute("href"), page.url).href;
|
|
2159
|
+
const href = new URL(anchor.getAttribute("href"), anchor.baseURI || page.url).href;
|
|
1849
2160
|
const name = anchor.hasAttribute("download") ? anchor.getAttribute("download") ?? "" : null;
|
|
1850
2161
|
if (href.startsWith("blob:") && name !== null) void downloadBlob(href, name);
|
|
1851
2162
|
else navigateTo(href, name);
|
|
@@ -1887,7 +2198,8 @@ function buildPageRenderer({
|
|
|
1887
2198
|
if (typeof response.documentCookie === "string") page.cookie = response.documentCookie;
|
|
1888
2199
|
return response;
|
|
1889
2200
|
});
|
|
1890
|
-
result.abort = () => networkPending.abort?.();
|
|
2201
|
+
result.abort = (reason) => networkPending.abort?.(reason);
|
|
2202
|
+
result.finished = networkPending.finished;
|
|
1891
2203
|
return result;
|
|
1892
2204
|
},
|
|
1893
2205
|
};
|
|
@@ -1901,7 +2213,7 @@ function buildPageRenderer({
|
|
|
1901
2213
|
function submitForm(form, submitter = null) {
|
|
1902
2214
|
const method = (submitter?.getAttribute("formmethod") ?? form.getAttribute("method") ?? "get").toLowerCase();
|
|
1903
2215
|
const action = submitter?.getAttribute("formaction") ?? form.getAttribute("action") ?? page.url;
|
|
1904
|
-
const url = new URL(action || page.url, page.url);
|
|
2216
|
+
const url = new URL(action || page.url, form.baseURI || page.url);
|
|
1905
2217
|
const entries = [...new page.window.FormData(form, submitter)];
|
|
1906
2218
|
if (method === "post") {
|
|
1907
2219
|
const enctype = (submitter?.getAttribute("formenctype") ?? form.getAttribute("enctype") ?? "").toLowerCase();
|
|
@@ -1951,14 +2263,7 @@ function buildPageRenderer({
|
|
|
1951
2263
|
const shift = scrollShift(element);
|
|
1952
2264
|
const scrollX = (fixed ? 0 : viewport.x) + shift.x;
|
|
1953
2265
|
const scrollY = (fixed ? 0 : viewport.y) + shift.y;
|
|
1954
|
-
|
|
1955
|
-
for (const node of page.renderTree.nodesById.values()) {
|
|
1956
|
-
if (node.domNode === element && !node.pseudo) {
|
|
1957
|
-
nodeId = node.id;
|
|
1958
|
-
break;
|
|
1959
|
-
}
|
|
1960
|
-
}
|
|
1961
|
-
const box = nodeId == null ? null : page.layout.boxes.find((candidate) => candidate.nodeId === nodeId && !candidate.inline);
|
|
2266
|
+
const box = elementBox(page.renderTree, page.layout, element)?.box;
|
|
1962
2267
|
if (box) return { left: box.x - scrollX, top: box.y - scrollY, width: box.width, height: box.height };
|
|
1963
2268
|
const content = elementBounds(page.renderTree, page.layout, element);
|
|
1964
2269
|
return content
|
|
@@ -2142,9 +2447,9 @@ function buildPageRenderer({
|
|
|
2142
2447
|
rebuildRenderTree();
|
|
2143
2448
|
render();
|
|
2144
2449
|
},
|
|
2145
|
-
screenshot() {
|
|
2450
|
+
screenshot(options) {
|
|
2146
2451
|
refresh();
|
|
2147
|
-
return viewportPng();
|
|
2452
|
+
return viewportPng(options);
|
|
2148
2453
|
},
|
|
2149
2454
|
/**
|
|
2150
2455
|
* Diagnostics (not exposed over IPC): page-space border boxes and chosen
|
|
@@ -2212,6 +2517,7 @@ function buildPageRenderer({
|
|
|
2212
2517
|
/** The compositor scrolled: follow it, and rasterize around the new position when idle. */
|
|
2213
2518
|
applyCompositorScroll(x, y) {
|
|
2214
2519
|
viewport.scrollTo(x, y);
|
|
2520
|
+
page.realm?.layoutChanged(page.layoutVersion);
|
|
2215
2521
|
syncedScroll = { x: viewport.x, y: viewport.y };
|
|
2216
2522
|
schedulePrepaint();
|
|
2217
2523
|
},
|
|
@@ -2242,8 +2548,13 @@ function buildPageRenderer({
|
|
|
2242
2548
|
bubbles: true, cancelable: true,
|
|
2243
2549
|
};
|
|
2244
2550
|
const prevented = page.realm.dispatch(target, new page.window.KeyboardEvent("keydown", init));
|
|
2551
|
+
let activated = false;
|
|
2245
2552
|
if (!prevented && !init.ctrlKey && !init.metaKey && !init.altKey) {
|
|
2246
|
-
if (key === "
|
|
2553
|
+
if ((key === "Enter" || key === " ") && isDisclosureControl(target)) {
|
|
2554
|
+
activated = true;
|
|
2555
|
+
if (key === "Enter") activateDisclosure(target);
|
|
2556
|
+
}
|
|
2557
|
+
else if (key === "Backspace") editFocusedText("", true);
|
|
2247
2558
|
else if (key === "Enter" && target.tagName === "TEXTAREA") {
|
|
2248
2559
|
// A search IME's action key submits a search form. Other textareas
|
|
2249
2560
|
// keep their normal newline behavior.
|
|
@@ -2255,8 +2566,9 @@ function buildPageRenderer({
|
|
|
2255
2566
|
}
|
|
2256
2567
|
else if (String(key).length === 1) editFocusedText(String(key));
|
|
2257
2568
|
}
|
|
2258
|
-
page.realm.dispatch(target, new page.window.KeyboardEvent("keyup", init));
|
|
2259
|
-
if (
|
|
2569
|
+
const upPrevented = page.realm.dispatch(target, new page.window.KeyboardEvent("keyup", init));
|
|
2570
|
+
if (activated && key === " " && !upPrevented) activateDisclosure(target);
|
|
2571
|
+
if (refresh() || prevented || activated) return { x: viewport.x, y: viewport.y };
|
|
2260
2572
|
}
|
|
2261
2573
|
const pageStep = Math.max(31, viewport.height - 31);
|
|
2262
2574
|
const delta = {
|
|
@@ -2319,9 +2631,6 @@ function buildPageRenderer({
|
|
|
2319
2631
|
*/
|
|
2320
2632
|
click(x, y) {
|
|
2321
2633
|
refresh();
|
|
2322
|
-
// Activation can toggle checked state (radios in a group, through a
|
|
2323
|
-
// label) without a DOM mutation, so the next restyle is a full one.
|
|
2324
|
-
markStyleDirty(null); // full: click
|
|
2325
2634
|
let region = hitTest(page.renderTree, page.layout, x, y, viewport, viewport);
|
|
2326
2635
|
let forwarded = false;
|
|
2327
2636
|
if (!region) {
|
|
@@ -2344,12 +2653,17 @@ function buildPageRenderer({
|
|
|
2344
2653
|
// it is now (scroll containers move their content); the interactive
|
|
2345
2654
|
// regions (laid out, unscrolled) stand in only where nothing is painted,
|
|
2346
2655
|
// and a search form's forwarded field takes the click on the form.
|
|
2347
|
-
const
|
|
2656
|
+
const hitNode = page.displayList ? renderNodeAtPoint(page.renderTree, page.displayList, x, y, viewport) : null;
|
|
2657
|
+
const precise = hitNode?.domNode?.nodeType === 1 ? hitNode.domNode : hitNode?.domNode?.parentElement;
|
|
2348
2658
|
const target = forwarded ? region.element : precise ?? region?.element ?? null;
|
|
2659
|
+
const generatedDisclosure = hitNode?.pseudo === "summary" && target?.tagName === "DETAILS";
|
|
2349
2660
|
if (!target) {
|
|
2350
2661
|
focusedFrame = null;
|
|
2351
2662
|
return null;
|
|
2352
2663
|
}
|
|
2664
|
+
// Event dispatch determines the activation target before handlers run.
|
|
2665
|
+
// A handler may replace the clicked descendants while retaining the link.
|
|
2666
|
+
const activationAnchor = target.closest?.("a[href]");
|
|
2353
2667
|
// A click inside an iframe belongs to its document alone.
|
|
2354
2668
|
const inFrame = target.tagName === "IFRAME" ? frameAt(target, x, y) : null;
|
|
2355
2669
|
focusedFrame = inFrame?.frame ?? null;
|
|
@@ -2370,11 +2684,12 @@ function buildPageRenderer({
|
|
|
2370
2684
|
}
|
|
2371
2685
|
// The press focuses the focusable element it lands in, unless mousedown was canceled.
|
|
2372
2686
|
const focus = () => {
|
|
2373
|
-
const focusable = target.closest?.("input, textarea, select, button, [tabindex]");
|
|
2687
|
+
const focusable = generatedDisclosure ? target : target.closest?.("input, textarea, select, button, summary, [tabindex]");
|
|
2374
2688
|
if (focusable && !focusable.disabled) focusable.focus();
|
|
2375
2689
|
};
|
|
2376
2690
|
if (!page.realm) focus();
|
|
2377
2691
|
else {
|
|
2692
|
+
const controlStates = controlStatesBeforeActivation();
|
|
2378
2693
|
// A press and release, then the click (UI Events §3.5).
|
|
2379
2694
|
const init = { bubbles: true, cancelable: true, composed: true, clientX: Number(x), clientY: Number(y), button: 0 };
|
|
2380
2695
|
const PointerEvent = page.window.PointerEvent ?? page.window.MouseEvent;
|
|
@@ -2384,16 +2699,22 @@ function buildPageRenderer({
|
|
|
2384
2699
|
page.realm.dispatch(target, new PointerEvent("pointerup", pointer));
|
|
2385
2700
|
page.realm.dispatch(target, new page.window.MouseEvent("mouseup", init));
|
|
2386
2701
|
const prevented = page.realm.dispatch(target, new page.window.MouseEvent("click", { ...init, detail: 1 }));
|
|
2702
|
+
invalidateActivatedControls(controlStates);
|
|
2387
2703
|
refresh();
|
|
2388
2704
|
if (prevented) return null;
|
|
2389
2705
|
}
|
|
2706
|
+
if (generatedDisclosure) {
|
|
2707
|
+
target.open = !target.open;
|
|
2708
|
+
refresh();
|
|
2709
|
+
return null;
|
|
2710
|
+
}
|
|
2390
2711
|
if ((target.tagName === "AUDIO" || target.tagName === "VIDEO") && target.hasAttribute("controls")) {
|
|
2391
2712
|
clickMedia(target, x, y);
|
|
2392
2713
|
return null;
|
|
2393
2714
|
}
|
|
2394
|
-
const anchor =
|
|
2395
|
-
if (!anchor) return null;
|
|
2396
|
-
const href = new URL(anchor.getAttribute("href"), page.url).href;
|
|
2715
|
+
const anchor = activationAnchor;
|
|
2716
|
+
if (!anchor?.hasAttribute("href")) return null;
|
|
2717
|
+
const href = new URL(anchor.getAttribute("href"), anchor.baseURI || page.url).href;
|
|
2397
2718
|
if (href.startsWith("javascript:")) {
|
|
2398
2719
|
this.runJavaScriptURL(href);
|
|
2399
2720
|
return null;
|
|
@@ -2466,16 +2787,22 @@ function buildPageRenderer({
|
|
|
2466
2787
|
if (!page.realm) return undefined;
|
|
2467
2788
|
let value = page.realm.evaluate(utilityExpression);
|
|
2468
2789
|
if (value !== null && typeof value === "object" && typeof value.then === "function") {
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2790
|
+
let timer;
|
|
2791
|
+
try {
|
|
2792
|
+
value = await Promise.race([
|
|
2793
|
+
Promise.resolve(value),
|
|
2794
|
+
new Promise((resolve) => { timer = hostSetTimeout(resolve, 5000, undefined); }),
|
|
2795
|
+
]).catch((error) => String(error?.message ?? error));
|
|
2796
|
+
} finally { hostClearTimeout(timer); }
|
|
2473
2797
|
}
|
|
2474
2798
|
refresh();
|
|
2475
2799
|
return cloneableResult(value);
|
|
2476
2800
|
},
|
|
2477
2801
|
dispose() {
|
|
2802
|
+
if (disposedRenderer) return;
|
|
2478
2803
|
disposedRenderer = true;
|
|
2804
|
+
loadGeneration++;
|
|
2805
|
+
if (page.document) loadImageResource.cancelDocument(page.document);
|
|
2479
2806
|
hostClearTimeout(prepaintTimer);
|
|
2480
2807
|
hostClearTimeout(frameTimer);
|
|
2481
2808
|
disposeFrames();
|
|
@@ -2484,8 +2811,13 @@ function buildPageRenderer({
|
|
|
2484
2811
|
disposeVideos();
|
|
2485
2812
|
rasterSurface?.dispose();
|
|
2486
2813
|
rasterSurface = null;
|
|
2487
|
-
|
|
2814
|
+
rasterReadback.dispose();
|
|
2815
|
+
clearPaintParagraphs();
|
|
2816
|
+
documentFonts.dispose();
|
|
2817
|
+
page.window?.happyDOM?.close?.();
|
|
2488
2818
|
for (const image of page.decodedImages) image.delete();
|
|
2819
|
+
page.decodedImages = [];
|
|
2820
|
+
page.imageElements.clear();
|
|
2489
2821
|
disposeBackgrounds();
|
|
2490
2822
|
},
|
|
2491
2823
|
};
|
|
@@ -2511,18 +2843,22 @@ function buildPageRenderer({
|
|
|
2511
2843
|
function collectStyleSheets(document, documentUrl, linkSheets, pending = null) {
|
|
2512
2844
|
const sheets = [];
|
|
2513
2845
|
for (const element of document.querySelectorAll("style, link[rel~='stylesheet']")) {
|
|
2514
|
-
if (element
|
|
2846
|
+
if (!isCssStyleSheet(element)) continue;
|
|
2847
|
+
const applies = styleSheetMatches(element);
|
|
2515
2848
|
if (element.tagName === "STYLE") {
|
|
2516
|
-
sheets.push(element.textContent ?? "");
|
|
2849
|
+
if (applies) sheets.push(element.textContent ?? "");
|
|
2517
2850
|
continue;
|
|
2518
2851
|
}
|
|
2852
|
+
// A disabled link is fetched when enabled; nonmatching media may be
|
|
2853
|
+
// fetched now, but must never contribute rules to the screen cascade.
|
|
2854
|
+
if (element.hasAttribute("disabled")) continue;
|
|
2519
2855
|
const href = element.getAttribute("href");
|
|
2520
2856
|
let url;
|
|
2521
|
-
try { url = href ? new URL(href, documentUrl).href : null; } catch { url = null; }
|
|
2857
|
+
try { url = href ? new URL(href, element.baseURI || documentUrl).href : null; } catch { url = null; }
|
|
2522
2858
|
if (!url) continue;
|
|
2523
2859
|
const loaded = linkSheets.get(element);
|
|
2524
2860
|
if (loaded?.url === url) {
|
|
2525
|
-
if (loaded.text !== null) sheets.push(loaded.text);
|
|
2861
|
+
if (applies && loaded.text !== null) sheets.push(loaded.text);
|
|
2526
2862
|
continue;
|
|
2527
2863
|
}
|
|
2528
2864
|
const entry = { url, text: null };
|
|
@@ -2561,17 +2897,10 @@ function buildPageRenderer({
|
|
|
2561
2897
|
if (!src) return;
|
|
2562
2898
|
beginImageLoad(element, src);
|
|
2563
2899
|
try {
|
|
2564
|
-
const
|
|
2565
|
-
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
2566
|
-
// Untrusted image bytes are decoded here, inside the renderer.
|
|
2567
|
-
const bytes = response.body;
|
|
2568
|
-
const { width, height } = await new Bun.Image(bytes).metadata();
|
|
2569
|
-
const image = CanvasKit.MakeImageFromEncoded(bytes);
|
|
2570
|
-
if (!image) throw new Error("Image decoding failed");
|
|
2571
|
-
const resource = Object.freeze({ width, height, image, url: response.url });
|
|
2900
|
+
const resource = await loadImageResource(document, new URL(src, element.baseURI || documentUrl).href, imageCrossOrigin(element));
|
|
2572
2901
|
finishImageLoad(element, src, resource);
|
|
2573
2902
|
map.set(element, resource);
|
|
2574
|
-
decoded.push(image);
|
|
2903
|
+
decoded.push(resource.image);
|
|
2575
2904
|
entries.push({ element, src, resource });
|
|
2576
2905
|
} catch {
|
|
2577
2906
|
// Broken images keep their fallback replaced-element dimensions.
|
|
@@ -2691,7 +3020,7 @@ function buildPageRenderer({
|
|
|
2691
3020
|
const src = element.getAttribute("src")?.trim();
|
|
2692
3021
|
if (!src) return "about:blank";
|
|
2693
3022
|
try {
|
|
2694
|
-
return new URL(src, page.url).href;
|
|
3023
|
+
return new URL(src, element.baseURI || page.url).href;
|
|
2695
3024
|
} catch {
|
|
2696
3025
|
return "about:blank";
|
|
2697
3026
|
}
|
|
@@ -2794,6 +3123,12 @@ function buildPageRenderer({
|
|
|
2794
3123
|
}
|
|
2795
3124
|
|
|
2796
3125
|
function paintList() {
|
|
3126
|
+
for (const node of page.renderTree?.nodesById.values() ?? []) {
|
|
3127
|
+
if (node.tagName !== "CANVAS") continue;
|
|
3128
|
+
const resource = canvasResource(node.domNode);
|
|
3129
|
+
if (resource) page.resources.set(node.domNode, resource);
|
|
3130
|
+
else page.resources.delete(node.domNode);
|
|
3131
|
+
}
|
|
2797
3132
|
return buildDisplayList(page.layout, page.renderTree, page.resources, backgroundResource, scrollOffsetOf);
|
|
2798
3133
|
}
|
|
2799
3134
|
|
|
@@ -2803,13 +3138,7 @@ function buildPageRenderer({
|
|
|
2803
3138
|
|
|
2804
3139
|
/** The render node and layout box of an element, or null. */
|
|
2805
3140
|
function layoutBoxOf(element) {
|
|
2806
|
-
|
|
2807
|
-
for (const node of page.renderTree.nodesById.values()) {
|
|
2808
|
-
if (node.domNode !== element || node.pseudo) continue;
|
|
2809
|
-
const box = page.layout.boxes.find((candidate) => candidate.nodeId === node.id && !candidate.inline);
|
|
2810
|
-
return box ? { node, box } : null;
|
|
2811
|
-
}
|
|
2812
|
-
return null;
|
|
3141
|
+
return elementBox(page.renderTree, page.layout, element);
|
|
2813
3142
|
}
|
|
2814
3143
|
|
|
2815
3144
|
/**
|
|
@@ -2863,6 +3192,7 @@ function buildPageRenderer({
|
|
|
2863
3192
|
if (next.x || next.y) page.elementScroll.set(element, next);
|
|
2864
3193
|
else page.elementScroll.delete(element);
|
|
2865
3194
|
page.layoutVersion++;
|
|
3195
|
+
page.realm?.layoutChanged(page.layoutVersion);
|
|
2866
3196
|
page.displayList = paintList();
|
|
2867
3197
|
page.needsPaint = true;
|
|
2868
3198
|
// A scroll event at the element, as a task (CSSOM View §13.1).
|
|
@@ -2960,20 +3290,31 @@ function buildPageRenderer({
|
|
|
2960
3290
|
return left;
|
|
2961
3291
|
}
|
|
2962
3292
|
|
|
2963
|
-
/**
|
|
2964
|
-
function revealElement(element) {
|
|
2965
|
-
|
|
3293
|
+
/** Aligns the element within each scrollport, from the innermost out. */
|
|
3294
|
+
function revealElement(element, { block = "start", inline = "nearest", container = "all" } = {}) {
|
|
3295
|
+
let moved = false;
|
|
3296
|
+
let fixed = styleEngine.get(element)?.position === "fixed";
|
|
3297
|
+
const direction = styleEngine.get(element)?.direction;
|
|
3298
|
+
if (direction === "rtl" && inline !== "nearest" && inline !== "center") inline = inline === "start" ? "end" : "start";
|
|
3299
|
+
for (let node = element.parentElement; !fixed && node && node.nodeType === 1; node = node.parentElement) {
|
|
3300
|
+
if (node === page.document.documentElement) break;
|
|
3301
|
+
fixed = styleEngine.get(node)?.position === "fixed";
|
|
2966
3302
|
const geometry = scrollerGeometry(node);
|
|
2967
3303
|
if (!geometry) continue;
|
|
2968
3304
|
const target = viewportBounds(element);
|
|
2969
|
-
const
|
|
2970
|
-
if (!target || !
|
|
3305
|
+
const bounds = viewportBounds(node);
|
|
3306
|
+
if (!target || !bounds) continue;
|
|
2971
3307
|
const [borderTop, , , borderLeft] = styleEngine.get(node)?.borderWidths ?? [0, 0, 0, 0];
|
|
2972
|
-
setElementScroll(node,
|
|
3308
|
+
moved = setElementScroll(node,
|
|
3309
|
+
geometry.x + scrollAlignmentDelta(target.left - bounds.left - borderLeft, target.width, geometry.clientWidth, inline),
|
|
3310
|
+
geometry.y + scrollAlignmentDelta(target.top - bounds.top - borderTop, target.height, geometry.clientHeight, block)) || moved;
|
|
3311
|
+
if (container === "nearest") { if (moved) render(); return; }
|
|
2973
3312
|
}
|
|
2974
3313
|
const bounds = viewportBounds(element);
|
|
2975
|
-
if (bounds) viewport.scrollTo(
|
|
2976
|
-
|
|
3314
|
+
if (bounds && !fixed) moved = viewport.scrollTo(
|
|
3315
|
+
viewport.x + scrollAlignmentDelta(bounds.left, bounds.width, viewport.width, inline),
|
|
3316
|
+
viewport.y + scrollAlignmentDelta(bounds.top, bounds.height, viewport.height, block)) || moved;
|
|
3317
|
+
if (moved) render();
|
|
2977
3318
|
}
|
|
2978
3319
|
|
|
2979
3320
|
/** Every <video> and <audio> gets its (painted) resource: size, frame, playing, time. */
|
|
@@ -2989,7 +3330,7 @@ function buildPageRenderer({
|
|
|
2989
3330
|
|
|
2990
3331
|
function videoSource(element) {
|
|
2991
3332
|
const src = element.getAttribute("src") ?? element.querySelector("source[src]")?.getAttribute("src");
|
|
2992
|
-
try { return src ? new URL(src, page.url).href : null; } catch { return null; }
|
|
3333
|
+
try { return src ? new URL(src, element.baseURI || page.url).href : null; } catch { return null; }
|
|
2993
3334
|
}
|
|
2994
3335
|
|
|
2995
3336
|
// ---- media: <video> and <audio> share one pipeline. An <audio> is media
|
|
@@ -3095,6 +3436,22 @@ function buildPageRenderer({
|
|
|
3095
3436
|
if (!state) return;
|
|
3096
3437
|
state.mediaSource = source;
|
|
3097
3438
|
bindSourcePipes(state);
|
|
3439
|
+
// A later initialization segment can replace already-buffered video.
|
|
3440
|
+
// Shorten the active presentation at its epoch boundary as soon as that
|
|
3441
|
+
// boundary is known, without discarding the frames before it.
|
|
3442
|
+
if (state.video?.pipe?.snapshotAt) {
|
|
3443
|
+
const current = state.video.pipe.snapshotAt(state.video.startTime);
|
|
3444
|
+
const endTime = current.id === state.video.snapshotId ? current.endTime : current.startTime;
|
|
3445
|
+
if (endTime !== state.video.endTime) {
|
|
3446
|
+
state.video.endTime = endTime;
|
|
3447
|
+
if (state.video.presentation) onVideo?.({ type: "video-bind", id: state.video.presentation.id,
|
|
3448
|
+
endTime });
|
|
3449
|
+
}
|
|
3450
|
+
}
|
|
3451
|
+
if (state.sound?.pipe?.snapshotAt) {
|
|
3452
|
+
const current = state.sound.pipe.snapshotAt(state.sound.startTime);
|
|
3453
|
+
state.sound.endTime = current.id === state.sound.snapshotId ? current.endTime : current.startTime;
|
|
3454
|
+
}
|
|
3098
3455
|
if (state.audioPipe && state.audioPipe !== state.videoPipe) state.hasAudio = true;
|
|
3099
3456
|
void refreshProbe(state);
|
|
3100
3457
|
if (state.playing && state.hasAudio && !state.sound) ensureAudio(state);
|
|
@@ -3226,8 +3583,9 @@ function buildPageRenderer({
|
|
|
3226
3583
|
const id = nextVideoPresentation++;
|
|
3227
3584
|
const shared = new SharedArrayBuffer(16 + 480 * 480 * 4);
|
|
3228
3585
|
video.presentation = { id, shared, serial: 0 };
|
|
3229
|
-
onVideo({ type: "video-open", id, shared, bytes:
|
|
3586
|
+
onVideo({ type: "video-open", id, shared, bytes: video.bytes,
|
|
3230
3587
|
options: decodeOptions(state.videoMime || state.contentType, state.url), time: mediaTime(state),
|
|
3588
|
+
startTime: video.startTime ?? 0, endTime: video.endTime ?? Infinity,
|
|
3231
3589
|
hasAudio: state.hasAudio, audioId: state.sound?.id ?? null,
|
|
3232
3590
|
audioStart: state.sound ? state.sound.streamStart / state.sound.decoder.sampleRate : 0 });
|
|
3233
3591
|
}
|
|
@@ -3244,10 +3602,10 @@ function buildPageRenderer({
|
|
|
3244
3602
|
drawHints(canvas, region);
|
|
3245
3603
|
surface.flush();
|
|
3246
3604
|
const image = surface.makeImageSnapshot();
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3605
|
+
try {
|
|
3606
|
+
return image.readPixels(0, 0, { width, height, colorType: CanvasKit.ColorType.RGBA_8888,
|
|
3607
|
+
alphaType: CanvasKit.AlphaType.Unpremul, colorSpace: CanvasKit.ColorSpace.SRGB });
|
|
3608
|
+
} finally { image.delete(); }
|
|
3251
3609
|
};
|
|
3252
3610
|
const base = raster(false), mask = raster(true);
|
|
3253
3611
|
for (let i = 0; i < mask.length; i++) mask[i] = Math.max(0, mask[i] - base[i]);
|
|
@@ -3338,7 +3696,7 @@ function buildPageRenderer({
|
|
|
3338
3696
|
while (true) {
|
|
3339
3697
|
if (pipe) await pipe.waitFor(pipe.done ? pipe.size : Math.max(1, Math.min(want, pipe.size + 1)));
|
|
3340
3698
|
if (pipe?.error) throw pipe.error;
|
|
3341
|
-
const bytes = pipe
|
|
3699
|
+
const bytes = snapshot(pipe, state.bytes);
|
|
3342
3700
|
if (!bytes?.byteLength) {
|
|
3343
3701
|
if (!pipe || pipe.done) throw new Error(`media ${state.url}: empty response`);
|
|
3344
3702
|
continue;
|
|
@@ -3382,7 +3740,7 @@ function buildPageRenderer({
|
|
|
3382
3740
|
const pipe = state.videoPipe ?? state.pipe;
|
|
3383
3741
|
if (!pipe?.size) return;
|
|
3384
3742
|
try {
|
|
3385
|
-
const probe = await videoDecoder.probe(pipe.bytes());
|
|
3743
|
+
const probe = await videoDecoder.probe(snapshot(pipe, state.bytes, mediaTime(state)));
|
|
3386
3744
|
if (!state.durationOverride && probe.duration != null && probe.duration !== state.duration) {
|
|
3387
3745
|
state.duration = probe.duration;
|
|
3388
3746
|
state.resource.duration = probe.duration;
|
|
@@ -3431,19 +3789,21 @@ function buildPageRenderer({
|
|
|
3431
3789
|
return format ? { format } : {};
|
|
3432
3790
|
}
|
|
3433
3791
|
|
|
3434
|
-
function snapshot(pipe, bytes) {
|
|
3435
|
-
return pipe ? pipe.bytes() : bytes;
|
|
3792
|
+
function snapshot(pipe, bytes, time = 0) {
|
|
3793
|
+
return pipe?.snapshotAt ? pipe.snapshotAt(time).bytes : pipe ? pipe.bytes() : bytes;
|
|
3436
3794
|
}
|
|
3437
3795
|
|
|
3438
|
-
async function openVideoDecoder(state) {
|
|
3796
|
+
async function openVideoDecoder(state, time = mediaTime(state)) {
|
|
3439
3797
|
stopVideoPresentation(state);
|
|
3440
3798
|
const pipe = state.videoPipe ?? state.pipe;
|
|
3441
3799
|
const generation = pipe?.generation ?? 0;
|
|
3442
|
-
const
|
|
3800
|
+
const selected = pipe?.snapshotAt?.(time);
|
|
3801
|
+
const bytes = selected?.bytes ?? snapshot(pipe, state.bytes);
|
|
3443
3802
|
if (!bytes?.byteLength) throw new Error(`media ${state.url}: no video bytes`);
|
|
3444
3803
|
state.video?.decoder.close();
|
|
3445
3804
|
const decoder = await videoDecoder.decode(bytes, decodeOptions(state.videoMime || state.contentType, state.url));
|
|
3446
|
-
state.video = { decoder, frameTime: 0, done: false, pipe, openedGeneration: generation
|
|
3805
|
+
state.video = { decoder, bytes, frameTime: selected?.startTime ?? 0, done: false, pipe, openedGeneration: generation,
|
|
3806
|
+
snapshotId: selected?.id, startTime: selected?.startTime ?? 0, endTime: selected?.endTime ?? Infinity };
|
|
3447
3807
|
trace?.(`video decoded ${state.url}: ${decoder.width}x${decoder.height}`);
|
|
3448
3808
|
if (state.resource.width !== decoder.width || state.resource.height !== decoder.height) {
|
|
3449
3809
|
state.resource.width = decoder.width;
|
|
@@ -3459,13 +3819,14 @@ function buildPageRenderer({
|
|
|
3459
3819
|
* (no bytes yet, or audio_open hit EOF / invalid data on a short prefix).
|
|
3460
3820
|
* The previous decoder stays until a new one succeeds.
|
|
3461
3821
|
*/
|
|
3462
|
-
async function openAudioDecoder(state, { keepStream = false } = {}) {
|
|
3822
|
+
async function openAudioDecoder(state, { keepStream = false, time = mediaTime(state) } = {}) {
|
|
3463
3823
|
const serial = state.seekSerial;
|
|
3464
3824
|
const operation = (state.audioOpenSerial ?? 0) + 1;
|
|
3465
3825
|
state.audioOpenSerial = operation;
|
|
3466
3826
|
const pipe = state.audioPipe ?? state.pipe;
|
|
3467
3827
|
const generation = pipe?.generation ?? 0;
|
|
3468
|
-
const
|
|
3828
|
+
const selected = pipe?.snapshotAt?.(time);
|
|
3829
|
+
const bytes = selected?.bytes ?? snapshot(pipe, state.bytes);
|
|
3469
3830
|
const pending = pipe && !pipe.done && !pipe.error;
|
|
3470
3831
|
if (!bytes?.byteLength) {
|
|
3471
3832
|
if (pending) return false;
|
|
@@ -3502,8 +3863,10 @@ function buildPageRenderer({
|
|
|
3502
3863
|
throw new Error("audio format changed while output stream is active");
|
|
3503
3864
|
}
|
|
3504
3865
|
previous?.decoder.close();
|
|
3505
|
-
state.sound = { decoder, id: null, position:
|
|
3506
|
-
|
|
3866
|
+
state.sound = { decoder, id: null, position: Math.round((selected?.startTime ?? 0) * decoder.sampleRate),
|
|
3867
|
+
pending: firstSamples, history: [], finishing: false, done: false,
|
|
3868
|
+
pipe, openedGeneration: generation, snapshotId: selected?.id,
|
|
3869
|
+
startTime: selected?.startTime ?? 0, endTime: selected?.endTime ?? Infinity };
|
|
3507
3870
|
if (keepStream && previousId != null) {
|
|
3508
3871
|
// The output stream carries on with the new decoder's samples.
|
|
3509
3872
|
Object.assign(state.sound, { id: previousId, streamStart: previous.streamStart,
|
|
@@ -3554,7 +3917,10 @@ function buildPageRenderer({
|
|
|
3554
3917
|
function needsMore(track) {
|
|
3555
3918
|
const pipe = track?.pipe;
|
|
3556
3919
|
if (!pipe || pipe.error) return false;
|
|
3557
|
-
|
|
3920
|
+
const atBoundary = Number.isFinite(track.endTime) && (track.frameTime !== undefined
|
|
3921
|
+
? track.frameTime + 1e-6 >= track.endTime
|
|
3922
|
+
: track.position >= Math.round(track.endTime * track.decoder.sampleRate));
|
|
3923
|
+
return pipe.generation !== track.openedGeneration || !pipe.done || atBoundary;
|
|
3558
3924
|
}
|
|
3559
3925
|
|
|
3560
3926
|
/** A click on the media: on its progress track it seeks, anywhere else it plays or pauses. */
|
|
@@ -3658,9 +4024,15 @@ function buildPageRenderer({
|
|
|
3658
4024
|
/** Seek, reopening on a longer snapshot when the target is not in this prefix yet. */
|
|
3659
4025
|
async function placeVideo(state, seconds) {
|
|
3660
4026
|
for (let attempt = 0; attempt < 8; attempt++) {
|
|
3661
|
-
|
|
4027
|
+
const pipe = state.videoPipe ?? state.pipe;
|
|
4028
|
+
const selected = pipe?.snapshotAt?.(seconds);
|
|
4029
|
+
// A seek beyond an old snapshot can succeed at its last keyframe.
|
|
4030
|
+
// Reopen on newer bytes first; an exception does not detect that case.
|
|
4031
|
+
const stale = pipe && (pipe !== state.video?.pipe || pipe.generation !== state.video?.openedGeneration);
|
|
4032
|
+
if (!state.video || state.video.done || seconds === 0 || attempt > 0
|
|
4033
|
+
|| stale || (selected && selected.id !== state.video.snapshotId)) await openVideoDecoder(state, seconds);
|
|
3662
4034
|
try {
|
|
3663
|
-
state.video.frameTime = seekToKeyframe(state.video.decoder, seconds);
|
|
4035
|
+
state.video.frameTime = seekToKeyframe(state.video.decoder, seconds, state.video.startTime);
|
|
3664
4036
|
state.video.hideUntil = seconds;
|
|
3665
4037
|
state.video.done = false;
|
|
3666
4038
|
return;
|
|
@@ -3705,7 +4077,7 @@ function buildPageRenderer({
|
|
|
3705
4077
|
const reached = await advanceAudio(() => state.sound, seconds, {
|
|
3706
4078
|
needsMore,
|
|
3707
4079
|
active: () => state.audioPositioning === transaction && state.seekSerial === serial && state.document === page.document,
|
|
3708
|
-
reopen: () => openAudioDecoder(state, { keepStream: true }),
|
|
4080
|
+
reopen: () => openAudioDecoder(state, { keepStream: true, time: seconds }),
|
|
3709
4081
|
});
|
|
3710
4082
|
if (!reached && state.audioPositioning === transaction && state.seekSerial === serial) {
|
|
3711
4083
|
throw new Error(`audio could not reach ${seconds.toFixed(3)}s; refusing to replay an earlier prefix`);
|
|
@@ -3804,7 +4176,7 @@ function buildPageRenderer({
|
|
|
3804
4176
|
const video = state.video;
|
|
3805
4177
|
if (video?.needsLocalPosition && !video.presentation && !video.done) {
|
|
3806
4178
|
try {
|
|
3807
|
-
video.frameTime = seekToKeyframe(video.decoder, now);
|
|
4179
|
+
video.frameTime = seekToKeyframe(video.decoder, now, video.startTime);
|
|
3808
4180
|
video.hideUntil = now;
|
|
3809
4181
|
video.needsLocalPosition = false;
|
|
3810
4182
|
} catch (error) { failMedia(state, error, 3); return; }
|
|
@@ -3888,6 +4260,7 @@ function buildPageRenderer({
|
|
|
3888
4260
|
function drawVideoFrame(state, show) {
|
|
3889
4261
|
const video = state.video;
|
|
3890
4262
|
const decoder = video.decoder;
|
|
4263
|
+
if (video.frameTime + 1e-6 >= video.endTime) return "stall";
|
|
3891
4264
|
const width = Math.min(480, decoder.width);
|
|
3892
4265
|
const height = Math.max(1, Math.round(width * decoder.height / Math.max(1, decoder.width)));
|
|
3893
4266
|
let pixels;
|
|
@@ -3984,8 +4357,14 @@ function buildPageRenderer({
|
|
|
3984
4357
|
}
|
|
3985
4358
|
const { channels, sampleRate } = sound.decoder;
|
|
3986
4359
|
const frameBytes = channels * 2;
|
|
4360
|
+
const endSample = Math.round(sound.endTime * sampleRate);
|
|
3987
4361
|
sound.lastRequest = bytes;
|
|
3988
4362
|
for (let sent = 0; sent + frameBytes <= bytes;) {
|
|
4363
|
+
if (sound.position >= endSample) {
|
|
4364
|
+
sound.owed = (sound.owed ?? 0) + bytes - sent;
|
|
4365
|
+
void refillMedia(state, sound);
|
|
4366
|
+
return;
|
|
4367
|
+
}
|
|
3989
4368
|
let samples = sound.pending;
|
|
3990
4369
|
sound.pending = null;
|
|
3991
4370
|
if (!samples) {
|
|
@@ -4011,7 +4390,8 @@ function buildPageRenderer({
|
|
|
4011
4390
|
audio.finish(sound.id);
|
|
4012
4391
|
return;
|
|
4013
4392
|
}
|
|
4014
|
-
const
|
|
4393
|
+
const boundaryBytes = Math.max(0, endSample - sound.position) * frameBytes;
|
|
4394
|
+
const [taken, remaining] = splitSamples(samples, channels, Math.min(bytes - sent, boundaryBytes));
|
|
4015
4395
|
if (remaining.length) sound.pending = remaining;
|
|
4016
4396
|
sound.history.push({ start: sound.position, samples: taken });
|
|
4017
4397
|
sound.position += taken.length / channels;
|
|
@@ -4057,8 +4437,8 @@ function buildPageRenderer({
|
|
|
4057
4437
|
* binary search over seek targets finds where that keyframe starts (to a
|
|
4058
4438
|
* frame). The frames from there to the clock are then decoded unshown.
|
|
4059
4439
|
*/
|
|
4060
|
-
function seekToKeyframe(decoder, seconds) {
|
|
4061
|
-
return seekVideoKeyframe(decoder, seconds);
|
|
4440
|
+
function seekToKeyframe(decoder, seconds, startTime = 0) {
|
|
4441
|
+
return seekVideoKeyframe(decoder, seconds, startTime);
|
|
4062
4442
|
}
|
|
4063
4443
|
|
|
4064
4444
|
/**
|
|
@@ -4146,19 +4526,24 @@ function buildPageRenderer({
|
|
|
4146
4526
|
const resumeAt = stalledTrack === state.video && state.video ? Math.min(time, state.video.frameTime) : time;
|
|
4147
4527
|
// The picture must not go back to the keyframe the decoder restarts at.
|
|
4148
4528
|
const hideUntil = Math.max(state.video?.shownTime ?? 0, state.video?.hideUntil ?? 0);
|
|
4149
|
-
await openVideoDecoder(state);
|
|
4529
|
+
await openVideoDecoder(state, resumeAt);
|
|
4150
4530
|
state.video.hideUntil = hideUntil;
|
|
4151
4531
|
try {
|
|
4152
|
-
state.video.frameTime = seekToKeyframe(state.video.decoder, resumeAt);
|
|
4532
|
+
state.video.frameTime = seekToKeyframe(state.video.decoder, resumeAt, state.video.startTime);
|
|
4153
4533
|
} catch {
|
|
4154
|
-
state.video.frameTime =
|
|
4534
|
+
state.video.frameTime = state.video.startTime;
|
|
4155
4535
|
}
|
|
4156
4536
|
state.video.done = false;
|
|
4157
4537
|
}
|
|
4158
4538
|
if (refillAudio) {
|
|
4159
4539
|
let opened = false;
|
|
4160
4540
|
try {
|
|
4161
|
-
|
|
4541
|
+
let audioTime = held ? writtenEnd : time;
|
|
4542
|
+
if (state.sound && Number.isFinite(state.sound.endTime)
|
|
4543
|
+
&& state.sound.position >= Math.round(state.sound.endTime * state.sound.decoder.sampleRate)) {
|
|
4544
|
+
audioTime = Math.max(audioTime, state.sound.endTime);
|
|
4545
|
+
}
|
|
4546
|
+
opened = await openAudioDecoder(state, { keepStream: Boolean(held), time: audioTime });
|
|
4162
4547
|
} catch (error) {
|
|
4163
4548
|
if (!state.video) throw error;
|
|
4164
4549
|
reportError(error);
|
|
@@ -4242,7 +4627,7 @@ function buildPageRenderer({
|
|
|
4242
4627
|
/** The decoded CSS background image a url() names, if it has arrived. */
|
|
4243
4628
|
function backgroundResource(url) {
|
|
4244
4629
|
let absolute;
|
|
4245
|
-
try { absolute = new URL(url, page.url).href; } catch { return null; }
|
|
4630
|
+
try { absolute = new URL(url, page.document.baseURI || page.url).href; } catch { return null; }
|
|
4246
4631
|
return page.backgrounds.get(absolute)?.resource ?? null;
|
|
4247
4632
|
}
|
|
4248
4633
|
|
|
@@ -4255,28 +4640,25 @@ function buildPageRenderer({
|
|
|
4255
4640
|
if (!tree) return;
|
|
4256
4641
|
const document = page.document;
|
|
4257
4642
|
const seen = new Set();
|
|
4643
|
+
const used = new Set();
|
|
4258
4644
|
for (const node of tree.nodesById.values()) {
|
|
4259
4645
|
const value = node.style?.backgroundImage;
|
|
4260
4646
|
if (!value || seen.has(value) || !value.includes("url(")) continue;
|
|
4261
4647
|
seen.add(value);
|
|
4262
4648
|
for (const match of value.matchAll(CSS_URL)) {
|
|
4263
4649
|
let url;
|
|
4264
|
-
try { url = new URL(match[2], page.url).href; } catch { continue; }
|
|
4650
|
+
try { url = new URL(match[2], document.baseURI || page.url).href; } catch { continue; }
|
|
4651
|
+
used.add(url);
|
|
4265
4652
|
if (page.backgrounds.has(url)) continue;
|
|
4266
4653
|
const entry = { resource: null, loaded: null };
|
|
4267
4654
|
page.backgrounds.set(url, entry);
|
|
4268
|
-
entry.loaded =
|
|
4269
|
-
if (!response.ok || page.backgrounds.get(url) !== entry) return;
|
|
4270
|
-
const bytes = response.body;
|
|
4271
|
-
const { width, height } = await new Bun.Image(bytes).metadata();
|
|
4272
|
-
const image = CanvasKit.MakeImageFromEncoded(bytes);
|
|
4273
|
-
if (!image) return;
|
|
4655
|
+
entry.loaded = loadImageResource(document, url).then((resource) => {
|
|
4274
4656
|
if (page.backgrounds.get(url) !== entry || document !== page.document) {
|
|
4275
|
-
image.delete();
|
|
4657
|
+
resource.image.delete();
|
|
4276
4658
|
return;
|
|
4277
4659
|
}
|
|
4278
|
-
entry.resource =
|
|
4279
|
-
|
|
4660
|
+
entry.resource = resource;
|
|
4661
|
+
page.resourcePaintDirty = true;
|
|
4280
4662
|
page.needsPaint = true;
|
|
4281
4663
|
scheduleFrame();
|
|
4282
4664
|
}).catch(() => {
|
|
@@ -4284,6 +4666,11 @@ function buildPageRenderer({
|
|
|
4284
4666
|
});
|
|
4285
4667
|
}
|
|
4286
4668
|
}
|
|
4669
|
+
for (const [url, entry] of page.backgrounds) {
|
|
4670
|
+
if (used.has(url)) continue;
|
|
4671
|
+
entry.resource?.image.delete();
|
|
4672
|
+
page.backgrounds.delete(url);
|
|
4673
|
+
}
|
|
4287
4674
|
}
|
|
4288
4675
|
|
|
4289
4676
|
function disposeBackgrounds() {
|
|
@@ -4295,36 +4682,58 @@ function buildPageRenderer({
|
|
|
4295
4682
|
const document = page.document;
|
|
4296
4683
|
const generation = loadGeneration;
|
|
4297
4684
|
const staleImages = [];
|
|
4685
|
+
// Infinite feeds remove old DOM images without navigating. The resource
|
|
4686
|
+
// WeakMap alone cannot release their explicitly-owned Skia handles.
|
|
4687
|
+
for (const element of page.imageElements) {
|
|
4688
|
+
if (element.isConnected && element.ownerDocument === document && element.getAttribute("src")) continue;
|
|
4689
|
+
const image = page.resources.get(element)?.image;
|
|
4690
|
+
if (image) {
|
|
4691
|
+
staleImages.push(image);
|
|
4692
|
+
page.resourceLayoutDirty = true;
|
|
4693
|
+
}
|
|
4694
|
+
page.resources.delete(element);
|
|
4695
|
+
page.imageElements.delete(element);
|
|
4696
|
+
imageRequests.delete(element);
|
|
4697
|
+
}
|
|
4298
4698
|
for (const element of document.querySelectorAll("img")) {
|
|
4299
4699
|
const src = element.getAttribute("src");
|
|
4300
|
-
|
|
4700
|
+
const credentials = imageCrossOrigin(element);
|
|
4701
|
+
const key = imageRequestKey(src, credentials);
|
|
4702
|
+
if (!src || imageRequests.get(element) === key) continue;
|
|
4301
4703
|
let url;
|
|
4302
|
-
try { url = new URL(src, page.url).href; } catch { continue; }
|
|
4303
|
-
|
|
4304
|
-
|
|
4704
|
+
try { url = new URL(src, element.baseURI || page.url).href; } catch { continue; }
|
|
4705
|
+
const existing = page.resources.get(element);
|
|
4706
|
+
if (existing?.requestedURL === url && existing.crossOrigin === credentials) {
|
|
4707
|
+
imageRequests.set(element, key);
|
|
4305
4708
|
continue;
|
|
4306
4709
|
}
|
|
4307
|
-
imageRequests.set(element,
|
|
4710
|
+
imageRequests.set(element, key);
|
|
4308
4711
|
const oldImage = page.resources.get(element)?.image;
|
|
4309
|
-
if (oldImage)
|
|
4712
|
+
if (oldImage) {
|
|
4713
|
+
staleImages.push(oldImage);
|
|
4714
|
+
page.resourceLayoutDirty = true;
|
|
4715
|
+
}
|
|
4310
4716
|
page.resources.delete(element);
|
|
4311
4717
|
beginImageLoad(element, src);
|
|
4312
|
-
|
|
4313
|
-
|
|
4314
|
-
|
|
4315
|
-
|
|
4316
|
-
const image = CanvasKit.MakeImageFromEncoded(bytes);
|
|
4317
|
-
if (!image) throw new Error("Image decoding failed");
|
|
4318
|
-
if (generation !== loadGeneration || document !== page.document || element.getAttribute("src") !== src) {
|
|
4718
|
+
loadImageResource(document, url, credentials).then((resource) => {
|
|
4719
|
+
const { width, height, image } = resource;
|
|
4720
|
+
if (generation !== loadGeneration || document !== page.document || element.getAttribute("src") !== src
|
|
4721
|
+
|| imageCrossOrigin(element) !== credentials) {
|
|
4319
4722
|
image.delete();
|
|
4320
4723
|
return;
|
|
4321
4724
|
}
|
|
4322
|
-
page.resources.set(element,
|
|
4725
|
+
page.resources.set(element, resource);
|
|
4323
4726
|
page.decodedImages.push(image);
|
|
4324
|
-
|
|
4325
|
-
|
|
4326
|
-
const
|
|
4327
|
-
|
|
4727
|
+
page.imageElements.add(element);
|
|
4728
|
+
let node, run;
|
|
4729
|
+
for (const candidate of page.renderTree.nodesById.values()) {
|
|
4730
|
+
if (candidate.domNode === element) { node = candidate; break; }
|
|
4731
|
+
}
|
|
4732
|
+
if (node) outer: for (const fragment of page.layout.fragments) {
|
|
4733
|
+
for (const candidate of fragment.runs) {
|
|
4734
|
+
if (candidate.type === "image" && candidate.nodeId === node.id) { run = candidate; break outer; }
|
|
4735
|
+
}
|
|
4736
|
+
}
|
|
4328
4737
|
const cssWidth = node?.style?.width;
|
|
4329
4738
|
const cssHeight = node?.style?.height;
|
|
4330
4739
|
const specifiedWidth = typeof cssWidth === "number" ? cssWidth : node?.widthAttribute;
|
|
@@ -4335,17 +4744,20 @@ function buildPageRenderer({
|
|
|
4335
4744
|
const paintOnly = run && Number.isFinite(specifiedWidth) && Number.isFinite(specifiedHeight)
|
|
4336
4745
|
&& Math.abs(run.width - specifiedWidth) < 0.01 && Math.abs(run.height - specifiedHeight) < 0.01;
|
|
4337
4746
|
if (paintOnly) {
|
|
4338
|
-
page.
|
|
4747
|
+
page.resourcePaintDirty = true;
|
|
4339
4748
|
page.needsPaint = true;
|
|
4340
4749
|
} else {
|
|
4341
4750
|
page.dirty = true;
|
|
4342
4751
|
page.resourceLayoutDirty = true;
|
|
4343
4752
|
}
|
|
4344
|
-
|
|
4753
|
+
// All arrivals in this rendering opportunity share layout/raster.
|
|
4754
|
+
// An onload handler which reads geometry still flushes synchronously.
|
|
4755
|
+
finishImageLoad(element, src, { width, height }, type => element.dispatchEvent(new page.window.Event(type)));
|
|
4756
|
+
scheduleFrame();
|
|
4345
4757
|
}).catch((error) => {
|
|
4346
4758
|
// A failed image retains its fallback replaced-element dimensions.
|
|
4347
4759
|
trace?.(`image failed ${url}: ${error?.message ?? error}`);
|
|
4348
|
-
if (generation === loadGeneration && document === page.document) {
|
|
4760
|
+
if (generation === loadGeneration && document === page.document && imageCrossOrigin(element) === credentials) {
|
|
4349
4761
|
finishImageLoad(element, src, null, type => element.dispatchEvent(new page.window.Event(type)));
|
|
4350
4762
|
}
|
|
4351
4763
|
});
|