buninu-browser 0.2.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -1
- package/lib/cdp-server.js +47 -14
- package/lib/clipboard.js +22 -0
- 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 +40 -11
- 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 +77 -16
- package/lib/paint/paragraph-text.js +24 -0
- package/lib/paint/raster-readback.js +41 -0
- package/lib/paint/raw-frames.js +47 -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-channel.js +103 -0
- package/lib/renderer/compositor.js +34 -40
- package/lib/renderer/custom-element-reactions.js +25 -1
- package/lib/renderer/flags.js +1 -1
- package/lib/renderer/font-loading.js +227 -0
- package/lib/renderer/host.js +133 -98
- 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 +52 -5
- package/lib/renderer/media-source.js +126 -55
- package/lib/renderer/page-realm.js +287 -99
- package/lib/renderer/page-renderer.js +663 -239
- 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/clipboard.test.js +35 -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/browser-raw-demo.html +36 -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/raw-capture-benchmark.json +76 -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/raw-capture.test.js +152 -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/test/window-resize.test.js +36 -0
- package/tools/bench-raw-capture.js +32 -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, rawFrame, captureViewport, 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
|
}
|
|
@@ -214,6 +227,7 @@ function buildPageRenderer({
|
|
|
214
227
|
requestDownload = () => {},
|
|
215
228
|
reportError = () => {},
|
|
216
229
|
pageFetch = () => Promise.reject(new TypeError("network access is not available")),
|
|
230
|
+
clipboardWrite = () => Promise.reject(Error("Clipboard unavailable")),
|
|
217
231
|
webSockets = null,
|
|
218
232
|
audio = null,
|
|
219
233
|
setCookie = () => {},
|
|
@@ -231,38 +245,47 @@ function buildPageRenderer({
|
|
|
231
245
|
// An iframe's document (see syncFrames): frames go to the embedding page,
|
|
232
246
|
// which needs only the viewport, so nothing is painted ahead.
|
|
233
247
|
embedded = false,
|
|
248
|
+
allocateFramePixels = null,
|
|
234
249
|
// This document's window handle, made by the embedding page (see windowHandle).
|
|
235
250
|
frameWindow = null,
|
|
236
251
|
}, {
|
|
237
252
|
CanvasKit, loadedFamilies, fontCollection, fontFamilies, sansSerifFamily, monospaceFamily, monospaceBoldFamily,
|
|
238
|
-
genericFamilies,
|
|
253
|
+
genericFamilies, builtinTypefaces,
|
|
239
254
|
}) {
|
|
255
|
+
let documentFonts = null;
|
|
256
|
+
let fontSheets = null;
|
|
257
|
+
let fontEnvironment = "";
|
|
258
|
+
let fontDefinitions = [];
|
|
259
|
+
let fontDefinitionKey = "[]";
|
|
240
260
|
const resolvedFamilies = new Map();
|
|
241
261
|
/** First available family from a computed font-family list, then the UI fallbacks. */
|
|
242
|
-
function resolveFontFamilies(families = ["sans-serif"], weight = 400) {
|
|
262
|
+
function resolveFontFamilies(families = ["sans-serif"], weight = 400, fontStyle = "normal", point = null, stretch = 100) {
|
|
243
263
|
const bold = weight >= 600;
|
|
244
|
-
const key = `${
|
|
245
|
-
let resolved = resolvedFamilies.get(key);
|
|
264
|
+
const key = `${weight}/${fontStyle}/${stretch}\n${families.join("\n")}`;
|
|
265
|
+
let resolved = point === null && resolvedFamilies.get(key);
|
|
246
266
|
if (!resolved) {
|
|
247
|
-
let primary =
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
267
|
+
let primary = [];
|
|
268
|
+
for (const family of families) {
|
|
269
|
+
const web = documentFonts?.resolve(family, weight, fontStyle, point, stretch);
|
|
270
|
+
if (web?.length) { primary.push(...web); continue; }
|
|
271
|
+
const builtin = loadedFamilies.get(family.toLowerCase()) ?? genericFamilies.get(family.toLowerCase());
|
|
272
|
+
if (builtin) { primary.push(bold && builtin === monospaceFamily ? monospaceBoldFamily : builtin); break; }
|
|
273
|
+
}
|
|
274
|
+
if (!primary.length) primary = [sansSerifFamily];
|
|
275
|
+
resolved = [...primary, ...fontFamilies.filter((family) => !primary.includes(family))];
|
|
276
|
+
if (point === null) resolvedFamilies.set(key, resolved);
|
|
253
277
|
}
|
|
254
278
|
return resolved;
|
|
255
279
|
}
|
|
256
|
-
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
257
|
-
const emojiPresentation = /^\p{Emoji_Presentation}/u;
|
|
258
280
|
const renderTreeBuilder = new RenderTreeBuilder();
|
|
259
281
|
const layoutEngine = new LayoutEngine({ measureText, fontMetrics });
|
|
260
|
-
const styleEngine = new StyleEngine();
|
|
282
|
+
const styleEngine = new StyleEngine({ measureText });
|
|
261
283
|
// Incremental restyle: what may have changed style since the last
|
|
262
284
|
// computation. DOM mutations come from a MutationObserver; state that is
|
|
263
285
|
// not in the DOM tree (checked, value, focus) is marked by the code that
|
|
264
286
|
// changes it. `full` asks for a complete restyle.
|
|
265
|
-
const styleChanges = { full: true, roots: new Set(), rootFeatures: new Map(), records: [], observer: null,
|
|
287
|
+
const styleChanges = { full: true, roots: new Set(), rootFeatures: new Map(), records: [], observer: null,
|
|
288
|
+
activeElement: null, focusAncestors: new Set() };
|
|
266
289
|
let selectorTraits = { sibling: false, has: null, sheets: null };
|
|
267
290
|
let lastStyleParts = "";
|
|
268
291
|
let laidOutSheets = null;
|
|
@@ -306,6 +329,7 @@ function buildPageRenderer({
|
|
|
306
329
|
deviceScaleFactor: 1,
|
|
307
330
|
pageScale: 1,
|
|
308
331
|
needsPaint: true,
|
|
332
|
+
resourcePaintDirty: false,
|
|
309
333
|
loading: false,
|
|
310
334
|
hints: [],
|
|
311
335
|
hintsVisible: false,
|
|
@@ -316,6 +340,7 @@ function buildPageRenderer({
|
|
|
316
340
|
backgroundColor: "rgba(255, 255, 255, 1)",
|
|
317
341
|
resources: new WeakMap(),
|
|
318
342
|
decodedImages: [],
|
|
343
|
+
imageElements: new Set(),
|
|
319
344
|
imagesLoaded: null,
|
|
320
345
|
// CSS background images by absolute URL: { resource, loaded } (see queueBackgroundImages).
|
|
321
346
|
backgrounds: new Map(),
|
|
@@ -341,15 +366,58 @@ function buildPageRenderer({
|
|
|
341
366
|
if (typeof response.documentCookie === "string") page.cookie = response.documentCookie;
|
|
342
367
|
return response;
|
|
343
368
|
}
|
|
369
|
+
const loadImageResource = createImageResourceLoader(CanvasKit, fetchResource, pageFetch);
|
|
370
|
+
const renderCanvasText = createCanvasTextRenderer(CanvasKit, makeParagraph);
|
|
371
|
+
|
|
372
|
+
function resetDocumentFonts() {
|
|
373
|
+
documentFonts?.dispose();
|
|
374
|
+
fontSheets = null;
|
|
375
|
+
fontDefinitions = [];
|
|
376
|
+
if (fontDefinitionKey !== "[]") clearFontCaches();
|
|
377
|
+
fontEnvironment = "";
|
|
378
|
+
fontDefinitionKey = "[]";
|
|
379
|
+
documentFonts = new DocumentFonts({
|
|
380
|
+
CanvasKit, builtins: builtinTypefaces, defaultCollection: fontCollection, fetch: pageFetch,
|
|
381
|
+
availableFamilies: new Set([...loadedFamilies.keys(), ...genericFamilies.keys()]),
|
|
382
|
+
changed() {
|
|
383
|
+
if (disposedRenderer) return;
|
|
384
|
+
clearFontCaches();
|
|
385
|
+
// Font metrics also affect CSS ch units and normal line-height.
|
|
386
|
+
styleChanges.full = true;
|
|
387
|
+
page.dirty = true;
|
|
388
|
+
page.resourceLayoutDirty = true;
|
|
389
|
+
page.needsPaint = true;
|
|
390
|
+
scheduleFrame();
|
|
391
|
+
},
|
|
392
|
+
error: error => trace?.(`font failed: ${error?.message ?? error}`),
|
|
393
|
+
});
|
|
394
|
+
}
|
|
344
395
|
|
|
345
|
-
function
|
|
396
|
+
function clearFontCaches() {
|
|
397
|
+
resolvedFamilies.clear();
|
|
398
|
+
measuredWidths.clear();
|
|
399
|
+
measuredCount = 0;
|
|
400
|
+
fontKeys = new WeakMap();
|
|
401
|
+
fontMetricsCache.clear();
|
|
402
|
+
clearPaintParagraphs();
|
|
403
|
+
rasterized.list = null;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function queueFontLoads() {
|
|
407
|
+
if (page.renderTree && fontDefinitions.length) documentFonts.update(fontDefinitions,
|
|
408
|
+
[...page.renderTree.nodesById.values()].filter(node => node.type === "text"));
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function makeParagraph(text, computedStyle = null, fontSize = computedStyle?.fontSize ?? 16, scale = 1, foregroundPaint = null) {
|
|
346
412
|
const color = canvasColor(CanvasKit, computedStyle?.color ?? "rgba(0, 0, 0, 1)");
|
|
347
413
|
const weight = computedStyle?.fontWeight ?? 400;
|
|
348
|
-
const
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
414
|
+
const fontStyle = computedStyle?.fontStyle ?? "normal";
|
|
415
|
+
const stretch = computedStyle?.fontStretch ?? 100;
|
|
416
|
+
const widths = [50, 62.5, 75, 87.5, 100, 112.5, 125, 150, 200];
|
|
417
|
+
const width = widths.reduce((best, value, index) => Math.abs(value - stretch) < Math.abs(widths[best] - stretch) ? index : best, 4) + 1;
|
|
418
|
+
const slant = fontStyle === "italic" ? CanvasKit.FontSlant.Italic
|
|
419
|
+
: fontStyle === "oblique" ? CanvasKit.FontSlant.Oblique : CanvasKit.FontSlant.Upright;
|
|
420
|
+
const families = resolveFontFamilies(computedStyle?.fontFamily, weight, fontStyle, null, stretch);
|
|
353
421
|
// text-decoration lines, drawn by SkParagraph under/over/through the glyphs.
|
|
354
422
|
const decoration = computedStyle?.decoration ? {
|
|
355
423
|
decoration: computedStyle.decoration.lines.reduce((mask, line) => mask | ({
|
|
@@ -363,30 +431,60 @@ function buildPageRenderer({
|
|
|
363
431
|
color,
|
|
364
432
|
fontFamilies: families,
|
|
365
433
|
fontSize: Math.max(1, Math.round(fontSize * scale)),
|
|
366
|
-
fontStyle: { weight },
|
|
434
|
+
fontStyle: { weight, slant, width },
|
|
367
435
|
// Roboto is a variable font; static fonts ignore the axis and match by weight.
|
|
368
|
-
fontVariations: [{ axis: "wght", value: weight }],
|
|
436
|
+
fontVariations: [{ axis: "wght", value: weight }, { axis: "wdth", value: stretch }],
|
|
369
437
|
...decoration,
|
|
370
438
|
},
|
|
371
439
|
maxLines: 1,
|
|
440
|
+
...(computedStyle?.direction ? { textDirection: computedStyle.direction === "rtl" ? CanvasKit.TextDirection.RTL : CanvasKit.TextDirection.LTR } : {}),
|
|
372
441
|
});
|
|
373
|
-
const builder = CanvasKit.ParagraphBuilder.MakeFromFontCollection(style, fontCollection);
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
442
|
+
const builder = CanvasKit.ParagraphBuilder.MakeFromFontCollection(style, documentFonts?.collection ?? fontCollection);
|
|
443
|
+
const backgroundPaint = foregroundPaint ? new CanvasKit.Paint() : null;
|
|
444
|
+
if (backgroundPaint) backgroundPaint.setColor(CanvasKit.TRANSPARENT);
|
|
445
|
+
const pushTextStyle = foregroundPaint
|
|
446
|
+
? value => builder.pushPaintStyle(value, foregroundPaint, backgroundPaint) : value => builder.pushStyle(value);
|
|
447
|
+
const textStyle = (fontFamilies) => CanvasKit.TextStyle({ color, ...decoration, fontFamilies,
|
|
448
|
+
fontSize: Math.max(1, Math.round(fontSize * scale)), fontStyle: { weight, slant, width },
|
|
449
|
+
fontVariations: [{ axis: "wght", value: weight }, { axis: "wdth", value: stretch }] });
|
|
450
|
+
function append(text, fonts) {
|
|
451
|
+
let emojiStyle;
|
|
452
|
+
const writer = foregroundPaint ? { addText: value => builder.addText(value), pushStyle: pushTextStyle, pop: () => builder.pop() } : builder;
|
|
453
|
+
appendParagraphText(writer, text, () => emojiStyle ??= textStyle([
|
|
454
|
+
...fonts.filter(name => name.includes("Emoji")), ...fonts.filter(name => !name.includes("Emoji")),
|
|
455
|
+
]));
|
|
387
456
|
}
|
|
457
|
+
if (foregroundPaint) pushTextStyle(textStyle(families));
|
|
458
|
+
if (documentFonts?.hasUnicodeRanges && text) {
|
|
459
|
+
// A downloaded subset may physically contain more glyphs than its CSS
|
|
460
|
+
// unicode-range permits. Apply the range when choosing native families,
|
|
461
|
+
// preserving whole adjacent runs for shaping instead of per-letter calls.
|
|
462
|
+
const choices = new Map();
|
|
463
|
+
let run = "", previous = null, previousKey = "";
|
|
464
|
+
const flush = () => {
|
|
465
|
+
if (!run) return;
|
|
466
|
+
pushTextStyle(textStyle(previous));
|
|
467
|
+
append(run, previous);
|
|
468
|
+
builder.pop();
|
|
469
|
+
};
|
|
470
|
+
for (const character of text) {
|
|
471
|
+
const point = character.codePointAt(0);
|
|
472
|
+
let choice = choices.get(point);
|
|
473
|
+
if (!choice) {
|
|
474
|
+
const fonts = resolveFontFamilies(computedStyle?.fontFamily, weight, fontStyle, point, stretch);
|
|
475
|
+
choices.set(point, choice = { fonts, key: fonts.join("\n") });
|
|
476
|
+
}
|
|
477
|
+
if (run && choice.key !== previousKey) { flush(); run = ""; }
|
|
478
|
+
previous = choice.fonts;
|
|
479
|
+
previousKey = choice.key;
|
|
480
|
+
run += character;
|
|
481
|
+
}
|
|
482
|
+
flush();
|
|
483
|
+
} else append(text, families);
|
|
484
|
+
if (foregroundPaint) builder.pop();
|
|
388
485
|
const paragraph = builder.build();
|
|
389
486
|
builder.delete();
|
|
487
|
+
backgroundPaint?.delete();
|
|
390
488
|
return paragraph;
|
|
391
489
|
}
|
|
392
490
|
|
|
@@ -420,22 +518,24 @@ function buildPageRenderer({
|
|
|
420
518
|
}
|
|
421
519
|
|
|
422
520
|
// Computed styles are immutable and shared, so each one's key is computed once.
|
|
423
|
-
|
|
521
|
+
let fontKeys = new WeakMap();
|
|
424
522
|
function fontKeyOf(computedStyle) {
|
|
425
523
|
if (computedStyle && typeof computedStyle === "object") {
|
|
426
524
|
const cached = fontKeys.get(computedStyle);
|
|
427
525
|
if (cached) return cached;
|
|
428
526
|
}
|
|
429
527
|
const weight = computedStyle?.fontWeight ?? 400;
|
|
430
|
-
const
|
|
431
|
-
|
|
528
|
+
const fontStyle = computedStyle?.fontStyle ?? "normal";
|
|
529
|
+
const stretch = computedStyle?.fontStretch ?? 100;
|
|
530
|
+
const key = `${Math.max(1, Math.round(computedStyle?.fontSize ?? 16))}/${weight}/${fontStyle}/${stretch}/${
|
|
531
|
+
resolveFontFamilies(computedStyle?.fontFamily, weight, fontStyle, null, stretch).join(",")}`;
|
|
432
532
|
if (computedStyle && typeof computedStyle === "object") fontKeys.set(computedStyle, key);
|
|
433
533
|
return key;
|
|
434
534
|
}
|
|
435
535
|
|
|
436
536
|
const fontMetricsCache = new Map();
|
|
437
537
|
function fontMetrics(computedStyle = null) {
|
|
438
|
-
const key =
|
|
538
|
+
const key = fontKeyOf(computedStyle);
|
|
439
539
|
let metrics = fontMetricsCache.get(key);
|
|
440
540
|
if (!metrics) {
|
|
441
541
|
const paragraph = makeParagraph("Hg", computedStyle);
|
|
@@ -452,9 +552,10 @@ function buildPageRenderer({
|
|
|
452
552
|
// thread scrolls on its own (keys, fragments, script, a new document).
|
|
453
553
|
let scrollEpoch = 0;
|
|
454
554
|
let syncedScroll = { x: 0, y: 0 };
|
|
555
|
+
let syncedViewport = { width: 0, height: 0 };
|
|
455
556
|
let tileSerial = 0;
|
|
456
557
|
let lastTile = null;
|
|
457
|
-
|
|
558
|
+
const captures = new FrameCaptureCache();
|
|
458
559
|
let prepaintTimer = null;
|
|
459
560
|
let frameTimer = null;
|
|
460
561
|
let lastRebuildMs = 0;
|
|
@@ -469,6 +570,67 @@ function buildPageRenderer({
|
|
|
469
570
|
let rasterSurface = null;
|
|
470
571
|
let rasterSurfaceWidth = 0;
|
|
471
572
|
let rasterSurfaceHeight = 0;
|
|
573
|
+
const rasterReadback = new RasterReadback(CanvasKit, { shared: Boolean(onFrame), allocate: allocateFramePixels });
|
|
574
|
+
// A scroll or unrelated DOM update does not change text shaping. Keep a
|
|
575
|
+
// bounded set of visible paragraphs keyed by their complete shaping input.
|
|
576
|
+
// Both limits matter: a page may contain many unique or very long strings.
|
|
577
|
+
const paintedParagraphs = new Map();
|
|
578
|
+
let paragraphKeys = new WeakMap();
|
|
579
|
+
let paintedCharacters = 0;
|
|
580
|
+
const MAX_PAINT_PARAGRAPHS = 256;
|
|
581
|
+
const MAX_PAINT_CHARACTERS = 64_000;
|
|
582
|
+
|
|
583
|
+
function releasePaintParagraph(key, entry) {
|
|
584
|
+
entry.paragraph.delete();
|
|
585
|
+
paintedParagraphs.delete(key);
|
|
586
|
+
paintedCharacters -= entry.characters;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function clearPaintParagraphs() {
|
|
590
|
+
renderCanvasText.clear();
|
|
591
|
+
for (const entry of paintedParagraphs.values()) entry.paragraph.delete();
|
|
592
|
+
paintedParagraphs.clear();
|
|
593
|
+
paragraphKeys = new WeakMap();
|
|
594
|
+
paintedCharacters = 0;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function paragraphForPaint(item, scale, width) {
|
|
598
|
+
const retained = item.text.length <= 4096;
|
|
599
|
+
let cachedKey = retained && paragraphKeys.get(item);
|
|
600
|
+
if (retained && (!cachedKey || cachedKey.scale !== scale || cachedKey.width !== width)) {
|
|
601
|
+
cachedKey = { scale, width, key: JSON.stringify([item.text, item.color, item.font,
|
|
602
|
+
item.decoration, scale, width]) };
|
|
603
|
+
paragraphKeys.set(item, cachedKey);
|
|
604
|
+
}
|
|
605
|
+
const key = cachedKey?.key;
|
|
606
|
+
let entry = retained && paintedParagraphs.get(key);
|
|
607
|
+
if (entry) {
|
|
608
|
+
paintedParagraphs.delete(key);
|
|
609
|
+
paintedParagraphs.set(key, entry);
|
|
610
|
+
return entry;
|
|
611
|
+
}
|
|
612
|
+
const paragraph = makeParagraph(item.text, {
|
|
613
|
+
color: item.color, fontSize: item.font.size, fontWeight: item.font.weight, fontFamily: item.font.family,
|
|
614
|
+
fontStyle: item.font.style, fontStretch: item.font.stretch,
|
|
615
|
+
decoration: item.decoration,
|
|
616
|
+
}, undefined, scale);
|
|
617
|
+
try {
|
|
618
|
+
paragraph.layout(width);
|
|
619
|
+
} catch (error) {
|
|
620
|
+
paragraph.delete();
|
|
621
|
+
throw error;
|
|
622
|
+
}
|
|
623
|
+
entry = { paragraph, retained, characters: item.text.length };
|
|
624
|
+
if (entry.retained) {
|
|
625
|
+
paintedParagraphs.set(key, entry);
|
|
626
|
+
paintedCharacters += item.text.length;
|
|
627
|
+
while (paintedParagraphs.size > MAX_PAINT_PARAGRAPHS || paintedCharacters > MAX_PAINT_CHARACTERS) {
|
|
628
|
+
const [oldKey, oldEntry] = paintedParagraphs.entries().next().value;
|
|
629
|
+
releasePaintParagraph(oldKey, oldEntry);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
return entry;
|
|
633
|
+
}
|
|
472
634
|
|
|
473
635
|
/** Hint overlay contents, as a comparable key. */
|
|
474
636
|
function overlayKey() {
|
|
@@ -485,6 +647,8 @@ function buildPageRenderer({
|
|
|
485
647
|
if (page.displayList?.items.some((item) => item.fixed)
|
|
486
648
|
&& (rasterized.scrollX !== viewport.x || rasterized.scrollY !== viewport.y)) return false;
|
|
487
649
|
if (rasterized.list === page.displayList) return true;
|
|
650
|
+
if (rasterized.list?.images.length !== page.displayList?.images.length
|
|
651
|
+
|| page.displayList?.images.some((resource, index) => resource !== rasterized.list.images[index])) return false;
|
|
488
652
|
if (!rasterized.list || !page.displayList || !Bun.deepEquals(rasterized.list.items, page.displayList.items)) return false;
|
|
489
653
|
rasterized.list = page.displayList;
|
|
490
654
|
return true;
|
|
@@ -492,6 +656,12 @@ function buildPageRenderer({
|
|
|
492
656
|
|
|
493
657
|
/** Rasterizes the viewport and hands it to the compositor. */
|
|
494
658
|
function render() {
|
|
659
|
+
if (disposedRenderer) return;
|
|
660
|
+
if (page.resourcePaintDirty) {
|
|
661
|
+
page.displayList = paintList();
|
|
662
|
+
page.resourcePaintDirty = false;
|
|
663
|
+
}
|
|
664
|
+
page.realm?.layoutChanged(page.layoutVersion);
|
|
495
665
|
if (onVideo) for (const state of page.media.values()) readPresentedFrame(state);
|
|
496
666
|
const width = Math.max(1, Math.floor(page.width));
|
|
497
667
|
const height = Math.max(1, Math.floor(page.height));
|
|
@@ -499,15 +669,27 @@ function buildPageRenderer({
|
|
|
499
669
|
if (lastTile && tileCovers(lastTile, region) && sameRasterInput()) {
|
|
500
670
|
page.needsPaint = false;
|
|
501
671
|
// Only this thread's scroll position changed: tell the compositor.
|
|
502
|
-
if (viewport.x !== syncedScroll.x || viewport.y !== syncedScroll.y
|
|
672
|
+
if (viewport.x !== syncedScroll.x || viewport.y !== syncedScroll.y
|
|
673
|
+
|| page.width !== syncedViewport.width || page.height !== syncedViewport.height) publish(lastTile);
|
|
503
674
|
} else {
|
|
504
|
-
|
|
675
|
+
// Keep a previously prepainted region during ordinary page updates.
|
|
676
|
+
// Shrinking to the viewport and expanding again 30ms later used to
|
|
677
|
+
// rasterize each animation twice and repeatedly resize native surfaces.
|
|
678
|
+
const retained = !page.loading && lastTile && tileCovers(lastTile, region)
|
|
679
|
+
&& lastTile.height <= height * 2.5;
|
|
680
|
+
publish(rasterTile(retained ? { x: lastTile.x, y: lastTile.y, width: lastTile.width, height: lastTile.height } : region));
|
|
505
681
|
}
|
|
506
682
|
schedulePrepaint();
|
|
507
683
|
}
|
|
508
684
|
|
|
509
685
|
/** Rasterizes a page rectangle (CSS pixels) into RGBA pixels. */
|
|
510
686
|
function rasterTile(region) {
|
|
687
|
+
// Prepaint can run before a queued canvas/image repaint. Refresh native
|
|
688
|
+
// snapshots here too: the old display list may hold a released image.
|
|
689
|
+
if (page.resourcePaintDirty) {
|
|
690
|
+
page.displayList = paintList();
|
|
691
|
+
page.resourcePaintDirty = false;
|
|
692
|
+
}
|
|
511
693
|
const scale = page.deviceScaleFactor;
|
|
512
694
|
const pixelWidth = Math.max(1, Math.round(region.width * scale));
|
|
513
695
|
const pixelHeight = Math.max(1, Math.round(region.height * scale));
|
|
@@ -526,14 +708,9 @@ function buildPageRenderer({
|
|
|
526
708
|
drawHints(canvas, region);
|
|
527
709
|
surface.flush();
|
|
528
710
|
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();
|
|
711
|
+
let pixels;
|
|
712
|
+
try { pixels = rasterReadback.read(image, pixelWidth, pixelHeight); }
|
|
713
|
+
finally { image.delete(); }
|
|
537
714
|
const videoLayer = makeVideoLayer(canvas, surface, region, scale, pixelWidth, pixelHeight);
|
|
538
715
|
page.needsPaint = false;
|
|
539
716
|
rasterized = {
|
|
@@ -557,6 +734,7 @@ function buildPageRenderer({
|
|
|
557
734
|
function publish(tile) {
|
|
558
735
|
lastTile = tile;
|
|
559
736
|
tileSerial++;
|
|
737
|
+
syncedViewport = { width: page.width, height: page.height };
|
|
560
738
|
if (viewport.x !== syncedScroll.x || viewport.y !== syncedScroll.y) {
|
|
561
739
|
scrollEpoch++;
|
|
562
740
|
syncedScroll = { x: viewport.x, y: viewport.y };
|
|
@@ -587,7 +765,7 @@ function buildPageRenderer({
|
|
|
587
765
|
|
|
588
766
|
/** After a paint or a compositor scroll, rasterize around the viewport once this thread is idle. */
|
|
589
767
|
function schedulePrepaint() {
|
|
590
|
-
if (!onFrame || embedded) return;
|
|
768
|
+
if (disposedRenderer || !onFrame || embedded) return;
|
|
591
769
|
hostClearTimeout(prepaintTimer);
|
|
592
770
|
prepaintTimer = hostSetTimeout(() => {
|
|
593
771
|
prepaintTimer = null;
|
|
@@ -606,25 +784,28 @@ function buildPageRenderer({
|
|
|
606
784
|
|
|
607
785
|
/** A rendering opportunity for DOM changes nobody has asked to see yet. */
|
|
608
786
|
function scheduleFrame() {
|
|
609
|
-
if (!onFrame || frameTimer || page.loading) return;
|
|
787
|
+
if (disposedRenderer || !onFrame || frameTimer || page.loading) return;
|
|
610
788
|
frameTimer = hostSetTimeout(() => {
|
|
611
789
|
frameTimer = null;
|
|
612
790
|
refresh();
|
|
613
|
-
//
|
|
614
|
-
//
|
|
615
|
-
//
|
|
616
|
-
}, Math.max(
|
|
791
|
+
// Coalesce ordinary mutations for 16ms. Expensive style/layout rebuilds
|
|
792
|
+
// retain a longer idle window so related script mutations can settle
|
|
793
|
+
// instead of continuously rebuilding a large document.
|
|
794
|
+
}, Math.max(16, lastRebuildMs * 2));
|
|
617
795
|
}
|
|
618
796
|
|
|
619
|
-
/** The viewport as
|
|
620
|
-
async function viewportPng() {
|
|
797
|
+
/** The viewport as an encoded image (base64), cut from the latest tile. */
|
|
798
|
+
async function viewportPng({ format = "png", quality = 80, optimizeForSpeed = false, clip } = {}) {
|
|
621
799
|
if (!lastTile) return "";
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
encoded = { key, png: Buffer.from(await encodeImage(pixels, width, height)).toString("base64") };
|
|
800
|
+
if (format === 'rgba' || format === 'bgra') {
|
|
801
|
+
const {pixels,width,height} = captureViewport(lastTile, viewport.x, viewport.y, page.width, page.height,clip);
|
|
802
|
+
return rawFrame(pixels,width,height,format);
|
|
626
803
|
}
|
|
627
|
-
|
|
804
|
+
const key = `${tileSerial}:${viewport.x}:${viewport.y}:${page.width}:${page.height}:${format}:${quality}:${optimizeForSpeed}:${JSON.stringify(clip)}`;
|
|
805
|
+
return captures.get(key, () => {
|
|
806
|
+
const { pixels, width, height } = captureViewport(lastTile, viewport.x, viewport.y, page.width, page.height,clip);
|
|
807
|
+
return encodeImageBase64(pixels, width, height, { format, quality, optimizeForSpeed });
|
|
808
|
+
});
|
|
628
809
|
}
|
|
629
810
|
|
|
630
811
|
/** Rasterizes display items intersecting the viewport onto a CanvasKit canvas. */
|
|
@@ -637,11 +818,12 @@ function buildPageRenderer({
|
|
|
637
818
|
pixel(rect.x - view.x), pixel(rect.y - view.y), pixel(rect.width), pixel(rect.height),
|
|
638
819
|
);
|
|
639
820
|
const deviceRRect = (rect, radii) => radii && (radii.x > 0 || radii.y > 0)
|
|
640
|
-
?
|
|
821
|
+
? roundedRect(deviceRect(rect), radii, scale)
|
|
641
822
|
: null;
|
|
642
823
|
// Returns a paint plus a release function that also frees its shader.
|
|
643
824
|
const fillPaint = (item) => {
|
|
644
825
|
const paint = new CanvasKit.Paint();
|
|
826
|
+
if (item.radii && (item.radii.x > 0 || item.radii.y > 0)) paint.setAntiAlias(true);
|
|
645
827
|
const shader = item.gradient ? gradientShader(item.gradient, view, scale) : null;
|
|
646
828
|
if (shader) paint.setShader(shader);
|
|
647
829
|
else paint.setColor(canvasColor(CanvasKit, item.color));
|
|
@@ -831,25 +1013,23 @@ function buildPageRenderer({
|
|
|
831
1013
|
} else if (item.op === "drawSvg") {
|
|
832
1014
|
drawSvg(item);
|
|
833
1015
|
} 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));
|
|
1016
|
+
const entry = paragraphForPaint(item, scale, pixel(Math.max(1, view.width + view.x - item.x)));
|
|
1017
|
+
try {
|
|
1018
|
+
if (item.gradient) {
|
|
1019
|
+
// background-clip:text: draw glyphs, then keep the gradient only where they are.
|
|
1020
|
+
canvas.saveLayer(null, deviceRect(item.bounds));
|
|
1021
|
+
canvas.drawParagraph(entry.paragraph, pixel(item.x - view.x), pixel(item.y - view.y));
|
|
1022
|
+
const paint = fillPaint(item);
|
|
1023
|
+
paint.setBlendMode(CanvasKit.BlendMode.SrcIn);
|
|
1024
|
+
canvas.drawRect(deviceRect(item.bounds), paint);
|
|
1025
|
+
paint.release();
|
|
1026
|
+
canvas.restore();
|
|
1027
|
+
} else {
|
|
1028
|
+
canvas.drawParagraph(entry.paragraph, pixel(item.x - view.x), pixel(item.y - view.y));
|
|
1029
|
+
}
|
|
1030
|
+
} finally {
|
|
1031
|
+
if (!entry.retained) entry.paragraph.delete();
|
|
851
1032
|
}
|
|
852
|
-
paragraph.delete();
|
|
853
1033
|
}
|
|
854
1034
|
}
|
|
855
1035
|
}
|
|
@@ -866,13 +1046,9 @@ function buildPageRenderer({
|
|
|
866
1046
|
const Y0 = (item.rect.y - view.y) * scale;
|
|
867
1047
|
const X1 = X0 + item.rect.width * scale;
|
|
868
1048
|
const Y1 = Y0 + item.rect.height * scale;
|
|
869
|
-
const rx = item.radii.x * scale;
|
|
870
|
-
const ry = item.radii.y * scale;
|
|
871
1049
|
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));
|
|
1050
|
+
const outer = roundedRect([X0, Y0, X1, Y1], item.radii, scale);
|
|
1051
|
+
const inner = roundedRect([x0, y0, x1, y1], insetRadii(item.radii, item.widths), scale);
|
|
876
1052
|
const sides = [
|
|
877
1053
|
[X0, Y0, X1, Y0, x1, y0, x0, y0],
|
|
878
1054
|
[X1, Y0, X1, Y1, x1, y1, x1, y0],
|
|
@@ -1029,6 +1205,29 @@ function buildPageRenderer({
|
|
|
1029
1205
|
}
|
|
1030
1206
|
}
|
|
1031
1207
|
|
|
1208
|
+
function nativeControlState(element) {
|
|
1209
|
+
return [element.value, element.checked, element.indeterminate, element.selected, element.selectedIndex];
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
/** HTML activation can change control properties without attribute records
|
|
1213
|
+
* (including a different radio in the same group). Compare these few native
|
|
1214
|
+
* states instead of restyling the entire document for every pointer click.
|
|
1215
|
+
*/
|
|
1216
|
+
function controlStatesBeforeActivation() {
|
|
1217
|
+
return new Map(Array.from(page.document.querySelectorAll("input,select,textarea,option"),
|
|
1218
|
+
element => [element,nativeControlState(element)]));
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
function invalidateActivatedControls(previous) {
|
|
1222
|
+
for (const [element,before] of previous) {
|
|
1223
|
+
const after = nativeControlState(element);
|
|
1224
|
+
if (before.some((value,index) => value !== after[index])) {
|
|
1225
|
+
markStyleDirty(element);
|
|
1226
|
+
page.dirty = true;
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1032
1231
|
function observeStyleChanges(document) {
|
|
1033
1232
|
styleChanges.observer?.disconnect();
|
|
1034
1233
|
styleChanges.records = [];
|
|
@@ -1036,6 +1235,7 @@ function buildPageRenderer({
|
|
|
1036
1235
|
styleChanges.rootFeatures = new Map();
|
|
1037
1236
|
styleChanges.full = "new document";
|
|
1038
1237
|
styleChanges.activeElement = null;
|
|
1238
|
+
styleChanges.focusAncestors = new Set();
|
|
1039
1239
|
const observer = new page.window.MutationObserver((records) => { styleChanges.records.push(...records); });
|
|
1040
1240
|
observer.observe(document, { subtree: true, childList: true, attributes: true, attributeOldValue: true, characterData: true });
|
|
1041
1241
|
styleChanges.observer = observer;
|
|
@@ -1060,6 +1260,7 @@ function buildPageRenderer({
|
|
|
1060
1260
|
descendantFeatures: union("descendantFeatures"), siblingFeatures: union("siblingFeatures"),
|
|
1061
1261
|
descendantStateCompounds: parts.flatMap((part) => part.descendantStateCompounds),
|
|
1062
1262
|
siblingTargetCompounds: [...union("siblingTargetCompounds")],
|
|
1263
|
+
observedAttributes: union("observedAttributes"),
|
|
1063
1264
|
forward: any("forward"), firstChild: any("firstChild"), backward: any("backward"), lastChild: any("lastChild"),
|
|
1064
1265
|
defined: any("defined"), target: any("target"),
|
|
1065
1266
|
};
|
|
@@ -1189,11 +1390,12 @@ function buildPageRenderer({
|
|
|
1189
1390
|
let treeDirty = false;
|
|
1190
1391
|
let full = styleChanges.full;
|
|
1191
1392
|
const active = page.document.activeElement;
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1393
|
+
const previousActive = styleChanges.activeElement;
|
|
1394
|
+
const previousFocusAncestors = styleChanges.focusAncestors;
|
|
1395
|
+
const focusAncestors = new Set();
|
|
1396
|
+
for (let element = active; element; element = element.parentElement) focusAncestors.add(element);
|
|
1397
|
+
styleChanges.activeElement = active;
|
|
1398
|
+
styleChanges.focusAncestors = focusAncestors;
|
|
1197
1399
|
// Elements whose subtree restyles (`roots`), and elements whose own
|
|
1198
1400
|
// matches may change but whose subtree restyles only if their style does
|
|
1199
1401
|
// (`self`, decided by the style engine).
|
|
@@ -1221,7 +1423,8 @@ function buildPageRenderer({
|
|
|
1221
1423
|
if (!states.length) return false;
|
|
1222
1424
|
const available = new Set(elementFeatures(element));
|
|
1223
1425
|
return traits.descendantStateCompounds.some((entry) => (states.includes(":state") || entry.states.some((state) => states.includes(state)))
|
|
1224
|
-
&& entry.required.every((feature) => available.has(feature))
|
|
1426
|
+
&& entry.required.every((feature) => available.has(feature))
|
|
1427
|
+
&& entry.alternatives.every(group => group.some(required => required.every(feature => available.has(feature)))));
|
|
1225
1428
|
};
|
|
1226
1429
|
/** An element whose features changed: itself, and wider where non-subject compounds use them. */
|
|
1227
1430
|
const changed = (element, features) => {
|
|
@@ -1238,22 +1441,38 @@ function buildPageRenderer({
|
|
|
1238
1441
|
if (matchesChanged(structuralState, element)) roots.add(element);
|
|
1239
1442
|
};
|
|
1240
1443
|
if (!full && !hasState) full = "first restyle";
|
|
1241
|
-
//
|
|
1242
|
-
//
|
|
1243
|
-
|
|
1244
|
-
|
|
1444
|
+
// Only the explicitly marked roots need converting to feature changes;
|
|
1445
|
+
// roots added by relational/focus invalidation below must remain roots.
|
|
1446
|
+
const markedRoots = [...roots];
|
|
1447
|
+
roots.clear();
|
|
1448
|
+
for (const element of markedRoots) {
|
|
1245
1449
|
if (!full) changed(element, styleChanges.rootFeatures.get(element) ?? [":state"]);
|
|
1246
1450
|
}
|
|
1451
|
+
if (!full) {
|
|
1452
|
+
// Focus affects the two subjects, their :focus-within ancestor paths,
|
|
1453
|
+
// and selectors related to those nodes. Keep the previous path because
|
|
1454
|
+
// the old focused element may have been removed or reparented already.
|
|
1455
|
+
if (active !== previousActive) {
|
|
1456
|
+
if (previousActive) changed(previousActive, [":focus", ":focus-visible"]);
|
|
1457
|
+
if (active) changed(active, [":focus", ":focus-visible"]);
|
|
1458
|
+
}
|
|
1459
|
+
for (const element of previousFocusAncestors) if (!focusAncestors.has(element)) changed(element, [":focus-within"]);
|
|
1460
|
+
for (const element of focusAncestors) if (!previousFocusAncestors.has(element)) changed(element, [":focus-within"]);
|
|
1461
|
+
}
|
|
1247
1462
|
for (const record of records) {
|
|
1248
1463
|
if (full) break;
|
|
1249
1464
|
const target = record.target;
|
|
1250
1465
|
if (record.type === "attributes") {
|
|
1251
1466
|
if (target.nodeType !== 1) continue;
|
|
1252
1467
|
const name = record.attributeName;
|
|
1468
|
+
// Happy DOM reports null for an empty attribute's previous value on
|
|
1469
|
+
// removal. Null therefore cannot prove that the attribute is unchanged.
|
|
1470
|
+
if (record.oldValue !== null && record.oldValue === target.getAttribute(name)) continue;
|
|
1253
1471
|
// data/ARIA/title changes have no direct render-tree representation;
|
|
1254
1472
|
// selectors that observe them are handled by the style diff below.
|
|
1255
1473
|
// Other attributes may alter controls, replaced elements or visibility.
|
|
1256
1474
|
if (!name.startsWith("data-") && !name.startsWith("aria-") && name !== "title") treeDirty = true;
|
|
1475
|
+
else if (!traits.observedAttributes.has(name.toLowerCase())) continue;
|
|
1257
1476
|
const features = ["[" + name];
|
|
1258
1477
|
if (name === "class") {
|
|
1259
1478
|
const before = new Set(String(record.oldValue ?? "").split(/\s+/).filter(Boolean));
|
|
@@ -1266,6 +1485,12 @@ function buildPageRenderer({
|
|
|
1266
1485
|
if (id) features.push("#" + id);
|
|
1267
1486
|
}
|
|
1268
1487
|
changed(target, features);
|
|
1488
|
+
// The UA disclosure marker depends on its parent's open state even
|
|
1489
|
+
// when no author selector observes [open].
|
|
1490
|
+
if (name === "open" && target.tagName === "DETAILS") {
|
|
1491
|
+
const summary = [...target.children].find(element => element.tagName === "SUMMARY");
|
|
1492
|
+
if (summary) self.add(summary);
|
|
1493
|
+
}
|
|
1269
1494
|
} else if (record.type === "childList") {
|
|
1270
1495
|
treeDirty = true;
|
|
1271
1496
|
if (target.nodeType !== 1) { full = `children of ${target.nodeName}`; break; }
|
|
@@ -1280,6 +1505,11 @@ function buildPageRenderer({
|
|
|
1280
1505
|
}
|
|
1281
1506
|
}
|
|
1282
1507
|
self.add(target);
|
|
1508
|
+
// Adding, removing or moving a summary can change which one receives
|
|
1509
|
+
// the UA list-item display and disclosure marker.
|
|
1510
|
+
if (target.tagName === "DETAILS") {
|
|
1511
|
+
for (const child of target.children) if (child.tagName === "SUMMARY") self.add(child);
|
|
1512
|
+
}
|
|
1283
1513
|
hasChanges(traits, target, add, hasChecked);
|
|
1284
1514
|
let next = record.nextSibling;
|
|
1285
1515
|
while (next && next.nodeType !== 1) next = next.nextSibling;
|
|
@@ -1318,16 +1548,26 @@ function buildPageRenderer({
|
|
|
1318
1548
|
}
|
|
1319
1549
|
|
|
1320
1550
|
function replaceParsedDocument(parsed, styleSheets = [], imageResources = emptyImageResources(), linkSheets = null) {
|
|
1551
|
+
if (page.document) loadImageResource.cancelDocument(page.document);
|
|
1552
|
+
rasterReadback.dispose();
|
|
1553
|
+
clearPaintParagraphs();
|
|
1554
|
+
resetDocumentFonts();
|
|
1321
1555
|
disposeFrames();
|
|
1322
1556
|
closePiano();
|
|
1323
1557
|
disposeVideos();
|
|
1324
1558
|
page.realm?.dispose();
|
|
1325
1559
|
page.realm = null;
|
|
1326
|
-
page.window?.happyDOM?.
|
|
1560
|
+
page.window?.happyDOM?.close?.();
|
|
1327
1561
|
for (const image of page.decodedImages) image.delete();
|
|
1328
1562
|
disposeBackgrounds();
|
|
1329
1563
|
page.window = parsed.window;
|
|
1330
1564
|
page.document = parsed.document;
|
|
1565
|
+
observeDocumentCanvases(parsed.document, (node) => {
|
|
1566
|
+
if (disposedRenderer || node.ownerDocument !== page.document || !node.isConnected) return;
|
|
1567
|
+
page.resourcePaintDirty = true;
|
|
1568
|
+
page.needsPaint = true;
|
|
1569
|
+
scheduleFrame();
|
|
1570
|
+
});
|
|
1331
1571
|
observeStyleChanges(parsed.document);
|
|
1332
1572
|
page.title = parsed.document.title;
|
|
1333
1573
|
page.styleSheets = styleSheets;
|
|
@@ -1335,6 +1575,8 @@ function buildPageRenderer({
|
|
|
1335
1575
|
page.resources = imageResources.map;
|
|
1336
1576
|
initializeVideos(parsed.document, page.resources);
|
|
1337
1577
|
page.decodedImages = imageResources.decoded;
|
|
1578
|
+
page.imageElements = new Set(imageResources.entries.map(entry => entry.element));
|
|
1579
|
+
page.resourcePaintDirty = false;
|
|
1338
1580
|
page.imagesLoaded = null;
|
|
1339
1581
|
imageRequests = new WeakMap();
|
|
1340
1582
|
page.elementScroll = new Map();
|
|
@@ -1355,6 +1597,20 @@ function buildPageRenderer({
|
|
|
1355
1597
|
const ms = (from, to) => Math.round(to - from);
|
|
1356
1598
|
const sheets = currentStyleSheets();
|
|
1357
1599
|
const environment = { width: page.width, height: page.height, mobile };
|
|
1600
|
+
const documentBaseURL = page.document.baseURI || page.url;
|
|
1601
|
+
const environmentKey = `${page.width}/${page.height}/${mobile}/${documentBaseURL}`;
|
|
1602
|
+
if (fontSheets !== sheets || fontEnvironment !== environmentKey) {
|
|
1603
|
+
fontSheets = sheets;
|
|
1604
|
+
fontEnvironment = environmentKey;
|
|
1605
|
+
fontDefinitions = sheets.flatMap(sheet => /@font-face\b/i.test(sheet) ? extractFontFaces(sheet, documentBaseURL, environment) : []);
|
|
1606
|
+
documentFonts.setFaces(fontDefinitions);
|
|
1607
|
+
const key = JSON.stringify(fontDefinitions);
|
|
1608
|
+
if (key !== fontDefinitionKey) {
|
|
1609
|
+
fontDefinitionKey = key;
|
|
1610
|
+
clearFontCaches();
|
|
1611
|
+
page.resourceLayoutDirty = true;
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1358
1614
|
const changes = takeStyleChanges(sheets); // drained even when unused, so records do not pile up
|
|
1359
1615
|
const taken = hostPerformance.now();
|
|
1360
1616
|
// A rendering opportunity can be scheduled by lifecycle work which did
|
|
@@ -1404,17 +1660,19 @@ function buildPageRenderer({
|
|
|
1404
1660
|
measureText, fontMetrics, width: page.width, viewportHeight: page.height, x: 0, y: 0,
|
|
1405
1661
|
});
|
|
1406
1662
|
page.displayList = paintList();
|
|
1663
|
+
page.resourcePaintDirty = false;
|
|
1407
1664
|
// The content may now reach less far than a container was scrolled.
|
|
1408
1665
|
if (clampElementScroll()) page.displayList = paintList();
|
|
1409
1666
|
page.resourceLayoutDirty = false;
|
|
1410
1667
|
laidOutSheets = sheets;
|
|
1411
1668
|
setContentSize();
|
|
1669
|
+
queueFontLoads();
|
|
1412
1670
|
return { style: Math.round(styled - started), layout: Math.round(hostPerformance.now() - styled) };
|
|
1413
1671
|
}
|
|
1414
1672
|
|
|
1415
1673
|
/** BUNINU_VERIFY_STYLE: compare an incremental restyle with a full one, element by element. */
|
|
1416
1674
|
function verifyIncrementalStyle(sheets, environment) {
|
|
1417
|
-
const reference = new StyleEngine().compute(page.document, sheets, environment);
|
|
1675
|
+
const reference = new StyleEngine({ measureText }).compute(page.document, sheets, environment);
|
|
1418
1676
|
const serialize = (style) => JSON.stringify(style, (key, value) => typeof value === "bigint" ? String(value) : value);
|
|
1419
1677
|
let mismatches = 0;
|
|
1420
1678
|
for (const element of page.document.querySelectorAll("*")) {
|
|
@@ -1450,13 +1708,14 @@ function buildPageRenderer({
|
|
|
1450
1708
|
staleImages = queueImageResources();
|
|
1451
1709
|
}
|
|
1452
1710
|
const timing = styleAndLayout();
|
|
1453
|
-
|
|
1454
|
-
const
|
|
1455
|
-
|
|
1456
|
-
image.delete();
|
|
1711
|
+
if (staleImages.length) {
|
|
1712
|
+
const stale = new Set(staleImages);
|
|
1713
|
+
page.decodedImages = page.decodedImages.filter(image => !stale.has(image));
|
|
1714
|
+
for (const image of stale) image.delete();
|
|
1457
1715
|
}
|
|
1458
1716
|
if (!timing.skipped) page.needsPaint = true;
|
|
1459
1717
|
syncFrames();
|
|
1718
|
+
page.realm?.layoutChanged(page.layoutVersion);
|
|
1460
1719
|
if (!timing.skipped) queueBackgroundImages();
|
|
1461
1720
|
lastRebuildMs = hostPerformance.now() - started;
|
|
1462
1721
|
trace?.(`${page.url}: restyle and relayout (${reason}) ${Math.round(lastRebuildMs)}ms`
|
|
@@ -1475,6 +1734,7 @@ function buildPageRenderer({
|
|
|
1475
1734
|
* page); the rest of the load continues, see `whenLoaded`.
|
|
1476
1735
|
*/
|
|
1477
1736
|
function loadDocument(document) {
|
|
1737
|
+
if (disposedRenderer) return Promise.reject(new Error("Renderer has been disposed"));
|
|
1478
1738
|
const generation = ++loadGeneration;
|
|
1479
1739
|
let commit;
|
|
1480
1740
|
const committed = new Promise((resolve, reject) => {
|
|
@@ -1494,7 +1754,7 @@ function buildPageRenderer({
|
|
|
1494
1754
|
}
|
|
1495
1755
|
|
|
1496
1756
|
async function runLoad({
|
|
1497
|
-
url, source = "", contentType = "text/html", status = 200, statusText = "", error = null, cookie = "",
|
|
1757
|
+
url, source = "", contentType = "text/html", status = 200, statusText = "", error = null, cookie = "", referrer = "",
|
|
1498
1758
|
}, generation, commit) {
|
|
1499
1759
|
const current = () => generation === loadGeneration;
|
|
1500
1760
|
const finish = () => {
|
|
@@ -1538,7 +1798,7 @@ function buildPageRenderer({
|
|
|
1538
1798
|
phaseStart = now;
|
|
1539
1799
|
} : () => {};
|
|
1540
1800
|
const isHtml = contentType.toLowerCase().includes("html");
|
|
1541
|
-
const parsed = parseHTMLDocument(isHtml ? source : "<body></body>", url);
|
|
1801
|
+
const parsed = parseHTMLDocument(isHtml ? source : "<body></body>", url, referrer);
|
|
1542
1802
|
phase("parse");
|
|
1543
1803
|
// Stylesheets block rendering and scripts; images do not (only the
|
|
1544
1804
|
// window load event waits for them, see whenImagesLoaded): one slow image
|
|
@@ -1605,6 +1865,9 @@ function buildPageRenderer({
|
|
|
1605
1865
|
refresh();
|
|
1606
1866
|
phase("style, layout and raster after scripts");
|
|
1607
1867
|
} else {
|
|
1868
|
+
await documentFonts.ready();
|
|
1869
|
+
if (!current()) return;
|
|
1870
|
+
refresh();
|
|
1608
1871
|
finish();
|
|
1609
1872
|
}
|
|
1610
1873
|
}
|
|
@@ -1620,7 +1883,7 @@ function buildPageRenderer({
|
|
|
1620
1883
|
const preloaded = new Map();
|
|
1621
1884
|
const preload = (href) => {
|
|
1622
1885
|
let target;
|
|
1623
|
-
try { target = new URL(href, documentUrl); } catch { return; }
|
|
1886
|
+
try { target = new URL(href, document.baseURI || documentUrl); } catch { return; }
|
|
1624
1887
|
if (!["http:", "https:"].includes(target.protocol) || preloaded.has(target.href)) return;
|
|
1625
1888
|
const response = fetchResource(target.href, "script");
|
|
1626
1889
|
response.catch(() => {}); // reported when the script runs
|
|
@@ -1636,7 +1899,8 @@ function buildPageRenderer({
|
|
|
1636
1899
|
|
|
1637
1900
|
/** Frees what a superseded load fetched. */
|
|
1638
1901
|
async function discard(images, parsed = null) {
|
|
1639
|
-
parsed?.
|
|
1902
|
+
if (parsed?.document) loadImageResource.cancelDocument(parsed.document);
|
|
1903
|
+
parsed?.window?.happyDOM?.close?.();
|
|
1640
1904
|
const loaded = await images.catch(() => emptyImageResources());
|
|
1641
1905
|
for (const image of loaded.decoded) image.delete();
|
|
1642
1906
|
}
|
|
@@ -1651,7 +1915,7 @@ function buildPageRenderer({
|
|
|
1651
1915
|
// queueImageResources must not fetch these again.
|
|
1652
1916
|
for (const element of document.querySelectorAll("img")) {
|
|
1653
1917
|
const src = element.getAttribute("src");
|
|
1654
|
-
if (src) imageRequests.set(element, src);
|
|
1918
|
+
if (src) imageRequests.set(element, imageRequestKey(src, imageCrossOrigin(element)));
|
|
1655
1919
|
}
|
|
1656
1920
|
page.imagesLoaded = images.then((loaded) => {
|
|
1657
1921
|
if (generation !== loadGeneration || document !== page.document) {
|
|
@@ -1659,12 +1923,13 @@ function buildPageRenderer({
|
|
|
1659
1923
|
return;
|
|
1660
1924
|
}
|
|
1661
1925
|
for (const { element, src, resource } of loaded.entries) {
|
|
1662
|
-
if (element.getAttribute("src") !== src || page.resources.get(element)) {
|
|
1926
|
+
if (element.getAttribute("src") !== src || imageCrossOrigin(element) !== resource.crossOrigin || page.resources.get(element)) {
|
|
1663
1927
|
resource.image.delete();
|
|
1664
1928
|
continue;
|
|
1665
1929
|
}
|
|
1666
1930
|
page.resources.set(element, resource);
|
|
1667
1931
|
page.decodedImages.push(resource.image);
|
|
1932
|
+
page.imageElements.add(element);
|
|
1668
1933
|
finishImageLoad(element, src, resource, type => element.dispatchEvent(new page.window.Event(type)));
|
|
1669
1934
|
}
|
|
1670
1935
|
page.dirty = true;
|
|
@@ -1675,18 +1940,44 @@ function buildPageRenderer({
|
|
|
1675
1940
|
|
|
1676
1941
|
function inlineStyleSheets(document) {
|
|
1677
1942
|
return [...document.querySelectorAll("style")]
|
|
1678
|
-
.filter((element) =>
|
|
1943
|
+
.filter((element) => isCssStyleSheet(element) && styleSheetMatches(element))
|
|
1679
1944
|
.map((element) => element.textContent ?? "");
|
|
1680
1945
|
}
|
|
1681
1946
|
|
|
1947
|
+
function isCssStyleSheet(element) {
|
|
1948
|
+
const type = element.getAttribute("type")?.trim().toLowerCase();
|
|
1949
|
+
return (!type || type === "text/css") && !element.closest("noscript");
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
function styleSheetMatches(element) {
|
|
1953
|
+
return mediaQueryMatches(element.getAttribute("media") ?? "", { width: page.width, height: page.height, mobile })
|
|
1954
|
+
&& !(element.tagName === "LINK" && element.hasAttribute("disabled"));
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1682
1957
|
/** Brings style, layout and raster up to date; returns whether it repainted. */
|
|
1683
1958
|
function refresh() {
|
|
1959
|
+
if (disposedRenderer) return false;
|
|
1684
1960
|
if (page.dirty) rebuildRenderTree();
|
|
1685
1961
|
if (!page.needsPaint) return false;
|
|
1686
1962
|
render();
|
|
1687
1963
|
return true;
|
|
1688
1964
|
}
|
|
1689
1965
|
|
|
1966
|
+
function isDisclosureControl(element) {
|
|
1967
|
+
if (element?.tagName === "DETAILS") return ![...element.children].some(child => child.tagName === "SUMMARY");
|
|
1968
|
+
return element?.tagName === "SUMMARY" && element.parentElement?.tagName === "DETAILS"
|
|
1969
|
+
&& [...element.parentElement.children].find(child => child.tagName === "SUMMARY") === element;
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
function activateDisclosure(element) {
|
|
1973
|
+
const prevented = page.realm.dispatch(element, new page.window.MouseEvent("click", {
|
|
1974
|
+
bubbles: true, cancelable: true, composed: true, detail: 0,
|
|
1975
|
+
}));
|
|
1976
|
+
// An explicit summary is activated by the DOM's click default action.
|
|
1977
|
+
// The generated label has no DOM summary, so its owner toggles here.
|
|
1978
|
+
if (!prevented && element.tagName === "DETAILS") element.open = !element.open;
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1690
1981
|
function editFocusedText(text, backspace = false) {
|
|
1691
1982
|
const element = page.document.activeElement;
|
|
1692
1983
|
if (!element || !["INPUT", "TEXTAREA"].includes(element.tagName) || element.disabled || element.readOnly) return;
|
|
@@ -1765,6 +2056,31 @@ function buildPageRenderer({
|
|
|
1765
2056
|
page.dirty = true;
|
|
1766
2057
|
scheduleFrame();
|
|
1767
2058
|
},
|
|
2059
|
+
activationState: controlStatesBeforeActivation,
|
|
2060
|
+
activationChanged(previous) {
|
|
2061
|
+
invalidateActivatedControls(previous);
|
|
2062
|
+
if (page.dirty) scheduleFrame();
|
|
2063
|
+
},
|
|
2064
|
+
canvasImage(element) {
|
|
2065
|
+
if (!element) return null;
|
|
2066
|
+
if (element.tagName === "CANVAS") return canvasResource(element);
|
|
2067
|
+
const resource = page.resources.get(element);
|
|
2068
|
+
if (!resource?.image) return null;
|
|
2069
|
+
const url = resource.url ?? element.src ?? "";
|
|
2070
|
+
const originClean = resource.originClean ?? (url.startsWith("data:") || originOf(url) === originOf(page.url));
|
|
2071
|
+
return { ...resource, originClean };
|
|
2072
|
+
},
|
|
2073
|
+
canvasFont(element, value) {
|
|
2074
|
+
return parseCanvasFont(value, styleEngine.get(element)?.fontSize ?? 16,
|
|
2075
|
+
styleEngine.get(page.document.documentElement)?.fontSize ?? 16);
|
|
2076
|
+
},
|
|
2077
|
+
canvasText(element, request) {
|
|
2078
|
+
const style = { ...request.settings.parsedFont,
|
|
2079
|
+
direction: request.settings.direction === "inherit" ? styleEngine.get(element)?.direction ?? "ltr" : request.settings.direction };
|
|
2080
|
+
documentFonts.update(fontDefinitions, [{ text: String(request.args[0]), style }]);
|
|
2081
|
+
return renderCanvasText({ ...request, style,
|
|
2082
|
+
settings: { ...request.settings, direction: style.direction } });
|
|
2083
|
+
},
|
|
1768
2084
|
mediaState,
|
|
1769
2085
|
playMedia,
|
|
1770
2086
|
pauseMedia,
|
|
@@ -1788,7 +2104,7 @@ function buildPageRenderer({
|
|
|
1788
2104
|
scheduleFrame();
|
|
1789
2105
|
}
|
|
1790
2106
|
},
|
|
1791
|
-
whenImagesLoaded: () => Promise.all([page.imagesLoaded, ...[...page.backgrounds.values()].map((entry) => entry.loaded)]),
|
|
2107
|
+
whenImagesLoaded: () => Promise.all([page.imagesLoaded, documentFonts.ready(), ...[...page.backgrounds.values()].map((entry) => entry.loaded)]),
|
|
1792
2108
|
afterTask() {},
|
|
1793
2109
|
boundsOf(element) {
|
|
1794
2110
|
if (page.dirty) rebuildRenderTree("script read geometry");
|
|
@@ -1820,9 +2136,9 @@ function buildPageRenderer({
|
|
|
1820
2136
|
if (page.dirty) rebuildRenderTree("script scrolled");
|
|
1821
2137
|
setElementScroll(element, x, y);
|
|
1822
2138
|
},
|
|
1823
|
-
scrollIntoView(element) {
|
|
2139
|
+
scrollIntoView(element, options) {
|
|
1824
2140
|
if (page.dirty) rebuildRenderTree("script scrolled");
|
|
1825
|
-
revealElement(element);
|
|
2141
|
+
revealElement(element, options);
|
|
1826
2142
|
},
|
|
1827
2143
|
computedStyle(element) {
|
|
1828
2144
|
if (!element) return null;
|
|
@@ -1845,7 +2161,7 @@ function buildPageRenderer({
|
|
|
1845
2161
|
},
|
|
1846
2162
|
navigate: (url) => navigateTo(String(url)),
|
|
1847
2163
|
activateLink(anchor) {
|
|
1848
|
-
const href = new URL(anchor.getAttribute("href"), page.url).href;
|
|
2164
|
+
const href = new URL(anchor.getAttribute("href"), anchor.baseURI || page.url).href;
|
|
1849
2165
|
const name = anchor.hasAttribute("download") ? anchor.getAttribute("download") ?? "" : null;
|
|
1850
2166
|
if (href.startsWith("blob:") && name !== null) void downloadBlob(href, name);
|
|
1851
2167
|
else navigateTo(href, name);
|
|
@@ -1880,6 +2196,7 @@ function buildPageRenderer({
|
|
|
1880
2196
|
page.cookie = mergeDocumentCookie(page.cookie, String(value));
|
|
1881
2197
|
setCookie(String(value));
|
|
1882
2198
|
},
|
|
2199
|
+
clipboardWrite,
|
|
1883
2200
|
webSockets,
|
|
1884
2201
|
pageFetch(request) {
|
|
1885
2202
|
const networkPending = pageFetch(request);
|
|
@@ -1887,7 +2204,8 @@ function buildPageRenderer({
|
|
|
1887
2204
|
if (typeof response.documentCookie === "string") page.cookie = response.documentCookie;
|
|
1888
2205
|
return response;
|
|
1889
2206
|
});
|
|
1890
|
-
result.abort = () => networkPending.abort?.();
|
|
2207
|
+
result.abort = (reason) => networkPending.abort?.(reason);
|
|
2208
|
+
result.finished = networkPending.finished;
|
|
1891
2209
|
return result;
|
|
1892
2210
|
},
|
|
1893
2211
|
};
|
|
@@ -1901,7 +2219,7 @@ function buildPageRenderer({
|
|
|
1901
2219
|
function submitForm(form, submitter = null) {
|
|
1902
2220
|
const method = (submitter?.getAttribute("formmethod") ?? form.getAttribute("method") ?? "get").toLowerCase();
|
|
1903
2221
|
const action = submitter?.getAttribute("formaction") ?? form.getAttribute("action") ?? page.url;
|
|
1904
|
-
const url = new URL(action || page.url, page.url);
|
|
2222
|
+
const url = new URL(action || page.url, form.baseURI || page.url);
|
|
1905
2223
|
const entries = [...new page.window.FormData(form, submitter)];
|
|
1906
2224
|
if (method === "post") {
|
|
1907
2225
|
const enctype = (submitter?.getAttribute("formenctype") ?? form.getAttribute("enctype") ?? "").toLowerCase();
|
|
@@ -1951,14 +2269,7 @@ function buildPageRenderer({
|
|
|
1951
2269
|
const shift = scrollShift(element);
|
|
1952
2270
|
const scrollX = (fixed ? 0 : viewport.x) + shift.x;
|
|
1953
2271
|
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);
|
|
2272
|
+
const box = elementBox(page.renderTree, page.layout, element)?.box;
|
|
1962
2273
|
if (box) return { left: box.x - scrollX, top: box.y - scrollY, width: box.width, height: box.height };
|
|
1963
2274
|
const content = elementBounds(page.renderTree, page.layout, element);
|
|
1964
2275
|
return content
|
|
@@ -2132,6 +2443,7 @@ function buildPageRenderer({
|
|
|
2132
2443
|
* visualViewport.scale.
|
|
2133
2444
|
*/
|
|
2134
2445
|
resize(width, height, deviceScaleFactor = 1, pageScale = 1) {
|
|
2446
|
+
const resized = width !== page.width || height !== page.height;
|
|
2135
2447
|
page.pageScale = Number.isFinite(pageScale) && pageScale > 0 ? pageScale : 1;
|
|
2136
2448
|
page.width = width;
|
|
2137
2449
|
page.height = height;
|
|
@@ -2140,11 +2452,15 @@ function buildPageRenderer({
|
|
|
2140
2452
|
: 1;
|
|
2141
2453
|
viewport.resize(width, height);
|
|
2142
2454
|
rebuildRenderTree();
|
|
2455
|
+
// Listeners must observe the new viewport and layout. A repeated metrics
|
|
2456
|
+
// request (or DPR-only change) does not resize the CSS viewport.
|
|
2457
|
+
if (resized && page.realm)
|
|
2458
|
+
page.realm.dispatch(page.window, new page.window.Event("resize"));
|
|
2143
2459
|
render();
|
|
2144
2460
|
},
|
|
2145
|
-
screenshot() {
|
|
2461
|
+
screenshot(options) {
|
|
2146
2462
|
refresh();
|
|
2147
|
-
return viewportPng();
|
|
2463
|
+
return viewportPng(options);
|
|
2148
2464
|
},
|
|
2149
2465
|
/**
|
|
2150
2466
|
* Diagnostics (not exposed over IPC): page-space border boxes and chosen
|
|
@@ -2212,6 +2528,7 @@ function buildPageRenderer({
|
|
|
2212
2528
|
/** The compositor scrolled: follow it, and rasterize around the new position when idle. */
|
|
2213
2529
|
applyCompositorScroll(x, y) {
|
|
2214
2530
|
viewport.scrollTo(x, y);
|
|
2531
|
+
page.realm?.layoutChanged(page.layoutVersion);
|
|
2215
2532
|
syncedScroll = { x: viewport.x, y: viewport.y };
|
|
2216
2533
|
schedulePrepaint();
|
|
2217
2534
|
},
|
|
@@ -2242,8 +2559,13 @@ function buildPageRenderer({
|
|
|
2242
2559
|
bubbles: true, cancelable: true,
|
|
2243
2560
|
};
|
|
2244
2561
|
const prevented = page.realm.dispatch(target, new page.window.KeyboardEvent("keydown", init));
|
|
2562
|
+
let activated = false;
|
|
2245
2563
|
if (!prevented && !init.ctrlKey && !init.metaKey && !init.altKey) {
|
|
2246
|
-
if (key === "
|
|
2564
|
+
if ((key === "Enter" || key === " ") && isDisclosureControl(target)) {
|
|
2565
|
+
activated = true;
|
|
2566
|
+
if (key === "Enter") activateDisclosure(target);
|
|
2567
|
+
}
|
|
2568
|
+
else if (key === "Backspace") editFocusedText("", true);
|
|
2247
2569
|
else if (key === "Enter" && target.tagName === "TEXTAREA") {
|
|
2248
2570
|
// A search IME's action key submits a search form. Other textareas
|
|
2249
2571
|
// keep their normal newline behavior.
|
|
@@ -2255,8 +2577,9 @@ function buildPageRenderer({
|
|
|
2255
2577
|
}
|
|
2256
2578
|
else if (String(key).length === 1) editFocusedText(String(key));
|
|
2257
2579
|
}
|
|
2258
|
-
page.realm.dispatch(target, new page.window.KeyboardEvent("keyup", init));
|
|
2259
|
-
if (
|
|
2580
|
+
const upPrevented = page.realm.dispatch(target, new page.window.KeyboardEvent("keyup", init));
|
|
2581
|
+
if (activated && key === " " && !upPrevented) activateDisclosure(target);
|
|
2582
|
+
if (refresh() || prevented || activated) return { x: viewport.x, y: viewport.y };
|
|
2260
2583
|
}
|
|
2261
2584
|
const pageStep = Math.max(31, viewport.height - 31);
|
|
2262
2585
|
const delta = {
|
|
@@ -2319,9 +2642,6 @@ function buildPageRenderer({
|
|
|
2319
2642
|
*/
|
|
2320
2643
|
click(x, y) {
|
|
2321
2644
|
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
2645
|
let region = hitTest(page.renderTree, page.layout, x, y, viewport, viewport);
|
|
2326
2646
|
let forwarded = false;
|
|
2327
2647
|
if (!region) {
|
|
@@ -2344,12 +2664,17 @@ function buildPageRenderer({
|
|
|
2344
2664
|
// it is now (scroll containers move their content); the interactive
|
|
2345
2665
|
// regions (laid out, unscrolled) stand in only where nothing is painted,
|
|
2346
2666
|
// and a search form's forwarded field takes the click on the form.
|
|
2347
|
-
const
|
|
2667
|
+
const hitNode = page.displayList ? renderNodeAtPoint(page.renderTree, page.displayList, x, y, viewport) : null;
|
|
2668
|
+
const precise = hitNode?.domNode?.nodeType === 1 ? hitNode.domNode : hitNode?.domNode?.parentElement;
|
|
2348
2669
|
const target = forwarded ? region.element : precise ?? region?.element ?? null;
|
|
2670
|
+
const generatedDisclosure = hitNode?.pseudo === "summary" && target?.tagName === "DETAILS";
|
|
2349
2671
|
if (!target) {
|
|
2350
2672
|
focusedFrame = null;
|
|
2351
2673
|
return null;
|
|
2352
2674
|
}
|
|
2675
|
+
// Event dispatch determines the activation target before handlers run.
|
|
2676
|
+
// A handler may replace the clicked descendants while retaining the link.
|
|
2677
|
+
const activationAnchor = target.closest?.("a[href]");
|
|
2353
2678
|
// A click inside an iframe belongs to its document alone.
|
|
2354
2679
|
const inFrame = target.tagName === "IFRAME" ? frameAt(target, x, y) : null;
|
|
2355
2680
|
focusedFrame = inFrame?.frame ?? null;
|
|
@@ -2370,11 +2695,12 @@ function buildPageRenderer({
|
|
|
2370
2695
|
}
|
|
2371
2696
|
// The press focuses the focusable element it lands in, unless mousedown was canceled.
|
|
2372
2697
|
const focus = () => {
|
|
2373
|
-
const focusable = target.closest?.("input, textarea, select, button, [tabindex]");
|
|
2698
|
+
const focusable = generatedDisclosure ? target : target.closest?.("input, textarea, select, button, summary, [tabindex]");
|
|
2374
2699
|
if (focusable && !focusable.disabled) focusable.focus();
|
|
2375
2700
|
};
|
|
2376
2701
|
if (!page.realm) focus();
|
|
2377
2702
|
else {
|
|
2703
|
+
const controlStates = controlStatesBeforeActivation();
|
|
2378
2704
|
// A press and release, then the click (UI Events §3.5).
|
|
2379
2705
|
const init = { bubbles: true, cancelable: true, composed: true, clientX: Number(x), clientY: Number(y), button: 0 };
|
|
2380
2706
|
const PointerEvent = page.window.PointerEvent ?? page.window.MouseEvent;
|
|
@@ -2384,16 +2710,22 @@ function buildPageRenderer({
|
|
|
2384
2710
|
page.realm.dispatch(target, new PointerEvent("pointerup", pointer));
|
|
2385
2711
|
page.realm.dispatch(target, new page.window.MouseEvent("mouseup", init));
|
|
2386
2712
|
const prevented = page.realm.dispatch(target, new page.window.MouseEvent("click", { ...init, detail: 1 }));
|
|
2713
|
+
invalidateActivatedControls(controlStates);
|
|
2387
2714
|
refresh();
|
|
2388
2715
|
if (prevented) return null;
|
|
2389
2716
|
}
|
|
2717
|
+
if (generatedDisclosure) {
|
|
2718
|
+
target.open = !target.open;
|
|
2719
|
+
refresh();
|
|
2720
|
+
return null;
|
|
2721
|
+
}
|
|
2390
2722
|
if ((target.tagName === "AUDIO" || target.tagName === "VIDEO") && target.hasAttribute("controls")) {
|
|
2391
2723
|
clickMedia(target, x, y);
|
|
2392
2724
|
return null;
|
|
2393
2725
|
}
|
|
2394
|
-
const anchor =
|
|
2395
|
-
if (!anchor) return null;
|
|
2396
|
-
const href = new URL(anchor.getAttribute("href"), page.url).href;
|
|
2726
|
+
const anchor = activationAnchor;
|
|
2727
|
+
if (!anchor?.hasAttribute("href")) return null;
|
|
2728
|
+
const href = new URL(anchor.getAttribute("href"), anchor.baseURI || page.url).href;
|
|
2397
2729
|
if (href.startsWith("javascript:")) {
|
|
2398
2730
|
this.runJavaScriptURL(href);
|
|
2399
2731
|
return null;
|
|
@@ -2466,16 +2798,22 @@ function buildPageRenderer({
|
|
|
2466
2798
|
if (!page.realm) return undefined;
|
|
2467
2799
|
let value = page.realm.evaluate(utilityExpression);
|
|
2468
2800
|
if (value !== null && typeof value === "object" && typeof value.then === "function") {
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2801
|
+
let timer;
|
|
2802
|
+
try {
|
|
2803
|
+
value = await Promise.race([
|
|
2804
|
+
Promise.resolve(value),
|
|
2805
|
+
new Promise((resolve) => { timer = hostSetTimeout(resolve, 5000, undefined); }),
|
|
2806
|
+
]).catch((error) => String(error?.message ?? error));
|
|
2807
|
+
} finally { hostClearTimeout(timer); }
|
|
2473
2808
|
}
|
|
2474
2809
|
refresh();
|
|
2475
2810
|
return cloneableResult(value);
|
|
2476
2811
|
},
|
|
2477
2812
|
dispose() {
|
|
2813
|
+
if (disposedRenderer) return;
|
|
2478
2814
|
disposedRenderer = true;
|
|
2815
|
+
loadGeneration++;
|
|
2816
|
+
if (page.document) loadImageResource.cancelDocument(page.document);
|
|
2479
2817
|
hostClearTimeout(prepaintTimer);
|
|
2480
2818
|
hostClearTimeout(frameTimer);
|
|
2481
2819
|
disposeFrames();
|
|
@@ -2484,8 +2822,13 @@ function buildPageRenderer({
|
|
|
2484
2822
|
disposeVideos();
|
|
2485
2823
|
rasterSurface?.dispose();
|
|
2486
2824
|
rasterSurface = null;
|
|
2487
|
-
|
|
2825
|
+
rasterReadback.dispose();
|
|
2826
|
+
clearPaintParagraphs();
|
|
2827
|
+
documentFonts.dispose();
|
|
2828
|
+
page.window?.happyDOM?.close?.();
|
|
2488
2829
|
for (const image of page.decodedImages) image.delete();
|
|
2830
|
+
page.decodedImages = [];
|
|
2831
|
+
page.imageElements.clear();
|
|
2489
2832
|
disposeBackgrounds();
|
|
2490
2833
|
},
|
|
2491
2834
|
};
|
|
@@ -2511,18 +2854,22 @@ function buildPageRenderer({
|
|
|
2511
2854
|
function collectStyleSheets(document, documentUrl, linkSheets, pending = null) {
|
|
2512
2855
|
const sheets = [];
|
|
2513
2856
|
for (const element of document.querySelectorAll("style, link[rel~='stylesheet']")) {
|
|
2514
|
-
if (element
|
|
2857
|
+
if (!isCssStyleSheet(element)) continue;
|
|
2858
|
+
const applies = styleSheetMatches(element);
|
|
2515
2859
|
if (element.tagName === "STYLE") {
|
|
2516
|
-
sheets.push(element.textContent ?? "");
|
|
2860
|
+
if (applies) sheets.push(element.textContent ?? "");
|
|
2517
2861
|
continue;
|
|
2518
2862
|
}
|
|
2863
|
+
// A disabled link is fetched when enabled; nonmatching media may be
|
|
2864
|
+
// fetched now, but must never contribute rules to the screen cascade.
|
|
2865
|
+
if (element.hasAttribute("disabled")) continue;
|
|
2519
2866
|
const href = element.getAttribute("href");
|
|
2520
2867
|
let url;
|
|
2521
|
-
try { url = href ? new URL(href, documentUrl).href : null; } catch { url = null; }
|
|
2868
|
+
try { url = href ? new URL(href, element.baseURI || documentUrl).href : null; } catch { url = null; }
|
|
2522
2869
|
if (!url) continue;
|
|
2523
2870
|
const loaded = linkSheets.get(element);
|
|
2524
2871
|
if (loaded?.url === url) {
|
|
2525
|
-
if (loaded.text !== null) sheets.push(loaded.text);
|
|
2872
|
+
if (applies && loaded.text !== null) sheets.push(loaded.text);
|
|
2526
2873
|
continue;
|
|
2527
2874
|
}
|
|
2528
2875
|
const entry = { url, text: null };
|
|
@@ -2561,17 +2908,10 @@ function buildPageRenderer({
|
|
|
2561
2908
|
if (!src) return;
|
|
2562
2909
|
beginImageLoad(element, src);
|
|
2563
2910
|
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 });
|
|
2911
|
+
const resource = await loadImageResource(document, new URL(src, element.baseURI || documentUrl).href, imageCrossOrigin(element));
|
|
2572
2912
|
finishImageLoad(element, src, resource);
|
|
2573
2913
|
map.set(element, resource);
|
|
2574
|
-
decoded.push(image);
|
|
2914
|
+
decoded.push(resource.image);
|
|
2575
2915
|
entries.push({ element, src, resource });
|
|
2576
2916
|
} catch {
|
|
2577
2917
|
// Broken images keep their fallback replaced-element dimensions.
|
|
@@ -2640,6 +2980,7 @@ function buildPageRenderer({
|
|
|
2640
2980
|
requestDownload,
|
|
2641
2981
|
reportError,
|
|
2642
2982
|
pageFetch,
|
|
2983
|
+
clipboardWrite,
|
|
2643
2984
|
webSockets,
|
|
2644
2985
|
setCookie,
|
|
2645
2986
|
consoleMessage,
|
|
@@ -2691,7 +3032,7 @@ function buildPageRenderer({
|
|
|
2691
3032
|
const src = element.getAttribute("src")?.trim();
|
|
2692
3033
|
if (!src) return "about:blank";
|
|
2693
3034
|
try {
|
|
2694
|
-
return new URL(src, page.url).href;
|
|
3035
|
+
return new URL(src, element.baseURI || page.url).href;
|
|
2695
3036
|
} catch {
|
|
2696
3037
|
return "about:blank";
|
|
2697
3038
|
}
|
|
@@ -2794,6 +3135,12 @@ function buildPageRenderer({
|
|
|
2794
3135
|
}
|
|
2795
3136
|
|
|
2796
3137
|
function paintList() {
|
|
3138
|
+
for (const node of page.renderTree?.nodesById.values() ?? []) {
|
|
3139
|
+
if (node.tagName !== "CANVAS") continue;
|
|
3140
|
+
const resource = canvasResource(node.domNode);
|
|
3141
|
+
if (resource) page.resources.set(node.domNode, resource);
|
|
3142
|
+
else page.resources.delete(node.domNode);
|
|
3143
|
+
}
|
|
2797
3144
|
return buildDisplayList(page.layout, page.renderTree, page.resources, backgroundResource, scrollOffsetOf);
|
|
2798
3145
|
}
|
|
2799
3146
|
|
|
@@ -2803,13 +3150,7 @@ function buildPageRenderer({
|
|
|
2803
3150
|
|
|
2804
3151
|
/** The render node and layout box of an element, or null. */
|
|
2805
3152
|
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;
|
|
3153
|
+
return elementBox(page.renderTree, page.layout, element);
|
|
2813
3154
|
}
|
|
2814
3155
|
|
|
2815
3156
|
/**
|
|
@@ -2863,6 +3204,7 @@ function buildPageRenderer({
|
|
|
2863
3204
|
if (next.x || next.y) page.elementScroll.set(element, next);
|
|
2864
3205
|
else page.elementScroll.delete(element);
|
|
2865
3206
|
page.layoutVersion++;
|
|
3207
|
+
page.realm?.layoutChanged(page.layoutVersion);
|
|
2866
3208
|
page.displayList = paintList();
|
|
2867
3209
|
page.needsPaint = true;
|
|
2868
3210
|
// A scroll event at the element, as a task (CSSOM View §13.1).
|
|
@@ -2960,20 +3302,31 @@ function buildPageRenderer({
|
|
|
2960
3302
|
return left;
|
|
2961
3303
|
}
|
|
2962
3304
|
|
|
2963
|
-
/**
|
|
2964
|
-
function revealElement(element) {
|
|
2965
|
-
|
|
3305
|
+
/** Aligns the element within each scrollport, from the innermost out. */
|
|
3306
|
+
function revealElement(element, { block = "start", inline = "nearest", container = "all" } = {}) {
|
|
3307
|
+
let moved = false;
|
|
3308
|
+
let fixed = styleEngine.get(element)?.position === "fixed";
|
|
3309
|
+
const direction = styleEngine.get(element)?.direction;
|
|
3310
|
+
if (direction === "rtl" && inline !== "nearest" && inline !== "center") inline = inline === "start" ? "end" : "start";
|
|
3311
|
+
for (let node = element.parentElement; !fixed && node && node.nodeType === 1; node = node.parentElement) {
|
|
3312
|
+
if (node === page.document.documentElement) break;
|
|
3313
|
+
fixed = styleEngine.get(node)?.position === "fixed";
|
|
2966
3314
|
const geometry = scrollerGeometry(node);
|
|
2967
3315
|
if (!geometry) continue;
|
|
2968
3316
|
const target = viewportBounds(element);
|
|
2969
|
-
const
|
|
2970
|
-
if (!target || !
|
|
3317
|
+
const bounds = viewportBounds(node);
|
|
3318
|
+
if (!target || !bounds) continue;
|
|
2971
3319
|
const [borderTop, , , borderLeft] = styleEngine.get(node)?.borderWidths ?? [0, 0, 0, 0];
|
|
2972
|
-
setElementScroll(node,
|
|
3320
|
+
moved = setElementScroll(node,
|
|
3321
|
+
geometry.x + scrollAlignmentDelta(target.left - bounds.left - borderLeft, target.width, geometry.clientWidth, inline),
|
|
3322
|
+
geometry.y + scrollAlignmentDelta(target.top - bounds.top - borderTop, target.height, geometry.clientHeight, block)) || moved;
|
|
3323
|
+
if (container === "nearest") { if (moved) render(); return; }
|
|
2973
3324
|
}
|
|
2974
3325
|
const bounds = viewportBounds(element);
|
|
2975
|
-
if (bounds) viewport.scrollTo(
|
|
2976
|
-
|
|
3326
|
+
if (bounds && !fixed) moved = viewport.scrollTo(
|
|
3327
|
+
viewport.x + scrollAlignmentDelta(bounds.left, bounds.width, viewport.width, inline),
|
|
3328
|
+
viewport.y + scrollAlignmentDelta(bounds.top, bounds.height, viewport.height, block)) || moved;
|
|
3329
|
+
if (moved) render();
|
|
2977
3330
|
}
|
|
2978
3331
|
|
|
2979
3332
|
/** Every <video> and <audio> gets its (painted) resource: size, frame, playing, time. */
|
|
@@ -2989,7 +3342,7 @@ function buildPageRenderer({
|
|
|
2989
3342
|
|
|
2990
3343
|
function videoSource(element) {
|
|
2991
3344
|
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; }
|
|
3345
|
+
try { return src ? new URL(src, element.baseURI || page.url).href : null; } catch { return null; }
|
|
2993
3346
|
}
|
|
2994
3347
|
|
|
2995
3348
|
// ---- media: <video> and <audio> share one pipeline. An <audio> is media
|
|
@@ -3095,6 +3448,22 @@ function buildPageRenderer({
|
|
|
3095
3448
|
if (!state) return;
|
|
3096
3449
|
state.mediaSource = source;
|
|
3097
3450
|
bindSourcePipes(state);
|
|
3451
|
+
// A later initialization segment can replace already-buffered video.
|
|
3452
|
+
// Shorten the active presentation at its epoch boundary as soon as that
|
|
3453
|
+
// boundary is known, without discarding the frames before it.
|
|
3454
|
+
if (state.video?.pipe?.snapshotAt) {
|
|
3455
|
+
const current = state.video.pipe.snapshotAt(state.video.startTime);
|
|
3456
|
+
const endTime = current.id === state.video.snapshotId ? current.endTime : current.startTime;
|
|
3457
|
+
if (endTime !== state.video.endTime) {
|
|
3458
|
+
state.video.endTime = endTime;
|
|
3459
|
+
if (state.video.presentation) onVideo?.({ type: "video-bind", id: state.video.presentation.id,
|
|
3460
|
+
endTime });
|
|
3461
|
+
}
|
|
3462
|
+
}
|
|
3463
|
+
if (state.sound?.pipe?.snapshotAt) {
|
|
3464
|
+
const current = state.sound.pipe.snapshotAt(state.sound.startTime);
|
|
3465
|
+
state.sound.endTime = current.id === state.sound.snapshotId ? current.endTime : current.startTime;
|
|
3466
|
+
}
|
|
3098
3467
|
if (state.audioPipe && state.audioPipe !== state.videoPipe) state.hasAudio = true;
|
|
3099
3468
|
void refreshProbe(state);
|
|
3100
3469
|
if (state.playing && state.hasAudio && !state.sound) ensureAudio(state);
|
|
@@ -3226,8 +3595,9 @@ function buildPageRenderer({
|
|
|
3226
3595
|
const id = nextVideoPresentation++;
|
|
3227
3596
|
const shared = new SharedArrayBuffer(16 + 480 * 480 * 4);
|
|
3228
3597
|
video.presentation = { id, shared, serial: 0 };
|
|
3229
|
-
onVideo({ type: "video-open", id, shared, bytes:
|
|
3598
|
+
onVideo({ type: "video-open", id, shared, bytes: video.bytes,
|
|
3230
3599
|
options: decodeOptions(state.videoMime || state.contentType, state.url), time: mediaTime(state),
|
|
3600
|
+
startTime: video.startTime ?? 0, endTime: video.endTime ?? Infinity,
|
|
3231
3601
|
hasAudio: state.hasAudio, audioId: state.sound?.id ?? null,
|
|
3232
3602
|
audioStart: state.sound ? state.sound.streamStart / state.sound.decoder.sampleRate : 0 });
|
|
3233
3603
|
}
|
|
@@ -3244,10 +3614,10 @@ function buildPageRenderer({
|
|
|
3244
3614
|
drawHints(canvas, region);
|
|
3245
3615
|
surface.flush();
|
|
3246
3616
|
const image = surface.makeImageSnapshot();
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3617
|
+
try {
|
|
3618
|
+
return image.readPixels(0, 0, { width, height, colorType: CanvasKit.ColorType.RGBA_8888,
|
|
3619
|
+
alphaType: CanvasKit.AlphaType.Unpremul, colorSpace: CanvasKit.ColorSpace.SRGB });
|
|
3620
|
+
} finally { image.delete(); }
|
|
3251
3621
|
};
|
|
3252
3622
|
const base = raster(false), mask = raster(true);
|
|
3253
3623
|
for (let i = 0; i < mask.length; i++) mask[i] = Math.max(0, mask[i] - base[i]);
|
|
@@ -3338,7 +3708,7 @@ function buildPageRenderer({
|
|
|
3338
3708
|
while (true) {
|
|
3339
3709
|
if (pipe) await pipe.waitFor(pipe.done ? pipe.size : Math.max(1, Math.min(want, pipe.size + 1)));
|
|
3340
3710
|
if (pipe?.error) throw pipe.error;
|
|
3341
|
-
const bytes = pipe
|
|
3711
|
+
const bytes = snapshot(pipe, state.bytes);
|
|
3342
3712
|
if (!bytes?.byteLength) {
|
|
3343
3713
|
if (!pipe || pipe.done) throw new Error(`media ${state.url}: empty response`);
|
|
3344
3714
|
continue;
|
|
@@ -3382,7 +3752,7 @@ function buildPageRenderer({
|
|
|
3382
3752
|
const pipe = state.videoPipe ?? state.pipe;
|
|
3383
3753
|
if (!pipe?.size) return;
|
|
3384
3754
|
try {
|
|
3385
|
-
const probe = await videoDecoder.probe(pipe.bytes());
|
|
3755
|
+
const probe = await videoDecoder.probe(snapshot(pipe, state.bytes, mediaTime(state)));
|
|
3386
3756
|
if (!state.durationOverride && probe.duration != null && probe.duration !== state.duration) {
|
|
3387
3757
|
state.duration = probe.duration;
|
|
3388
3758
|
state.resource.duration = probe.duration;
|
|
@@ -3431,19 +3801,21 @@ function buildPageRenderer({
|
|
|
3431
3801
|
return format ? { format } : {};
|
|
3432
3802
|
}
|
|
3433
3803
|
|
|
3434
|
-
function snapshot(pipe, bytes) {
|
|
3435
|
-
return pipe ? pipe.bytes() : bytes;
|
|
3804
|
+
function snapshot(pipe, bytes, time = 0) {
|
|
3805
|
+
return pipe?.snapshotAt ? pipe.snapshotAt(time).bytes : pipe ? pipe.bytes() : bytes;
|
|
3436
3806
|
}
|
|
3437
3807
|
|
|
3438
|
-
async function openVideoDecoder(state) {
|
|
3808
|
+
async function openVideoDecoder(state, time = mediaTime(state)) {
|
|
3439
3809
|
stopVideoPresentation(state);
|
|
3440
3810
|
const pipe = state.videoPipe ?? state.pipe;
|
|
3441
3811
|
const generation = pipe?.generation ?? 0;
|
|
3442
|
-
const
|
|
3812
|
+
const selected = pipe?.snapshotAt?.(time);
|
|
3813
|
+
const bytes = selected?.bytes ?? snapshot(pipe, state.bytes);
|
|
3443
3814
|
if (!bytes?.byteLength) throw new Error(`media ${state.url}: no video bytes`);
|
|
3444
3815
|
state.video?.decoder.close();
|
|
3445
3816
|
const decoder = await videoDecoder.decode(bytes, decodeOptions(state.videoMime || state.contentType, state.url));
|
|
3446
|
-
state.video = { decoder, frameTime: 0, done: false, pipe, openedGeneration: generation
|
|
3817
|
+
state.video = { decoder, bytes, frameTime: selected?.startTime ?? 0, done: false, pipe, openedGeneration: generation,
|
|
3818
|
+
snapshotId: selected?.id, startTime: selected?.startTime ?? 0, endTime: selected?.endTime ?? Infinity };
|
|
3447
3819
|
trace?.(`video decoded ${state.url}: ${decoder.width}x${decoder.height}`);
|
|
3448
3820
|
if (state.resource.width !== decoder.width || state.resource.height !== decoder.height) {
|
|
3449
3821
|
state.resource.width = decoder.width;
|
|
@@ -3459,13 +3831,14 @@ function buildPageRenderer({
|
|
|
3459
3831
|
* (no bytes yet, or audio_open hit EOF / invalid data on a short prefix).
|
|
3460
3832
|
* The previous decoder stays until a new one succeeds.
|
|
3461
3833
|
*/
|
|
3462
|
-
async function openAudioDecoder(state, { keepStream = false } = {}) {
|
|
3834
|
+
async function openAudioDecoder(state, { keepStream = false, time = mediaTime(state) } = {}) {
|
|
3463
3835
|
const serial = state.seekSerial;
|
|
3464
3836
|
const operation = (state.audioOpenSerial ?? 0) + 1;
|
|
3465
3837
|
state.audioOpenSerial = operation;
|
|
3466
3838
|
const pipe = state.audioPipe ?? state.pipe;
|
|
3467
3839
|
const generation = pipe?.generation ?? 0;
|
|
3468
|
-
const
|
|
3840
|
+
const selected = pipe?.snapshotAt?.(time);
|
|
3841
|
+
const bytes = selected?.bytes ?? snapshot(pipe, state.bytes);
|
|
3469
3842
|
const pending = pipe && !pipe.done && !pipe.error;
|
|
3470
3843
|
if (!bytes?.byteLength) {
|
|
3471
3844
|
if (pending) return false;
|
|
@@ -3502,8 +3875,10 @@ function buildPageRenderer({
|
|
|
3502
3875
|
throw new Error("audio format changed while output stream is active");
|
|
3503
3876
|
}
|
|
3504
3877
|
previous?.decoder.close();
|
|
3505
|
-
state.sound = { decoder, id: null, position:
|
|
3506
|
-
|
|
3878
|
+
state.sound = { decoder, id: null, position: Math.round((selected?.startTime ?? 0) * decoder.sampleRate),
|
|
3879
|
+
pending: firstSamples, history: [], finishing: false, done: false,
|
|
3880
|
+
pipe, openedGeneration: generation, snapshotId: selected?.id,
|
|
3881
|
+
startTime: selected?.startTime ?? 0, endTime: selected?.endTime ?? Infinity };
|
|
3507
3882
|
if (keepStream && previousId != null) {
|
|
3508
3883
|
// The output stream carries on with the new decoder's samples.
|
|
3509
3884
|
Object.assign(state.sound, { id: previousId, streamStart: previous.streamStart,
|
|
@@ -3554,7 +3929,10 @@ function buildPageRenderer({
|
|
|
3554
3929
|
function needsMore(track) {
|
|
3555
3930
|
const pipe = track?.pipe;
|
|
3556
3931
|
if (!pipe || pipe.error) return false;
|
|
3557
|
-
|
|
3932
|
+
const atBoundary = Number.isFinite(track.endTime) && (track.frameTime !== undefined
|
|
3933
|
+
? track.frameTime + 1e-6 >= track.endTime
|
|
3934
|
+
: track.position >= Math.round(track.endTime * track.decoder.sampleRate));
|
|
3935
|
+
return pipe.generation !== track.openedGeneration || !pipe.done || atBoundary;
|
|
3558
3936
|
}
|
|
3559
3937
|
|
|
3560
3938
|
/** A click on the media: on its progress track it seeks, anywhere else it plays or pauses. */
|
|
@@ -3658,9 +4036,15 @@ function buildPageRenderer({
|
|
|
3658
4036
|
/** Seek, reopening on a longer snapshot when the target is not in this prefix yet. */
|
|
3659
4037
|
async function placeVideo(state, seconds) {
|
|
3660
4038
|
for (let attempt = 0; attempt < 8; attempt++) {
|
|
3661
|
-
|
|
4039
|
+
const pipe = state.videoPipe ?? state.pipe;
|
|
4040
|
+
const selected = pipe?.snapshotAt?.(seconds);
|
|
4041
|
+
// A seek beyond an old snapshot can succeed at its last keyframe.
|
|
4042
|
+
// Reopen on newer bytes first; an exception does not detect that case.
|
|
4043
|
+
const stale = pipe && (pipe !== state.video?.pipe || pipe.generation !== state.video?.openedGeneration);
|
|
4044
|
+
if (!state.video || state.video.done || seconds === 0 || attempt > 0
|
|
4045
|
+
|| stale || (selected && selected.id !== state.video.snapshotId)) await openVideoDecoder(state, seconds);
|
|
3662
4046
|
try {
|
|
3663
|
-
state.video.frameTime = seekToKeyframe(state.video.decoder, seconds);
|
|
4047
|
+
state.video.frameTime = seekToKeyframe(state.video.decoder, seconds, state.video.startTime);
|
|
3664
4048
|
state.video.hideUntil = seconds;
|
|
3665
4049
|
state.video.done = false;
|
|
3666
4050
|
return;
|
|
@@ -3705,7 +4089,7 @@ function buildPageRenderer({
|
|
|
3705
4089
|
const reached = await advanceAudio(() => state.sound, seconds, {
|
|
3706
4090
|
needsMore,
|
|
3707
4091
|
active: () => state.audioPositioning === transaction && state.seekSerial === serial && state.document === page.document,
|
|
3708
|
-
reopen: () => openAudioDecoder(state, { keepStream: true }),
|
|
4092
|
+
reopen: () => openAudioDecoder(state, { keepStream: true, time: seconds }),
|
|
3709
4093
|
});
|
|
3710
4094
|
if (!reached && state.audioPositioning === transaction && state.seekSerial === serial) {
|
|
3711
4095
|
throw new Error(`audio could not reach ${seconds.toFixed(3)}s; refusing to replay an earlier prefix`);
|
|
@@ -3804,7 +4188,7 @@ function buildPageRenderer({
|
|
|
3804
4188
|
const video = state.video;
|
|
3805
4189
|
if (video?.needsLocalPosition && !video.presentation && !video.done) {
|
|
3806
4190
|
try {
|
|
3807
|
-
video.frameTime = seekToKeyframe(video.decoder, now);
|
|
4191
|
+
video.frameTime = seekToKeyframe(video.decoder, now, video.startTime);
|
|
3808
4192
|
video.hideUntil = now;
|
|
3809
4193
|
video.needsLocalPosition = false;
|
|
3810
4194
|
} catch (error) { failMedia(state, error, 3); return; }
|
|
@@ -3888,6 +4272,7 @@ function buildPageRenderer({
|
|
|
3888
4272
|
function drawVideoFrame(state, show) {
|
|
3889
4273
|
const video = state.video;
|
|
3890
4274
|
const decoder = video.decoder;
|
|
4275
|
+
if (video.frameTime + 1e-6 >= video.endTime) return "stall";
|
|
3891
4276
|
const width = Math.min(480, decoder.width);
|
|
3892
4277
|
const height = Math.max(1, Math.round(width * decoder.height / Math.max(1, decoder.width)));
|
|
3893
4278
|
let pixels;
|
|
@@ -3984,8 +4369,14 @@ function buildPageRenderer({
|
|
|
3984
4369
|
}
|
|
3985
4370
|
const { channels, sampleRate } = sound.decoder;
|
|
3986
4371
|
const frameBytes = channels * 2;
|
|
4372
|
+
const endSample = Math.round(sound.endTime * sampleRate);
|
|
3987
4373
|
sound.lastRequest = bytes;
|
|
3988
4374
|
for (let sent = 0; sent + frameBytes <= bytes;) {
|
|
4375
|
+
if (sound.position >= endSample) {
|
|
4376
|
+
sound.owed = (sound.owed ?? 0) + bytes - sent;
|
|
4377
|
+
void refillMedia(state, sound);
|
|
4378
|
+
return;
|
|
4379
|
+
}
|
|
3989
4380
|
let samples = sound.pending;
|
|
3990
4381
|
sound.pending = null;
|
|
3991
4382
|
if (!samples) {
|
|
@@ -4011,7 +4402,8 @@ function buildPageRenderer({
|
|
|
4011
4402
|
audio.finish(sound.id);
|
|
4012
4403
|
return;
|
|
4013
4404
|
}
|
|
4014
|
-
const
|
|
4405
|
+
const boundaryBytes = Math.max(0, endSample - sound.position) * frameBytes;
|
|
4406
|
+
const [taken, remaining] = splitSamples(samples, channels, Math.min(bytes - sent, boundaryBytes));
|
|
4015
4407
|
if (remaining.length) sound.pending = remaining;
|
|
4016
4408
|
sound.history.push({ start: sound.position, samples: taken });
|
|
4017
4409
|
sound.position += taken.length / channels;
|
|
@@ -4057,8 +4449,8 @@ function buildPageRenderer({
|
|
|
4057
4449
|
* binary search over seek targets finds where that keyframe starts (to a
|
|
4058
4450
|
* frame). The frames from there to the clock are then decoded unshown.
|
|
4059
4451
|
*/
|
|
4060
|
-
function seekToKeyframe(decoder, seconds) {
|
|
4061
|
-
return seekVideoKeyframe(decoder, seconds);
|
|
4452
|
+
function seekToKeyframe(decoder, seconds, startTime = 0) {
|
|
4453
|
+
return seekVideoKeyframe(decoder, seconds, startTime);
|
|
4062
4454
|
}
|
|
4063
4455
|
|
|
4064
4456
|
/**
|
|
@@ -4146,19 +4538,24 @@ function buildPageRenderer({
|
|
|
4146
4538
|
const resumeAt = stalledTrack === state.video && state.video ? Math.min(time, state.video.frameTime) : time;
|
|
4147
4539
|
// The picture must not go back to the keyframe the decoder restarts at.
|
|
4148
4540
|
const hideUntil = Math.max(state.video?.shownTime ?? 0, state.video?.hideUntil ?? 0);
|
|
4149
|
-
await openVideoDecoder(state);
|
|
4541
|
+
await openVideoDecoder(state, resumeAt);
|
|
4150
4542
|
state.video.hideUntil = hideUntil;
|
|
4151
4543
|
try {
|
|
4152
|
-
state.video.frameTime = seekToKeyframe(state.video.decoder, resumeAt);
|
|
4544
|
+
state.video.frameTime = seekToKeyframe(state.video.decoder, resumeAt, state.video.startTime);
|
|
4153
4545
|
} catch {
|
|
4154
|
-
state.video.frameTime =
|
|
4546
|
+
state.video.frameTime = state.video.startTime;
|
|
4155
4547
|
}
|
|
4156
4548
|
state.video.done = false;
|
|
4157
4549
|
}
|
|
4158
4550
|
if (refillAudio) {
|
|
4159
4551
|
let opened = false;
|
|
4160
4552
|
try {
|
|
4161
|
-
|
|
4553
|
+
let audioTime = held ? writtenEnd : time;
|
|
4554
|
+
if (state.sound && Number.isFinite(state.sound.endTime)
|
|
4555
|
+
&& state.sound.position >= Math.round(state.sound.endTime * state.sound.decoder.sampleRate)) {
|
|
4556
|
+
audioTime = Math.max(audioTime, state.sound.endTime);
|
|
4557
|
+
}
|
|
4558
|
+
opened = await openAudioDecoder(state, { keepStream: Boolean(held), time: audioTime });
|
|
4162
4559
|
} catch (error) {
|
|
4163
4560
|
if (!state.video) throw error;
|
|
4164
4561
|
reportError(error);
|
|
@@ -4242,7 +4639,7 @@ function buildPageRenderer({
|
|
|
4242
4639
|
/** The decoded CSS background image a url() names, if it has arrived. */
|
|
4243
4640
|
function backgroundResource(url) {
|
|
4244
4641
|
let absolute;
|
|
4245
|
-
try { absolute = new URL(url, page.url).href; } catch { return null; }
|
|
4642
|
+
try { absolute = new URL(url, page.document.baseURI || page.url).href; } catch { return null; }
|
|
4246
4643
|
return page.backgrounds.get(absolute)?.resource ?? null;
|
|
4247
4644
|
}
|
|
4248
4645
|
|
|
@@ -4255,28 +4652,25 @@ function buildPageRenderer({
|
|
|
4255
4652
|
if (!tree) return;
|
|
4256
4653
|
const document = page.document;
|
|
4257
4654
|
const seen = new Set();
|
|
4655
|
+
const used = new Set();
|
|
4258
4656
|
for (const node of tree.nodesById.values()) {
|
|
4259
4657
|
const value = node.style?.backgroundImage;
|
|
4260
4658
|
if (!value || seen.has(value) || !value.includes("url(")) continue;
|
|
4261
4659
|
seen.add(value);
|
|
4262
4660
|
for (const match of value.matchAll(CSS_URL)) {
|
|
4263
4661
|
let url;
|
|
4264
|
-
try { url = new URL(match[2], page.url).href; } catch { continue; }
|
|
4662
|
+
try { url = new URL(match[2], document.baseURI || page.url).href; } catch { continue; }
|
|
4663
|
+
used.add(url);
|
|
4265
4664
|
if (page.backgrounds.has(url)) continue;
|
|
4266
4665
|
const entry = { resource: null, loaded: null };
|
|
4267
4666
|
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;
|
|
4667
|
+
entry.loaded = loadImageResource(document, url).then((resource) => {
|
|
4274
4668
|
if (page.backgrounds.get(url) !== entry || document !== page.document) {
|
|
4275
|
-
image.delete();
|
|
4669
|
+
resource.image.delete();
|
|
4276
4670
|
return;
|
|
4277
4671
|
}
|
|
4278
|
-
entry.resource =
|
|
4279
|
-
|
|
4672
|
+
entry.resource = resource;
|
|
4673
|
+
page.resourcePaintDirty = true;
|
|
4280
4674
|
page.needsPaint = true;
|
|
4281
4675
|
scheduleFrame();
|
|
4282
4676
|
}).catch(() => {
|
|
@@ -4284,6 +4678,11 @@ function buildPageRenderer({
|
|
|
4284
4678
|
});
|
|
4285
4679
|
}
|
|
4286
4680
|
}
|
|
4681
|
+
for (const [url, entry] of page.backgrounds) {
|
|
4682
|
+
if (used.has(url)) continue;
|
|
4683
|
+
entry.resource?.image.delete();
|
|
4684
|
+
page.backgrounds.delete(url);
|
|
4685
|
+
}
|
|
4287
4686
|
}
|
|
4288
4687
|
|
|
4289
4688
|
function disposeBackgrounds() {
|
|
@@ -4295,36 +4694,58 @@ function buildPageRenderer({
|
|
|
4295
4694
|
const document = page.document;
|
|
4296
4695
|
const generation = loadGeneration;
|
|
4297
4696
|
const staleImages = [];
|
|
4697
|
+
// Infinite feeds remove old DOM images without navigating. The resource
|
|
4698
|
+
// WeakMap alone cannot release their explicitly-owned Skia handles.
|
|
4699
|
+
for (const element of page.imageElements) {
|
|
4700
|
+
if (element.isConnected && element.ownerDocument === document && element.getAttribute("src")) continue;
|
|
4701
|
+
const image = page.resources.get(element)?.image;
|
|
4702
|
+
if (image) {
|
|
4703
|
+
staleImages.push(image);
|
|
4704
|
+
page.resourceLayoutDirty = true;
|
|
4705
|
+
}
|
|
4706
|
+
page.resources.delete(element);
|
|
4707
|
+
page.imageElements.delete(element);
|
|
4708
|
+
imageRequests.delete(element);
|
|
4709
|
+
}
|
|
4298
4710
|
for (const element of document.querySelectorAll("img")) {
|
|
4299
4711
|
const src = element.getAttribute("src");
|
|
4300
|
-
|
|
4712
|
+
const credentials = imageCrossOrigin(element);
|
|
4713
|
+
const key = imageRequestKey(src, credentials);
|
|
4714
|
+
if (!src || imageRequests.get(element) === key) continue;
|
|
4301
4715
|
let url;
|
|
4302
|
-
try { url = new URL(src, page.url).href; } catch { continue; }
|
|
4303
|
-
|
|
4304
|
-
|
|
4716
|
+
try { url = new URL(src, element.baseURI || page.url).href; } catch { continue; }
|
|
4717
|
+
const existing = page.resources.get(element);
|
|
4718
|
+
if (existing?.requestedURL === url && existing.crossOrigin === credentials) {
|
|
4719
|
+
imageRequests.set(element, key);
|
|
4305
4720
|
continue;
|
|
4306
4721
|
}
|
|
4307
|
-
imageRequests.set(element,
|
|
4722
|
+
imageRequests.set(element, key);
|
|
4308
4723
|
const oldImage = page.resources.get(element)?.image;
|
|
4309
|
-
if (oldImage)
|
|
4724
|
+
if (oldImage) {
|
|
4725
|
+
staleImages.push(oldImage);
|
|
4726
|
+
page.resourceLayoutDirty = true;
|
|
4727
|
+
}
|
|
4310
4728
|
page.resources.delete(element);
|
|
4311
4729
|
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) {
|
|
4730
|
+
loadImageResource(document, url, credentials).then((resource) => {
|
|
4731
|
+
const { width, height, image } = resource;
|
|
4732
|
+
if (generation !== loadGeneration || document !== page.document || element.getAttribute("src") !== src
|
|
4733
|
+
|| imageCrossOrigin(element) !== credentials) {
|
|
4319
4734
|
image.delete();
|
|
4320
4735
|
return;
|
|
4321
4736
|
}
|
|
4322
|
-
page.resources.set(element,
|
|
4737
|
+
page.resources.set(element, resource);
|
|
4323
4738
|
page.decodedImages.push(image);
|
|
4324
|
-
|
|
4325
|
-
|
|
4326
|
-
const
|
|
4327
|
-
|
|
4739
|
+
page.imageElements.add(element);
|
|
4740
|
+
let node, run;
|
|
4741
|
+
for (const candidate of page.renderTree.nodesById.values()) {
|
|
4742
|
+
if (candidate.domNode === element) { node = candidate; break; }
|
|
4743
|
+
}
|
|
4744
|
+
if (node) outer: for (const fragment of page.layout.fragments) {
|
|
4745
|
+
for (const candidate of fragment.runs) {
|
|
4746
|
+
if (candidate.type === "image" && candidate.nodeId === node.id) { run = candidate; break outer; }
|
|
4747
|
+
}
|
|
4748
|
+
}
|
|
4328
4749
|
const cssWidth = node?.style?.width;
|
|
4329
4750
|
const cssHeight = node?.style?.height;
|
|
4330
4751
|
const specifiedWidth = typeof cssWidth === "number" ? cssWidth : node?.widthAttribute;
|
|
@@ -4335,17 +4756,20 @@ function buildPageRenderer({
|
|
|
4335
4756
|
const paintOnly = run && Number.isFinite(specifiedWidth) && Number.isFinite(specifiedHeight)
|
|
4336
4757
|
&& Math.abs(run.width - specifiedWidth) < 0.01 && Math.abs(run.height - specifiedHeight) < 0.01;
|
|
4337
4758
|
if (paintOnly) {
|
|
4338
|
-
page.
|
|
4759
|
+
page.resourcePaintDirty = true;
|
|
4339
4760
|
page.needsPaint = true;
|
|
4340
4761
|
} else {
|
|
4341
4762
|
page.dirty = true;
|
|
4342
4763
|
page.resourceLayoutDirty = true;
|
|
4343
4764
|
}
|
|
4344
|
-
|
|
4765
|
+
// All arrivals in this rendering opportunity share layout/raster.
|
|
4766
|
+
// An onload handler which reads geometry still flushes synchronously.
|
|
4767
|
+
finishImageLoad(element, src, { width, height }, type => element.dispatchEvent(new page.window.Event(type)));
|
|
4768
|
+
scheduleFrame();
|
|
4345
4769
|
}).catch((error) => {
|
|
4346
4770
|
// A failed image retains its fallback replaced-element dimensions.
|
|
4347
4771
|
trace?.(`image failed ${url}: ${error?.message ?? error}`);
|
|
4348
|
-
if (generation === loadGeneration && document === page.document) {
|
|
4772
|
+
if (generation === loadGeneration && document === page.document && imageCrossOrigin(element) === credentials) {
|
|
4349
4773
|
finishImageLoad(element, src, null, type => element.dispatchEvent(new page.window.Event(type)));
|
|
4350
4774
|
}
|
|
4351
4775
|
});
|