buninu-browser 0.0.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +17 -0
- package/LICENSES/brotli.txt +19 -0
- package/README.md +41 -7
- package/THIRD_PARTY_NOTICES.md +30 -6
- package/buninu-browser.js +10 -2
- package/lib/audio/pcm.js +16 -0
- package/lib/audio/piano.js +109 -0
- package/lib/audio/pulse-client.js +413 -0
- package/lib/cdp-server.js +25 -6
- package/lib/document-types.js +308 -0
- package/lib/download-directory.js +28 -0
- package/lib/download.js +63 -0
- package/lib/headless-shell.js +108 -14
- package/lib/input/hit-test.js +57 -8
- package/lib/layout/text-layout.js +112 -27
- package/lib/network/network-service.js +7 -3
- package/lib/network/resolver.js +16 -0
- package/lib/paint/display-list.js +2 -2
- package/lib/render-tree/index.js +13 -0
- package/lib/renderer/bindings.js +541 -53
- package/lib/renderer/flags.js +8 -5
- package/lib/renderer/host.js +110 -9
- package/lib/renderer/main.js +35 -3
- package/lib/renderer/module-transform.js +30 -23
- package/lib/renderer/page-realm.js +226 -22
- package/lib/renderer/page-renderer.js +1293 -212
- package/lib/screenshot-cli.js +61 -0
- package/lib/style/computed-style.js +268 -20
- package/lib/user-agent.js +12 -0
- package/package.json +1 -1
- package/patches/happy-dom/0003-defer-script-events-to-renderer.patch +40 -0
- package/patches/happy-dom/0004-insert-adjacent-html-in-order.patch +19 -0
- package/patches/happy-dom/0005-invalidate-matches-on-structure-changes.patch +43 -0
- package/patches/happy-dom/README.md +12 -0
- package/patches/ses/0001-allow-regexp-own-tostring.patch +13 -0
- package/patches/ses/README.md +16 -0
- package/patches/wasmpeg/0001-windows-wasm-path.patch +14 -0
- package/patches/wasmpeg/README.md +28 -0
- package/test/computed-style.test.js +52 -0
- package/test/document-types.test.js +111 -0
- package/test/download.test.js +192 -0
- package/test/fixtures/tone.mp3 +0 -0
- package/test/flex.test.js +11 -0
- package/test/happy-dom-matches.test.js +25 -0
- package/test/headless-shell.test.js +27 -0
- package/test/hit-test.test.js +37 -0
- package/test/page-scripts.test.js +388 -29
- package/test/piano.test.js +65 -0
- package/test/positioning.test.js +11 -0
- package/test/pulse-client.test.js +130 -0
- package/test/render-tree.test.js +14 -0
- package/test/resolver.test.js +12 -1
- package/test/screenshot-cli.test.js +19 -0
- package/test/text-layout.test.js +22 -0
- package/test/user-agent.test.js +42 -0
- package/vendor/happy-dom/packages/happy-dom/src/nodes/element/Element.ts +3 -6
- package/vendor/happy-dom/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts +2 -20
- package/vendor/happy-dom/packages/happy-dom/src/query-selector/QuerySelector.ts +15 -3
- package/vendor/ses/dist/ses.mjs +2 -0
- package/vendor/wasmpeg/VENDORING.md +7 -3
- package/vendor/wasmpeg/src/js/gpu.js +2 -1
|
@@ -23,25 +23,55 @@
|
|
|
23
23
|
* after the load event.
|
|
24
24
|
*/
|
|
25
25
|
import CanvasKitInit from "../../usr/lib/canvaskit/canvaskit.js";
|
|
26
|
+
import { DESKTOP_USER_AGENT } from "../user-agent.js";
|
|
26
27
|
import { parseHTMLDocument } from "../happy-dom/parser.js";
|
|
27
28
|
import { layoutText } from "../layout/text-layout.js";
|
|
28
29
|
import { ScrollViewport } from "../layout/scroll-viewport.js";
|
|
29
30
|
import { elementBounds, hitTest, interactiveRegions } from "../input/hit-test.js";
|
|
30
31
|
import { RenderTreeBuilder, renderTreeText } from "../render-tree/index.js";
|
|
31
|
-
import { StyleEngine } from "../style/computed-style.js";
|
|
32
|
+
import { StyleEngine, topmostConnected } from "../style/computed-style.js";
|
|
32
33
|
import { buildDisplayList, visibleItems } from "../paint/display-list.js";
|
|
33
34
|
import { backgroundBytes, cropViewport, encodeImage } from "../paint/frame.js";
|
|
34
35
|
import { createPageRealm } from "./page-realm.js";
|
|
35
36
|
import { MOBILE_USER_AGENT } from "./flags.js";
|
|
37
|
+
import { formatHint } from "../../vendor/wasmpeg/src/js/formats.js";
|
|
38
|
+
import { floatToS16, splitSamples } from "../audio/pcm.js";
|
|
39
|
+
import { pianoNote, PianoMixer } from "../audio/piano.js";
|
|
36
40
|
|
|
37
41
|
// Captured at load: in host mode page scripts share this global object.
|
|
38
42
|
const hostPerformance = globalThis.performance;
|
|
39
43
|
const hostSetTimeout = globalThis.setTimeout;
|
|
40
44
|
const hostClearTimeout = globalThis.clearTimeout;
|
|
45
|
+
const hostSetInterval = globalThis.setInterval;
|
|
46
|
+
const hostClearInterval = globalThis.clearInterval;
|
|
41
47
|
|
|
42
48
|
/** How long subresources may take before a preview of the unstyled document is shown. */
|
|
43
49
|
export const PREVIEW_DELAY_MS = 300;
|
|
44
50
|
|
|
51
|
+
// Media controls: the bar's height over video, and where its parts sit.
|
|
52
|
+
const MEDIA_BAR_HEIGHT = 24;
|
|
53
|
+
// Decoded audio already sent, kept so pause/resume continues exactly without decoding again.
|
|
54
|
+
const AUDIO_HISTORY_SECONDS = 3;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Horizontal layout of a media control bar `width` CSS pixels wide: the
|
|
58
|
+
* play/pause mark takes the first 30, then "m:ss / m:ss" when there is room,
|
|
59
|
+
* then the progress track that clicks seek.
|
|
60
|
+
*/
|
|
61
|
+
export function mediaBarLayout(width) {
|
|
62
|
+
const timeWidth = width >= 200 ? 86 : 0;
|
|
63
|
+
const trackStart = 30 + timeWidth;
|
|
64
|
+
return { timeStart: 30, timeWidth, trackStart, trackEnd: Math.max(trackStart + 10, width - 12) };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function formatMediaTime(seconds) {
|
|
68
|
+
const whole = Math.floor(seconds);
|
|
69
|
+
const hours = Math.floor(whole / 3600);
|
|
70
|
+
const minutes = Math.floor(whole / 60) % 60;
|
|
71
|
+
const rest = String(whole % 60).padStart(2, "0");
|
|
72
|
+
return hours ? `${hours}:${String(minutes).padStart(2, "0")}:${rest}` : `${minutes}:${rest}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
45
75
|
const canvasKitDirectory = new URL("../../usr/lib/canvaskit/", import.meta.url);
|
|
46
76
|
const fontDirectory = new URL("../../usr/share/fonts/", import.meta.url);
|
|
47
77
|
|
|
@@ -49,10 +79,12 @@ const fontDirectory = new URL("../../usr/share/fonts/", import.meta.url);
|
|
|
49
79
|
* @param {{
|
|
50
80
|
* fetchResource(url: string, kind: "stylesheet" | "image" | "script" | "metadata" | "video"):
|
|
51
81
|
* Promise<{ ok: boolean, status: number, url: string, contentType: string, body: Uint8Array }>,
|
|
52
|
-
* requestNavigation?(url: string): void,
|
|
82
|
+
* requestNavigation?(url: string, downloadName?: string | null, fragmentFrom?: { x: number, y: number } | null): void,
|
|
83
|
+
* requestDownload?(payload: { url: string, name: string, data: Uint8Array }): void,
|
|
53
84
|
* reportError?(message: string): void,
|
|
54
85
|
* pageFetch?(request: object): Promise<object>,
|
|
55
86
|
* webSockets?: { open(url, protocols, onEvent): number, send(id, data): void, close(id, code, reason): void } | null,
|
|
87
|
+
* audio?: { open(options, onEvent): number, write(id, data): void, finish(id): void, close(id): void } | null,
|
|
56
88
|
* setCookie?(value: string): void,
|
|
57
89
|
* consoleMessage?: ((level: string, text: string) => void) | null,
|
|
58
90
|
* trace?: ((text: string) => void) | null,
|
|
@@ -62,11 +94,13 @@ const fontDirectory = new URL("../../usr/share/fonts/", import.meta.url);
|
|
|
62
94
|
* }} options
|
|
63
95
|
*/
|
|
64
96
|
export async function createPageRenderer({
|
|
65
|
-
fetchResource,
|
|
97
|
+
fetchResource: rawFetchResource,
|
|
66
98
|
requestNavigation = () => {},
|
|
99
|
+
requestDownload = () => {},
|
|
67
100
|
reportError = () => {},
|
|
68
101
|
pageFetch = () => Promise.reject(new TypeError("network access is not available")),
|
|
69
102
|
webSockets = null,
|
|
103
|
+
audio = null,
|
|
70
104
|
setCookie = () => {},
|
|
71
105
|
consoleMessage = null,
|
|
72
106
|
trace = null,
|
|
@@ -74,6 +108,8 @@ export async function createPageRenderer({
|
|
|
74
108
|
onLifecycle = null,
|
|
75
109
|
videoDecoder = null,
|
|
76
110
|
mobile = false,
|
|
111
|
+
verifyStyle = false,
|
|
112
|
+
fullStyle = false,
|
|
77
113
|
}) {
|
|
78
114
|
const wasmBinary = await Bun.file(new URL("canvaskit.wasm", canvasKitDirectory)).arrayBuffer();
|
|
79
115
|
const CanvasKit = await CanvasKitInit({ wasmBinary });
|
|
@@ -131,6 +167,19 @@ export async function createPageRenderer({
|
|
|
131
167
|
const emojiPresentation = /^\p{Emoji_Presentation}/u;
|
|
132
168
|
const renderTreeBuilder = new RenderTreeBuilder();
|
|
133
169
|
const styleEngine = new StyleEngine();
|
|
170
|
+
// Incremental restyle: what may have changed style since the last
|
|
171
|
+
// computation. DOM mutations come from a MutationObserver; state that is
|
|
172
|
+
// not in the DOM tree (checked, value, focus) is marked by the code that
|
|
173
|
+
// changes it. `full` asks for a complete restyle.
|
|
174
|
+
const styleChanges = { full: true, roots: new Set(), records: [], observer: null, activeElement: null };
|
|
175
|
+
let selectorTraits = { sibling: false, has: null, sheets: null };
|
|
176
|
+
let lastStyleParts = "";
|
|
177
|
+
const sheetTraitsCache = new Map();
|
|
178
|
+
let lastStyleReason = "";
|
|
179
|
+
let hasState = null;
|
|
180
|
+
let hasStateStale = false;
|
|
181
|
+
let structuralState = null;
|
|
182
|
+
let siblingState = null;
|
|
134
183
|
const viewport = new ScrollViewport(800, 600);
|
|
135
184
|
const page = {
|
|
136
185
|
url: "about:blank",
|
|
@@ -149,19 +198,28 @@ export async function createPageRenderer({
|
|
|
149
198
|
hints: [],
|
|
150
199
|
hintsVisible: false,
|
|
151
200
|
styleSheets: [],
|
|
201
|
+
linkSheets: null,
|
|
152
202
|
backgroundColor: "rgba(255, 255, 255, 1)",
|
|
153
203
|
resources: new WeakMap(),
|
|
154
204
|
decodedImages: [],
|
|
155
|
-
|
|
205
|
+
imagesLoaded: null,
|
|
206
|
+
// <video> and <audio> playback states, by element (see playMedia).
|
|
207
|
+
media: new Map(),
|
|
156
208
|
realm: null,
|
|
157
209
|
// "off" until the renderer process has set up its sandbox.
|
|
158
210
|
scripting: "off",
|
|
159
211
|
dirty: false,
|
|
160
|
-
userAgent: mobile ? MOBILE_USER_AGENT :
|
|
212
|
+
userAgent: mobile ? MOBILE_USER_AGENT : DESKTOP_USER_AGENT,
|
|
161
213
|
// document.cookie as last reported by the controller (non-HttpOnly only).
|
|
162
214
|
cookie: "",
|
|
163
215
|
};
|
|
164
216
|
|
|
217
|
+
async function fetchResource(url, kind) {
|
|
218
|
+
const response = await rawFetchResource(url, kind);
|
|
219
|
+
if (typeof response.documentCookie === "string") page.cookie = response.documentCookie;
|
|
220
|
+
return response;
|
|
221
|
+
}
|
|
222
|
+
|
|
165
223
|
function makeParagraph(text, computedStyle = null, fontSize = computedStyle?.fontSize ?? 16, scale = 1) {
|
|
166
224
|
const color = canvasColor(CanvasKit, computedStyle?.color ?? "rgba(0, 0, 0, 1)");
|
|
167
225
|
const weight = computedStyle?.fontWeight ?? 400;
|
|
@@ -416,7 +474,10 @@ export async function createPageRenderer({
|
|
|
416
474
|
frameTimer = hostSetTimeout(() => {
|
|
417
475
|
frameTimer = null;
|
|
418
476
|
refresh();
|
|
419
|
-
|
|
477
|
+
// Leave one rebuild-sized idle window after an expensive frame. Without
|
|
478
|
+
// it, a large document can spend all its time producing back-to-back
|
|
479
|
+
// frames while scripts are still making related mutations.
|
|
480
|
+
}, Math.max(50, lastRebuildMs * 2));
|
|
420
481
|
}
|
|
421
482
|
|
|
422
483
|
/** The viewport as a PNG (base64), cut from the latest tile. */
|
|
@@ -585,15 +646,17 @@ export async function createPageRenderer({
|
|
|
585
646
|
const path = CanvasKit.Path.MakeFromSVGString(
|
|
586
647
|
`M ${centerX - 6 * scale} ${centerY - 10 * scale} L ${centerX + 11 * scale} ${centerY} L ${centerX - 6 * scale} ${centerY + 10 * scale} Z`);
|
|
587
648
|
if (path) { canvas.drawPath(path, paint); path.delete(); }
|
|
588
|
-
} else {
|
|
589
|
-
const bar = CanvasKit.XYWHRect(rect[0], rect[3] - 24 * scale, rect[2] - rect[0], 24 * scale);
|
|
590
|
-
canvas.drawRect(bar, paint);
|
|
591
|
-
paint.setColor(CanvasKit.Color(255, 255, 255, 1));
|
|
592
|
-
canvas.drawRect(CanvasKit.XYWHRect(rect[0] + 10 * scale, rect[3] - 18 * scale, 4 * scale, 12 * scale), paint);
|
|
593
|
-
canvas.drawRect(CanvasKit.XYWHRect(rect[0] + 17 * scale, rect[3] - 18 * scale, 4 * scale, 12 * scale), paint);
|
|
594
649
|
}
|
|
595
|
-
|
|
650
|
+
// The control bar (play/pause, time, seekable progress) once playing or known.
|
|
651
|
+
if (resource?.playing || Number.isFinite(resource?.duration)) {
|
|
652
|
+
drawMediaBar(canvas, item.rect.x - view.x, item.rect.y + item.rect.height - MEDIA_BAR_HEIGHT - view.y,
|
|
653
|
+
item.rect.width, MEDIA_BAR_HEIGHT, resource, scale, true);
|
|
654
|
+
}
|
|
596
655
|
paint.delete();
|
|
656
|
+
canvas.restore();
|
|
657
|
+
} else if (item.op === "drawAudio") {
|
|
658
|
+
const resource = displayList.images[item.image];
|
|
659
|
+
drawMediaBar(canvas, item.rect.x - view.x, item.rect.y - view.y, item.rect.width, item.rect.height, resource, scale, false);
|
|
597
660
|
} else if (item.op === "drawSvg") {
|
|
598
661
|
drawSvg(item);
|
|
599
662
|
} else if (item.op === "drawText") {
|
|
@@ -694,6 +757,53 @@ export async function createPageRenderer({
|
|
|
694
757
|
|
|
695
758
|
// Overlays are placed in page coordinates (viewport position plus the
|
|
696
759
|
// scroll offset when they were created), so they stay on their element.
|
|
760
|
+
/**
|
|
761
|
+
* A media control bar in CSS pixels at (x, y), relative to the tile: a
|
|
762
|
+
* play/pause mark, "m:ss / m:ss" and a progress track that clicks seek
|
|
763
|
+
* (see mediaBarLayout). Video draws it over the picture, audio as its box.
|
|
764
|
+
*/
|
|
765
|
+
function drawMediaBar(canvas, x, y, width, height, resource, scale, overlay) {
|
|
766
|
+
const paint = new CanvasKit.Paint();
|
|
767
|
+
paint.setAntiAlias(true);
|
|
768
|
+
const box = CanvasKit.XYWHRect(x * scale, y * scale, width * scale, height * scale);
|
|
769
|
+
paint.setColor(overlay ? CanvasKit.Color(0, 0, 0, 0.65) : CanvasKit.Color(241, 243, 244, 1));
|
|
770
|
+
if (overlay) canvas.drawRect(box, paint);
|
|
771
|
+
else canvas.drawRRect(CanvasKit.RRectXY(box, height / 2 * scale, height / 2 * scale), paint);
|
|
772
|
+
paint.setColor(overlay ? CanvasKit.Color(255, 255, 255, 1) : CanvasKit.Color(32, 33, 36, 1));
|
|
773
|
+
const middle = (y + height / 2) * scale;
|
|
774
|
+
const iconX = (x + 12) * scale;
|
|
775
|
+
if (resource?.playing) {
|
|
776
|
+
canvas.drawRect(CanvasKit.XYWHRect(iconX, middle - 6 * scale, 3.5 * scale, 12 * scale), paint);
|
|
777
|
+
canvas.drawRect(CanvasKit.XYWHRect(iconX + 6.5 * scale, middle - 6 * scale, 3.5 * scale, 12 * scale), paint);
|
|
778
|
+
} else {
|
|
779
|
+
const path = CanvasKit.Path.MakeFromSVGString(
|
|
780
|
+
`M ${iconX} ${middle - 7 * scale} L ${iconX + 11 * scale} ${middle} L ${iconX} ${middle + 7 * scale} Z`);
|
|
781
|
+
if (path) { canvas.drawPath(path, paint); path.delete(); }
|
|
782
|
+
}
|
|
783
|
+
const layout = mediaBarLayout(width);
|
|
784
|
+
const duration = resource?.duration;
|
|
785
|
+
const time = Math.max(0, Math.min(Number.isFinite(duration) ? duration : Infinity, resource?.time ?? 0));
|
|
786
|
+
if (layout.timeWidth) {
|
|
787
|
+
const text = `${formatMediaTime(time)} / ${Number.isFinite(duration) ? formatMediaTime(duration) : "--:--"}`;
|
|
788
|
+
const paragraph = makeParagraph(text, { color: overlay ? "rgba(255, 255, 255, 1)" : "rgba(32, 33, 36, 1)",
|
|
789
|
+
fontFamily: ["sans-serif"], fontWeight: 400 }, 11, scale);
|
|
790
|
+
paragraph.layout(layout.timeWidth * scale);
|
|
791
|
+
canvas.drawParagraph(paragraph, (x + layout.timeStart) * scale, middle - paragraph.getHeight() / 2);
|
|
792
|
+
paragraph.delete();
|
|
793
|
+
}
|
|
794
|
+
const trackStart = (x + layout.trackStart) * scale;
|
|
795
|
+
const trackWidth = (layout.trackEnd - layout.trackStart) * scale;
|
|
796
|
+
paint.setColor(overlay ? CanvasKit.Color(255, 255, 255, 0.35) : CanvasKit.Color(0, 0, 0, 0.18));
|
|
797
|
+
canvas.drawRRect(CanvasKit.RRectXY(CanvasKit.XYWHRect(trackStart, middle - 2 * scale, trackWidth, 4 * scale), 2 * scale, 2 * scale), paint);
|
|
798
|
+
if (Number.isFinite(duration) && duration > 0) {
|
|
799
|
+
const played = trackWidth * time / duration;
|
|
800
|
+
paint.setColor(overlay ? CanvasKit.Color(255, 255, 255, 1) : CanvasKit.Color(26, 115, 232, 1));
|
|
801
|
+
canvas.drawRRect(CanvasKit.RRectXY(CanvasKit.XYWHRect(trackStart, middle - 2 * scale, played, 4 * scale), 2 * scale, 2 * scale), paint);
|
|
802
|
+
canvas.drawCircle(trackStart + played, middle, 5 * scale, paint);
|
|
803
|
+
}
|
|
804
|
+
paint.delete();
|
|
805
|
+
}
|
|
806
|
+
|
|
697
807
|
function drawHints(canvas, region) {
|
|
698
808
|
if (!page.hintsVisible) return;
|
|
699
809
|
const paint = new CanvasKit.Paint();
|
|
@@ -733,7 +843,332 @@ export async function createPageRenderer({
|
|
|
733
843
|
replaceParsedDocument(parseHTMLDocument(html, url), styleSheets, imageResources);
|
|
734
844
|
}
|
|
735
845
|
|
|
736
|
-
|
|
846
|
+
/** `linkSheets` (link element -> { url, text }) makes the sheet list follow the DOM; without it the given sheets are fixed. */
|
|
847
|
+
/** Marks a node whose own state changed (its subtree may restyle); null asks for a full restyle. */
|
|
848
|
+
function markStyleDirty(node) {
|
|
849
|
+
// Documents and fragments carry no state selectors read; their children's
|
|
850
|
+
// changes arrive as mutation records.
|
|
851
|
+
if (node?.nodeType === 9 || node?.nodeType === 11) return;
|
|
852
|
+
const element = node?.nodeType === 1 ? node : node?.parentElement;
|
|
853
|
+
if (!element) styleChanges.full ||= node ? `touched ${node.nodeName}` : "unknown change";
|
|
854
|
+
else styleChanges.roots.add(element);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function observeStyleChanges(document) {
|
|
858
|
+
styleChanges.observer?.disconnect();
|
|
859
|
+
styleChanges.records = [];
|
|
860
|
+
styleChanges.roots = new Set();
|
|
861
|
+
styleChanges.full = "new document";
|
|
862
|
+
styleChanges.activeElement = null;
|
|
863
|
+
const observer = new page.window.MutationObserver((records) => { styleChanges.records.push(...records); });
|
|
864
|
+
observer.observe(document, { subtree: true, childList: true, attributes: true, attributeOldValue: true, characterData: true });
|
|
865
|
+
styleChanges.observer = observer;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* What the stylesheets' selectors make sensitive: sibling combinators let
|
|
870
|
+
* a change restyle the following siblings; :has() lets one change the
|
|
871
|
+
* match of an ancestor (or, with + and ~, of an earlier sibling), so the
|
|
872
|
+
* compounds that contain it are re-checked there (`hasCompounds`).
|
|
873
|
+
*/
|
|
874
|
+
function selectorTraitsFor(sheets) {
|
|
875
|
+
if (selectorTraits.sheets === sheets) return selectorTraits;
|
|
876
|
+
const parts = sheets.map(sheetTraitsFor);
|
|
877
|
+
// Only the sheets in use stay cached.
|
|
878
|
+
if (sheetTraitsCache.size > sheets.length) for (const sheet of new Set(sheetTraitsCache.keys()).difference(new Set(sheets))) sheetTraitsCache.delete(sheet);
|
|
879
|
+
const union = (key) => new Set(parts.flatMap((part) => [...part[key]]));
|
|
880
|
+
const any = (key) => parts.some((part) => part[key]);
|
|
881
|
+
selectorTraits = {
|
|
882
|
+
sheets, sibling: any("sibling"), hasSiblings: any("hasSiblings"),
|
|
883
|
+
hasCompounds: [...union("hasCompounds")], structuralCompounds: [...union("structuralCompounds")],
|
|
884
|
+
descendantFeatures: union("descendantFeatures"), siblingFeatures: union("siblingFeatures"),
|
|
885
|
+
siblingTargetCompounds: [...union("siblingTargetCompounds")],
|
|
886
|
+
forward: any("forward"), firstChild: any("firstChild"), backward: any("backward"), lastChild: any("lastChild"),
|
|
887
|
+
defined: any("defined"), target: any("target"),
|
|
888
|
+
};
|
|
889
|
+
// Checked against the old compounds this time, re-recorded after the restyle.
|
|
890
|
+
hasStateStale = true;
|
|
891
|
+
return selectorTraits;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* One sheet's share of `selectorTraitsFor`, cached by its text (sheets come
|
|
896
|
+
* and go one at a time, and parsing all of them again is slow).
|
|
897
|
+
*/
|
|
898
|
+
function sheetTraitsFor(sheet) {
|
|
899
|
+
let traits = sheetTraitsCache.get(sheet);
|
|
900
|
+
if (traits) return traits;
|
|
901
|
+
const text = String(sheet).replace(/\/\*[\s\S]*?\*\//g, "");
|
|
902
|
+
const plain = text.replace(/\[[^\]]*\]/g, "[]").replace(/\{[^}]*\}/g, "{}");
|
|
903
|
+
const sibling = /[^\s~|^$*=\[]\s*[+~]\s*[^\s=]/.test(plain);
|
|
904
|
+
const hasCompounds = new Set();
|
|
905
|
+
let hasSiblings = false;
|
|
906
|
+
const selectorsOnly = text.replace(/\{[^}]*\}/g, "{}");
|
|
907
|
+
for (const block of selectorsOnly.matchAll(/[^{}]*:has\([^{]*\{/g)) {
|
|
908
|
+
for (const selector of splitTopLevelText(block[0].slice(0, -1), ",")) {
|
|
909
|
+
for (const compound of compoundsOf(selector)) {
|
|
910
|
+
if (!compound.includes(":has(")) continue;
|
|
911
|
+
hasCompounds.add(compound.replace(/::[\w-]+(\([^)]*\))?$/, ""));
|
|
912
|
+
if (/:has\(\s*[+~]/.test(compound)) hasSiblings = true;
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
// Structural pseudo-classes: which siblings a child list change can
|
|
917
|
+
// affect, and the compounds where they are not the subject (".a:last-child .b"),
|
|
918
|
+
// whose matches decide whether a sibling's subtree must restyle too.
|
|
919
|
+
const structural = /:(?:nth-child|nth-last-child|first-child|last-child|only-child|nth-of-type|nth-last-of-type|first-of-type|last-of-type|only-of-type)\b/;
|
|
920
|
+
const structuralCompounds = new Set();
|
|
921
|
+
for (const block of selectorsOnly.matchAll(/[^{}]*\{/g)) {
|
|
922
|
+
if (!structural.test(block[0])) continue;
|
|
923
|
+
for (const selector of splitTopLevelText(block[0].slice(0, -1), ",")) {
|
|
924
|
+
const compounds = compoundsOf(selector);
|
|
925
|
+
for (const compound of compounds.slice(0, -1)) if (structural.test(compound)) structuralCompounds.add(compound);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
// Invalidation features (class ".x", id "#x", attribute "[x", tag "t:x",
|
|
929
|
+
// state ":state", anything "*") of compounds that are not a selector's
|
|
930
|
+
// subject: left of a descendant/child combinator, a change to them can
|
|
931
|
+
// restyle the element's subtree; left of + / ~, its later siblings.
|
|
932
|
+
const descendantFeatures = new Set();
|
|
933
|
+
const siblingFeatures = new Set();
|
|
934
|
+
// Selectors ending in a compound right of + / ~ that is not the subject
|
|
935
|
+
// (".a + .b" of ".a + .b .c"): a later sibling's subtree restyles only
|
|
936
|
+
// when its match of one of them changes.
|
|
937
|
+
const siblingTargetCompounds = new Set();
|
|
938
|
+
for (const block of selectorsOnly.matchAll(/[^{}@]*\{/g)) {
|
|
939
|
+
for (const selector of splitTopLevelText(block[0].slice(0, -1), ",")) {
|
|
940
|
+
const parts = complexParts(selector);
|
|
941
|
+
parts.forEach(({ compound, combinator }, index) => {
|
|
942
|
+
const nested = /\([^)]*[\s>+~][^)]*\)/.test(compound); // :is(.a .b) hides combinators
|
|
943
|
+
const previous = parts[index - 1]?.combinator;
|
|
944
|
+
if ((previous === "+" || previous === "~") && index < parts.length - 1) {
|
|
945
|
+
// With what is left of it ("li:first-child + li"): the relation to
|
|
946
|
+
// the earlier sibling can end while the compound still matches.
|
|
947
|
+
const left = parts.slice(0, index).map((part) => `${part.compound} ${part.combinator === " " ? "" : part.combinator + " "}`).join("");
|
|
948
|
+
siblingTargetCompounds.add(left + compound);
|
|
949
|
+
}
|
|
950
|
+
if (index === parts.length - 1 && !nested) return;
|
|
951
|
+
const features = compoundFeatures(compound);
|
|
952
|
+
if (nested || combinator === " " || combinator === ">") for (const feature of features) descendantFeatures.add(feature);
|
|
953
|
+
if (nested || combinator === "+" || combinator === "~") for (const feature of features) siblingFeatures.add(feature);
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
traits = {
|
|
958
|
+
sibling, hasCompounds, hasSiblings, structuralCompounds, descendantFeatures, siblingFeatures, siblingTargetCompounds,
|
|
959
|
+
forward: /:(?:nth-child|nth-of-type|first-of-type|only-of-type)\b/.test(selectorsOnly),
|
|
960
|
+
firstChild: /:(?:first-child|only-child)\b/.test(selectorsOnly),
|
|
961
|
+
backward: /:(?:nth-last-child|nth-last-of-type|last-of-type|only-of-type)\b/.test(selectorsOnly),
|
|
962
|
+
lastChild: /:(?:last-child|only-child)\b/.test(selectorsOnly),
|
|
963
|
+
defined: text.includes(":defined"), target: /:target\b/.test(text),
|
|
964
|
+
};
|
|
965
|
+
sheetTraitsCache.set(sheet, traits);
|
|
966
|
+
return traits;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/**
|
|
970
|
+
* Which elements match each :has() and non-subject structural compound, as
|
|
971
|
+
* of the last restyle. After an incremental round the kept maps are
|
|
972
|
+
* current, so only compounds new to the sheets are looked up.
|
|
973
|
+
*/
|
|
974
|
+
function recordHasState(traits) {
|
|
975
|
+
const reuse = hasState !== null;
|
|
976
|
+
const record = (compounds, previous) => {
|
|
977
|
+
const state = new Map();
|
|
978
|
+
for (const compound of compounds) {
|
|
979
|
+
if (reuse && previous?.has(compound)) { state.set(compound, previous.get(compound)); continue; }
|
|
980
|
+
try { state.set(compound, new Set(page.document.querySelectorAll(compound))); } catch { /* invalid: never matches */ }
|
|
981
|
+
}
|
|
982
|
+
return state;
|
|
983
|
+
};
|
|
984
|
+
hasState = record(traits.hasCompounds, hasState);
|
|
985
|
+
structuralState = record(traits.structuralCompounds, structuralState);
|
|
986
|
+
siblingState = record(traits.siblingTargetCompounds, siblingState);
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/** Brings the recorded matches of an inserted subtree up to date (it may have been in the document before). */
|
|
990
|
+
function recordInserted(root) {
|
|
991
|
+
for (const state of [hasState, structuralState, siblingState]) {
|
|
992
|
+
for (const [compound, matched] of state ?? []) {
|
|
993
|
+
let inside;
|
|
994
|
+
try {
|
|
995
|
+
inside = new Set(root.querySelectorAll(compound));
|
|
996
|
+
if (root.matches(compound)) inside.add(root);
|
|
997
|
+
} catch { continue; }
|
|
998
|
+
for (const element of [root, ...root.getElementsByTagName("*")]) {
|
|
999
|
+
if (inside.has(element)) matched.add(element); else matched.delete(element);
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
/** Re-checks `element` against recorded compounds; whether any match changed (the state is updated). */
|
|
1007
|
+
function matchesChanged(state, element) {
|
|
1008
|
+
let changed = false;
|
|
1009
|
+
for (const [compound, matched] of state ?? []) {
|
|
1010
|
+
let now = false;
|
|
1011
|
+
try { now = element.matches(compound); } catch {}
|
|
1012
|
+
if (now === matched.has(element)) continue;
|
|
1013
|
+
if (now) matched.add(element); else matched.delete(element);
|
|
1014
|
+
changed = true;
|
|
1015
|
+
}
|
|
1016
|
+
return changed;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* Elements whose :has() matches changed around a mutated node: its
|
|
1021
|
+
* ancestors (and their earlier siblings for :has(+ / ~)), re-checked
|
|
1022
|
+
* against the recorded state, which is updated as it goes.
|
|
1023
|
+
*/
|
|
1024
|
+
function hasChanges(traits, node, add, checked) {
|
|
1025
|
+
if (!hasState || hasState.size === 0) return;
|
|
1026
|
+
for (let element = node?.nodeType === 1 ? node : node?.parentElement; element; element = element.parentElement) {
|
|
1027
|
+
// The records describe one final DOM, so each element is checked once a
|
|
1028
|
+
// round; its ancestors were too when it was.
|
|
1029
|
+
if (checked.has(element)) return;
|
|
1030
|
+
const candidates = [element];
|
|
1031
|
+
if (traits.hasSiblings) for (let sibling = element.previousElementSibling; sibling; sibling = sibling.previousElementSibling) candidates.push(sibling);
|
|
1032
|
+
for (const candidate of candidates) {
|
|
1033
|
+
if (candidate !== element && checked.has(candidate)) continue;
|
|
1034
|
+
checked.add(candidate);
|
|
1035
|
+
for (const [compound, matched] of hasState) {
|
|
1036
|
+
let now = false;
|
|
1037
|
+
try { now = candidate.matches(compound); } catch {}
|
|
1038
|
+
if (now === matched.has(candidate)) continue;
|
|
1039
|
+
if (now) matched.add(candidate); else matched.delete(candidate);
|
|
1040
|
+
add(candidate);
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
/**
|
|
1047
|
+
* The style computation's dirty set, or null for a full restyle. Pending
|
|
1048
|
+
* mutation records become subtree roots: an attribute change restyles the
|
|
1049
|
+
* element's subtree (descendant selectors), a child list change its
|
|
1050
|
+
* parent's (structural pseudo-classes), with siblings included when the
|
|
1051
|
+
* sheets use sibling combinators; a change :has() may observe restyles all.
|
|
1052
|
+
*/
|
|
1053
|
+
function takeStyleChanges(sheets) {
|
|
1054
|
+
const traits = selectorTraitsFor(sheets);
|
|
1055
|
+
const records = styleChanges.records;
|
|
1056
|
+
if (styleChanges.observer) records.push(...styleChanges.observer.takeRecords());
|
|
1057
|
+
const roots = styleChanges.roots;
|
|
1058
|
+
let full = styleChanges.full;
|
|
1059
|
+
const active = page.document.activeElement;
|
|
1060
|
+
if (active !== styleChanges.activeElement) {
|
|
1061
|
+
// :focus, :focus-within and :focus-visible follow the focused element.
|
|
1062
|
+
if (styleChanges.activeElement !== null) full ||= "focus moved";
|
|
1063
|
+
styleChanges.activeElement = active;
|
|
1064
|
+
}
|
|
1065
|
+
// Elements whose subtree restyles (`roots`), and elements whose own
|
|
1066
|
+
// matches may change but whose subtree restyles only if their style does
|
|
1067
|
+
// (`self`, decided by the style engine).
|
|
1068
|
+
const self = new Set();
|
|
1069
|
+
const add = (element) => roots.add(element);
|
|
1070
|
+
// Elements already re-checked this round against the :has(), sibling and structural state.
|
|
1071
|
+
const hasChecked = new Set();
|
|
1072
|
+
const siblingChecked = new Set();
|
|
1073
|
+
const structuralChecked = new Set();
|
|
1074
|
+
const inserted = new Set();
|
|
1075
|
+
// Siblings after a change: their own matches may differ; their subtrees
|
|
1076
|
+
// only when a non-subject compound right of + / ~ now matches differently.
|
|
1077
|
+
const laterSibling = (element) => {
|
|
1078
|
+
self.add(element);
|
|
1079
|
+
if (siblingChecked.has(element)) return;
|
|
1080
|
+
siblingChecked.add(element);
|
|
1081
|
+
if (matchesChanged(siblingState, element)) roots.add(element);
|
|
1082
|
+
};
|
|
1083
|
+
const later = (element) => { for (let next = element?.nextElementSibling; next; next = next.nextElementSibling) laterSibling(next); };
|
|
1084
|
+
/** An element whose features changed: itself, and wider where non-subject compounds use them. */
|
|
1085
|
+
const changed = (element, features) => {
|
|
1086
|
+
self.add(element);
|
|
1087
|
+
if (features.some((feature) => traits.descendantFeatures.has(feature))) roots.add(element);
|
|
1088
|
+
if (traits.siblingFeatures.has("*") || features.some((feature) => traits.siblingFeatures.has(feature))) later(element);
|
|
1089
|
+
hasChanges(traits, element, add, hasChecked);
|
|
1090
|
+
};
|
|
1091
|
+
const touchSibling = (element) => {
|
|
1092
|
+
self.add(element);
|
|
1093
|
+
if (structuralChecked.has(element)) return;
|
|
1094
|
+
structuralChecked.add(element);
|
|
1095
|
+
if (matchesChanged(structuralState, element)) roots.add(element);
|
|
1096
|
+
};
|
|
1097
|
+
if (!full && !hasState) full = "first restyle";
|
|
1098
|
+
// Marked elements: state outside the DOM (checked, value) or a DOM change
|
|
1099
|
+
// the records also describe; both start as a change of the element's state.
|
|
1100
|
+
for (const element of [...roots]) {
|
|
1101
|
+
roots.delete(element);
|
|
1102
|
+
if (!full) changed(element, [":state"]);
|
|
1103
|
+
}
|
|
1104
|
+
for (const record of records) {
|
|
1105
|
+
if (full) break;
|
|
1106
|
+
const target = record.target;
|
|
1107
|
+
if (record.type === "attributes") {
|
|
1108
|
+
if (target.nodeType !== 1) continue;
|
|
1109
|
+
const name = record.attributeName;
|
|
1110
|
+
const features = ["[" + name];
|
|
1111
|
+
if (name === "class") {
|
|
1112
|
+
const before = new Set(String(record.oldValue ?? "").split(/\s+/).filter(Boolean));
|
|
1113
|
+
const after = new Set(target.classList);
|
|
1114
|
+
for (const value of before) if (!after.has(value)) features.push("." + value);
|
|
1115
|
+
for (const value of after) if (!before.has(value)) features.push("." + value);
|
|
1116
|
+
} else if (name === "id") {
|
|
1117
|
+
if (record.oldValue) features.push("#" + record.oldValue);
|
|
1118
|
+
const id = target.getAttribute("id");
|
|
1119
|
+
if (id) features.push("#" + id);
|
|
1120
|
+
}
|
|
1121
|
+
changed(target, features);
|
|
1122
|
+
} else if (record.type === "childList") {
|
|
1123
|
+
if (target.nodeType !== 1) { full = `children of ${target.nodeName}`; break; }
|
|
1124
|
+
// Inserted elements restyle; the parent itself may change (:empty);
|
|
1125
|
+
// siblings around the change point may match structural
|
|
1126
|
+
// pseudo-classes differently, and the ones after it sibling
|
|
1127
|
+
// combinators whose left side the inserted or removed nodes match.
|
|
1128
|
+
for (const node of record.addedNodes) {
|
|
1129
|
+
if (node.nodeType === 1 && node.parentElement === target) {
|
|
1130
|
+
add(node);
|
|
1131
|
+
inserted.add(node);
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
self.add(target);
|
|
1135
|
+
hasChanges(traits, target, add, hasChecked);
|
|
1136
|
+
let next = record.nextSibling;
|
|
1137
|
+
while (next && next.nodeType !== 1) next = next.nextSibling;
|
|
1138
|
+
let previous = record.previousSibling;
|
|
1139
|
+
while (previous && previous.nodeType !== 1) previous = previous.previousSibling;
|
|
1140
|
+
if (next?.parentElement !== target) next = null;
|
|
1141
|
+
if (previous?.parentElement !== target) previous = null;
|
|
1142
|
+
if (traits.forward) for (let element = next; element; element = element.nextElementSibling) touchSibling(element);
|
|
1143
|
+
else if (traits.firstChild && next) touchSibling(next);
|
|
1144
|
+
if (traits.backward) for (let element = previous; element; element = element.previousElementSibling) touchSibling(element);
|
|
1145
|
+
else if (traits.lastChild && previous) touchSibling(previous);
|
|
1146
|
+
if (next && traits.siblingFeatures.size) {
|
|
1147
|
+
const moved = [...record.addedNodes, ...record.removedNodes, previous].filter((node) => node?.nodeType === 1);
|
|
1148
|
+
if (traits.siblingFeatures.has("*") || moved.some((node) => elementFeatures(node).some((feature) => traits.siblingFeatures.has(feature)))) {
|
|
1149
|
+
for (let element = next; element; element = element.nextElementSibling) laterSibling(element);
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
} else if (record.type === "characterData") {
|
|
1153
|
+
// Only :empty and :has() can see text: the parent itself.
|
|
1154
|
+
const parent = target.parentElement;
|
|
1155
|
+
if (parent) {
|
|
1156
|
+
self.add(parent);
|
|
1157
|
+
hasChanges(traits, parent, add, hasChecked);
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
if (!full) for (const node of topmostConnected(inserted, page.document)) recordInserted(node);
|
|
1162
|
+
styleChanges.records = [];
|
|
1163
|
+
styleChanges.roots = new Set();
|
|
1164
|
+
styleChanges.full = false;
|
|
1165
|
+
lastStyleReason = full || `${roots.size} root(s), ${self.size} self`;
|
|
1166
|
+
if (full) hasState = null; // recorded again after the full restyle
|
|
1167
|
+
return full ? null : { roots, self };
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
function replaceParsedDocument(parsed, styleSheets = [], imageResources = emptyImageResources(), linkSheets = null) {
|
|
1171
|
+
closePiano();
|
|
737
1172
|
disposeVideos();
|
|
738
1173
|
page.realm?.dispose();
|
|
739
1174
|
page.realm = null;
|
|
@@ -741,11 +1176,14 @@ export async function createPageRenderer({
|
|
|
741
1176
|
for (const image of page.decodedImages) image.delete();
|
|
742
1177
|
page.window = parsed.window;
|
|
743
1178
|
page.document = parsed.document;
|
|
1179
|
+
observeStyleChanges(parsed.document);
|
|
744
1180
|
page.title = parsed.document.title;
|
|
745
1181
|
page.styleSheets = styleSheets;
|
|
1182
|
+
page.linkSheets = linkSheets;
|
|
746
1183
|
page.resources = imageResources.map;
|
|
747
1184
|
initializeVideos(parsed.document, page.resources);
|
|
748
1185
|
page.decodedImages = imageResources.decoded;
|
|
1186
|
+
page.imagesLoaded = null;
|
|
749
1187
|
imageRequests = new WeakMap();
|
|
750
1188
|
const timing = styleAndLayout();
|
|
751
1189
|
trace?.(`${page.url}: first style ${timing.style}ms, layout ${timing.layout}ms`);
|
|
@@ -759,10 +1197,24 @@ export async function createPageRenderer({
|
|
|
759
1197
|
/** Style, render tree, layout and display list of the current document. */
|
|
760
1198
|
function styleAndLayout() {
|
|
761
1199
|
const started = hostPerformance.now();
|
|
762
|
-
|
|
1200
|
+
const sheets = currentStyleSheets();
|
|
1201
|
+
const environment = { width: page.width, height: page.height, mobile };
|
|
1202
|
+
const changes = takeStyleChanges(sheets); // drained even when unused, so records do not pile up
|
|
1203
|
+
const taken = hostPerformance.now();
|
|
1204
|
+
styleEngine.compute(page.document, sheets, environment, fullStyle ? null : changes?.roots ?? null, changes?.self ?? null);
|
|
1205
|
+
const computed = hostPerformance.now();
|
|
1206
|
+
if (!hasState || hasStateStale) {
|
|
1207
|
+
recordHasState(selectorTraitsFor(sheets));
|
|
1208
|
+
hasStateStale = false;
|
|
1209
|
+
}
|
|
1210
|
+
const recorded = hostPerformance.now();
|
|
1211
|
+
if (verifyStyle && styleEngine.incremental) verifyIncrementalStyle(sheets, environment);
|
|
1212
|
+
const verified = hostPerformance.now();
|
|
763
1213
|
page.backgroundColor = documentBackground(page.document, styleEngine);
|
|
764
1214
|
page.renderTree = renderTreeBuilder.build(page.document, styleEngine, page.resources);
|
|
765
1215
|
const styled = hostPerformance.now();
|
|
1216
|
+
const ms = (from, to) => Math.round(to - from);
|
|
1217
|
+
lastStyleParts = `changes ${ms(started, taken)}, compute ${ms(taken, computed)}, record ${ms(computed, recorded)}, tree ${ms(verified, styled)}`;
|
|
766
1218
|
page.layout = layoutText(page.renderTree, {
|
|
767
1219
|
measureText, fontMetrics, width: page.width, viewportHeight: page.height, x: 0, y: 0,
|
|
768
1220
|
});
|
|
@@ -771,6 +1223,27 @@ export async function createPageRenderer({
|
|
|
771
1223
|
return { style: Math.round(styled - started), layout: Math.round(hostPerformance.now() - styled) };
|
|
772
1224
|
}
|
|
773
1225
|
|
|
1226
|
+
/** BUNINU_VERIFY_STYLE: compare an incremental restyle with a full one, element by element. */
|
|
1227
|
+
function verifyIncrementalStyle(sheets, environment) {
|
|
1228
|
+
const reference = new StyleEngine().compute(page.document, sheets, environment);
|
|
1229
|
+
const serialize = (style) => JSON.stringify(style, (key, value) => typeof value === "bigint" ? String(value) : value);
|
|
1230
|
+
let mismatches = 0;
|
|
1231
|
+
for (const element of page.document.querySelectorAll("*")) {
|
|
1232
|
+
const ours = serialize(styleEngine.get(element));
|
|
1233
|
+
const expected = serialize(reference.get(element));
|
|
1234
|
+
const pseudo = ["before", "after"].some((name) =>
|
|
1235
|
+
serialize(styleEngine.getPseudo(element, name)) !== serialize(reference.getPseudo(element, name)));
|
|
1236
|
+
if (ours === expected && !pseudo) continue;
|
|
1237
|
+
if (mismatches++ < 3) {
|
|
1238
|
+
const a = styleEngine.get(element);
|
|
1239
|
+
const b = reference.get(element);
|
|
1240
|
+
const fields = Object.keys(b).filter((key) => serialize(a[key]) !== serialize(b[key]));
|
|
1241
|
+
reportError(`buninu-browser: incremental style mismatch at <${element.localName} class="${element.getAttribute("class") ?? ""}">: ${pseudo ? "pseudo " : ""}${fields.join(", ")}`);
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
if (mismatches) reportError(`buninu-browser: incremental style: ${mismatches} element(s) differ from a full restyle`);
|
|
1245
|
+
}
|
|
1246
|
+
|
|
774
1247
|
function rebuildRenderTree(reason = "update") {
|
|
775
1248
|
const started = hostPerformance.now();
|
|
776
1249
|
page.dirty = false;
|
|
@@ -779,7 +1252,7 @@ export async function createPageRenderer({
|
|
|
779
1252
|
const timing = styleAndLayout();
|
|
780
1253
|
lastRebuildMs = hostPerformance.now() - started;
|
|
781
1254
|
trace?.(`${page.url}: restyle and relayout (${reason}) ${Math.round(lastRebuildMs)}ms`
|
|
782
|
-
+ ` (style ${timing.style}ms, layout ${timing.layout}ms)`);
|
|
1255
|
+
+ ` (style ${timing.style}ms for ${styleEngine.incremental ? styleEngine.restyled : "all"} elements: ${lastStyleReason}; ${lastStyleParts}, layout ${timing.layout}ms)`);
|
|
783
1256
|
}
|
|
784
1257
|
|
|
785
1258
|
// Each loadDocument starts a generation; stages of a superseded load stop.
|
|
@@ -849,37 +1322,40 @@ export async function createPageRenderer({
|
|
|
849
1322
|
const isHtml = contentType.toLowerCase().includes("html");
|
|
850
1323
|
const parsed = parseHTMLDocument(isHtml ? source : "<body></body>", url);
|
|
851
1324
|
phase("parse");
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
1325
|
+
// Stylesheets block rendering and scripts; images do not (only the
|
|
1326
|
+
// window load event waits for them, see whenImagesLoaded): one slow image
|
|
1327
|
+
// must not hold back the page's scripts. Images that are already here
|
|
1328
|
+
// when the styles are join the first paint; the rest are drawn as they come.
|
|
1329
|
+
const linkSheets = isHtml ? new WeakMap() : null;
|
|
1330
|
+
const styleSheets = isHtml ? loadStyleSheets(parsed.document, url, linkSheets) : Promise.resolve([]);
|
|
1331
|
+
const images = isHtml ? loadImageResources(parsed.document, url) : Promise.resolve(emptyImageResources());
|
|
1332
|
+
let imagesReady = null;
|
|
1333
|
+
images.then((loaded) => { imagesReady = loaded; });
|
|
855
1334
|
let timer;
|
|
856
|
-
let
|
|
857
|
-
|
|
1335
|
+
let sheets = await Promise.race([
|
|
1336
|
+
styleSheets,
|
|
858
1337
|
new Promise((resolve) => (timer = hostSetTimeout(resolve, PREVIEW_DELAY_MS, null))),
|
|
859
1338
|
]);
|
|
860
1339
|
hostClearTimeout(timer);
|
|
861
|
-
if (!current()) return discard(
|
|
862
|
-
if (!
|
|
863
|
-
//
|
|
1340
|
+
if (!current()) return discard(images, parsed);
|
|
1341
|
+
if (!sheets) {
|
|
1342
|
+
// Stylesheets are slow: show the document with the user-agent
|
|
864
1343
|
// defaults and its inline <style> sheets meanwhile.
|
|
865
|
-
replaceParsedDocument(parsed, inlineStyleSheets(parsed.document), emptyImageResources());
|
|
1344
|
+
replaceParsedDocument(parsed, inlineStyleSheets(parsed.document), emptyImageResources(), linkSheets);
|
|
1345
|
+
awaitImages(images, generation);
|
|
866
1346
|
render();
|
|
867
1347
|
phase("preview");
|
|
868
1348
|
commit.resolve();
|
|
869
|
-
|
|
870
|
-
if (!current()) return
|
|
871
|
-
phase(`${
|
|
1349
|
+
sheets = await styleSheets;
|
|
1350
|
+
if (!current()) return;
|
|
1351
|
+
phase(`${sheets.length} stylesheets`);
|
|
872
1352
|
// Same document, now styled; the scroll position is kept.
|
|
873
|
-
|
|
874
|
-
page.styleSheets = ready[0];
|
|
875
|
-
page.resources = ready[1].map;
|
|
876
|
-
initializeVideos(page.document, page.resources);
|
|
877
|
-
page.decodedImages = ready[1].decoded;
|
|
878
|
-
imageRequests = new WeakMap();
|
|
1353
|
+
page.styleSheets = sheets;
|
|
879
1354
|
rebuildRenderTree("stylesheets loaded");
|
|
880
1355
|
} else {
|
|
881
|
-
phase(`${
|
|
882
|
-
replaceParsedDocument(parsed,
|
|
1356
|
+
phase(`${sheets.length} stylesheets${imagesReady ? ` and ${imagesReady.decoded.length} images` : ""}`);
|
|
1357
|
+
replaceParsedDocument(parsed, sheets, imagesReady ?? emptyImageResources(), linkSheets);
|
|
1358
|
+
if (!imagesReady) awaitImages(images, generation);
|
|
883
1359
|
}
|
|
884
1360
|
phase("style and layout");
|
|
885
1361
|
if (!isHtml) {
|
|
@@ -905,7 +1381,6 @@ export async function createPageRenderer({
|
|
|
905
1381
|
phase("scripts");
|
|
906
1382
|
page.loading = false;
|
|
907
1383
|
refresh();
|
|
908
|
-
void hydrateGitHubBranchInfo(generation);
|
|
909
1384
|
phase("style, layout and raster after scripts");
|
|
910
1385
|
} else {
|
|
911
1386
|
finish();
|
|
@@ -913,14 +1388,46 @@ export async function createPageRenderer({
|
|
|
913
1388
|
}
|
|
914
1389
|
|
|
915
1390
|
/** Frees what a superseded load fetched. */
|
|
916
|
-
async function discard(
|
|
1391
|
+
async function discard(images, parsed = null) {
|
|
917
1392
|
parsed?.window?.happyDOM?.abort?.();
|
|
918
|
-
const
|
|
919
|
-
for (const image of
|
|
1393
|
+
const loaded = await images.catch(() => emptyImageResources());
|
|
1394
|
+
for (const image of loaded.decoded) image.delete();
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
/**
|
|
1398
|
+
* The document's initial images, still on their way: they are drawn when
|
|
1399
|
+
* they arrive (unless a script changed the src meanwhile), and the load
|
|
1400
|
+
* event waits for them.
|
|
1401
|
+
*/
|
|
1402
|
+
function awaitImages(images, generation) {
|
|
1403
|
+
const document = page.document;
|
|
1404
|
+
// queueImageResources must not fetch these again.
|
|
1405
|
+
for (const element of document.querySelectorAll("img")) {
|
|
1406
|
+
const src = element.getAttribute("src");
|
|
1407
|
+
if (src) imageRequests.set(element, src);
|
|
1408
|
+
}
|
|
1409
|
+
page.imagesLoaded = images.then((loaded) => {
|
|
1410
|
+
if (generation !== loadGeneration || document !== page.document) {
|
|
1411
|
+
for (const image of loaded.decoded) image.delete();
|
|
1412
|
+
return;
|
|
1413
|
+
}
|
|
1414
|
+
for (const { element, src, resource } of loaded.entries) {
|
|
1415
|
+
if (element.getAttribute("src") !== src || page.resources.get(element)) {
|
|
1416
|
+
resource.image.delete();
|
|
1417
|
+
continue;
|
|
1418
|
+
}
|
|
1419
|
+
page.resources.set(element, resource);
|
|
1420
|
+
page.decodedImages.push(resource.image);
|
|
1421
|
+
}
|
|
1422
|
+
page.dirty = true;
|
|
1423
|
+
scheduleFrame();
|
|
1424
|
+
}, () => {});
|
|
920
1425
|
}
|
|
921
1426
|
|
|
922
1427
|
function inlineStyleSheets(document) {
|
|
923
|
-
return [...document.querySelectorAll("style")]
|
|
1428
|
+
return [...document.querySelectorAll("style")]
|
|
1429
|
+
.filter((element) => !element.closest("noscript"))
|
|
1430
|
+
.map((element) => element.textContent ?? "");
|
|
924
1431
|
}
|
|
925
1432
|
|
|
926
1433
|
/** Brings style, layout and raster up to date; returns whether it repainted. */
|
|
@@ -944,6 +1451,15 @@ export async function createPageRenderer({
|
|
|
944
1451
|
const element = page.document.activeElement;
|
|
945
1452
|
if (!element || !["INPUT", "TEXTAREA"].includes(element.tagName) || element.disabled || element.readOnly) return;
|
|
946
1453
|
if (element.tagName === "INPUT" && !["text", "search", "url", "email", "tel", "password"].includes(element.type)) return;
|
|
1454
|
+
// Mobile IMEs often send committed text without a usable keydown (key 229
|
|
1455
|
+
// or Unidentified). Handle that before the normal beforeinput insertion.
|
|
1456
|
+
if (!backspace && element.getAttribute("id") === "buninu-piano-input" && audio && text.length === 1) {
|
|
1457
|
+
const note = pianoNote(text);
|
|
1458
|
+
if (note !== null) {
|
|
1459
|
+
playPiano(note);
|
|
1460
|
+
return;
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
947
1463
|
const value = String(element.value ?? "");
|
|
948
1464
|
let start = Number.isInteger(element.selectionStart) ? element.selectionStart : value.length;
|
|
949
1465
|
const end = Number.isInteger(element.selectionEnd) ? element.selectionEnd : start;
|
|
@@ -957,23 +1473,71 @@ export async function createPageRenderer({
|
|
|
957
1473
|
if (prevented) return;
|
|
958
1474
|
}
|
|
959
1475
|
element.value = value.slice(0, start) + text + value.slice(end);
|
|
1476
|
+
markStyleDirty(element); // :placeholder-shown, :valid / :invalid
|
|
960
1477
|
element.setSelectionRange?.(start + text.length, start + text.length);
|
|
961
1478
|
if (page.realm) page.realm.dispatch(element, new page.window.Event("input", { bubbles: true }));
|
|
962
1479
|
page.dirty = true;
|
|
963
1480
|
}
|
|
964
1481
|
|
|
1482
|
+
let pianoStreamId = null;
|
|
1483
|
+
let pianoMixer = null;
|
|
1484
|
+
let pianoIdleTimer = null;
|
|
1485
|
+
const PIANO_IDLE_MS = 2000;
|
|
1486
|
+
|
|
1487
|
+
function playPiano(note) {
|
|
1488
|
+
pianoMixer ??= new PianoMixer();
|
|
1489
|
+
pianoMixer.trigger(note);
|
|
1490
|
+
hostClearTimeout(pianoIdleTimer);
|
|
1491
|
+
pianoIdleTimer = hostSetTimeout(closePiano, PIANO_IDLE_MS);
|
|
1492
|
+
if (pianoStreamId !== null) return;
|
|
1493
|
+
const id = audio.open({ rate: 48000, channels: 1, name: "piano" }, (event) => {
|
|
1494
|
+
if (pianoStreamId !== id) return;
|
|
1495
|
+
if (event.event === "request") audio.write(id, pianoMixer.render(event.bytes));
|
|
1496
|
+
else if (event.event === "error" || event.event === "ended") {
|
|
1497
|
+
pianoStreamId = null;
|
|
1498
|
+
pianoMixer = null;
|
|
1499
|
+
if (event.event === "error") reportError(new Error(`piano audio: ${event.message}`));
|
|
1500
|
+
}
|
|
1501
|
+
});
|
|
1502
|
+
pianoStreamId = id;
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
function closePiano() {
|
|
1506
|
+
hostClearTimeout(pianoIdleTimer);
|
|
1507
|
+
pianoIdleTimer = null;
|
|
1508
|
+
if (pianoStreamId !== null) audio?.close(pianoStreamId);
|
|
1509
|
+
pianoStreamId = null;
|
|
1510
|
+
pianoMixer = null;
|
|
1511
|
+
}
|
|
1512
|
+
|
|
965
1513
|
function realmHooks(generation) {
|
|
966
1514
|
return {
|
|
967
|
-
|
|
1515
|
+
definedChanged(name) {
|
|
1516
|
+
// An upgrade changes :defined for that tag's elements only.
|
|
1517
|
+
if (selectorTraits.defined) for (const element of page.document.getElementsByTagName(String(name))) markStyleDirty(element);
|
|
1518
|
+
page.dirty = true;
|
|
1519
|
+
scheduleFrame();
|
|
1520
|
+
},
|
|
1521
|
+
touch(node = null) {
|
|
1522
|
+
markStyleDirty(node);
|
|
968
1523
|
page.dirty = true;
|
|
969
1524
|
scheduleFrame();
|
|
970
1525
|
},
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
1526
|
+
mediaState,
|
|
1527
|
+
playMedia,
|
|
1528
|
+
pauseMedia,
|
|
1529
|
+
seekMedia,
|
|
974
1530
|
lifecycle(name) {
|
|
975
|
-
if (generation
|
|
1531
|
+
if (generation !== loadGeneration) return;
|
|
1532
|
+
onLifecycle?.(name, generation);
|
|
1533
|
+
// Parsing and the deferred scripts are done: changes from async
|
|
1534
|
+
// scripts and late images get frames, without waiting for load.
|
|
1535
|
+
if (name === "DOMContentLoaded") {
|
|
1536
|
+
page.loading = false;
|
|
1537
|
+
scheduleFrame();
|
|
1538
|
+
}
|
|
976
1539
|
},
|
|
1540
|
+
whenImagesLoaded: () => page.imagesLoaded,
|
|
977
1541
|
afterTask() {},
|
|
978
1542
|
boundsOf(element) {
|
|
979
1543
|
if (page.dirty) rebuildRenderTree("script read geometry");
|
|
@@ -997,8 +1561,20 @@ export async function createPageRenderer({
|
|
|
997
1561
|
if (page.dirty) rebuildRenderTree("getComputedStyle");
|
|
998
1562
|
return styleEngine.get(element);
|
|
999
1563
|
},
|
|
1000
|
-
navigate: (url) =>
|
|
1564
|
+
navigate: (url) => navigateTo(String(url)),
|
|
1565
|
+
activateLink(anchor) {
|
|
1566
|
+
const href = new URL(anchor.getAttribute("href"), page.url).href;
|
|
1567
|
+
const name = anchor.hasAttribute("download") ? anchor.getAttribute("download") ?? "" : null;
|
|
1568
|
+
if (href.startsWith("blob:") && name !== null) void downloadBlob(href, name);
|
|
1569
|
+
else navigateTo(href, name);
|
|
1570
|
+
},
|
|
1571
|
+
submitForm,
|
|
1001
1572
|
async fetchScript(url) {
|
|
1573
|
+
if (/^(?:blob|data):/i.test(url)) {
|
|
1574
|
+
const response = await fetch(url);
|
|
1575
|
+
if (!response.ok) throw new Error(`script ${url} failed with HTTP ${response.status}`);
|
|
1576
|
+
return response.text();
|
|
1577
|
+
}
|
|
1002
1578
|
const response = await fetchResource(url, "script");
|
|
1003
1579
|
if (!response.ok) throw new Error(`script ${url} failed with HTTP ${response.status}`);
|
|
1004
1580
|
return new TextDecoder().decode(response.body);
|
|
@@ -1020,6 +1596,18 @@ export async function createPageRenderer({
|
|
|
1020
1596
|
};
|
|
1021
1597
|
}
|
|
1022
1598
|
|
|
1599
|
+
function submitForm(form, submitter = null) {
|
|
1600
|
+
const method = (submitter?.getAttribute("formmethod") ?? form.getAttribute("method") ?? "get").toLowerCase();
|
|
1601
|
+
if (method !== "get") return;
|
|
1602
|
+
const action = submitter?.getAttribute("formaction") ?? form.getAttribute("action") ?? page.url;
|
|
1603
|
+
const url = new URL(action || page.url, page.url);
|
|
1604
|
+
url.search = "";
|
|
1605
|
+
for (const [name, value] of new page.window.FormData(form, submitter)) {
|
|
1606
|
+
if (typeof value === "string") url.searchParams.append(name, value);
|
|
1607
|
+
}
|
|
1608
|
+
navigateTo(url.href);
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1023
1611
|
/** The border box of an element's layout box in viewport coordinates. */
|
|
1024
1612
|
function viewportBounds(element) {
|
|
1025
1613
|
let fixed = false;
|
|
@@ -1044,27 +1632,66 @@ export async function createPageRenderer({
|
|
|
1044
1632
|
}
|
|
1045
1633
|
|
|
1046
1634
|
/**
|
|
1047
|
-
*
|
|
1048
|
-
*
|
|
1635
|
+
* Navigation a page starts (a link, location.href = ...). A fragment of
|
|
1636
|
+
* the current document is navigated to at once, as HTML specifies, so the
|
|
1637
|
+
* page's own click and popstate handlers already see the new location;
|
|
1638
|
+
* the controller then only records the history entry (with the scroll
|
|
1639
|
+
* position being left). Anything else goes to the controller.
|
|
1049
1640
|
*/
|
|
1050
|
-
function
|
|
1641
|
+
function navigateTo(url, downloadName = null) {
|
|
1642
|
+
const from = downloadName === null ? fragmentNavigation(url) : null;
|
|
1643
|
+
requestNavigation(url, downloadName, from);
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
/**
|
|
1647
|
+
* Navigates to `url` at once if it is a fragment of the current document;
|
|
1648
|
+
* returns the scroll offset it left, or null when it is not such a URL.
|
|
1649
|
+
*/
|
|
1650
|
+
function fragmentNavigation(url) {
|
|
1651
|
+
const target = new URL(url, page.url);
|
|
1652
|
+
const current = new URL(page.url);
|
|
1653
|
+
target.hash = current.hash = "";
|
|
1654
|
+
if (!page.realm || !String(url).includes("#") || target.href !== current.href) return null;
|
|
1655
|
+
const from = { x: viewport.x, y: viewport.y };
|
|
1656
|
+
void showFragment(new URL(url, page.url).href);
|
|
1657
|
+
return from;
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
async function showFragment(url) {
|
|
1661
|
+
// :target moves from the old fragment's element to the new one's.
|
|
1662
|
+
if (selectorTraits.target) {
|
|
1663
|
+
for (const href of [page.url, url]) {
|
|
1664
|
+
let id = "";
|
|
1665
|
+
try { id = decodeURIComponent(new URL(href).hash.slice(1)); } catch {}
|
|
1666
|
+
const target = id && page.document.getElementById(id);
|
|
1667
|
+
if (target) markStyleDirty(target);
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
page.url = url;
|
|
1671
|
+
scrollToFragment(url);
|
|
1672
|
+
// Pages that map fragments to their own targets scroll from popstate or hashchange listeners.
|
|
1673
|
+
await page.realm?.navigateToFragment(url);
|
|
1674
|
+
render();
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
/** Scrolls to the fragment's target (HTML §7.4.6.4). */
|
|
1678
|
+
function scrollToFragment(href) {
|
|
1051
1679
|
const url = new URL(href);
|
|
1052
1680
|
if (!url.hash || url.hash === "#") {
|
|
1053
1681
|
viewport.scrollTo(0, 0);
|
|
1054
|
-
return
|
|
1682
|
+
return;
|
|
1055
1683
|
}
|
|
1056
1684
|
let identifier;
|
|
1057
1685
|
try {
|
|
1058
|
-
identifier =
|
|
1686
|
+
identifier = decodeURIComponent(url.hash.slice(1));
|
|
1059
1687
|
} catch {
|
|
1060
|
-
identifier =
|
|
1688
|
+
identifier = url.hash.slice(1);
|
|
1061
1689
|
}
|
|
1062
1690
|
const target = page.document.getElementById(identifier)
|
|
1063
1691
|
?? [...page.document.getElementsByTagName("a")]
|
|
1064
1692
|
.find((element) => element.getAttribute("name") === identifier);
|
|
1065
1693
|
const bounds = target && elementBounds(page.renderTree, page.layout, target);
|
|
1066
1694
|
if (bounds) viewport.scrollTo(viewport.x, bounds.top);
|
|
1067
|
-
return Boolean(target);
|
|
1068
1695
|
}
|
|
1069
1696
|
|
|
1070
1697
|
function collectHints() {
|
|
@@ -1102,6 +1729,18 @@ export async function createPageRenderer({
|
|
|
1102
1729
|
render();
|
|
1103
1730
|
}
|
|
1104
1731
|
|
|
1732
|
+
async function downloadBlob(url, name) {
|
|
1733
|
+
try {
|
|
1734
|
+
const response = await fetch(url);
|
|
1735
|
+
if (!response.ok) throw new Error(`blob download failed: HTTP ${response.status}`);
|
|
1736
|
+
const data = new Uint8Array(await response.arrayBuffer());
|
|
1737
|
+
if (data.byteLength > 64 * 1024 * 1024) throw new Error("blob download exceeds 64 MB");
|
|
1738
|
+
requestDownload({ url, name, data });
|
|
1739
|
+
} catch (error) {
|
|
1740
|
+
reportError(error);
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1105
1744
|
const api = {
|
|
1106
1745
|
loadDocument,
|
|
1107
1746
|
/** Enables page scripts once the sandbox is ready: "ses", "host", or "off". */
|
|
@@ -1116,18 +1755,7 @@ export async function createPageRenderer({
|
|
|
1116
1755
|
return page.realm?.console ?? [];
|
|
1117
1756
|
},
|
|
1118
1757
|
/** Same-document navigation: keep the document, move to the fragment. */
|
|
1119
|
-
|
|
1120
|
-
page.url = url;
|
|
1121
|
-
const found = scrollToFragment(url);
|
|
1122
|
-
// Pages that map fragments to their own targets scroll from a hashchange
|
|
1123
|
-
// listener. Rendered Markdown (GitHub, Gitea) prefixes heading ids with
|
|
1124
|
-
// "user-content-" and relies on such a script; if the page did not
|
|
1125
|
-
// scroll, go to that target instead.
|
|
1126
|
-
const before = viewport.y;
|
|
1127
|
-
await page.realm?.navigateToFragment(url);
|
|
1128
|
-
if (!found && viewport.y === before) scrollToFragment(url, "user-content-");
|
|
1129
|
-
render();
|
|
1130
|
-
},
|
|
1758
|
+
showFragment,
|
|
1131
1759
|
url() {
|
|
1132
1760
|
return page.url;
|
|
1133
1761
|
},
|
|
@@ -1235,6 +1863,14 @@ export async function createPageRenderer({
|
|
|
1235
1863
|
},
|
|
1236
1864
|
press(key, options = {}) {
|
|
1237
1865
|
refresh();
|
|
1866
|
+
const pianoInput = page.document?.activeElement;
|
|
1867
|
+
if (audio && pianoInput?.getAttribute?.("id") === "buninu-piano-input") {
|
|
1868
|
+
const note = pianoNote(String(key), String(options.code ?? ""));
|
|
1869
|
+
if (note !== null && !options.repeat && !options.modifiers) {
|
|
1870
|
+
playPiano(note);
|
|
1871
|
+
return { x: viewport.x, y: viewport.y };
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1238
1874
|
// Pages see keydown/keyup first; preventDefault() cancels the default scroll.
|
|
1239
1875
|
if (page.realm) {
|
|
1240
1876
|
const target = page.document.activeElement ?? page.document.body;
|
|
@@ -1248,7 +1884,15 @@ export async function createPageRenderer({
|
|
|
1248
1884
|
const prevented = page.realm.dispatch(target, new page.window.KeyboardEvent("keydown", init));
|
|
1249
1885
|
if (!prevented && !init.ctrlKey && !init.metaKey && !init.altKey) {
|
|
1250
1886
|
if (key === "Backspace") editFocusedText("", true);
|
|
1251
|
-
else if (key === "Enter" && target.tagName === "TEXTAREA")
|
|
1887
|
+
else if (key === "Enter" && target.tagName === "TEXTAREA") {
|
|
1888
|
+
// A search IME's action key submits a search form. Other textareas
|
|
1889
|
+
// keep their normal newline behavior.
|
|
1890
|
+
const form = target.form;
|
|
1891
|
+
if (target.getAttribute("inputmode")?.toLowerCase() === "search" && form && form.checkValidity()) {
|
|
1892
|
+
const event = new page.window.SubmitEvent("submit", { bubbles: true, cancelable: true, submitter: form });
|
|
1893
|
+
if (!page.realm.dispatch(form, event)) submitForm(form);
|
|
1894
|
+
} else editFocusedText("\n");
|
|
1895
|
+
}
|
|
1252
1896
|
else if (String(key).length === 1) editFocusedText(String(key));
|
|
1253
1897
|
}
|
|
1254
1898
|
page.realm.dispatch(target, new page.window.KeyboardEvent("keyup", init));
|
|
@@ -1283,8 +1927,33 @@ export async function createPageRenderer({
|
|
|
1283
1927
|
*/
|
|
1284
1928
|
click(x, y) {
|
|
1285
1929
|
refresh();
|
|
1286
|
-
|
|
1930
|
+
// Activation can toggle checked state (radios in a group, through a
|
|
1931
|
+
// label) without a DOM mutation, so the next restyle is a full one.
|
|
1932
|
+
markStyleDirty(null); // full: click
|
|
1933
|
+
let region = hitTest(page.renderTree, page.layout, x, y, viewport, viewport);
|
|
1934
|
+
if (!region) {
|
|
1935
|
+
// Some search forms draw a wide control around a narrow textarea and
|
|
1936
|
+
// rely on script to forward clicks. Preserve that affordance when the
|
|
1937
|
+
// form has exactly one search field, even if its script did not run.
|
|
1938
|
+
for (const form of page.document.querySelectorAll('form[role="search"]')) {
|
|
1939
|
+
const bounds = viewportBounds(form);
|
|
1940
|
+
if (!bounds || x < bounds.left || x >= bounds.left + bounds.width
|
|
1941
|
+
|| y < bounds.top || y >= bounds.top + bounds.height) continue;
|
|
1942
|
+
const fields = form.querySelectorAll('textarea[inputmode="search"], input[type="search"]');
|
|
1943
|
+
if (fields.length === 1) region = { element: fields[0] };
|
|
1944
|
+
break;
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1287
1947
|
if (!region) return null;
|
|
1948
|
+
if (page.url === "about:audio") {
|
|
1949
|
+
const key = region.element.closest?.("[data-piano-note]");
|
|
1950
|
+
const note = Number(key?.getAttribute("data-piano-note"));
|
|
1951
|
+
if (key && Number.isInteger(note) && note >= 0 && note <= 127 && audio) {
|
|
1952
|
+
playPiano(note);
|
|
1953
|
+
page.document.getElementById("buninu-piano-input")?.focus();
|
|
1954
|
+
return null;
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1288
1957
|
if (["INPUT", "TEXTAREA", "SELECT", "BUTTON"].includes(region.element.tagName)) {
|
|
1289
1958
|
region.element.focus();
|
|
1290
1959
|
}
|
|
@@ -1296,10 +1965,8 @@ export async function createPageRenderer({
|
|
|
1296
1965
|
refresh();
|
|
1297
1966
|
if (prevented) return null;
|
|
1298
1967
|
}
|
|
1299
|
-
if (region.element.tagName === "VIDEO") {
|
|
1300
|
-
|
|
1301
|
-
if (state?.playing) pauseVideo(region.element);
|
|
1302
|
-
else void playVideo(region.element);
|
|
1968
|
+
if (region.element.tagName === "AUDIO" || region.element.tagName === "VIDEO") {
|
|
1969
|
+
clickMedia(region.element, x, y);
|
|
1303
1970
|
return null;
|
|
1304
1971
|
}
|
|
1305
1972
|
const anchor = region.element.closest?.("a[href]");
|
|
@@ -1309,7 +1976,13 @@ export async function createPageRenderer({
|
|
|
1309
1976
|
this.runJavaScriptURL(href);
|
|
1310
1977
|
return null;
|
|
1311
1978
|
}
|
|
1312
|
-
|
|
1979
|
+
if (href.startsWith("blob:") && anchor.hasAttribute("download")) {
|
|
1980
|
+
void downloadBlob(href, anchor.getAttribute("download") ?? "");
|
|
1981
|
+
return null;
|
|
1982
|
+
}
|
|
1983
|
+
if (anchor.hasAttribute("download")) return { url: href, download: anchor.getAttribute("download") ?? "" };
|
|
1984
|
+
const fragmentFrom = fragmentNavigation(href);
|
|
1985
|
+
return fragmentFrom ? { url: href, fragmentFrom } : href;
|
|
1313
1986
|
},
|
|
1314
1987
|
/**
|
|
1315
1988
|
* Navigating to a javascript: URL runs its percent-decoded source as a
|
|
@@ -1383,6 +2056,7 @@ export async function createPageRenderer({
|
|
|
1383
2056
|
hostClearTimeout(prepaintTimer);
|
|
1384
2057
|
hostClearTimeout(frameTimer);
|
|
1385
2058
|
page.realm?.dispose();
|
|
2059
|
+
closePiano();
|
|
1386
2060
|
disposeVideos();
|
|
1387
2061
|
page.window?.happyDOM?.abort?.();
|
|
1388
2062
|
for (const image of page.decodedImages) image.delete();
|
|
@@ -1395,26 +2069,66 @@ export async function createPageRenderer({
|
|
|
1395
2069
|
render();
|
|
1396
2070
|
return api;
|
|
1397
2071
|
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
const
|
|
1401
|
-
|
|
2072
|
+
/** Fetches the document's linked stylesheets (in parallel), then lists all its sheets. */
|
|
2073
|
+
async function loadStyleSheets(document, documentUrl, linkSheets) {
|
|
2074
|
+
const pending = [];
|
|
2075
|
+
collectStyleSheets(document, documentUrl, linkSheets, pending);
|
|
2076
|
+
await Promise.all(pending);
|
|
2077
|
+
return collectStyleSheets(document, documentUrl, linkSheets);
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
/**
|
|
2081
|
+
* The document's style sheets as they are now, in document order: each
|
|
2082
|
+
* <style>'s current text and each loaded <link rel=stylesheet>. Sheets that
|
|
2083
|
+
* scripts add later count too; a link not fetched yet is requested (its
|
|
2084
|
+
* promise goes to `pending`) and redraws the page when it arrives.
|
|
2085
|
+
*/
|
|
2086
|
+
function collectStyleSheets(document, documentUrl, linkSheets, pending = null) {
|
|
2087
|
+
const sheets = [];
|
|
2088
|
+
for (const element of document.querySelectorAll("style, link[rel~='stylesheet']")) {
|
|
2089
|
+
if (element.closest("noscript")) continue;
|
|
2090
|
+
if (element.tagName === "STYLE") {
|
|
2091
|
+
sheets.push(element.textContent ?? "");
|
|
2092
|
+
continue;
|
|
2093
|
+
}
|
|
1402
2094
|
const href = element.getAttribute("href");
|
|
1403
|
-
|
|
1404
|
-
try {
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
2095
|
+
let url;
|
|
2096
|
+
try { url = href ? new URL(href, documentUrl).href : null; } catch { url = null; }
|
|
2097
|
+
if (!url) continue;
|
|
2098
|
+
const loaded = linkSheets.get(element);
|
|
2099
|
+
if (loaded?.url === url) {
|
|
2100
|
+
if (loaded.text !== null) sheets.push(loaded.text);
|
|
2101
|
+
continue;
|
|
1410
2102
|
}
|
|
1411
|
-
|
|
1412
|
-
|
|
2103
|
+
const entry = { url, text: null };
|
|
2104
|
+
linkSheets.set(element, entry);
|
|
2105
|
+
const request = fetchResource(url, "stylesheet").then((response) => {
|
|
2106
|
+
if (response.ok) entry.text = new TextDecoder().decode(response.body);
|
|
2107
|
+
}, () => {
|
|
2108
|
+
// A failed stylesheet must not prevent the document itself from loading.
|
|
2109
|
+
}).then(() => {
|
|
2110
|
+
if (entry.text === null || document !== page.document || linkSheets.get(element) !== entry) return;
|
|
2111
|
+
page.dirty = true;
|
|
2112
|
+
scheduleFrame();
|
|
2113
|
+
});
|
|
2114
|
+
pending?.push(request);
|
|
2115
|
+
}
|
|
2116
|
+
return sheets;
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
/** The current sheet list, the same array while nothing changed (the style engine caches parsed rules by it). */
|
|
2120
|
+
function currentStyleSheets() {
|
|
2121
|
+
if (!page.linkSheets) return page.styleSheets;
|
|
2122
|
+
const sheets = collectStyleSheets(page.document, page.url, page.linkSheets);
|
|
2123
|
+
const previous = page.styleSheets;
|
|
2124
|
+
if (previous.length === sheets.length && sheets.every((sheet, index) => sheet === previous[index])) return previous;
|
|
2125
|
+
return page.styleSheets = sheets;
|
|
1413
2126
|
}
|
|
1414
2127
|
|
|
1415
2128
|
async function loadImageResources(document, documentUrl) {
|
|
1416
2129
|
const map = new WeakMap();
|
|
1417
2130
|
const decoded = [];
|
|
2131
|
+
const entries = [];
|
|
1418
2132
|
await Promise.all([...document.querySelectorAll("img")].map(async (element) => {
|
|
1419
2133
|
const src = element.getAttribute("src");
|
|
1420
2134
|
if (!src) return;
|
|
@@ -1429,20 +2143,22 @@ export async function createPageRenderer({
|
|
|
1429
2143
|
const resource = Object.freeze({ width, height, image, url: response.url });
|
|
1430
2144
|
map.set(element, resource);
|
|
1431
2145
|
decoded.push(image);
|
|
2146
|
+
entries.push({ element, src, resource });
|
|
1432
2147
|
} catch {
|
|
1433
2148
|
// Broken images keep their fallback replaced-element dimensions.
|
|
1434
2149
|
}
|
|
1435
2150
|
}));
|
|
1436
|
-
return { map, decoded };
|
|
2151
|
+
return { map, decoded, entries };
|
|
1437
2152
|
}
|
|
1438
2153
|
|
|
2154
|
+
/** Every <video> and <audio> gets its (painted) resource: size, frame, playing, time. */
|
|
1439
2155
|
function initializeVideos(document, resources) {
|
|
1440
|
-
for (const element of document.querySelectorAll("video")) {
|
|
2156
|
+
for (const element of document.querySelectorAll("video, audio")) {
|
|
1441
2157
|
if (resources.get(element)) continue;
|
|
1442
|
-
const
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
image: null, playing: false,
|
|
2158
|
+
const audioOnly = element.tagName === "AUDIO";
|
|
2159
|
+
resources.set(element, { width: audioOnly ? 300 : Number(element.getAttribute("width")) || 300,
|
|
2160
|
+
height: audioOnly ? 32 : Number(element.getAttribute("height")) || 150,
|
|
2161
|
+
bitmapWidth: 0, bitmapHeight: 0, image: null, playing: false, time: 0, duration: NaN });
|
|
1446
2162
|
}
|
|
1447
2163
|
}
|
|
1448
2164
|
|
|
@@ -1451,155 +2167,412 @@ export async function createPageRenderer({
|
|
|
1451
2167
|
try { return src ? new URL(src, page.url).href : null; } catch { return null; }
|
|
1452
2168
|
}
|
|
1453
2169
|
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
2170
|
+
// ---- media: <video> and <audio> share one pipeline. An <audio> is media
|
|
2171
|
+
// whose picture is not shown (its box is only the 300x32 control bar), and
|
|
2172
|
+
// a video's own sound plays too. wasmpeg decodes (frames and PCM); PCM goes
|
|
2173
|
+
// to the controller, which plays it through PulseAudio paced to real time.
|
|
2174
|
+
// One clock: `base` is the position where the current run started and
|
|
2175
|
+
// `clockStart` when it did. Frames are drawn when the clock reaches them.
|
|
2176
|
+
// wasmpeg seeks video (to a keyframe) but not audio, so an audio seek
|
|
2177
|
+
// decodes forward and skips (from the start again when going back).
|
|
2178
|
+
|
|
2179
|
+
/** HTMLMediaElement state for page script. */
|
|
2180
|
+
function mediaState(element) {
|
|
2181
|
+
const state = page.media.get(element);
|
|
2182
|
+
return { paused: !state?.playing, ended: Boolean(state?.ended), currentTime: state ? mediaTime(state) : 0,
|
|
2183
|
+
duration: state?.duration ?? NaN };
|
|
1457
2184
|
}
|
|
1458
2185
|
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
2186
|
+
/** The element's playback state, created (not loaded) on first use. */
|
|
2187
|
+
function mediaStateFor(element) {
|
|
2188
|
+
const resource = page.resources.get(element);
|
|
2189
|
+
const url = videoSource(element);
|
|
2190
|
+
if (!videoDecoder || !resource || !url) return null;
|
|
2191
|
+
let state = page.media.get(element);
|
|
2192
|
+
if (state && state.url !== url) {
|
|
2193
|
+
closeMedia(state);
|
|
2194
|
+
state = null;
|
|
1468
2195
|
}
|
|
1469
|
-
state
|
|
1470
|
-
|
|
1471
|
-
|
|
2196
|
+
if (!state) {
|
|
2197
|
+
state = { element, url, resource, bytes: null, loading: null, showsPicture: element.tagName === "VIDEO",
|
|
2198
|
+
video: null, sound: null, playing: false, ended: false, duration: NaN, base: 0, clockStart: null,
|
|
2199
|
+
timer: null, lastTimeUpdate: -1, generation: loadGeneration, document: page.document };
|
|
2200
|
+
page.media.set(element, state);
|
|
2201
|
+
}
|
|
2202
|
+
return state;
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
/** Current position in seconds. */
|
|
2206
|
+
function mediaTime(state) {
|
|
2207
|
+
const running = state.playing && state.clockStart !== null ? (hostPerformance.now() - state.clockStart) / 1000 : 0;
|
|
2208
|
+
const time = state.base + running;
|
|
2209
|
+
return Number.isFinite(state.duration) ? Math.min(state.duration, time) : time;
|
|
2210
|
+
}
|
|
2211
|
+
|
|
2212
|
+
/** Fires a media event (play, pause, ended, seeked, timeupdate...) at the element. */
|
|
2213
|
+
function mediaEvent(state, type) {
|
|
2214
|
+
if (state.document !== page.document || !page.realm) return;
|
|
2215
|
+
try {
|
|
2216
|
+
page.realm.dispatch(state.element, new page.window.Event(type));
|
|
2217
|
+
} catch {}
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
/** The control bar and frame redraw without a relayout. */
|
|
2221
|
+
function repaintMedia() {
|
|
1472
2222
|
rasterized.list = null;
|
|
1473
2223
|
render();
|
|
1474
2224
|
}
|
|
1475
2225
|
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
if (state)
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
2226
|
+
/** Fetches (once), probes, and opens the decoders for what the file holds. */
|
|
2227
|
+
function loadMedia(state) {
|
|
2228
|
+
state.loading ??= (async () => {
|
|
2229
|
+
if (!state.bytes) {
|
|
2230
|
+
const response = await fetchResource(state.url, state.showsPicture ? "video" : "audio");
|
|
2231
|
+
if (!response.ok) throw new Error(`media ${state.url} failed with HTTP ${response.status}`);
|
|
2232
|
+
state.bytes = response.body;
|
|
2233
|
+
}
|
|
2234
|
+
if (!Number.isFinite(state.duration) && !state.probed) {
|
|
2235
|
+
const probe = await videoDecoder.probe(state.bytes);
|
|
2236
|
+
state.probed = true;
|
|
2237
|
+
state.duration = probe.duration ?? NaN;
|
|
2238
|
+
state.hasVideo = state.showsPicture && probe.video?.width > 0;
|
|
2239
|
+
state.hasAudio = probe.audio?.sampleRate > 0;
|
|
2240
|
+
state.resource.duration = state.duration;
|
|
2241
|
+
mediaEvent(state, "loadedmetadata");
|
|
2242
|
+
}
|
|
2243
|
+
if (state.hasVideo && !state.video) await openVideoDecoder(state);
|
|
2244
|
+
if (state.hasAudio && audio && !state.sound) await openAudioDecoder(state);
|
|
2245
|
+
})().finally(() => { state.loading = null; });
|
|
2246
|
+
return state.loading;
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2249
|
+
// FFmpeg probes the container; formats without magic bytes take a hint from the extension.
|
|
2250
|
+
function decodeOptions(state) {
|
|
2251
|
+
const hint = formatHint(new URL(state.url).pathname);
|
|
2252
|
+
return hint ? { format: hint } : {};
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
async function openVideoDecoder(state) {
|
|
2256
|
+
state.video?.decoder.close();
|
|
2257
|
+
const decoder = await videoDecoder.decode(state.bytes, decodeOptions(state));
|
|
2258
|
+
state.video = { decoder, frameTime: 0, done: false };
|
|
2259
|
+
trace?.(`video decoded ${state.url}: ${decoder.width}x${decoder.height}`);
|
|
2260
|
+
if (state.resource.width !== decoder.width || state.resource.height !== decoder.height) {
|
|
2261
|
+
state.resource.width = decoder.width;
|
|
2262
|
+
state.resource.height = decoder.height;
|
|
2263
|
+
page.dirty = true;
|
|
2264
|
+
refresh();
|
|
2265
|
+
}
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
async function openAudioDecoder(state) {
|
|
2269
|
+
state.sound?.decoder.close();
|
|
2270
|
+
const decoder = await videoDecoder.decodeAudio(state.bytes, decodeOptions(state));
|
|
2271
|
+
state.sound = { decoder, id: null, position: 0, pending: null, history: [], finishing: false, done: false };
|
|
2272
|
+
trace?.(`audio decoded ${state.url}: ${decoder.sampleRate} Hz, ${decoder.channels} channels`);
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
/** A click on the media: on its progress track it seeks, anywhere else it plays or pauses. */
|
|
2276
|
+
function clickMedia(element, x, y) {
|
|
2277
|
+
const box = viewportBounds(element);
|
|
2278
|
+
const state = page.media.get(element);
|
|
2279
|
+
const duration = state?.duration;
|
|
2280
|
+
if (box && Number.isFinite(duration) && duration > 0) {
|
|
2281
|
+
const layout = mediaBarLayout(box.width);
|
|
2282
|
+
const onBar = element.tagName === "AUDIO" || y - box.top >= box.height - MEDIA_BAR_HEIGHT;
|
|
2283
|
+
const relative = x - box.left;
|
|
2284
|
+
if (onBar && relative >= layout.trackStart - 6) {
|
|
2285
|
+
const fraction = Math.min(1, Math.max(0, (relative - layout.trackStart) / (layout.trackEnd - layout.trackStart)));
|
|
2286
|
+
void seekMedia(element, fraction * duration);
|
|
1515
2287
|
return;
|
|
1516
2288
|
}
|
|
1517
|
-
} else {
|
|
1518
|
-
state.playing = true;
|
|
1519
|
-
resource.playing = true;
|
|
1520
2289
|
}
|
|
1521
|
-
|
|
2290
|
+
if (state?.playing) pauseMedia(element);
|
|
2291
|
+
else void playMedia(element);
|
|
1522
2292
|
}
|
|
1523
2293
|
|
|
1524
|
-
function
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
state.
|
|
1534
|
-
|
|
1535
|
-
|
|
2294
|
+
async function playMedia(element) {
|
|
2295
|
+
const state = mediaStateFor(element);
|
|
2296
|
+
if (!state || state.playing) return;
|
|
2297
|
+
setMediaPlaying(state, true);
|
|
2298
|
+
try {
|
|
2299
|
+
await loadMedia(state);
|
|
2300
|
+
if (state.ended) {
|
|
2301
|
+
state.ended = false;
|
|
2302
|
+
await positionMedia(state, 0);
|
|
2303
|
+
} else if (state.needsPosition) {
|
|
2304
|
+
// After a pause the audio decoder is ahead of where playing resumes.
|
|
2305
|
+
await positionMedia(state, state.base, { videoToo: false });
|
|
2306
|
+
}
|
|
2307
|
+
state.needsPosition = false;
|
|
2308
|
+
} catch (error) {
|
|
2309
|
+
if (state.document === page.document) reportError(error);
|
|
2310
|
+
closeMedia(state);
|
|
2311
|
+
page.media.delete(element);
|
|
2312
|
+
repaintMedia();
|
|
1536
2313
|
return;
|
|
1537
2314
|
}
|
|
2315
|
+
if (!state.playing || state.document !== page.document) return;
|
|
2316
|
+
if (!state.video && !state.sound) {
|
|
2317
|
+
// Nothing this engine can play (audio with no audio output).
|
|
2318
|
+
setMediaPlaying(state, false);
|
|
2319
|
+
reportError(new Error(`media ${state.url}: nothing to play`));
|
|
2320
|
+
return;
|
|
2321
|
+
}
|
|
2322
|
+
startMedia(state);
|
|
2323
|
+
mediaEvent(state, "play");
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
function pauseMedia(element) {
|
|
2327
|
+
const state = page.media.get(element);
|
|
2328
|
+
if (!state?.playing) return;
|
|
2329
|
+
state.base = mediaTime(state);
|
|
2330
|
+
stopClock(state);
|
|
2331
|
+
state.needsPosition = true;
|
|
2332
|
+
setMediaPlaying(state, false);
|
|
2333
|
+
mediaEvent(state, "pause");
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
async function seekMedia(element, seconds) {
|
|
2337
|
+
const state = mediaStateFor(element);
|
|
2338
|
+
if (!state) return;
|
|
2339
|
+
const wasPlaying = state.playing;
|
|
2340
|
+
if (wasPlaying) stopClock(state);
|
|
2341
|
+
try {
|
|
2342
|
+
await loadMedia(state);
|
|
2343
|
+
const target = Math.max(0, Math.min(Number.isFinite(state.duration) ? state.duration : Infinity, Number(seconds) || 0));
|
|
2344
|
+
state.ended = false;
|
|
2345
|
+
await positionMedia(state, target);
|
|
2346
|
+
state.needsPosition = false;
|
|
2347
|
+
// A paused video shows the frame it landed on.
|
|
2348
|
+
if (state.video && !wasPlaying) drawVideoFrame(state, true);
|
|
2349
|
+
} catch (error) {
|
|
2350
|
+
reportError(error);
|
|
2351
|
+
return;
|
|
2352
|
+
}
|
|
2353
|
+
if (wasPlaying && state.playing && state.document === page.document) startMedia(state);
|
|
2354
|
+
state.resource.time = mediaTime(state);
|
|
2355
|
+
repaintMedia();
|
|
2356
|
+
mediaEvent(state, "seeked");
|
|
2357
|
+
mediaEvent(state, "timeupdate");
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
/** Moves the decoders (and the clock's base) to `seconds`. */
|
|
2361
|
+
async function positionMedia(state, seconds, { videoToo = true } = {}) {
|
|
2362
|
+
state.base = seconds;
|
|
2363
|
+
if (state.video && videoToo) {
|
|
2364
|
+
if (state.video.done || seconds === 0) await openVideoDecoder(state);
|
|
2365
|
+
if (seconds > 0) state.video.decoder.seek(seconds * 1000);
|
|
2366
|
+
state.video.frameTime = seconds;
|
|
2367
|
+
state.video.done = false;
|
|
2368
|
+
}
|
|
2369
|
+
if (state.sound) await positionAudio(state, seconds);
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
/** Puts the audio decoder at `seconds`: from the kept history, by decoding ahead, or from the start. */
|
|
2373
|
+
async function positionAudio(state, seconds) {
|
|
2374
|
+
const sound = state.sound;
|
|
2375
|
+
const { sampleRate, channels } = sound.decoder;
|
|
2376
|
+
const target = Math.max(0, Math.round(seconds * sampleRate));
|
|
2377
|
+
sound.done = false;
|
|
2378
|
+
const historyStart = sound.history[0]?.start ?? Infinity;
|
|
2379
|
+
if (target >= historyStart && target < sound.position) {
|
|
2380
|
+
// Resume inside audio already decoded: take it from the history.
|
|
2381
|
+
const parts = [];
|
|
2382
|
+
for (const chunk of sound.history) {
|
|
2383
|
+
const frames = chunk.samples.length / channels;
|
|
2384
|
+
if (chunk.start + frames <= target) continue;
|
|
2385
|
+
parts.push(chunk.start >= target ? chunk.samples : chunk.samples.subarray((target - chunk.start) * channels));
|
|
2386
|
+
}
|
|
2387
|
+
if (sound.pending) parts.push(sound.pending);
|
|
2388
|
+
sound.pending = concatSamples(parts);
|
|
2389
|
+
sound.position = target;
|
|
2390
|
+
sound.history = [];
|
|
2391
|
+
return;
|
|
2392
|
+
}
|
|
2393
|
+
if (target < sound.position) await openAudioDecoder(state);
|
|
2394
|
+
const current = state.sound;
|
|
2395
|
+
current.history = [];
|
|
2396
|
+
if (current.pending) {
|
|
2397
|
+
const frames = current.pending.length / channels;
|
|
2398
|
+
if (current.position + frames > target) {
|
|
2399
|
+
current.pending = current.pending.subarray((target - current.position) * channels);
|
|
2400
|
+
current.position = target;
|
|
2401
|
+
return;
|
|
2402
|
+
}
|
|
2403
|
+
current.position += frames;
|
|
2404
|
+
current.pending = null;
|
|
2405
|
+
}
|
|
2406
|
+
while (current.position < target) {
|
|
2407
|
+
const samples = current.decoder.nextSamples();
|
|
2408
|
+
if (!samples) return;
|
|
2409
|
+
const frames = samples.length / channels;
|
|
2410
|
+
if (current.position + frames > target) {
|
|
2411
|
+
current.pending = samples.subarray((target - current.position) * channels);
|
|
2412
|
+
current.position = target;
|
|
2413
|
+
return;
|
|
2414
|
+
}
|
|
2415
|
+
current.position += frames;
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
2418
|
+
|
|
2419
|
+
/** Starts the clock, the audio stream and the frame/time ticks. */
|
|
2420
|
+
function startMedia(state) {
|
|
2421
|
+
state.clockStart = hostPerformance.now();
|
|
2422
|
+
if (state.sound && !state.sound.done) openAudioStream(state);
|
|
2423
|
+
mediaTick(state);
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2426
|
+
function stopClock(state) {
|
|
2427
|
+
hostClearTimeout(state.timer);
|
|
2428
|
+
state.timer = null;
|
|
2429
|
+
state.clockStart = null;
|
|
2430
|
+
if (state.sound?.id != null) {
|
|
2431
|
+
audio.close(state.sound.id);
|
|
2432
|
+
state.sound.id = null;
|
|
2433
|
+
state.sound.finishing = false;
|
|
2434
|
+
}
|
|
2435
|
+
}
|
|
2436
|
+
|
|
2437
|
+
/** Draws the frames the clock has reached, updates time, ends when everything has played. */
|
|
2438
|
+
function mediaTick(state) {
|
|
2439
|
+
if (!state.playing || state.generation !== loadGeneration || state.document !== page.document) return;
|
|
2440
|
+
const now = mediaTime(state);
|
|
2441
|
+
const video = state.video;
|
|
2442
|
+
if (video && !video.done && video.frameTime <= now) {
|
|
2443
|
+
// Late frames are decoded but only the newest is shown.
|
|
2444
|
+
while (video.frameTime <= now && !video.done) {
|
|
2445
|
+
const nextIsLate = video.frameTime + 1 / (video.decoder.fps || 30) <= now;
|
|
2446
|
+
if (!drawVideoFrame(state, !nextIsLate)) video.done = true;
|
|
2447
|
+
}
|
|
2448
|
+
}
|
|
2449
|
+
state.resource.time = now;
|
|
2450
|
+
repaintMedia();
|
|
2451
|
+
if (Math.floor(now * 4) !== state.lastTimeUpdate) {
|
|
2452
|
+
state.lastTimeUpdate = Math.floor(now * 4);
|
|
2453
|
+
mediaEvent(state, "timeupdate");
|
|
2454
|
+
}
|
|
2455
|
+
const videoFinished = !video || video.done;
|
|
2456
|
+
const soundFinished = !state.sound || state.sound.done;
|
|
2457
|
+
// The clock at the duration ends it too: after a seek, frames restart at a
|
|
2458
|
+
// keyframe before the target, and wasmpeg does not tell their timestamps.
|
|
2459
|
+
const pastEnd = Number.isFinite(state.duration) && state.clockStart !== null
|
|
2460
|
+
&& state.base + (hostPerformance.now() - state.clockStart) / 1000 >= state.duration;
|
|
2461
|
+
if ((videoFinished && soundFinished) || (pastEnd && soundFinished)) return endMedia(state);
|
|
2462
|
+
const delay = video && !video.done ? Math.max(5, (video.frameTime - mediaTime(state)) * 1000) : 250;
|
|
2463
|
+
state.timer = hostSetTimeout(() => mediaTick(state), delay);
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2466
|
+
/** Decodes the next frame; shows it when `show`. False at the end of the stream. */
|
|
2467
|
+
function drawVideoFrame(state, show) {
|
|
2468
|
+
const decoder = state.video.decoder;
|
|
2469
|
+
const width = Math.min(480, decoder.width);
|
|
2470
|
+
const height = Math.max(1, Math.round(width * decoder.height / decoder.width));
|
|
2471
|
+
const pixels = decoder.nextFrame(width, height);
|
|
2472
|
+
if (!pixels) return false;
|
|
2473
|
+
state.video.frameTime += 1 / (decoder.fps || 30);
|
|
2474
|
+
if (!show) return true;
|
|
1538
2475
|
const image = CanvasKit.MakeImage({ width, height, alphaType: CanvasKit.AlphaType.Unpremul,
|
|
1539
2476
|
colorType: CanvasKit.ColorType.RGBA_8888, colorSpace: CanvasKit.ColorSpace.SRGB }, pixels, width * 4);
|
|
1540
|
-
if (!image) return
|
|
1541
|
-
|
|
1542
|
-
state.image = image;
|
|
2477
|
+
if (!image) return true;
|
|
2478
|
+
state.resource.image?.delete();
|
|
1543
2479
|
state.resource.image = image;
|
|
1544
2480
|
state.resource.bitmapWidth = width;
|
|
1545
2481
|
state.resource.bitmapHeight = height;
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
2482
|
+
return true;
|
|
2483
|
+
}
|
|
2484
|
+
|
|
2485
|
+
function openAudioStream(state) {
|
|
2486
|
+
const sound = state.sound;
|
|
2487
|
+
sound.finishing = false;
|
|
2488
|
+
const name = new URL(state.url).pathname.split("/").pop() || "audio";
|
|
2489
|
+
const id = audio.open({ rate: sound.decoder.sampleRate, channels: sound.decoder.channels, name }, (event) => {
|
|
2490
|
+
if (sound.id !== id) return;
|
|
2491
|
+
if (event.event === "request") feedAudio(state, event.bytes);
|
|
2492
|
+
else if (event.event === "ended") {
|
|
2493
|
+
sound.id = null;
|
|
2494
|
+
sound.done = true;
|
|
2495
|
+
mediaTick(state);
|
|
2496
|
+
} else if (event.event === "error") {
|
|
2497
|
+
sound.id = null;
|
|
2498
|
+
sound.done = true;
|
|
2499
|
+
reportError(new Error(`audio: ${event.message}`));
|
|
2500
|
+
// Video plays on without sound; audio alone stops.
|
|
2501
|
+
if (!state.video) pauseMedia(state.element);
|
|
2502
|
+
}
|
|
2503
|
+
});
|
|
2504
|
+
sound.id = id;
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2507
|
+
/** Sends about `bytes` of s16le audio (pending samples first, then decoding); at the end, lets it drain. */
|
|
2508
|
+
function feedAudio(state, bytes) {
|
|
2509
|
+
const sound = state.sound;
|
|
2510
|
+
if (!state.playing || sound.id === null || sound.finishing) return;
|
|
2511
|
+
const { channels, sampleRate } = sound.decoder;
|
|
2512
|
+
const frameBytes = channels * 2;
|
|
2513
|
+
for (let sent = 0; sent + frameBytes <= bytes;) {
|
|
2514
|
+
let samples = sound.pending;
|
|
2515
|
+
sound.pending = null;
|
|
2516
|
+
samples ??= sound.decoder.nextSamples();
|
|
2517
|
+
if (!samples) {
|
|
2518
|
+
sound.finishing = true;
|
|
2519
|
+
audio.finish(sound.id);
|
|
2520
|
+
return;
|
|
2521
|
+
}
|
|
2522
|
+
const [taken, remaining] = splitSamples(samples, channels, bytes - sent);
|
|
2523
|
+
if (remaining.length) sound.pending = remaining;
|
|
2524
|
+
sound.history.push({ start: sound.position, samples: taken });
|
|
2525
|
+
sound.position += taken.length / channels;
|
|
2526
|
+
while (sound.history.length > 1 && sound.position - sound.history[1].start > AUDIO_HISTORY_SECONDS * sampleRate) {
|
|
2527
|
+
sound.history.shift();
|
|
2528
|
+
}
|
|
2529
|
+
const pcm = floatToS16(taken);
|
|
2530
|
+
audio.write(sound.id, pcm);
|
|
2531
|
+
sent += pcm.length;
|
|
2532
|
+
}
|
|
1552
2533
|
}
|
|
1553
2534
|
|
|
1554
|
-
function
|
|
2535
|
+
function endMedia(state) {
|
|
2536
|
+
stopClock(state);
|
|
2537
|
+
state.ended = true;
|
|
2538
|
+
state.base = Number.isFinite(state.duration) ? state.duration : state.base;
|
|
2539
|
+
setMediaPlaying(state, false);
|
|
2540
|
+
mediaEvent(state, "pause");
|
|
2541
|
+
mediaEvent(state, "ended");
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
function setMediaPlaying(state, playing) {
|
|
2545
|
+
state.playing = playing;
|
|
2546
|
+
state.resource.playing = playing;
|
|
2547
|
+
state.resource.time = mediaTime(state);
|
|
2548
|
+
if (state.document === page.document) repaintMedia();
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2551
|
+
function closeMedia(state) {
|
|
2552
|
+
stopClock(state);
|
|
1555
2553
|
state.playing = false;
|
|
1556
|
-
|
|
1557
|
-
state.
|
|
1558
|
-
state.
|
|
2554
|
+
state.video?.decoder.close();
|
|
2555
|
+
state.video = null;
|
|
2556
|
+
state.sound?.decoder.close();
|
|
2557
|
+
state.sound = null;
|
|
2558
|
+
state.resource.image?.delete();
|
|
1559
2559
|
state.resource.image = null;
|
|
1560
2560
|
state.resource.playing = false;
|
|
1561
2561
|
}
|
|
1562
2562
|
|
|
1563
2563
|
function disposeVideos() {
|
|
1564
|
-
for (const state of page.
|
|
1565
|
-
page.
|
|
2564
|
+
for (const state of page.media.values()) closeMedia(state);
|
|
2565
|
+
page.media.clear();
|
|
1566
2566
|
}
|
|
1567
2567
|
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
const
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
try {
|
|
1575
|
-
const payload = JSON.parse(embedded.textContent ?? "").payload;
|
|
1576
|
-
const fork = payload?.codeViewLayoutRoute?.repo;
|
|
1577
|
-
const parent = payload?.sidebarAbout?.repo?.parentRepo;
|
|
1578
|
-
const branch = payload?.codeViewRepoRoute?.refInfo;
|
|
1579
|
-
if (!fork?.isFork || !parent?.ownerLogin || !parent?.name || branch?.refType !== "branch") return;
|
|
1580
|
-
const repoUrl = `https://api.github.com/repos/${encodeURIComponent(parent.ownerLogin)}/${encodeURIComponent(parent.name)}`;
|
|
1581
|
-
const compareUrl = `${repoUrl}/compare/HEAD...${encodeURIComponent(fork.ownerLogin)}:${encodeURIComponent(branch.name)}`;
|
|
1582
|
-
const [repoResponse, compareResponse] = await Promise.all([
|
|
1583
|
-
fetchResource(repoUrl, "metadata"), fetchResource(compareUrl, "metadata"),
|
|
1584
|
-
]);
|
|
1585
|
-
if (!repoResponse.ok || !compareResponse.ok) return;
|
|
1586
|
-
const decoder = new TextDecoder();
|
|
1587
|
-
const defaultBranch = JSON.parse(decoder.decode(repoResponse.body)).default_branch;
|
|
1588
|
-
const comparison = JSON.parse(decoder.decode(compareResponse.body));
|
|
1589
|
-
const ahead = comparison.ahead_by;
|
|
1590
|
-
const behind = comparison.behind_by;
|
|
1591
|
-
if (!defaultBranch || !Number.isInteger(ahead) || !Number.isInteger(behind)) return;
|
|
1592
|
-
if (generation !== loadGeneration || document !== page.document || !bar.querySelector(".Skeleton")) return;
|
|
1593
|
-
const baseName = `${parent.ownerLogin}/${parent.name}:${defaultBranch}`;
|
|
1594
|
-
const status = behind > 0
|
|
1595
|
-
? `This branch is ${ahead} commits ahead of and ${behind} commits behind ${baseName}.`
|
|
1596
|
-
: `This branch is ${ahead} commits ahead of ${baseName}.`;
|
|
1597
|
-
bar.textContent = status;
|
|
1598
|
-
page.dirty = true;
|
|
1599
|
-
refresh();
|
|
1600
|
-
} catch {
|
|
1601
|
-
// GitHub's own client can still replace the loading placeholder.
|
|
2568
|
+
function concatSamples(parts) {
|
|
2569
|
+
const out = new Float32Array(parts.reduce((sum, part) => sum + part.length, 0));
|
|
2570
|
+
let offset = 0;
|
|
2571
|
+
for (const part of parts) {
|
|
2572
|
+
out.set(part, offset);
|
|
2573
|
+
offset += part.length;
|
|
1602
2574
|
}
|
|
2575
|
+
return out;
|
|
1603
2576
|
}
|
|
1604
2577
|
|
|
1605
2578
|
function queueImageResources() {
|
|
@@ -1641,6 +2614,114 @@ export async function createPageRenderer({
|
|
|
1641
2614
|
}
|
|
1642
2615
|
}
|
|
1643
2616
|
|
|
2617
|
+
/** Splits at top-level `separator` (outside (), [] and quotes). */
|
|
2618
|
+
function splitTopLevelText(text, separator) {
|
|
2619
|
+
const parts = [];
|
|
2620
|
+
let depth = 0;
|
|
2621
|
+
let quote = "";
|
|
2622
|
+
let start = 0;
|
|
2623
|
+
for (let index = 0; index < text.length; index++) {
|
|
2624
|
+
const character = text[index];
|
|
2625
|
+
if (quote) { if (character === "\\") index++; else if (character === quote) quote = ""; continue; }
|
|
2626
|
+
if (character === '"' || character === "'") quote = character;
|
|
2627
|
+
else if (character === "\\") index++;
|
|
2628
|
+
else if (character === "(" || character === "[") depth++;
|
|
2629
|
+
else if (character === ")" || character === "]") depth--;
|
|
2630
|
+
else if (character === separator && depth === 0) { parts.push(text.slice(start, index).trim()); start = index + 1; }
|
|
2631
|
+
}
|
|
2632
|
+
parts.push(text.slice(start).trim());
|
|
2633
|
+
return parts.filter(Boolean);
|
|
2634
|
+
}
|
|
2635
|
+
|
|
2636
|
+
const STATE_PSEUDO = /:(?:checked|indeterminate|default|disabled|enabled|placeholder-shown|valid|invalid|in-range|out-of-range|required|optional|read-only|read-write|user-valid|user-invalid|autofill|open|popover-open|modal|target|focus|focus-visible|focus-within|hover|active|visited|link|any-link|defined|fullscreen)\b/;
|
|
2637
|
+
|
|
2638
|
+
/** CSS escapes in an identifier (".\\:popover-open", "\\31 a") resolved. */
|
|
2639
|
+
function cssUnescape(text) {
|
|
2640
|
+
return text.replace(/\\([0-9a-fA-F]{1,6})\s?/g, (_, hex) => String.fromCodePoint(parseInt(hex, 16))).replace(/\\(.)/g, "$1");
|
|
2641
|
+
}
|
|
2642
|
+
|
|
2643
|
+
/** Invalidation features a compound selector mentions (see selectorTraitsFor). */
|
|
2644
|
+
function compoundFeatures(compound) {
|
|
2645
|
+
const features = [];
|
|
2646
|
+
const plain = compound.replace(/\[[^\]]*\]/g, (attribute) => {
|
|
2647
|
+
const name = /^\[\s*([\w-]+)/.exec(attribute)?.[1];
|
|
2648
|
+
if (name) features.push("[" + name.toLowerCase());
|
|
2649
|
+
return "";
|
|
2650
|
+
});
|
|
2651
|
+
for (const match of plain.matchAll(/([.#])((?:\\[0-9a-fA-F]{1,6}\s?|\\.|[\w\u00a0-\uffff-])+)/g)) features.push(match[1] + cssUnescape(match[2]));
|
|
2652
|
+
const tag = /^(?:[a-zA-Z][\w-]*)/.exec(plain)?.[0];
|
|
2653
|
+
if (tag) features.push("t:" + tag.toLowerCase());
|
|
2654
|
+
if (STATE_PSEUDO.test(plain)) features.push(":state");
|
|
2655
|
+
// Nothing but a universal selector or pseudo-classes: it can match anything.
|
|
2656
|
+
if (!features.length || /^\*/.test(plain)) features.push("*");
|
|
2657
|
+
return features;
|
|
2658
|
+
}
|
|
2659
|
+
|
|
2660
|
+
/** The features an element has (for a sibling it inserts next to, for example). */
|
|
2661
|
+
function elementFeatures(element) {
|
|
2662
|
+
const features = ["t:" + element.localName];
|
|
2663
|
+
const id = element.getAttribute("id");
|
|
2664
|
+
if (id) features.push("#" + id);
|
|
2665
|
+
for (const name of element.classList) features.push("." + name);
|
|
2666
|
+
for (const attribute of element.attributes) features.push("[" + attribute.name);
|
|
2667
|
+
return features;
|
|
2668
|
+
}
|
|
2669
|
+
|
|
2670
|
+
/** A complex selector's compounds with the combinator after each (" ", ">", "+", "~", or null for the subject). */
|
|
2671
|
+
function complexParts(selector) {
|
|
2672
|
+
const parts = [];
|
|
2673
|
+
let depth = 0;
|
|
2674
|
+
let quote = "";
|
|
2675
|
+
let current = "";
|
|
2676
|
+
let combinator = null;
|
|
2677
|
+
const flush = () => {
|
|
2678
|
+
if (current) parts.push({ compound: current, combinator: null });
|
|
2679
|
+
current = "";
|
|
2680
|
+
};
|
|
2681
|
+
for (let index = 0; index < selector.length; index++) {
|
|
2682
|
+
const character = selector[index];
|
|
2683
|
+
if (quote) { current += character; if (character === "\\") current += selector[++index] ?? ""; else if (character === quote) quote = ""; continue; }
|
|
2684
|
+
if (character === '"' || character === "'") quote = character;
|
|
2685
|
+
if (character === "\\") { current += character + (selector[++index] ?? ""); continue; }
|
|
2686
|
+
if (character === "(" || character === "[") depth++;
|
|
2687
|
+
else if (character === ")" || character === "]") depth--;
|
|
2688
|
+
if (depth === 0 && /[\s>+~]/.test(character)) {
|
|
2689
|
+
flush();
|
|
2690
|
+
combinator = character.trim() ? character : combinator ?? " ";
|
|
2691
|
+
if (character.trim() && parts.length) parts.at(-1).combinator = character;
|
|
2692
|
+
else if (parts.length && parts.at(-1).combinator === null) parts.at(-1).combinator = " ";
|
|
2693
|
+
continue;
|
|
2694
|
+
}
|
|
2695
|
+
current += character;
|
|
2696
|
+
}
|
|
2697
|
+
flush();
|
|
2698
|
+
return parts;
|
|
2699
|
+
}
|
|
2700
|
+
|
|
2701
|
+
/** The compound selectors of a complex selector (split at top-level combinators). */
|
|
2702
|
+
function compoundsOf(selector) {
|
|
2703
|
+
const compounds = [];
|
|
2704
|
+
let depth = 0;
|
|
2705
|
+
let quote = "";
|
|
2706
|
+
let current = "";
|
|
2707
|
+
for (let index = 0; index < selector.length; index++) {
|
|
2708
|
+
const character = selector[index];
|
|
2709
|
+
if (quote) { current += character; if (character === "\\") current += selector[++index] ?? ""; else if (character === quote) quote = ""; continue; }
|
|
2710
|
+
if (character === '"' || character === "'") quote = character;
|
|
2711
|
+
if (character === "\\") { current += character + (selector[++index] ?? ""); continue; }
|
|
2712
|
+
if (character === "(" || character === "[") depth++;
|
|
2713
|
+
else if (character === ")" || character === "]") depth--;
|
|
2714
|
+
if (depth === 0 && /[\s>+~]/.test(character)) {
|
|
2715
|
+
if (current) compounds.push(current);
|
|
2716
|
+
current = "";
|
|
2717
|
+
continue;
|
|
2718
|
+
}
|
|
2719
|
+
current += character;
|
|
2720
|
+
}
|
|
2721
|
+
if (current) compounds.push(current);
|
|
2722
|
+
return compounds;
|
|
2723
|
+
}
|
|
2724
|
+
|
|
1644
2725
|
function canvasColor(CanvasKit, value) {
|
|
1645
2726
|
const match = /^rgba\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*\)$/.exec(value);
|
|
1646
2727
|
return match
|
|
@@ -1687,5 +2768,5 @@ function documentBackground(document, styleEngine) {
|
|
|
1687
2768
|
}
|
|
1688
2769
|
|
|
1689
2770
|
function emptyImageResources() {
|
|
1690
|
-
return { map: new WeakMap(), decoded: [] };
|
|
2771
|
+
return { map: new WeakMap(), decoded: [], entries: [] };
|
|
1691
2772
|
}
|