buninu-browser 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/audio/playback-buffer.js +27 -0
- package/lib/audio/pulse-client.js +18 -8
- package/lib/cdp-server.js +53 -14
- package/lib/happy-dom/document-base.js +33 -0
- package/lib/happy-dom/parser.js +20 -6
- package/lib/happy-dom/selector-matcher.js +2 -2
- package/lib/happy-dom/selector-pseudo-adapter.js +33 -0
- package/lib/happy-dom/svg-attribute-adapter.js +50 -0
- package/lib/headless-shell.js +12 -6
- package/lib/input/hit-test.js +67 -19
- package/lib/layout/text-layout.js +289 -131
- 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 +42 -0
- package/lib/media/webm-stream.js +106 -0
- package/lib/network/network-service.js +268 -120
- package/lib/paint/display-list.js +22 -27
- package/lib/paint/frame-cache.js +25 -0
- package/lib/paint/frame.js +42 -13
- package/lib/paint/paragraph-text.js +24 -0
- package/lib/paint/raster-readback.js +41 -0
- package/lib/paint/rounded-rect.js +82 -0
- package/lib/paint/shared-frame-pool.js +105 -0
- package/lib/render-tree/index.js +40 -6
- package/lib/render-tree/svg.js +15 -7
- package/lib/renderer/bindings.js +244 -82
- package/lib/renderer/broadcast-channel.js +76 -0
- package/lib/renderer/canvas-2d.js +257 -14
- package/lib/renderer/canvas-text.js +120 -0
- package/lib/renderer/compositor.js +27 -10
- package/lib/renderer/custom-element-reactions.js +42 -0
- package/lib/renderer/font-loading.js +227 -0
- package/lib/renderer/history.js +42 -0
- package/lib/renderer/host.js +36 -5
- package/lib/renderer/image-loading.js +27 -0
- package/lib/renderer/image-resources.js +65 -0
- package/lib/renderer/interfaces.js +28 -0
- package/lib/renderer/keyboard-input.js +12 -0
- package/lib/renderer/layout-observers.js +236 -0
- package/lib/renderer/main.js +33 -5
- package/lib/renderer/media-playback-events.js +13 -0
- package/lib/renderer/media-source.js +126 -55
- package/lib/renderer/page-realm.js +278 -147
- package/lib/renderer/page-renderer.js +840 -301
- package/lib/renderer/piano-input.js +22 -0
- package/lib/renderer/pointer-move.js +29 -0
- 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 +145 -0
- package/lib/renderer/workers.js +2 -2
- package/lib/style/computed-style.js +632 -108
- 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/compositor.test.js +38 -0
- package/test/computed-style-binding.test.js +151 -0
- package/test/computed-style.test.js +279 -1
- package/test/display-list.test.js +26 -0
- package/test/document-base.test.js +87 -0
- package/test/dom-platform.test.js +461 -0
- package/test/download.test.js +8 -0
- package/test/fetch-lifecycle.test.js +161 -0
- package/test/fixtures/canvas-paint.js +117 -0
- package/test/fixtures/canvas-text.js +42 -0
- package/test/fixtures/image-lifetime.js +90 -0
- package/test/fixtures/media-seek-snapshot.js +77 -0
- package/test/fixtures/mse-audio-chirp.webm +0 -0
- package/test/fixtures/mse-switch-large.mp4 +0 -0
- package/test/fixtures/mse-switch-small.mp4 +0 -0
- package/test/fixtures/renderer-memory.js +110 -0
- package/test/fixtures/summary-style.js +28 -0
- package/test/font-loading.test.js +97 -0
- package/test/grid.test.js +58 -0
- package/test/headless-shell.test.js +22 -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-playback-events.test.js +22 -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 +160 -2
- package/test/paint-frame.test.js +103 -0
- package/test/paint-paragraph-cache.test.js +87 -0
- package/test/piano-input.test.js +44 -0
- package/test/playback-buffer.test.js +31 -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 +27 -5
- package/test/raster-readback.test.js +40 -0
- package/test/render-tree.test.js +28 -0
- package/test/renderer-memory.test.js +11 -0
- package/test/selector-traits.test.js +24 -0
- package/test/shared-frame-pool.test.js +63 -0
- package/test/style-sheets.test.js +51 -0
- package/test/svg.test.js +29 -0
- package/test/text-layout.test.js +168 -0
- package/test/video-keyframe.test.js +61 -0
- package/test/video-presentation.test.js +136 -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
|
@@ -23,18 +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";
|
|
34
|
+
import { beginImageLoad, finishImageLoad } from "./image-loading.js";
|
|
35
|
+
import { createImageResourceLoader, imageCrossOrigin, imageRequestKey } from "./image-resources.js";
|
|
36
|
+
import { DocumentFonts } from "./font-loading.js";
|
|
37
|
+
import { dispatchPointerMove } from "./pointer-move.js";
|
|
38
|
+
import { keyboardInput } from "./keyboard-input.js";
|
|
39
|
+
import { playbackEvent } from "./media-playback-events.js";
|
|
32
40
|
import { RenderTreeBuilder, renderTreeText, sameLayoutInput } from "../render-tree/index.js";
|
|
33
|
-
import { absolutizeStyleSheetUrls, matchSelectors, StyleEngine, topmostConnected } from "../style/computed-style.js";
|
|
41
|
+
import { absolutizeStyleSheetUrls, extractFontFaces, matchSelectors, mediaQueryMatches, StyleEngine, topmostConnected } from "../style/computed-style.js";
|
|
34
42
|
import { compileSelector, matchesCompiled } from "../happy-dom/selector-matcher.js";
|
|
35
43
|
import { elementFeatures, sheetTraits } from "../style/selector-traits.js";
|
|
36
44
|
import { buildDisplayList, visibleItems } from "../paint/display-list.js";
|
|
37
|
-
import { backgroundBytes, cropViewport,
|
|
45
|
+
import { backgroundBytes, cropViewport, encodeImageBase64 } from "../paint/frame.js";
|
|
46
|
+
import { FrameCaptureCache } from "../paint/frame-cache.js";
|
|
47
|
+
import { RasterReadback } from "../paint/raster-readback.js";
|
|
48
|
+
import { insetRadii, roundedRect } from "../paint/rounded-rect.js";
|
|
49
|
+
import { appendParagraphText } from "../paint/paragraph-text.js";
|
|
38
50
|
import { CLASSIC_SCRIPT_TYPES, createPageRealm } from "./page-realm.js";
|
|
39
51
|
import { originOf } from "./messaging.js";
|
|
40
52
|
import { MOBILE_USER_AGENT } from "./flags.js";
|
|
@@ -42,7 +54,9 @@ import { formatHint } from "../../vendor/wasmpeg/src/js/formats.js";
|
|
|
42
54
|
import { containerFormat } from "./media-source.js";
|
|
43
55
|
import { floatToS16, splitSamples } from "../audio/pcm.js";
|
|
44
56
|
import { advanceAudio } from "../media/position-audio.js";
|
|
45
|
-
import {
|
|
57
|
+
import { seekVideoKeyframe } from "../media/video-keyframe.js";
|
|
58
|
+
import { PianoMixer } from "../audio/piano.js";
|
|
59
|
+
import { handlePianoInput } from "./piano-input.js";
|
|
46
60
|
|
|
47
61
|
// Captured at load: in host mode page scripts share this global object.
|
|
48
62
|
const hostPerformance = globalThis.performance;
|
|
@@ -67,11 +81,6 @@ const AUDIO_HISTORY_SECONDS = 3;
|
|
|
67
81
|
const RESUME_BUFFER_SECONDS = 2;
|
|
68
82
|
const RESUME_BUFFER_MAX_WAIT_MS = 6000;
|
|
69
83
|
const RESUME_BUFFER_IDLE_MS = 400;
|
|
70
|
-
// Finding a seek's keyframe (see seekToKeyframe): how far back it may lie,
|
|
71
|
-
// and the size of the pictures compared.
|
|
72
|
-
const KEYFRAME_SEARCH_SECONDS = 10;
|
|
73
|
-
const KEYFRAME_PROBE_WIDTH = 32;
|
|
74
|
-
const KEYFRAME_PROBE_HEIGHT = 18;
|
|
75
84
|
// element → MediaSource, and the reverse. One renderer owns one page pipeline.
|
|
76
85
|
const elementSources = new WeakMap();
|
|
77
86
|
const sourceElements = new WeakMap();
|
|
@@ -142,6 +151,11 @@ export function loadGraphics() {
|
|
|
142
151
|
const fontCollection = CanvasKit.FontCollection.Make();
|
|
143
152
|
fontCollection.setDefaultFontManager(fontProvider);
|
|
144
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);
|
|
145
159
|
|
|
146
160
|
const fontFamilies = uiFontNames.map((name) => (fontCatalogue.fonts[name].families ?? [name])[0]);
|
|
147
161
|
const sansSerifFamily = fontFamilies[0];
|
|
@@ -157,7 +171,7 @@ export function loadGraphics() {
|
|
|
157
171
|
]);
|
|
158
172
|
return loadedGraphics = {
|
|
159
173
|
CanvasKit, loadedFamilies, fontCollection, fontFamilies, sansSerifFamily, monospaceFamily, monospaceBoldFamily,
|
|
160
|
-
genericFamilies,
|
|
174
|
+
genericFamilies, builtinTypefaces,
|
|
161
175
|
};
|
|
162
176
|
})();
|
|
163
177
|
}
|
|
@@ -219,6 +233,7 @@ function buildPageRenderer({
|
|
|
219
233
|
consoleMessage = null,
|
|
220
234
|
trace = null,
|
|
221
235
|
onFrame = null,
|
|
236
|
+
onVideo = null,
|
|
222
237
|
onLifecycle = null,
|
|
223
238
|
videoDecoder = null,
|
|
224
239
|
// Prepares a classic script on another thread: (source) => Promise<{ code, parsed, strict }>.
|
|
@@ -229,38 +244,47 @@ function buildPageRenderer({
|
|
|
229
244
|
// An iframe's document (see syncFrames): frames go to the embedding page,
|
|
230
245
|
// which needs only the viewport, so nothing is painted ahead.
|
|
231
246
|
embedded = false,
|
|
247
|
+
allocateFramePixels = null,
|
|
232
248
|
// This document's window handle, made by the embedding page (see windowHandle).
|
|
233
249
|
frameWindow = null,
|
|
234
250
|
}, {
|
|
235
251
|
CanvasKit, loadedFamilies, fontCollection, fontFamilies, sansSerifFamily, monospaceFamily, monospaceBoldFamily,
|
|
236
|
-
genericFamilies,
|
|
252
|
+
genericFamilies, builtinTypefaces,
|
|
237
253
|
}) {
|
|
254
|
+
let documentFonts = null;
|
|
255
|
+
let fontSheets = null;
|
|
256
|
+
let fontEnvironment = "";
|
|
257
|
+
let fontDefinitions = [];
|
|
258
|
+
let fontDefinitionKey = "[]";
|
|
238
259
|
const resolvedFamilies = new Map();
|
|
239
260
|
/** First available family from a computed font-family list, then the UI fallbacks. */
|
|
240
|
-
function resolveFontFamilies(families = ["sans-serif"], weight = 400) {
|
|
261
|
+
function resolveFontFamilies(families = ["sans-serif"], weight = 400, fontStyle = "normal", point = null, stretch = 100) {
|
|
241
262
|
const bold = weight >= 600;
|
|
242
|
-
const key = `${
|
|
243
|
-
let resolved = resolvedFamilies.get(key);
|
|
263
|
+
const key = `${weight}/${fontStyle}/${stretch}\n${families.join("\n")}`;
|
|
264
|
+
let resolved = point === null && resolvedFamilies.get(key);
|
|
244
265
|
if (!resolved) {
|
|
245
|
-
let primary =
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
266
|
+
let primary = [];
|
|
267
|
+
for (const family of families) {
|
|
268
|
+
const web = documentFonts?.resolve(family, weight, fontStyle, point, stretch);
|
|
269
|
+
if (web?.length) { primary.push(...web); continue; }
|
|
270
|
+
const builtin = loadedFamilies.get(family.toLowerCase()) ?? genericFamilies.get(family.toLowerCase());
|
|
271
|
+
if (builtin) { primary.push(bold && builtin === monospaceFamily ? monospaceBoldFamily : builtin); break; }
|
|
272
|
+
}
|
|
273
|
+
if (!primary.length) primary = [sansSerifFamily];
|
|
274
|
+
resolved = [...primary, ...fontFamilies.filter((family) => !primary.includes(family))];
|
|
275
|
+
if (point === null) resolvedFamilies.set(key, resolved);
|
|
251
276
|
}
|
|
252
277
|
return resolved;
|
|
253
278
|
}
|
|
254
|
-
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
255
|
-
const emojiPresentation = /^\p{Emoji_Presentation}/u;
|
|
256
279
|
const renderTreeBuilder = new RenderTreeBuilder();
|
|
257
280
|
const layoutEngine = new LayoutEngine({ measureText, fontMetrics });
|
|
258
|
-
const styleEngine = new StyleEngine();
|
|
281
|
+
const styleEngine = new StyleEngine({ measureText });
|
|
259
282
|
// Incremental restyle: what may have changed style since the last
|
|
260
283
|
// computation. DOM mutations come from a MutationObserver; state that is
|
|
261
284
|
// not in the DOM tree (checked, value, focus) is marked by the code that
|
|
262
285
|
// changes it. `full` asks for a complete restyle.
|
|
263
|
-
const styleChanges = { full: true, roots: new Set(), rootFeatures: new Map(), records: [], observer: null,
|
|
286
|
+
const styleChanges = { full: true, roots: new Set(), rootFeatures: new Map(), records: [], observer: null,
|
|
287
|
+
activeElement: null, focusAncestors: new Set() };
|
|
264
288
|
let selectorTraits = { sibling: false, has: null, sheets: null };
|
|
265
289
|
let lastStyleParts = "";
|
|
266
290
|
let laidOutSheets = null;
|
|
@@ -304,6 +328,7 @@ function buildPageRenderer({
|
|
|
304
328
|
deviceScaleFactor: 1,
|
|
305
329
|
pageScale: 1,
|
|
306
330
|
needsPaint: true,
|
|
331
|
+
resourcePaintDirty: false,
|
|
307
332
|
loading: false,
|
|
308
333
|
hints: [],
|
|
309
334
|
hintsVisible: false,
|
|
@@ -314,6 +339,7 @@ function buildPageRenderer({
|
|
|
314
339
|
backgroundColor: "rgba(255, 255, 255, 1)",
|
|
315
340
|
resources: new WeakMap(),
|
|
316
341
|
decodedImages: [],
|
|
342
|
+
imageElements: new Set(),
|
|
317
343
|
imagesLoaded: null,
|
|
318
344
|
// CSS background images by absolute URL: { resource, loaded } (see queueBackgroundImages).
|
|
319
345
|
backgrounds: new Map(),
|
|
@@ -339,15 +365,58 @@ function buildPageRenderer({
|
|
|
339
365
|
if (typeof response.documentCookie === "string") page.cookie = response.documentCookie;
|
|
340
366
|
return response;
|
|
341
367
|
}
|
|
368
|
+
const loadImageResource = createImageResourceLoader(CanvasKit, fetchResource, pageFetch);
|
|
369
|
+
const renderCanvasText = createCanvasTextRenderer(CanvasKit, makeParagraph);
|
|
370
|
+
|
|
371
|
+
function resetDocumentFonts() {
|
|
372
|
+
documentFonts?.dispose();
|
|
373
|
+
fontSheets = null;
|
|
374
|
+
fontDefinitions = [];
|
|
375
|
+
if (fontDefinitionKey !== "[]") clearFontCaches();
|
|
376
|
+
fontEnvironment = "";
|
|
377
|
+
fontDefinitionKey = "[]";
|
|
378
|
+
documentFonts = new DocumentFonts({
|
|
379
|
+
CanvasKit, builtins: builtinTypefaces, defaultCollection: fontCollection, fetch: pageFetch,
|
|
380
|
+
availableFamilies: new Set([...loadedFamilies.keys(), ...genericFamilies.keys()]),
|
|
381
|
+
changed() {
|
|
382
|
+
if (disposedRenderer) return;
|
|
383
|
+
clearFontCaches();
|
|
384
|
+
// Font metrics also affect CSS ch units and normal line-height.
|
|
385
|
+
styleChanges.full = true;
|
|
386
|
+
page.dirty = true;
|
|
387
|
+
page.resourceLayoutDirty = true;
|
|
388
|
+
page.needsPaint = true;
|
|
389
|
+
scheduleFrame();
|
|
390
|
+
},
|
|
391
|
+
error: error => trace?.(`font failed: ${error?.message ?? error}`),
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function clearFontCaches() {
|
|
396
|
+
resolvedFamilies.clear();
|
|
397
|
+
measuredWidths.clear();
|
|
398
|
+
measuredCount = 0;
|
|
399
|
+
fontKeys = new WeakMap();
|
|
400
|
+
fontMetricsCache.clear();
|
|
401
|
+
clearPaintParagraphs();
|
|
402
|
+
rasterized.list = null;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function queueFontLoads() {
|
|
406
|
+
if (page.renderTree && fontDefinitions.length) documentFonts.update(fontDefinitions,
|
|
407
|
+
[...page.renderTree.nodesById.values()].filter(node => node.type === "text"));
|
|
408
|
+
}
|
|
342
409
|
|
|
343
|
-
function makeParagraph(text, computedStyle = null, fontSize = computedStyle?.fontSize ?? 16, scale = 1) {
|
|
410
|
+
function makeParagraph(text, computedStyle = null, fontSize = computedStyle?.fontSize ?? 16, scale = 1, foregroundPaint = null) {
|
|
344
411
|
const color = canvasColor(CanvasKit, computedStyle?.color ?? "rgba(0, 0, 0, 1)");
|
|
345
412
|
const weight = computedStyle?.fontWeight ?? 400;
|
|
346
|
-
const
|
|
347
|
-
const
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
413
|
+
const fontStyle = computedStyle?.fontStyle ?? "normal";
|
|
414
|
+
const stretch = computedStyle?.fontStretch ?? 100;
|
|
415
|
+
const widths = [50, 62.5, 75, 87.5, 100, 112.5, 125, 150, 200];
|
|
416
|
+
const width = widths.reduce((best, value, index) => Math.abs(value - stretch) < Math.abs(widths[best] - stretch) ? index : best, 4) + 1;
|
|
417
|
+
const slant = fontStyle === "italic" ? CanvasKit.FontSlant.Italic
|
|
418
|
+
: fontStyle === "oblique" ? CanvasKit.FontSlant.Oblique : CanvasKit.FontSlant.Upright;
|
|
419
|
+
const families = resolveFontFamilies(computedStyle?.fontFamily, weight, fontStyle, null, stretch);
|
|
351
420
|
// text-decoration lines, drawn by SkParagraph under/over/through the glyphs.
|
|
352
421
|
const decoration = computedStyle?.decoration ? {
|
|
353
422
|
decoration: computedStyle.decoration.lines.reduce((mask, line) => mask | ({
|
|
@@ -361,30 +430,60 @@ function buildPageRenderer({
|
|
|
361
430
|
color,
|
|
362
431
|
fontFamilies: families,
|
|
363
432
|
fontSize: Math.max(1, Math.round(fontSize * scale)),
|
|
364
|
-
fontStyle: { weight },
|
|
433
|
+
fontStyle: { weight, slant, width },
|
|
365
434
|
// Roboto is a variable font; static fonts ignore the axis and match by weight.
|
|
366
|
-
fontVariations: [{ axis: "wght", value: weight }],
|
|
435
|
+
fontVariations: [{ axis: "wght", value: weight }, { axis: "wdth", value: stretch }],
|
|
367
436
|
...decoration,
|
|
368
437
|
},
|
|
369
438
|
maxLines: 1,
|
|
439
|
+
...(computedStyle?.direction ? { textDirection: computedStyle.direction === "rtl" ? CanvasKit.TextDirection.RTL : CanvasKit.TextDirection.LTR } : {}),
|
|
370
440
|
});
|
|
371
|
-
const builder = CanvasKit.ParagraphBuilder.MakeFromFontCollection(style, fontCollection);
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
441
|
+
const builder = CanvasKit.ParagraphBuilder.MakeFromFontCollection(style, documentFonts?.collection ?? fontCollection);
|
|
442
|
+
const backgroundPaint = foregroundPaint ? new CanvasKit.Paint() : null;
|
|
443
|
+
if (backgroundPaint) backgroundPaint.setColor(CanvasKit.TRANSPARENT);
|
|
444
|
+
const pushTextStyle = foregroundPaint
|
|
445
|
+
? value => builder.pushPaintStyle(value, foregroundPaint, backgroundPaint) : value => builder.pushStyle(value);
|
|
446
|
+
const textStyle = (fontFamilies) => CanvasKit.TextStyle({ color, ...decoration, fontFamilies,
|
|
447
|
+
fontSize: Math.max(1, Math.round(fontSize * scale)), fontStyle: { weight, slant, width },
|
|
448
|
+
fontVariations: [{ axis: "wght", value: weight }, { axis: "wdth", value: stretch }] });
|
|
449
|
+
function append(text, fonts) {
|
|
450
|
+
let emojiStyle;
|
|
451
|
+
const writer = foregroundPaint ? { addText: value => builder.addText(value), pushStyle: pushTextStyle, pop: () => builder.pop() } : builder;
|
|
452
|
+
appendParagraphText(writer, text, () => emojiStyle ??= textStyle([
|
|
453
|
+
...fonts.filter(name => name.includes("Emoji")), ...fonts.filter(name => !name.includes("Emoji")),
|
|
454
|
+
]));
|
|
385
455
|
}
|
|
456
|
+
if (foregroundPaint) pushTextStyle(textStyle(families));
|
|
457
|
+
if (documentFonts?.hasUnicodeRanges && text) {
|
|
458
|
+
// A downloaded subset may physically contain more glyphs than its CSS
|
|
459
|
+
// unicode-range permits. Apply the range when choosing native families,
|
|
460
|
+
// preserving whole adjacent runs for shaping instead of per-letter calls.
|
|
461
|
+
const choices = new Map();
|
|
462
|
+
let run = "", previous = null, previousKey = "";
|
|
463
|
+
const flush = () => {
|
|
464
|
+
if (!run) return;
|
|
465
|
+
pushTextStyle(textStyle(previous));
|
|
466
|
+
append(run, previous);
|
|
467
|
+
builder.pop();
|
|
468
|
+
};
|
|
469
|
+
for (const character of text) {
|
|
470
|
+
const point = character.codePointAt(0);
|
|
471
|
+
let choice = choices.get(point);
|
|
472
|
+
if (!choice) {
|
|
473
|
+
const fonts = resolveFontFamilies(computedStyle?.fontFamily, weight, fontStyle, point, stretch);
|
|
474
|
+
choices.set(point, choice = { fonts, key: fonts.join("\n") });
|
|
475
|
+
}
|
|
476
|
+
if (run && choice.key !== previousKey) { flush(); run = ""; }
|
|
477
|
+
previous = choice.fonts;
|
|
478
|
+
previousKey = choice.key;
|
|
479
|
+
run += character;
|
|
480
|
+
}
|
|
481
|
+
flush();
|
|
482
|
+
} else append(text, families);
|
|
483
|
+
if (foregroundPaint) builder.pop();
|
|
386
484
|
const paragraph = builder.build();
|
|
387
485
|
builder.delete();
|
|
486
|
+
backgroundPaint?.delete();
|
|
388
487
|
return paragraph;
|
|
389
488
|
}
|
|
390
489
|
|
|
@@ -418,22 +517,24 @@ function buildPageRenderer({
|
|
|
418
517
|
}
|
|
419
518
|
|
|
420
519
|
// Computed styles are immutable and shared, so each one's key is computed once.
|
|
421
|
-
|
|
520
|
+
let fontKeys = new WeakMap();
|
|
422
521
|
function fontKeyOf(computedStyle) {
|
|
423
522
|
if (computedStyle && typeof computedStyle === "object") {
|
|
424
523
|
const cached = fontKeys.get(computedStyle);
|
|
425
524
|
if (cached) return cached;
|
|
426
525
|
}
|
|
427
526
|
const weight = computedStyle?.fontWeight ?? 400;
|
|
428
|
-
const
|
|
429
|
-
|
|
527
|
+
const fontStyle = computedStyle?.fontStyle ?? "normal";
|
|
528
|
+
const stretch = computedStyle?.fontStretch ?? 100;
|
|
529
|
+
const key = `${Math.max(1, Math.round(computedStyle?.fontSize ?? 16))}/${weight}/${fontStyle}/${stretch}/${
|
|
530
|
+
resolveFontFamilies(computedStyle?.fontFamily, weight, fontStyle, null, stretch).join(",")}`;
|
|
430
531
|
if (computedStyle && typeof computedStyle === "object") fontKeys.set(computedStyle, key);
|
|
431
532
|
return key;
|
|
432
533
|
}
|
|
433
534
|
|
|
434
535
|
const fontMetricsCache = new Map();
|
|
435
536
|
function fontMetrics(computedStyle = null) {
|
|
436
|
-
const key =
|
|
537
|
+
const key = fontKeyOf(computedStyle);
|
|
437
538
|
let metrics = fontMetricsCache.get(key);
|
|
438
539
|
if (!metrics) {
|
|
439
540
|
const paragraph = makeParagraph("Hg", computedStyle);
|
|
@@ -450,9 +551,10 @@ function buildPageRenderer({
|
|
|
450
551
|
// thread scrolls on its own (keys, fragments, script, a new document).
|
|
451
552
|
let scrollEpoch = 0;
|
|
452
553
|
let syncedScroll = { x: 0, y: 0 };
|
|
554
|
+
let syncedViewport = { width: 0, height: 0 };
|
|
453
555
|
let tileSerial = 0;
|
|
454
556
|
let lastTile = null;
|
|
455
|
-
|
|
557
|
+
const captures = new FrameCaptureCache();
|
|
456
558
|
let prepaintTimer = null;
|
|
457
559
|
let frameTimer = null;
|
|
458
560
|
let lastRebuildMs = 0;
|
|
@@ -463,9 +565,71 @@ function buildPageRenderer({
|
|
|
463
565
|
// PaintController reusing cached display items
|
|
464
566
|
// (platform/graphics/paint/paint_controller.cc, BSD).
|
|
465
567
|
let rasterized = { list: null, overlay: "", background: "", scale: 0 };
|
|
568
|
+
let nextVideoPresentation = 1;
|
|
466
569
|
let rasterSurface = null;
|
|
467
570
|
let rasterSurfaceWidth = 0;
|
|
468
571
|
let rasterSurfaceHeight = 0;
|
|
572
|
+
const rasterReadback = new RasterReadback(CanvasKit, { shared: Boolean(onFrame), allocate: allocateFramePixels });
|
|
573
|
+
// A scroll or unrelated DOM update does not change text shaping. Keep a
|
|
574
|
+
// bounded set of visible paragraphs keyed by their complete shaping input.
|
|
575
|
+
// Both limits matter: a page may contain many unique or very long strings.
|
|
576
|
+
const paintedParagraphs = new Map();
|
|
577
|
+
let paragraphKeys = new WeakMap();
|
|
578
|
+
let paintedCharacters = 0;
|
|
579
|
+
const MAX_PAINT_PARAGRAPHS = 256;
|
|
580
|
+
const MAX_PAINT_CHARACTERS = 64_000;
|
|
581
|
+
|
|
582
|
+
function releasePaintParagraph(key, entry) {
|
|
583
|
+
entry.paragraph.delete();
|
|
584
|
+
paintedParagraphs.delete(key);
|
|
585
|
+
paintedCharacters -= entry.characters;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function clearPaintParagraphs() {
|
|
589
|
+
renderCanvasText.clear();
|
|
590
|
+
for (const entry of paintedParagraphs.values()) entry.paragraph.delete();
|
|
591
|
+
paintedParagraphs.clear();
|
|
592
|
+
paragraphKeys = new WeakMap();
|
|
593
|
+
paintedCharacters = 0;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function paragraphForPaint(item, scale, width) {
|
|
597
|
+
const retained = item.text.length <= 4096;
|
|
598
|
+
let cachedKey = retained && paragraphKeys.get(item);
|
|
599
|
+
if (retained && (!cachedKey || cachedKey.scale !== scale || cachedKey.width !== width)) {
|
|
600
|
+
cachedKey = { scale, width, key: JSON.stringify([item.text, item.color, item.font,
|
|
601
|
+
item.decoration, scale, width]) };
|
|
602
|
+
paragraphKeys.set(item, cachedKey);
|
|
603
|
+
}
|
|
604
|
+
const key = cachedKey?.key;
|
|
605
|
+
let entry = retained && paintedParagraphs.get(key);
|
|
606
|
+
if (entry) {
|
|
607
|
+
paintedParagraphs.delete(key);
|
|
608
|
+
paintedParagraphs.set(key, entry);
|
|
609
|
+
return entry;
|
|
610
|
+
}
|
|
611
|
+
const paragraph = makeParagraph(item.text, {
|
|
612
|
+
color: item.color, fontSize: item.font.size, fontWeight: item.font.weight, fontFamily: item.font.family,
|
|
613
|
+
fontStyle: item.font.style, fontStretch: item.font.stretch,
|
|
614
|
+
decoration: item.decoration,
|
|
615
|
+
}, undefined, scale);
|
|
616
|
+
try {
|
|
617
|
+
paragraph.layout(width);
|
|
618
|
+
} catch (error) {
|
|
619
|
+
paragraph.delete();
|
|
620
|
+
throw error;
|
|
621
|
+
}
|
|
622
|
+
entry = { paragraph, retained, characters: item.text.length };
|
|
623
|
+
if (entry.retained) {
|
|
624
|
+
paintedParagraphs.set(key, entry);
|
|
625
|
+
paintedCharacters += item.text.length;
|
|
626
|
+
while (paintedParagraphs.size > MAX_PAINT_PARAGRAPHS || paintedCharacters > MAX_PAINT_CHARACTERS) {
|
|
627
|
+
const [oldKey, oldEntry] = paintedParagraphs.entries().next().value;
|
|
628
|
+
releasePaintParagraph(oldKey, oldEntry);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
return entry;
|
|
632
|
+
}
|
|
469
633
|
|
|
470
634
|
/** Hint overlay contents, as a comparable key. */
|
|
471
635
|
function overlayKey() {
|
|
@@ -482,6 +646,8 @@ function buildPageRenderer({
|
|
|
482
646
|
if (page.displayList?.items.some((item) => item.fixed)
|
|
483
647
|
&& (rasterized.scrollX !== viewport.x || rasterized.scrollY !== viewport.y)) return false;
|
|
484
648
|
if (rasterized.list === page.displayList) return true;
|
|
649
|
+
if (rasterized.list?.images.length !== page.displayList?.images.length
|
|
650
|
+
|| page.displayList?.images.some((resource, index) => resource !== rasterized.list.images[index])) return false;
|
|
485
651
|
if (!rasterized.list || !page.displayList || !Bun.deepEquals(rasterized.list.items, page.displayList.items)) return false;
|
|
486
652
|
rasterized.list = page.displayList;
|
|
487
653
|
return true;
|
|
@@ -489,26 +655,46 @@ function buildPageRenderer({
|
|
|
489
655
|
|
|
490
656
|
/** Rasterizes the viewport and hands it to the compositor. */
|
|
491
657
|
function render() {
|
|
658
|
+
if (disposedRenderer) return;
|
|
659
|
+
if (page.resourcePaintDirty) {
|
|
660
|
+
page.displayList = paintList();
|
|
661
|
+
page.resourcePaintDirty = false;
|
|
662
|
+
}
|
|
663
|
+
page.realm?.layoutChanged(page.layoutVersion);
|
|
664
|
+
if (onVideo) for (const state of page.media.values()) readPresentedFrame(state);
|
|
492
665
|
const width = Math.max(1, Math.floor(page.width));
|
|
493
666
|
const height = Math.max(1, Math.floor(page.height));
|
|
494
667
|
const region = { x: viewport.x, y: viewport.y, width, height };
|
|
495
668
|
if (lastTile && tileCovers(lastTile, region) && sameRasterInput()) {
|
|
496
669
|
page.needsPaint = false;
|
|
497
670
|
// Only this thread's scroll position changed: tell the compositor.
|
|
498
|
-
if (viewport.x !== syncedScroll.x || viewport.y !== syncedScroll.y
|
|
671
|
+
if (viewport.x !== syncedScroll.x || viewport.y !== syncedScroll.y
|
|
672
|
+
|| page.width !== syncedViewport.width || page.height !== syncedViewport.height) publish(lastTile);
|
|
499
673
|
} else {
|
|
500
|
-
|
|
674
|
+
// Keep a previously prepainted region during ordinary page updates.
|
|
675
|
+
// Shrinking to the viewport and expanding again 30ms later used to
|
|
676
|
+
// rasterize each animation twice and repeatedly resize native surfaces.
|
|
677
|
+
const retained = !page.loading && lastTile && tileCovers(lastTile, region)
|
|
678
|
+
&& lastTile.height <= height * 2.5;
|
|
679
|
+
publish(rasterTile(retained ? { x: lastTile.x, y: lastTile.y, width: lastTile.width, height: lastTile.height } : region));
|
|
501
680
|
}
|
|
502
681
|
schedulePrepaint();
|
|
503
682
|
}
|
|
504
683
|
|
|
505
684
|
/** Rasterizes a page rectangle (CSS pixels) into RGBA pixels. */
|
|
506
685
|
function rasterTile(region) {
|
|
686
|
+
// Prepaint can run before a queued canvas/image repaint. Refresh native
|
|
687
|
+
// snapshots here too: the old display list may hold a released image.
|
|
688
|
+
if (page.resourcePaintDirty) {
|
|
689
|
+
page.displayList = paintList();
|
|
690
|
+
page.resourcePaintDirty = false;
|
|
691
|
+
}
|
|
507
692
|
const scale = page.deviceScaleFactor;
|
|
508
693
|
const pixelWidth = Math.max(1, Math.round(region.width * scale));
|
|
509
694
|
const pixelHeight = Math.max(1, Math.round(region.height * scale));
|
|
510
695
|
if (!rasterSurface || rasterSurfaceWidth !== pixelWidth || rasterSurfaceHeight !== pixelHeight) {
|
|
511
|
-
|
|
696
|
+
// MakeSurface owns a malloc-backed pixel buffer; delete() alone leaks it.
|
|
697
|
+
rasterSurface?.dispose();
|
|
512
698
|
rasterSurface = CanvasKit.MakeSurface(pixelWidth, pixelHeight);
|
|
513
699
|
rasterSurfaceWidth = pixelWidth;
|
|
514
700
|
rasterSurfaceHeight = pixelHeight;
|
|
@@ -521,14 +707,10 @@ function buildPageRenderer({
|
|
|
521
707
|
drawHints(canvas, region);
|
|
522
708
|
surface.flush();
|
|
523
709
|
const image = surface.makeImageSnapshot();
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
alphaType: CanvasKit.AlphaType.Unpremul,
|
|
529
|
-
colorSpace: CanvasKit.ColorSpace.SRGB,
|
|
530
|
-
});
|
|
531
|
-
image.delete();
|
|
710
|
+
let pixels;
|
|
711
|
+
try { pixels = rasterReadback.read(image, pixelWidth, pixelHeight); }
|
|
712
|
+
finally { image.delete(); }
|
|
713
|
+
const videoLayer = makeVideoLayer(canvas, surface, region, scale, pixelWidth, pixelHeight);
|
|
532
714
|
page.needsPaint = false;
|
|
533
715
|
rasterized = {
|
|
534
716
|
list: page.displayList, overlay: overlayKey(), background: page.backgroundColor, scale: page.deviceScaleFactor,
|
|
@@ -544,12 +726,14 @@ function buildPageRenderer({
|
|
|
544
726
|
height: region.height,
|
|
545
727
|
scale,
|
|
546
728
|
background: backgroundBytes(page.backgroundColor),
|
|
729
|
+
videoLayer,
|
|
547
730
|
};
|
|
548
731
|
}
|
|
549
732
|
|
|
550
733
|
function publish(tile) {
|
|
551
734
|
lastTile = tile;
|
|
552
735
|
tileSerial++;
|
|
736
|
+
syncedViewport = { width: page.width, height: page.height };
|
|
553
737
|
if (viewport.x !== syncedScroll.x || viewport.y !== syncedScroll.y) {
|
|
554
738
|
scrollEpoch++;
|
|
555
739
|
syncedScroll = { x: viewport.x, y: viewport.y };
|
|
@@ -580,7 +764,7 @@ function buildPageRenderer({
|
|
|
580
764
|
|
|
581
765
|
/** After a paint or a compositor scroll, rasterize around the viewport once this thread is idle. */
|
|
582
766
|
function schedulePrepaint() {
|
|
583
|
-
if (!onFrame || embedded) return;
|
|
767
|
+
if (disposedRenderer || !onFrame || embedded) return;
|
|
584
768
|
hostClearTimeout(prepaintTimer);
|
|
585
769
|
prepaintTimer = hostSetTimeout(() => {
|
|
586
770
|
prepaintTimer = null;
|
|
@@ -599,7 +783,7 @@ function buildPageRenderer({
|
|
|
599
783
|
|
|
600
784
|
/** A rendering opportunity for DOM changes nobody has asked to see yet. */
|
|
601
785
|
function scheduleFrame() {
|
|
602
|
-
if (!onFrame || frameTimer || page.loading) return;
|
|
786
|
+
if (disposedRenderer || !onFrame || frameTimer || page.loading) return;
|
|
603
787
|
frameTimer = hostSetTimeout(() => {
|
|
604
788
|
frameTimer = null;
|
|
605
789
|
refresh();
|
|
@@ -609,32 +793,32 @@ function buildPageRenderer({
|
|
|
609
793
|
}, Math.max(50, lastRebuildMs * 2));
|
|
610
794
|
}
|
|
611
795
|
|
|
612
|
-
/** The viewport as
|
|
613
|
-
async function viewportPng() {
|
|
796
|
+
/** The viewport as an encoded image (base64), cut from the latest tile. */
|
|
797
|
+
async function viewportPng({ format = "png", quality = 80, optimizeForSpeed = false } = {}) {
|
|
614
798
|
if (!lastTile) return "";
|
|
615
|
-
const key = `${tileSerial}:${viewport.x}:${viewport.y}`;
|
|
616
|
-
|
|
799
|
+
const key = `${tileSerial}:${viewport.x}:${viewport.y}:${page.width}:${page.height}:${format}:${quality}:${optimizeForSpeed}`;
|
|
800
|
+
return captures.get(key, () => {
|
|
617
801
|
const { pixels, width, height } = cropViewport(lastTile, viewport.x, viewport.y, page.width, page.height);
|
|
618
|
-
|
|
619
|
-
}
|
|
620
|
-
return encoded.png;
|
|
802
|
+
return encodeImageBase64(pixels, width, height, { format, quality, optimizeForSpeed });
|
|
803
|
+
});
|
|
621
804
|
}
|
|
622
805
|
|
|
623
806
|
/** Rasterizes display items intersecting the viewport onto a CanvasKit canvas. */
|
|
624
807
|
const paintFailures = new Set();
|
|
625
808
|
|
|
626
|
-
function paintDisplayList(canvas, displayList, view, scale) {
|
|
809
|
+
function paintDisplayList(canvas, displayList, view, scale, videoOverride = null) {
|
|
627
810
|
if (!displayList) return;
|
|
628
811
|
const pixel = (value) => Math.round(value * scale);
|
|
629
812
|
const deviceRect = (rect) => CanvasKit.XYWHRect(
|
|
630
813
|
pixel(rect.x - view.x), pixel(rect.y - view.y), pixel(rect.width), pixel(rect.height),
|
|
631
814
|
);
|
|
632
815
|
const deviceRRect = (rect, radii) => radii && (radii.x > 0 || radii.y > 0)
|
|
633
|
-
?
|
|
816
|
+
? roundedRect(deviceRect(rect), radii, scale)
|
|
634
817
|
: null;
|
|
635
818
|
// Returns a paint plus a release function that also frees its shader.
|
|
636
819
|
const fillPaint = (item) => {
|
|
637
820
|
const paint = new CanvasKit.Paint();
|
|
821
|
+
if (item.radii && (item.radii.x > 0 || item.radii.y > 0)) paint.setAntiAlias(true);
|
|
638
822
|
const shader = item.gradient ? gradientShader(item.gradient, view, scale) : null;
|
|
639
823
|
if (shader) paint.setShader(shader);
|
|
640
824
|
else paint.setColor(canvasColor(CanvasKit, item.color));
|
|
@@ -654,6 +838,9 @@ function buildPageRenderer({
|
|
|
654
838
|
try {
|
|
655
839
|
if (item.fixed) canvas.translate(viewport.x * scale, viewport.y * scale);
|
|
656
840
|
if (item.clipRect) canvas.clipRect(deviceRect(item.clipRect), CanvasKit.ClipOp.Intersect, true);
|
|
841
|
+
for (const clip of item.roundedClips ?? []) {
|
|
842
|
+
canvas.clipRRect(deviceRRect(clip.rect, clip.radii), CanvasKit.ClipOp.Intersect, true);
|
|
843
|
+
}
|
|
657
844
|
// A scale in document space, as a device-space matrix around the item's device coordinates.
|
|
658
845
|
if (item.transform) {
|
|
659
846
|
const { a, d, e, f } = item.transform;
|
|
@@ -790,7 +977,10 @@ function buildPageRenderer({
|
|
|
790
977
|
canvas.clipRect(rect, CanvasKit.ClipOp.Intersect, true);
|
|
791
978
|
paint.setColor(CanvasKit.Color(0, 0, 0, 1));
|
|
792
979
|
canvas.drawRect(rect, paint);
|
|
793
|
-
if (resource
|
|
980
|
+
if (videoOverride?.resource === resource) {
|
|
981
|
+
paint.setColor(CanvasKit.Color(videoOverride.white ? 255 : 0, videoOverride.white ? 255 : 0, videoOverride.white ? 255 : 0, 1));
|
|
982
|
+
canvas.drawRect(rect, paint);
|
|
983
|
+
} else if (resource?.image) {
|
|
794
984
|
canvas.drawImageRect(resource.image,
|
|
795
985
|
CanvasKit.XYWHRect(0, 0, resource.bitmapWidth ?? resource.width, resource.bitmapHeight ?? resource.height),
|
|
796
986
|
rect, null, false);
|
|
@@ -798,7 +988,7 @@ function buildPageRenderer({
|
|
|
798
988
|
const centerX = rect[0] + (rect[2] - rect[0]) / 2;
|
|
799
989
|
const centerY = rect[1] + (rect[3] - rect[1]) / 2;
|
|
800
990
|
paint.setColor(CanvasKit.Color(0, 0, 0, 0.65));
|
|
801
|
-
if (!resource?.playing) {
|
|
991
|
+
if (item.controls && !resource?.playing) {
|
|
802
992
|
canvas.drawCircle(centerX, centerY, 22 * scale, paint);
|
|
803
993
|
paint.setColor(CanvasKit.Color(255, 255, 255, 1));
|
|
804
994
|
const path = CanvasKit.Path.MakeFromSVGString(
|
|
@@ -806,7 +996,7 @@ function buildPageRenderer({
|
|
|
806
996
|
if (path) { canvas.drawPath(path, paint); path.delete(); }
|
|
807
997
|
}
|
|
808
998
|
// The control bar (play/pause, time, seekable progress) once playing or known.
|
|
809
|
-
if (resource?.playing || Number.isFinite(resource?.duration)) {
|
|
999
|
+
if (item.controls && (resource?.playing || Number.isFinite(resource?.duration))) {
|
|
810
1000
|
drawMediaBar(canvas, item.rect.x - view.x, item.rect.y + item.rect.height - MEDIA_BAR_HEIGHT - view.y,
|
|
811
1001
|
item.rect.width, MEDIA_BAR_HEIGHT, resource, scale, true);
|
|
812
1002
|
}
|
|
@@ -818,25 +1008,23 @@ function buildPageRenderer({
|
|
|
818
1008
|
} else if (item.op === "drawSvg") {
|
|
819
1009
|
drawSvg(item);
|
|
820
1010
|
} else if (item.op === "drawText") {
|
|
821
|
-
const
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
canvas.drawParagraph(paragraph, pixel(item.x - view.x), pixel(item.y - view.y));
|
|
1011
|
+
const entry = paragraphForPaint(item, scale, pixel(Math.max(1, view.width + view.x - item.x)));
|
|
1012
|
+
try {
|
|
1013
|
+
if (item.gradient) {
|
|
1014
|
+
// background-clip:text: draw glyphs, then keep the gradient only where they are.
|
|
1015
|
+
canvas.saveLayer(null, deviceRect(item.bounds));
|
|
1016
|
+
canvas.drawParagraph(entry.paragraph, pixel(item.x - view.x), pixel(item.y - view.y));
|
|
1017
|
+
const paint = fillPaint(item);
|
|
1018
|
+
paint.setBlendMode(CanvasKit.BlendMode.SrcIn);
|
|
1019
|
+
canvas.drawRect(deviceRect(item.bounds), paint);
|
|
1020
|
+
paint.release();
|
|
1021
|
+
canvas.restore();
|
|
1022
|
+
} else {
|
|
1023
|
+
canvas.drawParagraph(entry.paragraph, pixel(item.x - view.x), pixel(item.y - view.y));
|
|
1024
|
+
}
|
|
1025
|
+
} finally {
|
|
1026
|
+
if (!entry.retained) entry.paragraph.delete();
|
|
838
1027
|
}
|
|
839
|
-
paragraph.delete();
|
|
840
1028
|
}
|
|
841
1029
|
}
|
|
842
1030
|
}
|
|
@@ -853,13 +1041,9 @@ function buildPageRenderer({
|
|
|
853
1041
|
const Y0 = (item.rect.y - view.y) * scale;
|
|
854
1042
|
const X1 = X0 + item.rect.width * scale;
|
|
855
1043
|
const Y1 = Y0 + item.rect.height * scale;
|
|
856
|
-
const rx = item.radii.x * scale;
|
|
857
|
-
const ry = item.radii.y * scale;
|
|
858
1044
|
const [x0, y0, x1, y1] = [X0 + left, Y0 + top, Math.max(X0 + left, X1 - right), Math.max(Y0 + top, Y1 - bottom)];
|
|
859
|
-
const outer =
|
|
860
|
-
const inner =
|
|
861
|
-
Math.max(0, rx - left), Math.max(0, ry - top), Math.max(0, rx - right), Math.max(0, ry - top),
|
|
862
|
-
Math.max(0, rx - right), Math.max(0, ry - bottom), Math.max(0, rx - left), Math.max(0, ry - bottom));
|
|
1045
|
+
const outer = roundedRect([X0, Y0, X1, Y1], item.radii, scale);
|
|
1046
|
+
const inner = roundedRect([x0, y0, x1, y1], insetRadii(item.radii, item.widths), scale);
|
|
863
1047
|
const sides = [
|
|
864
1048
|
[X0, Y0, X1, Y0, x1, y0, x0, y0],
|
|
865
1049
|
[X1, Y0, X1, Y1, x1, y1, x1, y0],
|
|
@@ -1016,6 +1200,29 @@ function buildPageRenderer({
|
|
|
1016
1200
|
}
|
|
1017
1201
|
}
|
|
1018
1202
|
|
|
1203
|
+
function nativeControlState(element) {
|
|
1204
|
+
return [element.value, element.checked, element.indeterminate, element.selected, element.selectedIndex];
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
/** HTML activation can change control properties without attribute records
|
|
1208
|
+
* (including a different radio in the same group). Compare these few native
|
|
1209
|
+
* states instead of restyling the entire document for every pointer click.
|
|
1210
|
+
*/
|
|
1211
|
+
function controlStatesBeforeActivation() {
|
|
1212
|
+
return new Map(Array.from(page.document.querySelectorAll("input,select,textarea,option"),
|
|
1213
|
+
element => [element,nativeControlState(element)]));
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
function invalidateActivatedControls(previous) {
|
|
1217
|
+
for (const [element,before] of previous) {
|
|
1218
|
+
const after = nativeControlState(element);
|
|
1219
|
+
if (before.some((value,index) => value !== after[index])) {
|
|
1220
|
+
markStyleDirty(element);
|
|
1221
|
+
page.dirty = true;
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1019
1226
|
function observeStyleChanges(document) {
|
|
1020
1227
|
styleChanges.observer?.disconnect();
|
|
1021
1228
|
styleChanges.records = [];
|
|
@@ -1023,6 +1230,7 @@ function buildPageRenderer({
|
|
|
1023
1230
|
styleChanges.rootFeatures = new Map();
|
|
1024
1231
|
styleChanges.full = "new document";
|
|
1025
1232
|
styleChanges.activeElement = null;
|
|
1233
|
+
styleChanges.focusAncestors = new Set();
|
|
1026
1234
|
const observer = new page.window.MutationObserver((records) => { styleChanges.records.push(...records); });
|
|
1027
1235
|
observer.observe(document, { subtree: true, childList: true, attributes: true, attributeOldValue: true, characterData: true });
|
|
1028
1236
|
styleChanges.observer = observer;
|
|
@@ -1047,6 +1255,7 @@ function buildPageRenderer({
|
|
|
1047
1255
|
descendantFeatures: union("descendantFeatures"), siblingFeatures: union("siblingFeatures"),
|
|
1048
1256
|
descendantStateCompounds: parts.flatMap((part) => part.descendantStateCompounds),
|
|
1049
1257
|
siblingTargetCompounds: [...union("siblingTargetCompounds")],
|
|
1258
|
+
observedAttributes: union("observedAttributes"),
|
|
1050
1259
|
forward: any("forward"), firstChild: any("firstChild"), backward: any("backward"), lastChild: any("lastChild"),
|
|
1051
1260
|
defined: any("defined"), target: any("target"),
|
|
1052
1261
|
};
|
|
@@ -1176,11 +1385,12 @@ function buildPageRenderer({
|
|
|
1176
1385
|
let treeDirty = false;
|
|
1177
1386
|
let full = styleChanges.full;
|
|
1178
1387
|
const active = page.document.activeElement;
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1388
|
+
const previousActive = styleChanges.activeElement;
|
|
1389
|
+
const previousFocusAncestors = styleChanges.focusAncestors;
|
|
1390
|
+
const focusAncestors = new Set();
|
|
1391
|
+
for (let element = active; element; element = element.parentElement) focusAncestors.add(element);
|
|
1392
|
+
styleChanges.activeElement = active;
|
|
1393
|
+
styleChanges.focusAncestors = focusAncestors;
|
|
1184
1394
|
// Elements whose subtree restyles (`roots`), and elements whose own
|
|
1185
1395
|
// matches may change but whose subtree restyles only if their style does
|
|
1186
1396
|
// (`self`, decided by the style engine).
|
|
@@ -1208,7 +1418,8 @@ function buildPageRenderer({
|
|
|
1208
1418
|
if (!states.length) return false;
|
|
1209
1419
|
const available = new Set(elementFeatures(element));
|
|
1210
1420
|
return traits.descendantStateCompounds.some((entry) => (states.includes(":state") || entry.states.some((state) => states.includes(state)))
|
|
1211
|
-
&& entry.required.every((feature) => available.has(feature))
|
|
1421
|
+
&& entry.required.every((feature) => available.has(feature))
|
|
1422
|
+
&& entry.alternatives.every(group => group.some(required => required.every(feature => available.has(feature)))));
|
|
1212
1423
|
};
|
|
1213
1424
|
/** An element whose features changed: itself, and wider where non-subject compounds use them. */
|
|
1214
1425
|
const changed = (element, features) => {
|
|
@@ -1225,22 +1436,38 @@ function buildPageRenderer({
|
|
|
1225
1436
|
if (matchesChanged(structuralState, element)) roots.add(element);
|
|
1226
1437
|
};
|
|
1227
1438
|
if (!full && !hasState) full = "first restyle";
|
|
1228
|
-
//
|
|
1229
|
-
//
|
|
1230
|
-
|
|
1231
|
-
|
|
1439
|
+
// Only the explicitly marked roots need converting to feature changes;
|
|
1440
|
+
// roots added by relational/focus invalidation below must remain roots.
|
|
1441
|
+
const markedRoots = [...roots];
|
|
1442
|
+
roots.clear();
|
|
1443
|
+
for (const element of markedRoots) {
|
|
1232
1444
|
if (!full) changed(element, styleChanges.rootFeatures.get(element) ?? [":state"]);
|
|
1233
1445
|
}
|
|
1446
|
+
if (!full) {
|
|
1447
|
+
// Focus affects the two subjects, their :focus-within ancestor paths,
|
|
1448
|
+
// and selectors related to those nodes. Keep the previous path because
|
|
1449
|
+
// the old focused element may have been removed or reparented already.
|
|
1450
|
+
if (active !== previousActive) {
|
|
1451
|
+
if (previousActive) changed(previousActive, [":focus", ":focus-visible"]);
|
|
1452
|
+
if (active) changed(active, [":focus", ":focus-visible"]);
|
|
1453
|
+
}
|
|
1454
|
+
for (const element of previousFocusAncestors) if (!focusAncestors.has(element)) changed(element, [":focus-within"]);
|
|
1455
|
+
for (const element of focusAncestors) if (!previousFocusAncestors.has(element)) changed(element, [":focus-within"]);
|
|
1456
|
+
}
|
|
1234
1457
|
for (const record of records) {
|
|
1235
1458
|
if (full) break;
|
|
1236
1459
|
const target = record.target;
|
|
1237
1460
|
if (record.type === "attributes") {
|
|
1238
1461
|
if (target.nodeType !== 1) continue;
|
|
1239
1462
|
const name = record.attributeName;
|
|
1463
|
+
// Happy DOM reports null for an empty attribute's previous value on
|
|
1464
|
+
// removal. Null therefore cannot prove that the attribute is unchanged.
|
|
1465
|
+
if (record.oldValue !== null && record.oldValue === target.getAttribute(name)) continue;
|
|
1240
1466
|
// data/ARIA/title changes have no direct render-tree representation;
|
|
1241
1467
|
// selectors that observe them are handled by the style diff below.
|
|
1242
1468
|
// Other attributes may alter controls, replaced elements or visibility.
|
|
1243
1469
|
if (!name.startsWith("data-") && !name.startsWith("aria-") && name !== "title") treeDirty = true;
|
|
1470
|
+
else if (!traits.observedAttributes.has(name.toLowerCase())) continue;
|
|
1244
1471
|
const features = ["[" + name];
|
|
1245
1472
|
if (name === "class") {
|
|
1246
1473
|
const before = new Set(String(record.oldValue ?? "").split(/\s+/).filter(Boolean));
|
|
@@ -1253,6 +1480,12 @@ function buildPageRenderer({
|
|
|
1253
1480
|
if (id) features.push("#" + id);
|
|
1254
1481
|
}
|
|
1255
1482
|
changed(target, features);
|
|
1483
|
+
// The UA disclosure marker depends on its parent's open state even
|
|
1484
|
+
// when no author selector observes [open].
|
|
1485
|
+
if (name === "open" && target.tagName === "DETAILS") {
|
|
1486
|
+
const summary = [...target.children].find(element => element.tagName === "SUMMARY");
|
|
1487
|
+
if (summary) self.add(summary);
|
|
1488
|
+
}
|
|
1256
1489
|
} else if (record.type === "childList") {
|
|
1257
1490
|
treeDirty = true;
|
|
1258
1491
|
if (target.nodeType !== 1) { full = `children of ${target.nodeName}`; break; }
|
|
@@ -1267,6 +1500,11 @@ function buildPageRenderer({
|
|
|
1267
1500
|
}
|
|
1268
1501
|
}
|
|
1269
1502
|
self.add(target);
|
|
1503
|
+
// Adding, removing or moving a summary can change which one receives
|
|
1504
|
+
// the UA list-item display and disclosure marker.
|
|
1505
|
+
if (target.tagName === "DETAILS") {
|
|
1506
|
+
for (const child of target.children) if (child.tagName === "SUMMARY") self.add(child);
|
|
1507
|
+
}
|
|
1270
1508
|
hasChanges(traits, target, add, hasChecked);
|
|
1271
1509
|
let next = record.nextSibling;
|
|
1272
1510
|
while (next && next.nodeType !== 1) next = next.nextSibling;
|
|
@@ -1305,16 +1543,26 @@ function buildPageRenderer({
|
|
|
1305
1543
|
}
|
|
1306
1544
|
|
|
1307
1545
|
function replaceParsedDocument(parsed, styleSheets = [], imageResources = emptyImageResources(), linkSheets = null) {
|
|
1546
|
+
if (page.document) loadImageResource.cancelDocument(page.document);
|
|
1547
|
+
rasterReadback.dispose();
|
|
1548
|
+
clearPaintParagraphs();
|
|
1549
|
+
resetDocumentFonts();
|
|
1308
1550
|
disposeFrames();
|
|
1309
1551
|
closePiano();
|
|
1310
1552
|
disposeVideos();
|
|
1311
1553
|
page.realm?.dispose();
|
|
1312
1554
|
page.realm = null;
|
|
1313
|
-
page.window?.happyDOM?.
|
|
1555
|
+
page.window?.happyDOM?.close?.();
|
|
1314
1556
|
for (const image of page.decodedImages) image.delete();
|
|
1315
1557
|
disposeBackgrounds();
|
|
1316
1558
|
page.window = parsed.window;
|
|
1317
1559
|
page.document = parsed.document;
|
|
1560
|
+
observeDocumentCanvases(parsed.document, (node) => {
|
|
1561
|
+
if (disposedRenderer || node.ownerDocument !== page.document || !node.isConnected) return;
|
|
1562
|
+
page.resourcePaintDirty = true;
|
|
1563
|
+
page.needsPaint = true;
|
|
1564
|
+
scheduleFrame();
|
|
1565
|
+
});
|
|
1318
1566
|
observeStyleChanges(parsed.document);
|
|
1319
1567
|
page.title = parsed.document.title;
|
|
1320
1568
|
page.styleSheets = styleSheets;
|
|
@@ -1322,6 +1570,8 @@ function buildPageRenderer({
|
|
|
1322
1570
|
page.resources = imageResources.map;
|
|
1323
1571
|
initializeVideos(parsed.document, page.resources);
|
|
1324
1572
|
page.decodedImages = imageResources.decoded;
|
|
1573
|
+
page.imageElements = new Set(imageResources.entries.map(entry => entry.element));
|
|
1574
|
+
page.resourcePaintDirty = false;
|
|
1325
1575
|
page.imagesLoaded = null;
|
|
1326
1576
|
imageRequests = new WeakMap();
|
|
1327
1577
|
page.elementScroll = new Map();
|
|
@@ -1342,6 +1592,20 @@ function buildPageRenderer({
|
|
|
1342
1592
|
const ms = (from, to) => Math.round(to - from);
|
|
1343
1593
|
const sheets = currentStyleSheets();
|
|
1344
1594
|
const environment = { width: page.width, height: page.height, mobile };
|
|
1595
|
+
const documentBaseURL = page.document.baseURI || page.url;
|
|
1596
|
+
const environmentKey = `${page.width}/${page.height}/${mobile}/${documentBaseURL}`;
|
|
1597
|
+
if (fontSheets !== sheets || fontEnvironment !== environmentKey) {
|
|
1598
|
+
fontSheets = sheets;
|
|
1599
|
+
fontEnvironment = environmentKey;
|
|
1600
|
+
fontDefinitions = sheets.flatMap(sheet => /@font-face\b/i.test(sheet) ? extractFontFaces(sheet, documentBaseURL, environment) : []);
|
|
1601
|
+
documentFonts.setFaces(fontDefinitions);
|
|
1602
|
+
const key = JSON.stringify(fontDefinitions);
|
|
1603
|
+
if (key !== fontDefinitionKey) {
|
|
1604
|
+
fontDefinitionKey = key;
|
|
1605
|
+
clearFontCaches();
|
|
1606
|
+
page.resourceLayoutDirty = true;
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1345
1609
|
const changes = takeStyleChanges(sheets); // drained even when unused, so records do not pile up
|
|
1346
1610
|
const taken = hostPerformance.now();
|
|
1347
1611
|
// A rendering opportunity can be scheduled by lifecycle work which did
|
|
@@ -1391,17 +1655,19 @@ function buildPageRenderer({
|
|
|
1391
1655
|
measureText, fontMetrics, width: page.width, viewportHeight: page.height, x: 0, y: 0,
|
|
1392
1656
|
});
|
|
1393
1657
|
page.displayList = paintList();
|
|
1658
|
+
page.resourcePaintDirty = false;
|
|
1394
1659
|
// The content may now reach less far than a container was scrolled.
|
|
1395
1660
|
if (clampElementScroll()) page.displayList = paintList();
|
|
1396
1661
|
page.resourceLayoutDirty = false;
|
|
1397
1662
|
laidOutSheets = sheets;
|
|
1398
1663
|
setContentSize();
|
|
1664
|
+
queueFontLoads();
|
|
1399
1665
|
return { style: Math.round(styled - started), layout: Math.round(hostPerformance.now() - styled) };
|
|
1400
1666
|
}
|
|
1401
1667
|
|
|
1402
1668
|
/** BUNINU_VERIFY_STYLE: compare an incremental restyle with a full one, element by element. */
|
|
1403
1669
|
function verifyIncrementalStyle(sheets, environment) {
|
|
1404
|
-
const reference = new StyleEngine().compute(page.document, sheets, environment);
|
|
1670
|
+
const reference = new StyleEngine({ measureText }).compute(page.document, sheets, environment);
|
|
1405
1671
|
const serialize = (style) => JSON.stringify(style, (key, value) => typeof value === "bigint" ? String(value) : value);
|
|
1406
1672
|
let mismatches = 0;
|
|
1407
1673
|
for (const element of page.document.querySelectorAll("*")) {
|
|
@@ -1437,13 +1703,14 @@ function buildPageRenderer({
|
|
|
1437
1703
|
staleImages = queueImageResources();
|
|
1438
1704
|
}
|
|
1439
1705
|
const timing = styleAndLayout();
|
|
1440
|
-
|
|
1441
|
-
const
|
|
1442
|
-
|
|
1443
|
-
image.delete();
|
|
1706
|
+
if (staleImages.length) {
|
|
1707
|
+
const stale = new Set(staleImages);
|
|
1708
|
+
page.decodedImages = page.decodedImages.filter(image => !stale.has(image));
|
|
1709
|
+
for (const image of stale) image.delete();
|
|
1444
1710
|
}
|
|
1445
1711
|
if (!timing.skipped) page.needsPaint = true;
|
|
1446
1712
|
syncFrames();
|
|
1713
|
+
page.realm?.layoutChanged(page.layoutVersion);
|
|
1447
1714
|
if (!timing.skipped) queueBackgroundImages();
|
|
1448
1715
|
lastRebuildMs = hostPerformance.now() - started;
|
|
1449
1716
|
trace?.(`${page.url}: restyle and relayout (${reason}) ${Math.round(lastRebuildMs)}ms`
|
|
@@ -1462,6 +1729,7 @@ function buildPageRenderer({
|
|
|
1462
1729
|
* page); the rest of the load continues, see `whenLoaded`.
|
|
1463
1730
|
*/
|
|
1464
1731
|
function loadDocument(document) {
|
|
1732
|
+
if (disposedRenderer) return Promise.reject(new Error("Renderer has been disposed"));
|
|
1465
1733
|
const generation = ++loadGeneration;
|
|
1466
1734
|
let commit;
|
|
1467
1735
|
const committed = new Promise((resolve, reject) => {
|
|
@@ -1481,7 +1749,7 @@ function buildPageRenderer({
|
|
|
1481
1749
|
}
|
|
1482
1750
|
|
|
1483
1751
|
async function runLoad({
|
|
1484
|
-
url, source = "", contentType = "text/html", status = 200, statusText = "", error = null, cookie = "",
|
|
1752
|
+
url, source = "", contentType = "text/html", status = 200, statusText = "", error = null, cookie = "", referrer = "",
|
|
1485
1753
|
}, generation, commit) {
|
|
1486
1754
|
const current = () => generation === loadGeneration;
|
|
1487
1755
|
const finish = () => {
|
|
@@ -1525,7 +1793,7 @@ function buildPageRenderer({
|
|
|
1525
1793
|
phaseStart = now;
|
|
1526
1794
|
} : () => {};
|
|
1527
1795
|
const isHtml = contentType.toLowerCase().includes("html");
|
|
1528
|
-
const parsed = parseHTMLDocument(isHtml ? source : "<body></body>", url);
|
|
1796
|
+
const parsed = parseHTMLDocument(isHtml ? source : "<body></body>", url, referrer);
|
|
1529
1797
|
phase("parse");
|
|
1530
1798
|
// Stylesheets block rendering and scripts; images do not (only the
|
|
1531
1799
|
// window load event waits for them, see whenImagesLoaded): one slow image
|
|
@@ -1592,6 +1860,9 @@ function buildPageRenderer({
|
|
|
1592
1860
|
refresh();
|
|
1593
1861
|
phase("style, layout and raster after scripts");
|
|
1594
1862
|
} else {
|
|
1863
|
+
await documentFonts.ready();
|
|
1864
|
+
if (!current()) return;
|
|
1865
|
+
refresh();
|
|
1595
1866
|
finish();
|
|
1596
1867
|
}
|
|
1597
1868
|
}
|
|
@@ -1607,7 +1878,7 @@ function buildPageRenderer({
|
|
|
1607
1878
|
const preloaded = new Map();
|
|
1608
1879
|
const preload = (href) => {
|
|
1609
1880
|
let target;
|
|
1610
|
-
try { target = new URL(href, documentUrl); } catch { return; }
|
|
1881
|
+
try { target = new URL(href, document.baseURI || documentUrl); } catch { return; }
|
|
1611
1882
|
if (!["http:", "https:"].includes(target.protocol) || preloaded.has(target.href)) return;
|
|
1612
1883
|
const response = fetchResource(target.href, "script");
|
|
1613
1884
|
response.catch(() => {}); // reported when the script runs
|
|
@@ -1623,7 +1894,8 @@ function buildPageRenderer({
|
|
|
1623
1894
|
|
|
1624
1895
|
/** Frees what a superseded load fetched. */
|
|
1625
1896
|
async function discard(images, parsed = null) {
|
|
1626
|
-
parsed?.
|
|
1897
|
+
if (parsed?.document) loadImageResource.cancelDocument(parsed.document);
|
|
1898
|
+
parsed?.window?.happyDOM?.close?.();
|
|
1627
1899
|
const loaded = await images.catch(() => emptyImageResources());
|
|
1628
1900
|
for (const image of loaded.decoded) image.delete();
|
|
1629
1901
|
}
|
|
@@ -1638,7 +1910,7 @@ function buildPageRenderer({
|
|
|
1638
1910
|
// queueImageResources must not fetch these again.
|
|
1639
1911
|
for (const element of document.querySelectorAll("img")) {
|
|
1640
1912
|
const src = element.getAttribute("src");
|
|
1641
|
-
if (src) imageRequests.set(element, src);
|
|
1913
|
+
if (src) imageRequests.set(element, imageRequestKey(src, imageCrossOrigin(element)));
|
|
1642
1914
|
}
|
|
1643
1915
|
page.imagesLoaded = images.then((loaded) => {
|
|
1644
1916
|
if (generation !== loadGeneration || document !== page.document) {
|
|
@@ -1646,12 +1918,14 @@ function buildPageRenderer({
|
|
|
1646
1918
|
return;
|
|
1647
1919
|
}
|
|
1648
1920
|
for (const { element, src, resource } of loaded.entries) {
|
|
1649
|
-
if (element.getAttribute("src") !== src || page.resources.get(element)) {
|
|
1921
|
+
if (element.getAttribute("src") !== src || imageCrossOrigin(element) !== resource.crossOrigin || page.resources.get(element)) {
|
|
1650
1922
|
resource.image.delete();
|
|
1651
1923
|
continue;
|
|
1652
1924
|
}
|
|
1653
1925
|
page.resources.set(element, resource);
|
|
1654
1926
|
page.decodedImages.push(resource.image);
|
|
1927
|
+
page.imageElements.add(element);
|
|
1928
|
+
finishImageLoad(element, src, resource, type => element.dispatchEvent(new page.window.Event(type)));
|
|
1655
1929
|
}
|
|
1656
1930
|
page.dirty = true;
|
|
1657
1931
|
page.resourceLayoutDirty = true;
|
|
@@ -1661,31 +1935,51 @@ function buildPageRenderer({
|
|
|
1661
1935
|
|
|
1662
1936
|
function inlineStyleSheets(document) {
|
|
1663
1937
|
return [...document.querySelectorAll("style")]
|
|
1664
|
-
.filter((element) =>
|
|
1938
|
+
.filter((element) => isCssStyleSheet(element) && styleSheetMatches(element))
|
|
1665
1939
|
.map((element) => element.textContent ?? "");
|
|
1666
1940
|
}
|
|
1667
1941
|
|
|
1942
|
+
function isCssStyleSheet(element) {
|
|
1943
|
+
const type = element.getAttribute("type")?.trim().toLowerCase();
|
|
1944
|
+
return (!type || type === "text/css") && !element.closest("noscript");
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
function styleSheetMatches(element) {
|
|
1948
|
+
return mediaQueryMatches(element.getAttribute("media") ?? "", { width: page.width, height: page.height, mobile })
|
|
1949
|
+
&& !(element.tagName === "LINK" && element.hasAttribute("disabled"));
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1668
1952
|
/** Brings style, layout and raster up to date; returns whether it repainted. */
|
|
1669
1953
|
function refresh() {
|
|
1954
|
+
if (disposedRenderer) return false;
|
|
1670
1955
|
if (page.dirty) rebuildRenderTree();
|
|
1671
1956
|
if (!page.needsPaint) return false;
|
|
1672
1957
|
render();
|
|
1673
1958
|
return true;
|
|
1674
1959
|
}
|
|
1675
1960
|
|
|
1961
|
+
function isDisclosureControl(element) {
|
|
1962
|
+
if (element?.tagName === "DETAILS") return ![...element.children].some(child => child.tagName === "SUMMARY");
|
|
1963
|
+
return element?.tagName === "SUMMARY" && element.parentElement?.tagName === "DETAILS"
|
|
1964
|
+
&& [...element.parentElement.children].find(child => child.tagName === "SUMMARY") === element;
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
function activateDisclosure(element) {
|
|
1968
|
+
const prevented = page.realm.dispatch(element, new page.window.MouseEvent("click", {
|
|
1969
|
+
bubbles: true, cancelable: true, composed: true, detail: 0,
|
|
1970
|
+
}));
|
|
1971
|
+
// An explicit summary is activated by the DOM's click default action.
|
|
1972
|
+
// The generated label has no DOM summary, so its owner toggles here.
|
|
1973
|
+
if (!prevented && element.tagName === "DETAILS") element.open = !element.open;
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1676
1976
|
function editFocusedText(text, backspace = false) {
|
|
1677
1977
|
const element = page.document.activeElement;
|
|
1678
1978
|
if (!element || !["INPUT", "TEXTAREA"].includes(element.tagName) || element.disabled || element.readOnly) return;
|
|
1679
1979
|
if (element.tagName === "INPUT" && !["text", "search", "url", "email", "tel", "password"].includes(element.type)) return;
|
|
1680
1980
|
// Mobile IMEs often send committed text without a usable keydown (key 229
|
|
1681
1981
|
// or Unidentified). Handle that before the normal beforeinput insertion.
|
|
1682
|
-
if (!backspace && element
|
|
1683
|
-
const note = pianoNote(text);
|
|
1684
|
-
if (note !== null) {
|
|
1685
|
-
playPiano(note);
|
|
1686
|
-
return;
|
|
1687
|
-
}
|
|
1688
|
-
}
|
|
1982
|
+
if (!backspace && handlePianoInput(element, { text }, audio ? playPiano : null)) return;
|
|
1689
1983
|
const value = String(element.value ?? "");
|
|
1690
1984
|
let start = Number.isInteger(element.selectionStart) ? element.selectionStart : value.length;
|
|
1691
1985
|
const end = Number.isInteger(element.selectionEnd) ? element.selectionEnd : start;
|
|
@@ -1757,6 +2051,31 @@ function buildPageRenderer({
|
|
|
1757
2051
|
page.dirty = true;
|
|
1758
2052
|
scheduleFrame();
|
|
1759
2053
|
},
|
|
2054
|
+
activationState: controlStatesBeforeActivation,
|
|
2055
|
+
activationChanged(previous) {
|
|
2056
|
+
invalidateActivatedControls(previous);
|
|
2057
|
+
if (page.dirty) scheduleFrame();
|
|
2058
|
+
},
|
|
2059
|
+
canvasImage(element) {
|
|
2060
|
+
if (!element) return null;
|
|
2061
|
+
if (element.tagName === "CANVAS") return canvasResource(element);
|
|
2062
|
+
const resource = page.resources.get(element);
|
|
2063
|
+
if (!resource?.image) return null;
|
|
2064
|
+
const url = resource.url ?? element.src ?? "";
|
|
2065
|
+
const originClean = resource.originClean ?? (url.startsWith("data:") || originOf(url) === originOf(page.url));
|
|
2066
|
+
return { ...resource, originClean };
|
|
2067
|
+
},
|
|
2068
|
+
canvasFont(element, value) {
|
|
2069
|
+
return parseCanvasFont(value, styleEngine.get(element)?.fontSize ?? 16,
|
|
2070
|
+
styleEngine.get(page.document.documentElement)?.fontSize ?? 16);
|
|
2071
|
+
},
|
|
2072
|
+
canvasText(element, request) {
|
|
2073
|
+
const style = { ...request.settings.parsedFont,
|
|
2074
|
+
direction: request.settings.direction === "inherit" ? styleEngine.get(element)?.direction ?? "ltr" : request.settings.direction };
|
|
2075
|
+
documentFonts.update(fontDefinitions, [{ text: String(request.args[0]), style }]);
|
|
2076
|
+
return renderCanvasText({ ...request, style,
|
|
2077
|
+
settings: { ...request.settings, direction: style.direction } });
|
|
2078
|
+
},
|
|
1760
2079
|
mediaState,
|
|
1761
2080
|
playMedia,
|
|
1762
2081
|
pauseMedia,
|
|
@@ -1780,7 +2099,7 @@ function buildPageRenderer({
|
|
|
1780
2099
|
scheduleFrame();
|
|
1781
2100
|
}
|
|
1782
2101
|
},
|
|
1783
|
-
whenImagesLoaded: () => Promise.all([page.imagesLoaded, ...[...page.backgrounds.values()].map((entry) => entry.loaded)]),
|
|
2102
|
+
whenImagesLoaded: () => Promise.all([page.imagesLoaded, documentFonts.ready(), ...[...page.backgrounds.values()].map((entry) => entry.loaded)]),
|
|
1784
2103
|
afterTask() {},
|
|
1785
2104
|
boundsOf(element) {
|
|
1786
2105
|
if (page.dirty) rebuildRenderTree("script read geometry");
|
|
@@ -1812,14 +2131,19 @@ function buildPageRenderer({
|
|
|
1812
2131
|
if (page.dirty) rebuildRenderTree("script scrolled");
|
|
1813
2132
|
setElementScroll(element, x, y);
|
|
1814
2133
|
},
|
|
1815
|
-
scrollIntoView(element) {
|
|
2134
|
+
scrollIntoView(element, options) {
|
|
1816
2135
|
if (page.dirty) rebuildRenderTree("script scrolled");
|
|
1817
|
-
revealElement(element);
|
|
2136
|
+
revealElement(element, options);
|
|
1818
2137
|
},
|
|
1819
2138
|
computedStyle(element) {
|
|
1820
2139
|
if (!element) return null;
|
|
1821
2140
|
if (page.dirty) rebuildRenderTree("getComputedStyle");
|
|
1822
|
-
|
|
2141
|
+
const style = styleEngine.get(element);
|
|
2142
|
+
if (style?.padding.some(value => typeof value === "object")) {
|
|
2143
|
+
const box = page.layout?.boxes.find(box => page.renderTree.nodesById.get(box.nodeId)?.domNode === element);
|
|
2144
|
+
if (box) return { ...style, padding: box.style.padding };
|
|
2145
|
+
}
|
|
2146
|
+
return style;
|
|
1823
2147
|
},
|
|
1824
2148
|
layoutVersion() {
|
|
1825
2149
|
if (page.dirty) rebuildRenderTree("intersection observer");
|
|
@@ -1832,7 +2156,7 @@ function buildPageRenderer({
|
|
|
1832
2156
|
},
|
|
1833
2157
|
navigate: (url) => navigateTo(String(url)),
|
|
1834
2158
|
activateLink(anchor) {
|
|
1835
|
-
const href = new URL(anchor.getAttribute("href"), page.url).href;
|
|
2159
|
+
const href = new URL(anchor.getAttribute("href"), anchor.baseURI || page.url).href;
|
|
1836
2160
|
const name = anchor.hasAttribute("download") ? anchor.getAttribute("download") ?? "" : null;
|
|
1837
2161
|
if (href.startsWith("blob:") && name !== null) void downloadBlob(href, name);
|
|
1838
2162
|
else navigateTo(href, name);
|
|
@@ -1874,7 +2198,8 @@ function buildPageRenderer({
|
|
|
1874
2198
|
if (typeof response.documentCookie === "string") page.cookie = response.documentCookie;
|
|
1875
2199
|
return response;
|
|
1876
2200
|
});
|
|
1877
|
-
result.abort = () => networkPending.abort?.();
|
|
2201
|
+
result.abort = (reason) => networkPending.abort?.(reason);
|
|
2202
|
+
result.finished = networkPending.finished;
|
|
1878
2203
|
return result;
|
|
1879
2204
|
},
|
|
1880
2205
|
};
|
|
@@ -1888,7 +2213,7 @@ function buildPageRenderer({
|
|
|
1888
2213
|
function submitForm(form, submitter = null) {
|
|
1889
2214
|
const method = (submitter?.getAttribute("formmethod") ?? form.getAttribute("method") ?? "get").toLowerCase();
|
|
1890
2215
|
const action = submitter?.getAttribute("formaction") ?? form.getAttribute("action") ?? page.url;
|
|
1891
|
-
const url = new URL(action || page.url, page.url);
|
|
2216
|
+
const url = new URL(action || page.url, form.baseURI || page.url);
|
|
1892
2217
|
const entries = [...new page.window.FormData(form, submitter)];
|
|
1893
2218
|
if (method === "post") {
|
|
1894
2219
|
const enctype = (submitter?.getAttribute("formenctype") ?? form.getAttribute("enctype") ?? "").toLowerCase();
|
|
@@ -1938,14 +2263,7 @@ function buildPageRenderer({
|
|
|
1938
2263
|
const shift = scrollShift(element);
|
|
1939
2264
|
const scrollX = (fixed ? 0 : viewport.x) + shift.x;
|
|
1940
2265
|
const scrollY = (fixed ? 0 : viewport.y) + shift.y;
|
|
1941
|
-
|
|
1942
|
-
for (const node of page.renderTree.nodesById.values()) {
|
|
1943
|
-
if (node.domNode === element && !node.pseudo) {
|
|
1944
|
-
nodeId = node.id;
|
|
1945
|
-
break;
|
|
1946
|
-
}
|
|
1947
|
-
}
|
|
1948
|
-
const box = nodeId == null ? null : page.layout.boxes.find((candidate) => candidate.nodeId === nodeId && !candidate.inline);
|
|
2266
|
+
const box = elementBox(page.renderTree, page.layout, element)?.box;
|
|
1949
2267
|
if (box) return { left: box.x - scrollX, top: box.y - scrollY, width: box.width, height: box.height };
|
|
1950
2268
|
const content = elementBounds(page.renderTree, page.layout, element);
|
|
1951
2269
|
return content
|
|
@@ -2129,9 +2447,9 @@ function buildPageRenderer({
|
|
|
2129
2447
|
rebuildRenderTree();
|
|
2130
2448
|
render();
|
|
2131
2449
|
},
|
|
2132
|
-
screenshot() {
|
|
2450
|
+
screenshot(options) {
|
|
2133
2451
|
refresh();
|
|
2134
|
-
return viewportPng();
|
|
2452
|
+
return viewportPng(options);
|
|
2135
2453
|
},
|
|
2136
2454
|
/**
|
|
2137
2455
|
* Diagnostics (not exposed over IPC): page-space border boxes and chosen
|
|
@@ -2199,6 +2517,7 @@ function buildPageRenderer({
|
|
|
2199
2517
|
/** The compositor scrolled: follow it, and rasterize around the new position when idle. */
|
|
2200
2518
|
applyCompositorScroll(x, y) {
|
|
2201
2519
|
viewport.scrollTo(x, y);
|
|
2520
|
+
page.realm?.layoutChanged(page.layoutVersion);
|
|
2202
2521
|
syncedScroll = { x: viewport.x, y: viewport.y };
|
|
2203
2522
|
schedulePrepaint();
|
|
2204
2523
|
},
|
|
@@ -2215,26 +2534,27 @@ function buildPageRenderer({
|
|
|
2215
2534
|
if (focusedFrame?.renderer) return focusedFrame.renderer.press(key, options);
|
|
2216
2535
|
refresh();
|
|
2217
2536
|
const pianoInput = page.document?.activeElement;
|
|
2218
|
-
if (audio
|
|
2219
|
-
|
|
2220
|
-
if (note !== null && !options.repeat && !options.modifiers) {
|
|
2221
|
-
playPiano(note);
|
|
2222
|
-
return { x: viewport.x, y: viewport.y };
|
|
2223
|
-
}
|
|
2537
|
+
if (handlePianoInput(pianoInput, { ...options, key }, audio ? playPiano : null)) {
|
|
2538
|
+
return { x: viewport.x, y: viewport.y };
|
|
2224
2539
|
}
|
|
2225
2540
|
// Pages see keydown/keyup first; preventDefault() cancels the default scroll.
|
|
2226
2541
|
if (page.realm) {
|
|
2227
2542
|
const target = page.document.activeElement ?? page.document.body;
|
|
2228
2543
|
const modifiers = Number(options?.modifiers) || 0;
|
|
2229
2544
|
const init = {
|
|
2230
|
-
|
|
2545
|
+
...keyboardInput(key, options), repeat: Boolean(options?.repeat),
|
|
2231
2546
|
altKey: Boolean(modifiers & 1), ctrlKey: Boolean(modifiers & 2),
|
|
2232
2547
|
metaKey: Boolean(modifiers & 4), shiftKey: Boolean(modifiers & 8),
|
|
2233
2548
|
bubbles: true, cancelable: true,
|
|
2234
2549
|
};
|
|
2235
2550
|
const prevented = page.realm.dispatch(target, new page.window.KeyboardEvent("keydown", init));
|
|
2551
|
+
let activated = false;
|
|
2236
2552
|
if (!prevented && !init.ctrlKey && !init.metaKey && !init.altKey) {
|
|
2237
|
-
if (key === "
|
|
2553
|
+
if ((key === "Enter" || key === " ") && isDisclosureControl(target)) {
|
|
2554
|
+
activated = true;
|
|
2555
|
+
if (key === "Enter") activateDisclosure(target);
|
|
2556
|
+
}
|
|
2557
|
+
else if (key === "Backspace") editFocusedText("", true);
|
|
2238
2558
|
else if (key === "Enter" && target.tagName === "TEXTAREA") {
|
|
2239
2559
|
// A search IME's action key submits a search form. Other textareas
|
|
2240
2560
|
// keep their normal newline behavior.
|
|
@@ -2246,8 +2566,9 @@ function buildPageRenderer({
|
|
|
2246
2566
|
}
|
|
2247
2567
|
else if (String(key).length === 1) editFocusedText(String(key));
|
|
2248
2568
|
}
|
|
2249
|
-
page.realm.dispatch(target, new page.window.KeyboardEvent("keyup", init));
|
|
2250
|
-
if (
|
|
2569
|
+
const upPrevented = page.realm.dispatch(target, new page.window.KeyboardEvent("keyup", init));
|
|
2570
|
+
if (activated && key === " " && !upPrevented) activateDisclosure(target);
|
|
2571
|
+
if (refresh() || prevented || activated) return { x: viewport.x, y: viewport.y };
|
|
2251
2572
|
}
|
|
2252
2573
|
const pageStep = Math.max(31, viewport.height - 31);
|
|
2253
2574
|
const delta = {
|
|
@@ -2272,10 +2593,37 @@ function buildPageRenderer({
|
|
|
2272
2593
|
scrollOffset() {
|
|
2273
2594
|
return { x: viewport.x, y: viewport.y };
|
|
2274
2595
|
},
|
|
2596
|
+
videoReport(report) {
|
|
2597
|
+
const state = [...page.media.values()].find(state => state.video?.presentation?.id === report.id);
|
|
2598
|
+
if (!state || !state.playing) return;
|
|
2599
|
+
if (report.error) {
|
|
2600
|
+
state.presentationFailed = true;
|
|
2601
|
+
stopVideoPresentation(state);
|
|
2602
|
+
const serial = state.seekSerial;
|
|
2603
|
+
void placeVideo(state, mediaTime(state)).then(() => {
|
|
2604
|
+
if (state.playing && state.document === page.document && state.seekSerial === serial) startMedia(state);
|
|
2605
|
+
}).catch(reportError);
|
|
2606
|
+
} else if (Number.isFinite(report.end)) {
|
|
2607
|
+
state.video.frameTime = report.end;
|
|
2608
|
+
if (needsMore(state.video)) void refillMedia(state, state.video);
|
|
2609
|
+
else {
|
|
2610
|
+
readPresentedFrame(state);
|
|
2611
|
+
stopVideoPresentation(state);
|
|
2612
|
+
state.video.done = true;
|
|
2613
|
+
}
|
|
2614
|
+
} else if (Number.isFinite(report.time)) state.video.shownTime = report.time;
|
|
2615
|
+
},
|
|
2275
2616
|
/** The wheel at a viewport point: the delta the page itself still has to scroll (see wheel). */
|
|
2276
2617
|
wheel(x, y, deltaX, deltaY, options = undefined) {
|
|
2277
2618
|
return wheel(Number(x), Number(y), Number(deltaX) || 0, Number(deltaY) || 0, options);
|
|
2278
2619
|
},
|
|
2620
|
+
/** Move the pointer in viewport coordinates, including boundary events. */
|
|
2621
|
+
move(x, y) {
|
|
2622
|
+
refresh();
|
|
2623
|
+
const target = elementAtPoint(page.renderTree, page.displayList, Number(x), Number(y), viewport);
|
|
2624
|
+
dispatchPointerMove(page.window, page.realm, target, Number(x), Number(y));
|
|
2625
|
+
refresh();
|
|
2626
|
+
},
|
|
2279
2627
|
/**
|
|
2280
2628
|
* Clicks at a viewport point: the page receives a click event first, and
|
|
2281
2629
|
* unless it calls preventDefault() a link under the point is returned for
|
|
@@ -2283,9 +2631,6 @@ function buildPageRenderer({
|
|
|
2283
2631
|
*/
|
|
2284
2632
|
click(x, y) {
|
|
2285
2633
|
refresh();
|
|
2286
|
-
// Activation can toggle checked state (radios in a group, through a
|
|
2287
|
-
// label) without a DOM mutation, so the next restyle is a full one.
|
|
2288
|
-
markStyleDirty(null); // full: click
|
|
2289
2634
|
let region = hitTest(page.renderTree, page.layout, x, y, viewport, viewport);
|
|
2290
2635
|
let forwarded = false;
|
|
2291
2636
|
if (!region) {
|
|
@@ -2308,12 +2653,17 @@ function buildPageRenderer({
|
|
|
2308
2653
|
// it is now (scroll containers move their content); the interactive
|
|
2309
2654
|
// regions (laid out, unscrolled) stand in only where nothing is painted,
|
|
2310
2655
|
// and a search form's forwarded field takes the click on the form.
|
|
2311
|
-
const
|
|
2656
|
+
const hitNode = page.displayList ? renderNodeAtPoint(page.renderTree, page.displayList, x, y, viewport) : null;
|
|
2657
|
+
const precise = hitNode?.domNode?.nodeType === 1 ? hitNode.domNode : hitNode?.domNode?.parentElement;
|
|
2312
2658
|
const target = forwarded ? region.element : precise ?? region?.element ?? null;
|
|
2659
|
+
const generatedDisclosure = hitNode?.pseudo === "summary" && target?.tagName === "DETAILS";
|
|
2313
2660
|
if (!target) {
|
|
2314
2661
|
focusedFrame = null;
|
|
2315
2662
|
return null;
|
|
2316
2663
|
}
|
|
2664
|
+
// Event dispatch determines the activation target before handlers run.
|
|
2665
|
+
// A handler may replace the clicked descendants while retaining the link.
|
|
2666
|
+
const activationAnchor = target.closest?.("a[href]");
|
|
2317
2667
|
// A click inside an iframe belongs to its document alone.
|
|
2318
2668
|
const inFrame = target.tagName === "IFRAME" ? frameAt(target, x, y) : null;
|
|
2319
2669
|
focusedFrame = inFrame?.frame ?? null;
|
|
@@ -2334,11 +2684,12 @@ function buildPageRenderer({
|
|
|
2334
2684
|
}
|
|
2335
2685
|
// The press focuses the focusable element it lands in, unless mousedown was canceled.
|
|
2336
2686
|
const focus = () => {
|
|
2337
|
-
const focusable = target.closest?.("input, textarea, select, button, [tabindex]");
|
|
2687
|
+
const focusable = generatedDisclosure ? target : target.closest?.("input, textarea, select, button, summary, [tabindex]");
|
|
2338
2688
|
if (focusable && !focusable.disabled) focusable.focus();
|
|
2339
2689
|
};
|
|
2340
2690
|
if (!page.realm) focus();
|
|
2341
2691
|
else {
|
|
2692
|
+
const controlStates = controlStatesBeforeActivation();
|
|
2342
2693
|
// A press and release, then the click (UI Events §3.5).
|
|
2343
2694
|
const init = { bubbles: true, cancelable: true, composed: true, clientX: Number(x), clientY: Number(y), button: 0 };
|
|
2344
2695
|
const PointerEvent = page.window.PointerEvent ?? page.window.MouseEvent;
|
|
@@ -2348,16 +2699,22 @@ function buildPageRenderer({
|
|
|
2348
2699
|
page.realm.dispatch(target, new PointerEvent("pointerup", pointer));
|
|
2349
2700
|
page.realm.dispatch(target, new page.window.MouseEvent("mouseup", init));
|
|
2350
2701
|
const prevented = page.realm.dispatch(target, new page.window.MouseEvent("click", { ...init, detail: 1 }));
|
|
2702
|
+
invalidateActivatedControls(controlStates);
|
|
2351
2703
|
refresh();
|
|
2352
2704
|
if (prevented) return null;
|
|
2353
2705
|
}
|
|
2354
|
-
if (
|
|
2706
|
+
if (generatedDisclosure) {
|
|
2707
|
+
target.open = !target.open;
|
|
2708
|
+
refresh();
|
|
2709
|
+
return null;
|
|
2710
|
+
}
|
|
2711
|
+
if ((target.tagName === "AUDIO" || target.tagName === "VIDEO") && target.hasAttribute("controls")) {
|
|
2355
2712
|
clickMedia(target, x, y);
|
|
2356
2713
|
return null;
|
|
2357
2714
|
}
|
|
2358
|
-
const anchor =
|
|
2359
|
-
if (!anchor) return null;
|
|
2360
|
-
const href = new URL(anchor.getAttribute("href"), page.url).href;
|
|
2715
|
+
const anchor = activationAnchor;
|
|
2716
|
+
if (!anchor?.hasAttribute("href")) return null;
|
|
2717
|
+
const href = new URL(anchor.getAttribute("href"), anchor.baseURI || page.url).href;
|
|
2361
2718
|
if (href.startsWith("javascript:")) {
|
|
2362
2719
|
this.runJavaScriptURL(href);
|
|
2363
2720
|
return null;
|
|
@@ -2430,26 +2787,37 @@ function buildPageRenderer({
|
|
|
2430
2787
|
if (!page.realm) return undefined;
|
|
2431
2788
|
let value = page.realm.evaluate(utilityExpression);
|
|
2432
2789
|
if (value !== null && typeof value === "object" && typeof value.then === "function") {
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2790
|
+
let timer;
|
|
2791
|
+
try {
|
|
2792
|
+
value = await Promise.race([
|
|
2793
|
+
Promise.resolve(value),
|
|
2794
|
+
new Promise((resolve) => { timer = hostSetTimeout(resolve, 5000, undefined); }),
|
|
2795
|
+
]).catch((error) => String(error?.message ?? error));
|
|
2796
|
+
} finally { hostClearTimeout(timer); }
|
|
2437
2797
|
}
|
|
2438
2798
|
refresh();
|
|
2439
2799
|
return cloneableResult(value);
|
|
2440
2800
|
},
|
|
2441
2801
|
dispose() {
|
|
2802
|
+
if (disposedRenderer) return;
|
|
2442
2803
|
disposedRenderer = true;
|
|
2804
|
+
loadGeneration++;
|
|
2805
|
+
if (page.document) loadImageResource.cancelDocument(page.document);
|
|
2443
2806
|
hostClearTimeout(prepaintTimer);
|
|
2444
2807
|
hostClearTimeout(frameTimer);
|
|
2445
2808
|
disposeFrames();
|
|
2446
2809
|
page.realm?.dispose();
|
|
2447
2810
|
closePiano();
|
|
2448
2811
|
disposeVideos();
|
|
2449
|
-
rasterSurface?.
|
|
2812
|
+
rasterSurface?.dispose();
|
|
2450
2813
|
rasterSurface = null;
|
|
2451
|
-
|
|
2814
|
+
rasterReadback.dispose();
|
|
2815
|
+
clearPaintParagraphs();
|
|
2816
|
+
documentFonts.dispose();
|
|
2817
|
+
page.window?.happyDOM?.close?.();
|
|
2452
2818
|
for (const image of page.decodedImages) image.delete();
|
|
2819
|
+
page.decodedImages = [];
|
|
2820
|
+
page.imageElements.clear();
|
|
2453
2821
|
disposeBackgrounds();
|
|
2454
2822
|
},
|
|
2455
2823
|
};
|
|
@@ -2475,18 +2843,22 @@ function buildPageRenderer({
|
|
|
2475
2843
|
function collectStyleSheets(document, documentUrl, linkSheets, pending = null) {
|
|
2476
2844
|
const sheets = [];
|
|
2477
2845
|
for (const element of document.querySelectorAll("style, link[rel~='stylesheet']")) {
|
|
2478
|
-
if (element
|
|
2846
|
+
if (!isCssStyleSheet(element)) continue;
|
|
2847
|
+
const applies = styleSheetMatches(element);
|
|
2479
2848
|
if (element.tagName === "STYLE") {
|
|
2480
|
-
sheets.push(element.textContent ?? "");
|
|
2849
|
+
if (applies) sheets.push(element.textContent ?? "");
|
|
2481
2850
|
continue;
|
|
2482
2851
|
}
|
|
2852
|
+
// A disabled link is fetched when enabled; nonmatching media may be
|
|
2853
|
+
// fetched now, but must never contribute rules to the screen cascade.
|
|
2854
|
+
if (element.hasAttribute("disabled")) continue;
|
|
2483
2855
|
const href = element.getAttribute("href");
|
|
2484
2856
|
let url;
|
|
2485
|
-
try { url = href ? new URL(href, documentUrl).href : null; } catch { url = null; }
|
|
2857
|
+
try { url = href ? new URL(href, element.baseURI || documentUrl).href : null; } catch { url = null; }
|
|
2486
2858
|
if (!url) continue;
|
|
2487
2859
|
const loaded = linkSheets.get(element);
|
|
2488
2860
|
if (loaded?.url === url) {
|
|
2489
|
-
if (loaded.text !== null) sheets.push(loaded.text);
|
|
2861
|
+
if (applies && loaded.text !== null) sheets.push(loaded.text);
|
|
2490
2862
|
continue;
|
|
2491
2863
|
}
|
|
2492
2864
|
const entry = { url, text: null };
|
|
@@ -2497,9 +2869,10 @@ function buildPageRenderer({
|
|
|
2497
2869
|
}, () => {
|
|
2498
2870
|
// A failed stylesheet must not prevent the document itself from loading.
|
|
2499
2871
|
}).then(() => {
|
|
2500
|
-
if (
|
|
2872
|
+
if (document !== page.document || linkSheets.get(element) !== entry) return;
|
|
2501
2873
|
page.dirty = true;
|
|
2502
2874
|
scheduleFrame();
|
|
2875
|
+
element.dispatchEvent(new page.window.Event(entry.text === null ? "error" : "load"));
|
|
2503
2876
|
});
|
|
2504
2877
|
pending?.push(request);
|
|
2505
2878
|
}
|
|
@@ -2522,20 +2895,16 @@ function buildPageRenderer({
|
|
|
2522
2895
|
await Promise.all([...document.querySelectorAll("img")].map(async (element) => {
|
|
2523
2896
|
const src = element.getAttribute("src");
|
|
2524
2897
|
if (!src) return;
|
|
2898
|
+
beginImageLoad(element, src);
|
|
2525
2899
|
try {
|
|
2526
|
-
const
|
|
2527
|
-
|
|
2528
|
-
// Untrusted image bytes are decoded here, inside the renderer.
|
|
2529
|
-
const bytes = response.body;
|
|
2530
|
-
const { width, height } = await new Bun.Image(bytes).metadata();
|
|
2531
|
-
const image = CanvasKit.MakeImageFromEncoded(bytes);
|
|
2532
|
-
if (!image) return;
|
|
2533
|
-
const resource = Object.freeze({ width, height, image, url: response.url });
|
|
2900
|
+
const resource = await loadImageResource(document, new URL(src, element.baseURI || documentUrl).href, imageCrossOrigin(element));
|
|
2901
|
+
finishImageLoad(element, src, resource);
|
|
2534
2902
|
map.set(element, resource);
|
|
2535
|
-
decoded.push(image);
|
|
2903
|
+
decoded.push(resource.image);
|
|
2536
2904
|
entries.push({ element, src, resource });
|
|
2537
2905
|
} catch {
|
|
2538
2906
|
// Broken images keep their fallback replaced-element dimensions.
|
|
2907
|
+
finishImageLoad(element, src, null);
|
|
2539
2908
|
}
|
|
2540
2909
|
}));
|
|
2541
2910
|
return { map, decoded, entries };
|
|
@@ -2651,7 +3020,7 @@ function buildPageRenderer({
|
|
|
2651
3020
|
const src = element.getAttribute("src")?.trim();
|
|
2652
3021
|
if (!src) return "about:blank";
|
|
2653
3022
|
try {
|
|
2654
|
-
return new URL(src, page.url).href;
|
|
3023
|
+
return new URL(src, element.baseURI || page.url).href;
|
|
2655
3024
|
} catch {
|
|
2656
3025
|
return "about:blank";
|
|
2657
3026
|
}
|
|
@@ -2754,6 +3123,12 @@ function buildPageRenderer({
|
|
|
2754
3123
|
}
|
|
2755
3124
|
|
|
2756
3125
|
function paintList() {
|
|
3126
|
+
for (const node of page.renderTree?.nodesById.values() ?? []) {
|
|
3127
|
+
if (node.tagName !== "CANVAS") continue;
|
|
3128
|
+
const resource = canvasResource(node.domNode);
|
|
3129
|
+
if (resource) page.resources.set(node.domNode, resource);
|
|
3130
|
+
else page.resources.delete(node.domNode);
|
|
3131
|
+
}
|
|
2757
3132
|
return buildDisplayList(page.layout, page.renderTree, page.resources, backgroundResource, scrollOffsetOf);
|
|
2758
3133
|
}
|
|
2759
3134
|
|
|
@@ -2763,13 +3138,7 @@ function buildPageRenderer({
|
|
|
2763
3138
|
|
|
2764
3139
|
/** The render node and layout box of an element, or null. */
|
|
2765
3140
|
function layoutBoxOf(element) {
|
|
2766
|
-
|
|
2767
|
-
for (const node of page.renderTree.nodesById.values()) {
|
|
2768
|
-
if (node.domNode !== element || node.pseudo) continue;
|
|
2769
|
-
const box = page.layout.boxes.find((candidate) => candidate.nodeId === node.id && !candidate.inline);
|
|
2770
|
-
return box ? { node, box } : null;
|
|
2771
|
-
}
|
|
2772
|
-
return null;
|
|
3141
|
+
return elementBox(page.renderTree, page.layout, element);
|
|
2773
3142
|
}
|
|
2774
3143
|
|
|
2775
3144
|
/**
|
|
@@ -2823,6 +3192,7 @@ function buildPageRenderer({
|
|
|
2823
3192
|
if (next.x || next.y) page.elementScroll.set(element, next);
|
|
2824
3193
|
else page.elementScroll.delete(element);
|
|
2825
3194
|
page.layoutVersion++;
|
|
3195
|
+
page.realm?.layoutChanged(page.layoutVersion);
|
|
2826
3196
|
page.displayList = paintList();
|
|
2827
3197
|
page.needsPaint = true;
|
|
2828
3198
|
// A scroll event at the element, as a task (CSSOM View §13.1).
|
|
@@ -2920,20 +3290,31 @@ function buildPageRenderer({
|
|
|
2920
3290
|
return left;
|
|
2921
3291
|
}
|
|
2922
3292
|
|
|
2923
|
-
/**
|
|
2924
|
-
function revealElement(element) {
|
|
2925
|
-
|
|
3293
|
+
/** Aligns the element within each scrollport, from the innermost out. */
|
|
3294
|
+
function revealElement(element, { block = "start", inline = "nearest", container = "all" } = {}) {
|
|
3295
|
+
let moved = false;
|
|
3296
|
+
let fixed = styleEngine.get(element)?.position === "fixed";
|
|
3297
|
+
const direction = styleEngine.get(element)?.direction;
|
|
3298
|
+
if (direction === "rtl" && inline !== "nearest" && inline !== "center") inline = inline === "start" ? "end" : "start";
|
|
3299
|
+
for (let node = element.parentElement; !fixed && node && node.nodeType === 1; node = node.parentElement) {
|
|
3300
|
+
if (node === page.document.documentElement) break;
|
|
3301
|
+
fixed = styleEngine.get(node)?.position === "fixed";
|
|
2926
3302
|
const geometry = scrollerGeometry(node);
|
|
2927
3303
|
if (!geometry) continue;
|
|
2928
3304
|
const target = viewportBounds(element);
|
|
2929
|
-
const
|
|
2930
|
-
if (!target || !
|
|
3305
|
+
const bounds = viewportBounds(node);
|
|
3306
|
+
if (!target || !bounds) continue;
|
|
2931
3307
|
const [borderTop, , , borderLeft] = styleEngine.get(node)?.borderWidths ?? [0, 0, 0, 0];
|
|
2932
|
-
setElementScroll(node,
|
|
3308
|
+
moved = setElementScroll(node,
|
|
3309
|
+
geometry.x + scrollAlignmentDelta(target.left - bounds.left - borderLeft, target.width, geometry.clientWidth, inline),
|
|
3310
|
+
geometry.y + scrollAlignmentDelta(target.top - bounds.top - borderTop, target.height, geometry.clientHeight, block)) || moved;
|
|
3311
|
+
if (container === "nearest") { if (moved) render(); return; }
|
|
2933
3312
|
}
|
|
2934
3313
|
const bounds = viewportBounds(element);
|
|
2935
|
-
if (bounds) viewport.scrollTo(
|
|
2936
|
-
|
|
3314
|
+
if (bounds && !fixed) moved = viewport.scrollTo(
|
|
3315
|
+
viewport.x + scrollAlignmentDelta(bounds.left, bounds.width, viewport.width, inline),
|
|
3316
|
+
viewport.y + scrollAlignmentDelta(bounds.top, bounds.height, viewport.height, block)) || moved;
|
|
3317
|
+
if (moved) render();
|
|
2937
3318
|
}
|
|
2938
3319
|
|
|
2939
3320
|
/** Every <video> and <audio> gets its (painted) resource: size, frame, playing, time. */
|
|
@@ -2949,7 +3330,7 @@ function buildPageRenderer({
|
|
|
2949
3330
|
|
|
2950
3331
|
function videoSource(element) {
|
|
2951
3332
|
const src = element.getAttribute("src") ?? element.querySelector("source[src]")?.getAttribute("src");
|
|
2952
|
-
try { return src ? new URL(src, page.url).href : null; } catch { return null; }
|
|
3333
|
+
try { return src ? new URL(src, element.baseURI || page.url).href : null; } catch { return null; }
|
|
2953
3334
|
}
|
|
2954
3335
|
|
|
2955
3336
|
// ---- media: <video> and <audio> share one pipeline. An <audio> is media
|
|
@@ -3055,6 +3436,22 @@ function buildPageRenderer({
|
|
|
3055
3436
|
if (!state) return;
|
|
3056
3437
|
state.mediaSource = source;
|
|
3057
3438
|
bindSourcePipes(state);
|
|
3439
|
+
// A later initialization segment can replace already-buffered video.
|
|
3440
|
+
// Shorten the active presentation at its epoch boundary as soon as that
|
|
3441
|
+
// boundary is known, without discarding the frames before it.
|
|
3442
|
+
if (state.video?.pipe?.snapshotAt) {
|
|
3443
|
+
const current = state.video.pipe.snapshotAt(state.video.startTime);
|
|
3444
|
+
const endTime = current.id === state.video.snapshotId ? current.endTime : current.startTime;
|
|
3445
|
+
if (endTime !== state.video.endTime) {
|
|
3446
|
+
state.video.endTime = endTime;
|
|
3447
|
+
if (state.video.presentation) onVideo?.({ type: "video-bind", id: state.video.presentation.id,
|
|
3448
|
+
endTime });
|
|
3449
|
+
}
|
|
3450
|
+
}
|
|
3451
|
+
if (state.sound?.pipe?.snapshotAt) {
|
|
3452
|
+
const current = state.sound.pipe.snapshotAt(state.sound.startTime);
|
|
3453
|
+
state.sound.endTime = current.id === state.sound.snapshotId ? current.endTime : current.startTime;
|
|
3454
|
+
}
|
|
3058
3455
|
if (state.audioPipe && state.audioPipe !== state.videoPipe) state.hasAudio = true;
|
|
3059
3456
|
void refreshProbe(state);
|
|
3060
3457
|
if (state.playing && state.hasAudio && !state.sound) ensureAudio(state);
|
|
@@ -3135,10 +3532,89 @@ function buildPageRenderer({
|
|
|
3135
3532
|
|
|
3136
3533
|
/** The control bar and frame redraw without a relayout. */
|
|
3137
3534
|
function repaintMedia() {
|
|
3535
|
+
// The compositor replaces this video's pixels independently. Page changes
|
|
3536
|
+
// still repaint normally; native controls get their periodic time update.
|
|
3537
|
+
if ([...page.media.values()].some(state => state.video?.presentation && state.playing)
|
|
3538
|
+
&& !page.dirty && !page.needsPaint) return;
|
|
3138
3539
|
rasterized.list = null;
|
|
3139
3540
|
render();
|
|
3140
3541
|
}
|
|
3141
3542
|
|
|
3543
|
+
function stopVideoPresentation(state) {
|
|
3544
|
+
const presentation = state.video?.presentation;
|
|
3545
|
+
if (!presentation) return;
|
|
3546
|
+
readPresentedFrame(state);
|
|
3547
|
+
onVideo?.({ type: "video-stop", id: presentation.id });
|
|
3548
|
+
state.video.presentation = null;
|
|
3549
|
+
state.video.needsLocalPosition = true;
|
|
3550
|
+
}
|
|
3551
|
+
|
|
3552
|
+
function readPresentedFrame(state) {
|
|
3553
|
+
const p = state.video?.presentation;
|
|
3554
|
+
if (!p) return;
|
|
3555
|
+
const meta = new Int32Array(p.shared, 0, 4);
|
|
3556
|
+
const serial = Atomics.load(meta, 0);
|
|
3557
|
+
if (serial % 2 || !serial || serial === p.serial) return;
|
|
3558
|
+
const width = Atomics.load(meta, 1), height = Atomics.load(meta, 2);
|
|
3559
|
+
if (width < 1 || height < 1 || width * height > 480 * 480) return;
|
|
3560
|
+
const pixels = new Uint8Array(p.shared, 16, width * height * 4).slice();
|
|
3561
|
+
if (serial !== Atomics.load(meta, 0)) return;
|
|
3562
|
+
const image = CanvasKit.MakeImage({ width, height, alphaType: CanvasKit.AlphaType.Unpremul,
|
|
3563
|
+
colorType: CanvasKit.ColorType.RGBA_8888, colorSpace: CanvasKit.ColorSpace.SRGB }, pixels, width * 4);
|
|
3564
|
+
if (!image) return;
|
|
3565
|
+
state.resource.image?.delete();
|
|
3566
|
+
Object.assign(state.resource, { image, bitmapWidth: width, bitmapHeight: height });
|
|
3567
|
+
p.serial = serial;
|
|
3568
|
+
}
|
|
3569
|
+
|
|
3570
|
+
function makeVideoLayer(canvas, surface, region, scale, width, height) {
|
|
3571
|
+
if (!onVideo) return null;
|
|
3572
|
+
const items = [...visibleItems(page.displayList, region, viewport)].filter(item => item.op === "drawVideo");
|
|
3573
|
+
const item = items.length === 1 ? items[0] : null;
|
|
3574
|
+
const resource = item && page.displayList.images[item.image];
|
|
3575
|
+
const state = [...page.media.values()].find(state => state.resource === resource);
|
|
3576
|
+
const eligible = state?.playing && state.video && !state.video.done && !state.buffering
|
|
3577
|
+
&& !state.presentationFailed && !item.controls && !item.fixed
|
|
3578
|
+
&& (!item.transform || (item.transform.a > 0 && item.transform.d > 0));
|
|
3579
|
+
for (const other of page.media.values()) if (!eligible || other !== state) stopVideoPresentation(other);
|
|
3580
|
+
if (!eligible) return null;
|
|
3581
|
+
const video = state.video;
|
|
3582
|
+
if (!video.presentation) {
|
|
3583
|
+
const id = nextVideoPresentation++;
|
|
3584
|
+
const shared = new SharedArrayBuffer(16 + 480 * 480 * 4);
|
|
3585
|
+
video.presentation = { id, shared, serial: 0 };
|
|
3586
|
+
onVideo({ type: "video-open", id, shared, bytes: video.bytes,
|
|
3587
|
+
options: decodeOptions(state.videoMime || state.contentType, state.url), time: mediaTime(state),
|
|
3588
|
+
startTime: video.startTime ?? 0, endTime: video.endTime ?? Infinity,
|
|
3589
|
+
hasAudio: state.hasAudio, audioId: state.sound?.id ?? null,
|
|
3590
|
+
audioStart: state.sound ? state.sound.streamStart / state.sound.decoder.sampleRate : 0 });
|
|
3591
|
+
}
|
|
3592
|
+
const rect = { ...item.rect };
|
|
3593
|
+
if (item.transform) {
|
|
3594
|
+
rect.x = rect.x * item.transform.a + item.transform.e;
|
|
3595
|
+
rect.y = rect.y * item.transform.d + item.transform.f;
|
|
3596
|
+
rect.width *= item.transform.a;
|
|
3597
|
+
rect.height *= item.transform.d;
|
|
3598
|
+
}
|
|
3599
|
+
const raster = white => {
|
|
3600
|
+
canvas.clear(canvasColor(CanvasKit, page.backgroundColor));
|
|
3601
|
+
paintDisplayList(canvas, page.displayList, region, scale, { resource, white });
|
|
3602
|
+
drawHints(canvas, region);
|
|
3603
|
+
surface.flush();
|
|
3604
|
+
const image = surface.makeImageSnapshot();
|
|
3605
|
+
try {
|
|
3606
|
+
return image.readPixels(0, 0, { width, height, colorType: CanvasKit.ColorType.RGBA_8888,
|
|
3607
|
+
alphaType: CanvasKit.AlphaType.Unpremul, colorSpace: CanvasKit.ColorSpace.SRGB });
|
|
3608
|
+
} finally { image.delete(); }
|
|
3609
|
+
};
|
|
3610
|
+
const base = raster(false), mask = raster(true);
|
|
3611
|
+
for (let i = 0; i < mask.length; i++) mask[i] = Math.max(0, mask[i] - base[i]);
|
|
3612
|
+
return { id: video.presentation.id, base, mask, rect: {
|
|
3613
|
+
x: Math.round((rect.x - region.x) * scale), y: Math.round((rect.y - region.y) * scale),
|
|
3614
|
+
width: Math.round(rect.width * scale), height: Math.round(rect.height * scale),
|
|
3615
|
+
} };
|
|
3616
|
+
}
|
|
3617
|
+
|
|
3142
3618
|
function isPipe(value) {
|
|
3143
3619
|
return Boolean(value && typeof value.waitFor === "function" && typeof value.bytes === "function");
|
|
3144
3620
|
}
|
|
@@ -3220,7 +3696,7 @@ function buildPageRenderer({
|
|
|
3220
3696
|
while (true) {
|
|
3221
3697
|
if (pipe) await pipe.waitFor(pipe.done ? pipe.size : Math.max(1, Math.min(want, pipe.size + 1)));
|
|
3222
3698
|
if (pipe?.error) throw pipe.error;
|
|
3223
|
-
const bytes = pipe
|
|
3699
|
+
const bytes = snapshot(pipe, state.bytes);
|
|
3224
3700
|
if (!bytes?.byteLength) {
|
|
3225
3701
|
if (!pipe || pipe.done) throw new Error(`media ${state.url}: empty response`);
|
|
3226
3702
|
continue;
|
|
@@ -3264,7 +3740,7 @@ function buildPageRenderer({
|
|
|
3264
3740
|
const pipe = state.videoPipe ?? state.pipe;
|
|
3265
3741
|
if (!pipe?.size) return;
|
|
3266
3742
|
try {
|
|
3267
|
-
const probe = await videoDecoder.probe(pipe.bytes());
|
|
3743
|
+
const probe = await videoDecoder.probe(snapshot(pipe, state.bytes, mediaTime(state)));
|
|
3268
3744
|
if (!state.durationOverride && probe.duration != null && probe.duration !== state.duration) {
|
|
3269
3745
|
state.duration = probe.duration;
|
|
3270
3746
|
state.resource.duration = probe.duration;
|
|
@@ -3313,18 +3789,21 @@ function buildPageRenderer({
|
|
|
3313
3789
|
return format ? { format } : {};
|
|
3314
3790
|
}
|
|
3315
3791
|
|
|
3316
|
-
function snapshot(pipe, bytes) {
|
|
3317
|
-
return pipe ? pipe.bytes() : bytes;
|
|
3792
|
+
function snapshot(pipe, bytes, time = 0) {
|
|
3793
|
+
return pipe?.snapshotAt ? pipe.snapshotAt(time).bytes : pipe ? pipe.bytes() : bytes;
|
|
3318
3794
|
}
|
|
3319
3795
|
|
|
3320
|
-
async function openVideoDecoder(state) {
|
|
3796
|
+
async function openVideoDecoder(state, time = mediaTime(state)) {
|
|
3797
|
+
stopVideoPresentation(state);
|
|
3321
3798
|
const pipe = state.videoPipe ?? state.pipe;
|
|
3322
3799
|
const generation = pipe?.generation ?? 0;
|
|
3323
|
-
const
|
|
3800
|
+
const selected = pipe?.snapshotAt?.(time);
|
|
3801
|
+
const bytes = selected?.bytes ?? snapshot(pipe, state.bytes);
|
|
3324
3802
|
if (!bytes?.byteLength) throw new Error(`media ${state.url}: no video bytes`);
|
|
3325
3803
|
state.video?.decoder.close();
|
|
3326
3804
|
const decoder = await videoDecoder.decode(bytes, decodeOptions(state.videoMime || state.contentType, state.url));
|
|
3327
|
-
state.video = { decoder, frameTime: 0, done: false, pipe, openedGeneration: generation
|
|
3805
|
+
state.video = { decoder, bytes, frameTime: selected?.startTime ?? 0, done: false, pipe, openedGeneration: generation,
|
|
3806
|
+
snapshotId: selected?.id, startTime: selected?.startTime ?? 0, endTime: selected?.endTime ?? Infinity };
|
|
3328
3807
|
trace?.(`video decoded ${state.url}: ${decoder.width}x${decoder.height}`);
|
|
3329
3808
|
if (state.resource.width !== decoder.width || state.resource.height !== decoder.height) {
|
|
3330
3809
|
state.resource.width = decoder.width;
|
|
@@ -3340,13 +3819,14 @@ function buildPageRenderer({
|
|
|
3340
3819
|
* (no bytes yet, or audio_open hit EOF / invalid data on a short prefix).
|
|
3341
3820
|
* The previous decoder stays until a new one succeeds.
|
|
3342
3821
|
*/
|
|
3343
|
-
async function openAudioDecoder(state, { keepStream = false } = {}) {
|
|
3822
|
+
async function openAudioDecoder(state, { keepStream = false, time = mediaTime(state) } = {}) {
|
|
3344
3823
|
const serial = state.seekSerial;
|
|
3345
3824
|
const operation = (state.audioOpenSerial ?? 0) + 1;
|
|
3346
3825
|
state.audioOpenSerial = operation;
|
|
3347
3826
|
const pipe = state.audioPipe ?? state.pipe;
|
|
3348
3827
|
const generation = pipe?.generation ?? 0;
|
|
3349
|
-
const
|
|
3828
|
+
const selected = pipe?.snapshotAt?.(time);
|
|
3829
|
+
const bytes = selected?.bytes ?? snapshot(pipe, state.bytes);
|
|
3350
3830
|
const pending = pipe && !pipe.done && !pipe.error;
|
|
3351
3831
|
if (!bytes?.byteLength) {
|
|
3352
3832
|
if (pending) return false;
|
|
@@ -3383,8 +3863,10 @@ function buildPageRenderer({
|
|
|
3383
3863
|
throw new Error("audio format changed while output stream is active");
|
|
3384
3864
|
}
|
|
3385
3865
|
previous?.decoder.close();
|
|
3386
|
-
state.sound = { decoder, id: null, position:
|
|
3387
|
-
|
|
3866
|
+
state.sound = { decoder, id: null, position: Math.round((selected?.startTime ?? 0) * decoder.sampleRate),
|
|
3867
|
+
pending: firstSamples, history: [], finishing: false, done: false,
|
|
3868
|
+
pipe, openedGeneration: generation, snapshotId: selected?.id,
|
|
3869
|
+
startTime: selected?.startTime ?? 0, endTime: selected?.endTime ?? Infinity };
|
|
3388
3870
|
if (keepStream && previousId != null) {
|
|
3389
3871
|
// The output stream carries on with the new decoder's samples.
|
|
3390
3872
|
Object.assign(state.sound, { id: previousId, streamStart: previous.streamStart,
|
|
@@ -3435,7 +3917,10 @@ function buildPageRenderer({
|
|
|
3435
3917
|
function needsMore(track) {
|
|
3436
3918
|
const pipe = track?.pipe;
|
|
3437
3919
|
if (!pipe || pipe.error) return false;
|
|
3438
|
-
|
|
3920
|
+
const atBoundary = Number.isFinite(track.endTime) && (track.frameTime !== undefined
|
|
3921
|
+
? track.frameTime + 1e-6 >= track.endTime
|
|
3922
|
+
: track.position >= Math.round(track.endTime * track.decoder.sampleRate));
|
|
3923
|
+
return pipe.generation !== track.openedGeneration || !pipe.done || atBoundary;
|
|
3439
3924
|
}
|
|
3440
3925
|
|
|
3441
3926
|
/** A click on the media: on its progress track it seeks, anywhere else it plays or pauses. */
|
|
@@ -3462,6 +3947,7 @@ function buildPageRenderer({
|
|
|
3462
3947
|
if (!state || state.playing) return;
|
|
3463
3948
|
state.error = null;
|
|
3464
3949
|
setMediaPlaying(state, true);
|
|
3950
|
+
playbackEvent(state, "play", mediaEvent);
|
|
3465
3951
|
try {
|
|
3466
3952
|
await loadMedia(state);
|
|
3467
3953
|
if (state.ended) {
|
|
@@ -3484,7 +3970,6 @@ function buildPageRenderer({
|
|
|
3484
3970
|
return;
|
|
3485
3971
|
}
|
|
3486
3972
|
startMedia(state);
|
|
3487
|
-
mediaEvent(state, "play");
|
|
3488
3973
|
}
|
|
3489
3974
|
|
|
3490
3975
|
function pauseMedia(element) {
|
|
@@ -3495,7 +3980,7 @@ function buildPageRenderer({
|
|
|
3495
3980
|
stopClock(state);
|
|
3496
3981
|
state.needsPosition = true;
|
|
3497
3982
|
setMediaPlaying(state, false);
|
|
3498
|
-
|
|
3983
|
+
playbackEvent(state, "pause", mediaEvent);
|
|
3499
3984
|
}
|
|
3500
3985
|
|
|
3501
3986
|
async function seekMedia(element, seconds) {
|
|
@@ -3539,9 +4024,15 @@ function buildPageRenderer({
|
|
|
3539
4024
|
/** Seek, reopening on a longer snapshot when the target is not in this prefix yet. */
|
|
3540
4025
|
async function placeVideo(state, seconds) {
|
|
3541
4026
|
for (let attempt = 0; attempt < 8; attempt++) {
|
|
3542
|
-
|
|
4027
|
+
const pipe = state.videoPipe ?? state.pipe;
|
|
4028
|
+
const selected = pipe?.snapshotAt?.(seconds);
|
|
4029
|
+
// A seek beyond an old snapshot can succeed at its last keyframe.
|
|
4030
|
+
// Reopen on newer bytes first; an exception does not detect that case.
|
|
4031
|
+
const stale = pipe && (pipe !== state.video?.pipe || pipe.generation !== state.video?.openedGeneration);
|
|
4032
|
+
if (!state.video || state.video.done || seconds === 0 || attempt > 0
|
|
4033
|
+
|| stale || (selected && selected.id !== state.video.snapshotId)) await openVideoDecoder(state, seconds);
|
|
3543
4034
|
try {
|
|
3544
|
-
state.video.frameTime = seekToKeyframe(state.video.decoder, seconds);
|
|
4035
|
+
state.video.frameTime = seekToKeyframe(state.video.decoder, seconds, state.video.startTime);
|
|
3545
4036
|
state.video.hideUntil = seconds;
|
|
3546
4037
|
state.video.done = false;
|
|
3547
4038
|
return;
|
|
@@ -3586,7 +4077,7 @@ function buildPageRenderer({
|
|
|
3586
4077
|
const reached = await advanceAudio(() => state.sound, seconds, {
|
|
3587
4078
|
needsMore,
|
|
3588
4079
|
active: () => state.audioPositioning === transaction && state.seekSerial === serial && state.document === page.document,
|
|
3589
|
-
reopen: () => openAudioDecoder(state, { keepStream: true }),
|
|
4080
|
+
reopen: () => openAudioDecoder(state, { keepStream: true, time: seconds }),
|
|
3590
4081
|
});
|
|
3591
4082
|
if (!reached && state.audioPositioning === transaction && state.seekSerial === serial) {
|
|
3592
4083
|
throw new Error(`audio could not reach ${seconds.toFixed(3)}s; refusing to replay an earlier prefix`);
|
|
@@ -3598,6 +4089,8 @@ function buildPageRenderer({
|
|
|
3598
4089
|
|
|
3599
4090
|
/** Starts the clock, the audio stream and the frame/time ticks. */
|
|
3600
4091
|
function startMedia(state) {
|
|
4092
|
+
hostClearTimeout(state.timer);
|
|
4093
|
+
state.timer = null;
|
|
3601
4094
|
// An output stream held through a stall (see refillMedia) is already
|
|
3602
4095
|
// open: the clock goes on from where its audio is, and what it asked for
|
|
3603
4096
|
// meanwhile is sent now.
|
|
@@ -3642,6 +4135,7 @@ function buildPageRenderer({
|
|
|
3642
4135
|
}
|
|
3643
4136
|
|
|
3644
4137
|
function stopClock(state, { keepAudio = false } = {}) {
|
|
4138
|
+
stopVideoPresentation(state);
|
|
3645
4139
|
hostClearTimeout(state.timer);
|
|
3646
4140
|
state.timer = null;
|
|
3647
4141
|
state.clockStart = null;
|
|
@@ -3680,7 +4174,18 @@ function buildPageRenderer({
|
|
|
3680
4174
|
if (!state.playing || state.generation !== loadGeneration || state.document !== page.document) return;
|
|
3681
4175
|
const now = mediaTime(state);
|
|
3682
4176
|
const video = state.video;
|
|
3683
|
-
if (video && !video.
|
|
4177
|
+
if (video?.needsLocalPosition && !video.presentation && !video.done) {
|
|
4178
|
+
try {
|
|
4179
|
+
video.frameTime = seekToKeyframe(video.decoder, now, video.startTime);
|
|
4180
|
+
video.hideUntil = now;
|
|
4181
|
+
video.needsLocalPosition = false;
|
|
4182
|
+
} catch (error) { failMedia(state, error, 3); return; }
|
|
4183
|
+
}
|
|
4184
|
+
if (video?.presentation) {
|
|
4185
|
+
// Decode and present on the compositor thread. This tick only updates
|
|
4186
|
+
// DOM media state; it must not decode the same movie a second time.
|
|
4187
|
+
video.frameTime = now + 1 / (video.decoder.fps || 30);
|
|
4188
|
+
} else if (video && !video.done && video.frameTime <= now) {
|
|
3684
4189
|
// Late frames are decoded but only the newest is shown.
|
|
3685
4190
|
const decodeDeadline = hostPerformance.now() + 100;
|
|
3686
4191
|
while (video.frameTime <= mediaTime(state) && !video.done) {
|
|
@@ -3713,6 +4218,10 @@ function buildPageRenderer({
|
|
|
3713
4218
|
}
|
|
3714
4219
|
}
|
|
3715
4220
|
state.resource.time = now;
|
|
4221
|
+
if (!state.buffering && !state.awaitingAudio) {
|
|
4222
|
+
playbackEvent(state, "playing", mediaEvent);
|
|
4223
|
+
if (!state.playing) return;
|
|
4224
|
+
}
|
|
3716
4225
|
repaintMedia();
|
|
3717
4226
|
if (Math.floor(now * 4) !== state.lastTimeUpdate) {
|
|
3718
4227
|
state.lastTimeUpdate = Math.floor(now * 4);
|
|
@@ -3751,6 +4260,7 @@ function buildPageRenderer({
|
|
|
3751
4260
|
function drawVideoFrame(state, show) {
|
|
3752
4261
|
const video = state.video;
|
|
3753
4262
|
const decoder = video.decoder;
|
|
4263
|
+
if (video.frameTime + 1e-6 >= video.endTime) return "stall";
|
|
3754
4264
|
const width = Math.min(480, decoder.width);
|
|
3755
4265
|
const height = Math.max(1, Math.round(width * decoder.height / Math.max(1, decoder.width)));
|
|
3756
4266
|
let pixels;
|
|
@@ -3796,7 +4306,8 @@ function buildPageRenderer({
|
|
|
3796
4306
|
if (!state.lastSyncTrace || now - state.lastSyncTrace >= 1000) {
|
|
3797
4307
|
state.lastSyncTrace = now;
|
|
3798
4308
|
trace?.(`media sync: audio played ${sound.playedTime.toFixed(3)}s, video shown ${(state.video?.shownTime ?? 0).toFixed(3)}s,`
|
|
3799
|
-
+ ` output queued ${Math.max(0, event.sentSeconds - event.seconds).toFixed(3)}s, sink ${Number(event.sinkSeconds ?? 0).toFixed(3)}s`
|
|
4309
|
+
+ ` output queued ${Math.max(0, event.sentSeconds - event.seconds).toFixed(3)}s, sink ${Number(event.sinkSeconds ?? 0).toFixed(3)}s`
|
|
4310
|
+
+ `, PCM reserve ${Number(event.bufferedSeconds ?? 0).toFixed(3)}s`);
|
|
3800
4311
|
}
|
|
3801
4312
|
} else if (event.event === "timing-error") {
|
|
3802
4313
|
// Do not silently switch to a free-running clock on missing feedback.
|
|
@@ -3830,6 +4341,8 @@ function buildPageRenderer({
|
|
|
3830
4341
|
}
|
|
3831
4342
|
});
|
|
3832
4343
|
sound.id = id;
|
|
4344
|
+
if (state.video?.presentation) onVideo?.({ type: "video-bind", id: state.video.presentation.id,
|
|
4345
|
+
audioId: id, audioStart: sound.streamStart / sound.decoder.sampleRate, time: mediaTime(state) });
|
|
3833
4346
|
}
|
|
3834
4347
|
|
|
3835
4348
|
/** Sends about `bytes` of s16le audio (pending samples first, then decoding); at the end, lets it drain. */
|
|
@@ -3838,14 +4351,20 @@ function buildPageRenderer({
|
|
|
3838
4351
|
if (!state.playing || sound.id === null || sound.finishing) return;
|
|
3839
4352
|
// While buffering the output plays what it holds and then nothing, as
|
|
3840
4353
|
// the video waits; what it asks for is sent when playing resumes.
|
|
3841
|
-
if (state.buffering || state.audioPositioning) {
|
|
4354
|
+
if (state.buffering || state.refilling || state.audioPositioning) {
|
|
3842
4355
|
sound.owed = (sound.owed ?? 0) + bytes;
|
|
3843
4356
|
return;
|
|
3844
4357
|
}
|
|
3845
4358
|
const { channels, sampleRate } = sound.decoder;
|
|
3846
4359
|
const frameBytes = channels * 2;
|
|
4360
|
+
const endSample = Math.round(sound.endTime * sampleRate);
|
|
3847
4361
|
sound.lastRequest = bytes;
|
|
3848
4362
|
for (let sent = 0; sent + frameBytes <= bytes;) {
|
|
4363
|
+
if (sound.position >= endSample) {
|
|
4364
|
+
sound.owed = (sound.owed ?? 0) + bytes - sent;
|
|
4365
|
+
void refillMedia(state, sound);
|
|
4366
|
+
return;
|
|
4367
|
+
}
|
|
3849
4368
|
let samples = sound.pending;
|
|
3850
4369
|
sound.pending = null;
|
|
3851
4370
|
if (!samples) {
|
|
@@ -3871,7 +4390,8 @@ function buildPageRenderer({
|
|
|
3871
4390
|
audio.finish(sound.id);
|
|
3872
4391
|
return;
|
|
3873
4392
|
}
|
|
3874
|
-
const
|
|
4393
|
+
const boundaryBytes = Math.max(0, endSample - sound.position) * frameBytes;
|
|
4394
|
+
const [taken, remaining] = splitSamples(samples, channels, Math.min(bytes - sent, boundaryBytes));
|
|
3875
4395
|
if (remaining.length) sound.pending = remaining;
|
|
3876
4396
|
sound.history.push({ start: sound.position, samples: taken });
|
|
3877
4397
|
sound.position += taken.length / channels;
|
|
@@ -3917,43 +4437,8 @@ function buildPageRenderer({
|
|
|
3917
4437
|
* binary search over seek targets finds where that keyframe starts (to a
|
|
3918
4438
|
* frame). The frames from there to the clock are then decoded unshown.
|
|
3919
4439
|
*/
|
|
3920
|
-
function seekToKeyframe(decoder, seconds) {
|
|
3921
|
-
|
|
3922
|
-
// first keyframe as well.
|
|
3923
|
-
const seek = (time) => decoder.seek(Math.max(1, time * 1000));
|
|
3924
|
-
const frame = 1 / (decoder.fps || 30);
|
|
3925
|
-
if (!(seconds >= frame)) {
|
|
3926
|
-
seek(0);
|
|
3927
|
-
return 0;
|
|
3928
|
-
}
|
|
3929
|
-
const probe = (time) => {
|
|
3930
|
-
seek(time);
|
|
3931
|
-
return pictureKey(decoder.nextFrame(KEYFRAME_PROBE_WIDTH, KEYFRAME_PROBE_HEIGHT));
|
|
3932
|
-
};
|
|
3933
|
-
const target = probe(seconds);
|
|
3934
|
-
let keyframe = seconds;
|
|
3935
|
-
if (target !== null) {
|
|
3936
|
-
let earlier = Math.max(0, seconds - KEYFRAME_SEARCH_SECONDS);
|
|
3937
|
-
if (earlier > 0 && probe(earlier) !== target) {
|
|
3938
|
-
// The keyframe lies in (earlier, keyframe]: narrow it to a frame.
|
|
3939
|
-
while (keyframe - earlier > frame) {
|
|
3940
|
-
const middle = (earlier + keyframe) / 2;
|
|
3941
|
-
if (probe(middle) === target) keyframe = middle;
|
|
3942
|
-
else earlier = middle;
|
|
3943
|
-
}
|
|
3944
|
-
} else {
|
|
3945
|
-
keyframe = earlier;
|
|
3946
|
-
}
|
|
3947
|
-
}
|
|
3948
|
-
seek(keyframe);
|
|
3949
|
-
return keyframe;
|
|
3950
|
-
}
|
|
3951
|
-
|
|
3952
|
-
function pictureKey(pixels) {
|
|
3953
|
-
if (!pixels) return null;
|
|
3954
|
-
let hash = 0x811c9dc5;
|
|
3955
|
-
for (let index = 0; index < pixels.length; index += 3) hash = Math.imul(hash ^ pixels[index], 0x01000193);
|
|
3956
|
-
return hash >>> 0;
|
|
4440
|
+
function seekToKeyframe(decoder, seconds, startTime = 0) {
|
|
4441
|
+
return seekVideoKeyframe(decoder, seconds, startTime);
|
|
3957
4442
|
}
|
|
3958
4443
|
|
|
3959
4444
|
/**
|
|
@@ -3963,17 +4448,33 @@ function buildPageRenderer({
|
|
|
3963
4448
|
* new snapshot. The reported output cursor remains the A/V master clock.
|
|
3964
4449
|
*/
|
|
3965
4450
|
function refillMedia(state, stalledTrack = null) {
|
|
3966
|
-
if (state.refilling)
|
|
4451
|
+
if (state.refilling) {
|
|
4452
|
+
// An audio prefetch may already be waiting for a later segment. If the
|
|
4453
|
+
// picture catches its own end meanwhile, it really must wait too.
|
|
4454
|
+
if (stalledTrack === state.video && !state.buffering) {
|
|
4455
|
+
state.buffering = true;
|
|
4456
|
+
stopClock(state, { keepAudio: true });
|
|
4457
|
+
if (state.playing) playbackEvent(state, "waiting", mediaEvent);
|
|
4458
|
+
}
|
|
4459
|
+
return state.refilling;
|
|
4460
|
+
}
|
|
3967
4461
|
const serial = state.seekSerial;
|
|
3968
|
-
state.buffering = true;
|
|
3969
4462
|
state.base = mediaTime(state);
|
|
3970
4463
|
// The audio output stays open through a stall: it plays the PCM it holds
|
|
3971
4464
|
// and then runs dry, and playing resumes right after that PCM. Closing
|
|
3972
4465
|
// and reopening it instead loses track of what was heard (a closed
|
|
3973
4466
|
// stream may still play out its device buffer, which then plays twice).
|
|
3974
4467
|
const holdAudio = Boolean(stalledTrack && state.sound && state.sound.id != null && !state.sound.finishing);
|
|
4468
|
+
// PCM prefetch reaches the end of a snapshot ahead of actual playback.
|
|
4469
|
+
// Keep presenting video against measured audio while the controller plays
|
|
4470
|
+
// its reserve; a decoder needing bytes is not itself an output underrun.
|
|
4471
|
+
const prefetch = holdAudio && stalledTrack === state.sound;
|
|
4472
|
+
state.buffering = !prefetch;
|
|
3975
4473
|
const stall = { at: hostPerformance.now(), base: state.base };
|
|
3976
|
-
|
|
4474
|
+
if (!prefetch) {
|
|
4475
|
+
stopClock(state, { keepAudio: holdAudio });
|
|
4476
|
+
if (state.playing) playbackEvent(state, "waiting", mediaEvent);
|
|
4477
|
+
}
|
|
3977
4478
|
// The first await lets `job` be assigned before the body looks at it,
|
|
3978
4479
|
// so a stall inside startMedia can begin a new refill.
|
|
3979
4480
|
const job = (async () => {
|
|
@@ -4025,19 +4526,24 @@ function buildPageRenderer({
|
|
|
4025
4526
|
const resumeAt = stalledTrack === state.video && state.video ? Math.min(time, state.video.frameTime) : time;
|
|
4026
4527
|
// The picture must not go back to the keyframe the decoder restarts at.
|
|
4027
4528
|
const hideUntil = Math.max(state.video?.shownTime ?? 0, state.video?.hideUntil ?? 0);
|
|
4028
|
-
await openVideoDecoder(state);
|
|
4529
|
+
await openVideoDecoder(state, resumeAt);
|
|
4029
4530
|
state.video.hideUntil = hideUntil;
|
|
4030
4531
|
try {
|
|
4031
|
-
state.video.frameTime = seekToKeyframe(state.video.decoder, resumeAt);
|
|
4532
|
+
state.video.frameTime = seekToKeyframe(state.video.decoder, resumeAt, state.video.startTime);
|
|
4032
4533
|
} catch {
|
|
4033
|
-
state.video.frameTime =
|
|
4534
|
+
state.video.frameTime = state.video.startTime;
|
|
4034
4535
|
}
|
|
4035
4536
|
state.video.done = false;
|
|
4036
4537
|
}
|
|
4037
4538
|
if (refillAudio) {
|
|
4038
4539
|
let opened = false;
|
|
4039
4540
|
try {
|
|
4040
|
-
|
|
4541
|
+
let audioTime = held ? writtenEnd : time;
|
|
4542
|
+
if (state.sound && Number.isFinite(state.sound.endTime)
|
|
4543
|
+
&& state.sound.position >= Math.round(state.sound.endTime * state.sound.decoder.sampleRate)) {
|
|
4544
|
+
audioTime = Math.max(audioTime, state.sound.endTime);
|
|
4545
|
+
}
|
|
4546
|
+
opened = await openAudioDecoder(state, { keepStream: Boolean(held), time: audioTime });
|
|
4041
4547
|
} catch (error) {
|
|
4042
4548
|
if (!state.video) throw error;
|
|
4043
4549
|
reportError(error);
|
|
@@ -4078,7 +4584,7 @@ function buildPageRenderer({
|
|
|
4078
4584
|
state.ended = true;
|
|
4079
4585
|
state.base = Number.isFinite(state.duration) ? state.duration : state.base;
|
|
4080
4586
|
setMediaPlaying(state, false);
|
|
4081
|
-
|
|
4587
|
+
playbackEvent(state, "pause", mediaEvent);
|
|
4082
4588
|
mediaEvent(state, "ended");
|
|
4083
4589
|
}
|
|
4084
4590
|
|
|
@@ -4121,7 +4627,7 @@ function buildPageRenderer({
|
|
|
4121
4627
|
/** The decoded CSS background image a url() names, if it has arrived. */
|
|
4122
4628
|
function backgroundResource(url) {
|
|
4123
4629
|
let absolute;
|
|
4124
|
-
try { absolute = new URL(url, page.url).href; } catch { return null; }
|
|
4630
|
+
try { absolute = new URL(url, page.document.baseURI || page.url).href; } catch { return null; }
|
|
4125
4631
|
return page.backgrounds.get(absolute)?.resource ?? null;
|
|
4126
4632
|
}
|
|
4127
4633
|
|
|
@@ -4134,28 +4640,25 @@ function buildPageRenderer({
|
|
|
4134
4640
|
if (!tree) return;
|
|
4135
4641
|
const document = page.document;
|
|
4136
4642
|
const seen = new Set();
|
|
4643
|
+
const used = new Set();
|
|
4137
4644
|
for (const node of tree.nodesById.values()) {
|
|
4138
4645
|
const value = node.style?.backgroundImage;
|
|
4139
4646
|
if (!value || seen.has(value) || !value.includes("url(")) continue;
|
|
4140
4647
|
seen.add(value);
|
|
4141
4648
|
for (const match of value.matchAll(CSS_URL)) {
|
|
4142
4649
|
let url;
|
|
4143
|
-
try { url = new URL(match[2], page.url).href; } catch { continue; }
|
|
4650
|
+
try { url = new URL(match[2], document.baseURI || page.url).href; } catch { continue; }
|
|
4651
|
+
used.add(url);
|
|
4144
4652
|
if (page.backgrounds.has(url)) continue;
|
|
4145
4653
|
const entry = { resource: null, loaded: null };
|
|
4146
4654
|
page.backgrounds.set(url, entry);
|
|
4147
|
-
entry.loaded =
|
|
4148
|
-
if (!response.ok || page.backgrounds.get(url) !== entry) return;
|
|
4149
|
-
const bytes = response.body;
|
|
4150
|
-
const { width, height } = await new Bun.Image(bytes).metadata();
|
|
4151
|
-
const image = CanvasKit.MakeImageFromEncoded(bytes);
|
|
4152
|
-
if (!image) return;
|
|
4655
|
+
entry.loaded = loadImageResource(document, url).then((resource) => {
|
|
4153
4656
|
if (page.backgrounds.get(url) !== entry || document !== page.document) {
|
|
4154
|
-
image.delete();
|
|
4657
|
+
resource.image.delete();
|
|
4155
4658
|
return;
|
|
4156
4659
|
}
|
|
4157
|
-
entry.resource =
|
|
4158
|
-
|
|
4660
|
+
entry.resource = resource;
|
|
4661
|
+
page.resourcePaintDirty = true;
|
|
4159
4662
|
page.needsPaint = true;
|
|
4160
4663
|
scheduleFrame();
|
|
4161
4664
|
}).catch(() => {
|
|
@@ -4163,6 +4666,11 @@ function buildPageRenderer({
|
|
|
4163
4666
|
});
|
|
4164
4667
|
}
|
|
4165
4668
|
}
|
|
4669
|
+
for (const [url, entry] of page.backgrounds) {
|
|
4670
|
+
if (used.has(url)) continue;
|
|
4671
|
+
entry.resource?.image.delete();
|
|
4672
|
+
page.backgrounds.delete(url);
|
|
4673
|
+
}
|
|
4166
4674
|
}
|
|
4167
4675
|
|
|
4168
4676
|
function disposeBackgrounds() {
|
|
@@ -4174,34 +4682,58 @@ function buildPageRenderer({
|
|
|
4174
4682
|
const document = page.document;
|
|
4175
4683
|
const generation = loadGeneration;
|
|
4176
4684
|
const staleImages = [];
|
|
4685
|
+
// Infinite feeds remove old DOM images without navigating. The resource
|
|
4686
|
+
// WeakMap alone cannot release their explicitly-owned Skia handles.
|
|
4687
|
+
for (const element of page.imageElements) {
|
|
4688
|
+
if (element.isConnected && element.ownerDocument === document && element.getAttribute("src")) continue;
|
|
4689
|
+
const image = page.resources.get(element)?.image;
|
|
4690
|
+
if (image) {
|
|
4691
|
+
staleImages.push(image);
|
|
4692
|
+
page.resourceLayoutDirty = true;
|
|
4693
|
+
}
|
|
4694
|
+
page.resources.delete(element);
|
|
4695
|
+
page.imageElements.delete(element);
|
|
4696
|
+
imageRequests.delete(element);
|
|
4697
|
+
}
|
|
4177
4698
|
for (const element of document.querySelectorAll("img")) {
|
|
4178
4699
|
const src = element.getAttribute("src");
|
|
4179
|
-
|
|
4700
|
+
const credentials = imageCrossOrigin(element);
|
|
4701
|
+
const key = imageRequestKey(src, credentials);
|
|
4702
|
+
if (!src || imageRequests.get(element) === key) continue;
|
|
4180
4703
|
let url;
|
|
4181
|
-
try { url = new URL(src, page.url).href; } catch { continue; }
|
|
4182
|
-
|
|
4183
|
-
|
|
4704
|
+
try { url = new URL(src, element.baseURI || page.url).href; } catch { continue; }
|
|
4705
|
+
const existing = page.resources.get(element);
|
|
4706
|
+
if (existing?.requestedURL === url && existing.crossOrigin === credentials) {
|
|
4707
|
+
imageRequests.set(element, key);
|
|
4184
4708
|
continue;
|
|
4185
4709
|
}
|
|
4186
|
-
imageRequests.set(element,
|
|
4710
|
+
imageRequests.set(element, key);
|
|
4187
4711
|
const oldImage = page.resources.get(element)?.image;
|
|
4188
|
-
if (oldImage)
|
|
4712
|
+
if (oldImage) {
|
|
4713
|
+
staleImages.push(oldImage);
|
|
4714
|
+
page.resourceLayoutDirty = true;
|
|
4715
|
+
}
|
|
4189
4716
|
page.resources.delete(element);
|
|
4190
|
-
|
|
4191
|
-
|
|
4192
|
-
const
|
|
4193
|
-
|
|
4194
|
-
|
|
4195
|
-
if (!image) return;
|
|
4196
|
-
if (generation !== loadGeneration || document !== page.document || element.getAttribute("src") !== src) {
|
|
4717
|
+
beginImageLoad(element, src);
|
|
4718
|
+
loadImageResource(document, url, credentials).then((resource) => {
|
|
4719
|
+
const { width, height, image } = resource;
|
|
4720
|
+
if (generation !== loadGeneration || document !== page.document || element.getAttribute("src") !== src
|
|
4721
|
+
|| imageCrossOrigin(element) !== credentials) {
|
|
4197
4722
|
image.delete();
|
|
4198
4723
|
return;
|
|
4199
4724
|
}
|
|
4200
|
-
page.resources.set(element,
|
|
4725
|
+
page.resources.set(element, resource);
|
|
4201
4726
|
page.decodedImages.push(image);
|
|
4202
|
-
|
|
4203
|
-
|
|
4204
|
-
|
|
4727
|
+
page.imageElements.add(element);
|
|
4728
|
+
let node, run;
|
|
4729
|
+
for (const candidate of page.renderTree.nodesById.values()) {
|
|
4730
|
+
if (candidate.domNode === element) { node = candidate; break; }
|
|
4731
|
+
}
|
|
4732
|
+
if (node) outer: for (const fragment of page.layout.fragments) {
|
|
4733
|
+
for (const candidate of fragment.runs) {
|
|
4734
|
+
if (candidate.type === "image" && candidate.nodeId === node.id) { run = candidate; break outer; }
|
|
4735
|
+
}
|
|
4736
|
+
}
|
|
4205
4737
|
const cssWidth = node?.style?.width;
|
|
4206
4738
|
const cssHeight = node?.style?.height;
|
|
4207
4739
|
const specifiedWidth = typeof cssWidth === "number" ? cssWidth : node?.widthAttribute;
|
|
@@ -4212,15 +4744,22 @@ function buildPageRenderer({
|
|
|
4212
4744
|
const paintOnly = run && Number.isFinite(specifiedWidth) && Number.isFinite(specifiedHeight)
|
|
4213
4745
|
&& Math.abs(run.width - specifiedWidth) < 0.01 && Math.abs(run.height - specifiedHeight) < 0.01;
|
|
4214
4746
|
if (paintOnly) {
|
|
4215
|
-
page.
|
|
4747
|
+
page.resourcePaintDirty = true;
|
|
4216
4748
|
page.needsPaint = true;
|
|
4217
4749
|
} else {
|
|
4218
4750
|
page.dirty = true;
|
|
4219
4751
|
page.resourceLayoutDirty = true;
|
|
4220
4752
|
}
|
|
4221
|
-
|
|
4222
|
-
|
|
4753
|
+
// All arrivals in this rendering opportunity share layout/raster.
|
|
4754
|
+
// An onload handler which reads geometry still flushes synchronously.
|
|
4755
|
+
finishImageLoad(element, src, { width, height }, type => element.dispatchEvent(new page.window.Event(type)));
|
|
4756
|
+
scheduleFrame();
|
|
4757
|
+
}).catch((error) => {
|
|
4223
4758
|
// A failed image retains its fallback replaced-element dimensions.
|
|
4759
|
+
trace?.(`image failed ${url}: ${error?.message ?? error}`);
|
|
4760
|
+
if (generation === loadGeneration && document === page.document && imageCrossOrigin(element) === credentials) {
|
|
4761
|
+
finishImageLoad(element, src, null, type => element.dispatchEvent(new page.window.Event(type)));
|
|
4762
|
+
}
|
|
4224
4763
|
});
|
|
4225
4764
|
}
|
|
4226
4765
|
return staleImages;
|