buninu-browser 0.1.0 → 0.2.1
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 +26 -4
- 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 +28 -7
- package/lib/document-types.js +308 -0
- package/lib/download-directory.js +28 -0
- package/lib/download.js +63 -0
- package/lib/happy-dom/selector-matcher.js +83 -0
- package/lib/headless-shell.js +165 -26
- package/lib/input/hit-test.js +57 -8
- package/lib/layout/text-layout.js +290 -41
- package/lib/network/network-service.js +177 -40
- package/lib/network/resolver.js +16 -0
- package/lib/paint/display-list.js +25 -10
- package/lib/render-tree/index.js +58 -0
- package/lib/renderer/bindings.js +543 -55
- package/lib/renderer/flags.js +8 -5
- package/lib/renderer/host.js +103 -7
- package/lib/renderer/main.js +35 -3
- package/lib/renderer/module-transform.js +30 -23
- package/lib/renderer/page-realm.js +228 -24
- package/lib/renderer/page-renderer.js +1264 -221
- package/lib/screenshot-cli.js +61 -0
- package/lib/style/computed-style.js +558 -229
- package/lib/style/selector-keys.js +302 -0
- package/lib/style/selector-traits.js +240 -0
- 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/test/computed-style.test.js +113 -1
- 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 +56 -0
- package/test/hit-test.test.js +37 -0
- package/test/log.test.js +4 -1
- package/test/network-service.test.js +67 -0
- package/test/page-scripts.test.js +437 -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 +36 -1
- package/test/resolver.test.js +12 -1
- package/test/screenshot-cli.test.js +19 -0
- package/test/selector-keys.test.js +39 -0
- package/test/selector-traits.test.js +19 -0
- package/test/text-layout.test.js +91 -1
- 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
|
@@ -23,24 +23,57 @@
|
|
|
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
|
-
import {
|
|
28
|
+
import { LayoutEngine } 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
|
-
import { RenderTreeBuilder, renderTreeText } from "../render-tree/index.js";
|
|
31
|
-
import { StyleEngine } from "../style/computed-style.js";
|
|
31
|
+
import { RenderTreeBuilder, renderTreeText, sameLayoutInput } from "../render-tree/index.js";
|
|
32
|
+
import { matchSelectors, StyleEngine, topmostConnected } from "../style/computed-style.js";
|
|
33
|
+
import { compileSelector, matchesCompiled } from "../happy-dom/selector-matcher.js";
|
|
34
|
+
import { elementFeatures, sheetTraits } from "../style/selector-traits.js";
|
|
32
35
|
import { buildDisplayList, visibleItems } from "../paint/display-list.js";
|
|
33
36
|
import { backgroundBytes, cropViewport, encodeImage } from "../paint/frame.js";
|
|
34
|
-
import { createPageRealm } from "./page-realm.js";
|
|
37
|
+
import { CLASSIC_SCRIPT_TYPES, createPageRealm } from "./page-realm.js";
|
|
35
38
|
import { MOBILE_USER_AGENT } from "./flags.js";
|
|
39
|
+
import { formatHint } from "../../vendor/wasmpeg/src/js/formats.js";
|
|
40
|
+
import { floatToS16, splitSamples } from "../audio/pcm.js";
|
|
41
|
+
import { pianoNote, PianoMixer } from "../audio/piano.js";
|
|
36
42
|
|
|
37
43
|
// Captured at load: in host mode page scripts share this global object.
|
|
38
44
|
const hostPerformance = globalThis.performance;
|
|
39
45
|
const hostSetTimeout = globalThis.setTimeout;
|
|
40
46
|
const hostClearTimeout = globalThis.clearTimeout;
|
|
47
|
+
const hostSetInterval = globalThis.setInterval;
|
|
48
|
+
const hostClearInterval = globalThis.clearInterval;
|
|
41
49
|
|
|
42
50
|
/** How long subresources may take before a preview of the unstyled document is shown. */
|
|
43
51
|
export const PREVIEW_DELAY_MS = 300;
|
|
52
|
+
const MAX_COMPILED_COMPOUNDS = 10_000;
|
|
53
|
+
|
|
54
|
+
// Media controls: the bar's height over video, and where its parts sit.
|
|
55
|
+
const MEDIA_BAR_HEIGHT = 24;
|
|
56
|
+
// Decoded audio already sent, kept so pause/resume continues exactly without decoding again.
|
|
57
|
+
const AUDIO_HISTORY_SECONDS = 3;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Horizontal layout of a media control bar `width` CSS pixels wide: the
|
|
61
|
+
* play/pause mark takes the first 30, then "m:ss / m:ss" when there is room,
|
|
62
|
+
* then the progress track that clicks seek.
|
|
63
|
+
*/
|
|
64
|
+
export function mediaBarLayout(width) {
|
|
65
|
+
const timeWidth = width >= 200 ? 86 : 0;
|
|
66
|
+
const trackStart = 30 + timeWidth;
|
|
67
|
+
return { timeStart: 30, timeWidth, trackStart, trackEnd: Math.max(trackStart + 10, width - 12) };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function formatMediaTime(seconds) {
|
|
71
|
+
const whole = Math.floor(seconds);
|
|
72
|
+
const hours = Math.floor(whole / 3600);
|
|
73
|
+
const minutes = Math.floor(whole / 60) % 60;
|
|
74
|
+
const rest = String(whole % 60).padStart(2, "0");
|
|
75
|
+
return hours ? `${hours}:${String(minutes).padStart(2, "0")}:${rest}` : `${minutes}:${rest}`;
|
|
76
|
+
}
|
|
44
77
|
|
|
45
78
|
const canvasKitDirectory = new URL("../../usr/lib/canvaskit/", import.meta.url);
|
|
46
79
|
const fontDirectory = new URL("../../usr/share/fonts/", import.meta.url);
|
|
@@ -49,10 +82,12 @@ const fontDirectory = new URL("../../usr/share/fonts/", import.meta.url);
|
|
|
49
82
|
* @param {{
|
|
50
83
|
* fetchResource(url: string, kind: "stylesheet" | "image" | "script" | "metadata" | "video"):
|
|
51
84
|
* Promise<{ ok: boolean, status: number, url: string, contentType: string, body: Uint8Array }>,
|
|
52
|
-
* requestNavigation?(url: string): void,
|
|
85
|
+
* requestNavigation?(url: string, downloadName?: string | null, fragmentFrom?: { x: number, y: number } | null): void,
|
|
86
|
+
* requestDownload?(payload: { url: string, name: string, data: Uint8Array }): void,
|
|
53
87
|
* reportError?(message: string): void,
|
|
54
88
|
* pageFetch?(request: object): Promise<object>,
|
|
55
89
|
* webSockets?: { open(url, protocols, onEvent): number, send(id, data): void, close(id, code, reason): void } | null,
|
|
90
|
+
* audio?: { open(options, onEvent): number, write(id, data): void, finish(id): void, close(id): void } | null,
|
|
56
91
|
* setCookie?(value: string): void,
|
|
57
92
|
* consoleMessage?: ((level: string, text: string) => void) | null,
|
|
58
93
|
* trace?: ((text: string) => void) | null,
|
|
@@ -62,11 +97,13 @@ const fontDirectory = new URL("../../usr/share/fonts/", import.meta.url);
|
|
|
62
97
|
* }} options
|
|
63
98
|
*/
|
|
64
99
|
export async function createPageRenderer({
|
|
65
|
-
fetchResource,
|
|
100
|
+
fetchResource: rawFetchResource,
|
|
66
101
|
requestNavigation = () => {},
|
|
102
|
+
requestDownload = () => {},
|
|
67
103
|
reportError = () => {},
|
|
68
104
|
pageFetch = () => Promise.reject(new TypeError("network access is not available")),
|
|
69
105
|
webSockets = null,
|
|
106
|
+
audio = null,
|
|
70
107
|
setCookie = () => {},
|
|
71
108
|
consoleMessage = null,
|
|
72
109
|
trace = null,
|
|
@@ -74,6 +111,8 @@ export async function createPageRenderer({
|
|
|
74
111
|
onLifecycle = null,
|
|
75
112
|
videoDecoder = null,
|
|
76
113
|
mobile = false,
|
|
114
|
+
verifyStyle = false,
|
|
115
|
+
fullStyle = false,
|
|
77
116
|
}) {
|
|
78
117
|
const wasmBinary = await Bun.file(new URL("canvaskit.wasm", canvasKitDirectory)).arrayBuffer();
|
|
79
118
|
const CanvasKit = await CanvasKitInit({ wasmBinary });
|
|
@@ -130,7 +169,24 @@ export async function createPageRenderer({
|
|
|
130
169
|
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
131
170
|
const emojiPresentation = /^\p{Emoji_Presentation}/u;
|
|
132
171
|
const renderTreeBuilder = new RenderTreeBuilder();
|
|
172
|
+
const layoutEngine = new LayoutEngine({ measureText, fontMetrics });
|
|
133
173
|
const styleEngine = new StyleEngine();
|
|
174
|
+
// Incremental restyle: what may have changed style since the last
|
|
175
|
+
// computation. DOM mutations come from a MutationObserver; state that is
|
|
176
|
+
// not in the DOM tree (checked, value, focus) is marked by the code that
|
|
177
|
+
// changes it. `full` asks for a complete restyle.
|
|
178
|
+
const styleChanges = { full: true, roots: new Set(), rootFeatures: new Map(), records: [], observer: null, activeElement: null };
|
|
179
|
+
let selectorTraits = { sibling: false, has: null, sheets: null };
|
|
180
|
+
let lastStyleParts = "";
|
|
181
|
+
let laidOutSheets = null;
|
|
182
|
+
const sheetTraitsCache = new Map();
|
|
183
|
+
// Invalidation compounds compiled for matchesCompound() and matchSelectors().
|
|
184
|
+
const compiledCompounds = new Map();
|
|
185
|
+
let lastStyleReason = "";
|
|
186
|
+
let hasState = null;
|
|
187
|
+
let hasStateStale = false;
|
|
188
|
+
let structuralState = null;
|
|
189
|
+
let siblingState = null;
|
|
134
190
|
const viewport = new ScrollViewport(800, 600);
|
|
135
191
|
const page = {
|
|
136
192
|
url: "about:blank",
|
|
@@ -149,19 +205,31 @@ export async function createPageRenderer({
|
|
|
149
205
|
hints: [],
|
|
150
206
|
hintsVisible: false,
|
|
151
207
|
styleSheets: [],
|
|
208
|
+
linkSheets: null,
|
|
209
|
+
// The document's scripts fetched ahead of their turn (see preloadScripts), by URL.
|
|
210
|
+
preloadedScripts: new Map(),
|
|
152
211
|
backgroundColor: "rgba(255, 255, 255, 1)",
|
|
153
212
|
resources: new WeakMap(),
|
|
154
213
|
decodedImages: [],
|
|
155
|
-
|
|
214
|
+
imagesLoaded: null,
|
|
215
|
+
// <video> and <audio> playback states, by element (see playMedia).
|
|
216
|
+
media: new Map(),
|
|
156
217
|
realm: null,
|
|
157
218
|
// "off" until the renderer process has set up its sandbox.
|
|
158
219
|
scripting: "off",
|
|
159
220
|
dirty: false,
|
|
160
|
-
|
|
221
|
+
resourceLayoutDirty: false,
|
|
222
|
+
userAgent: mobile ? MOBILE_USER_AGENT : DESKTOP_USER_AGENT,
|
|
161
223
|
// document.cookie as last reported by the controller (non-HttpOnly only).
|
|
162
224
|
cookie: "",
|
|
163
225
|
};
|
|
164
226
|
|
|
227
|
+
async function fetchResource(url, kind) {
|
|
228
|
+
const response = await rawFetchResource(url, kind);
|
|
229
|
+
if (typeof response.documentCookie === "string") page.cookie = response.documentCookie;
|
|
230
|
+
return response;
|
|
231
|
+
}
|
|
232
|
+
|
|
165
233
|
function makeParagraph(text, computedStyle = null, fontSize = computedStyle?.fontSize ?? 16, scale = 1) {
|
|
166
234
|
const color = canvasColor(CanvasKit, computedStyle?.color ?? "rgba(0, 0, 0, 1)");
|
|
167
235
|
const weight = computedStyle?.fontWeight ?? 400;
|
|
@@ -416,7 +484,10 @@ export async function createPageRenderer({
|
|
|
416
484
|
frameTimer = hostSetTimeout(() => {
|
|
417
485
|
frameTimer = null;
|
|
418
486
|
refresh();
|
|
419
|
-
|
|
487
|
+
// Leave one rebuild-sized idle window after an expensive frame. Without
|
|
488
|
+
// it, a large document can spend all its time producing back-to-back
|
|
489
|
+
// frames while scripts are still making related mutations.
|
|
490
|
+
}, Math.max(50, lastRebuildMs * 2));
|
|
420
491
|
}
|
|
421
492
|
|
|
422
493
|
/** The viewport as a PNG (base64), cut from the latest tile. */
|
|
@@ -585,15 +656,17 @@ export async function createPageRenderer({
|
|
|
585
656
|
const path = CanvasKit.Path.MakeFromSVGString(
|
|
586
657
|
`M ${centerX - 6 * scale} ${centerY - 10 * scale} L ${centerX + 11 * scale} ${centerY} L ${centerX - 6 * scale} ${centerY + 10 * scale} Z`);
|
|
587
658
|
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
659
|
}
|
|
595
|
-
|
|
660
|
+
// The control bar (play/pause, time, seekable progress) once playing or known.
|
|
661
|
+
if (resource?.playing || Number.isFinite(resource?.duration)) {
|
|
662
|
+
drawMediaBar(canvas, item.rect.x - view.x, item.rect.y + item.rect.height - MEDIA_BAR_HEIGHT - view.y,
|
|
663
|
+
item.rect.width, MEDIA_BAR_HEIGHT, resource, scale, true);
|
|
664
|
+
}
|
|
596
665
|
paint.delete();
|
|
666
|
+
canvas.restore();
|
|
667
|
+
} else if (item.op === "drawAudio") {
|
|
668
|
+
const resource = displayList.images[item.image];
|
|
669
|
+
drawMediaBar(canvas, item.rect.x - view.x, item.rect.y - view.y, item.rect.width, item.rect.height, resource, scale, false);
|
|
597
670
|
} else if (item.op === "drawSvg") {
|
|
598
671
|
drawSvg(item);
|
|
599
672
|
} else if (item.op === "drawText") {
|
|
@@ -694,6 +767,53 @@ export async function createPageRenderer({
|
|
|
694
767
|
|
|
695
768
|
// Overlays are placed in page coordinates (viewport position plus the
|
|
696
769
|
// scroll offset when they were created), so they stay on their element.
|
|
770
|
+
/**
|
|
771
|
+
* A media control bar in CSS pixels at (x, y), relative to the tile: a
|
|
772
|
+
* play/pause mark, "m:ss / m:ss" and a progress track that clicks seek
|
|
773
|
+
* (see mediaBarLayout). Video draws it over the picture, audio as its box.
|
|
774
|
+
*/
|
|
775
|
+
function drawMediaBar(canvas, x, y, width, height, resource, scale, overlay) {
|
|
776
|
+
const paint = new CanvasKit.Paint();
|
|
777
|
+
paint.setAntiAlias(true);
|
|
778
|
+
const box = CanvasKit.XYWHRect(x * scale, y * scale, width * scale, height * scale);
|
|
779
|
+
paint.setColor(overlay ? CanvasKit.Color(0, 0, 0, 0.65) : CanvasKit.Color(241, 243, 244, 1));
|
|
780
|
+
if (overlay) canvas.drawRect(box, paint);
|
|
781
|
+
else canvas.drawRRect(CanvasKit.RRectXY(box, height / 2 * scale, height / 2 * scale), paint);
|
|
782
|
+
paint.setColor(overlay ? CanvasKit.Color(255, 255, 255, 1) : CanvasKit.Color(32, 33, 36, 1));
|
|
783
|
+
const middle = (y + height / 2) * scale;
|
|
784
|
+
const iconX = (x + 12) * scale;
|
|
785
|
+
if (resource?.playing) {
|
|
786
|
+
canvas.drawRect(CanvasKit.XYWHRect(iconX, middle - 6 * scale, 3.5 * scale, 12 * scale), paint);
|
|
787
|
+
canvas.drawRect(CanvasKit.XYWHRect(iconX + 6.5 * scale, middle - 6 * scale, 3.5 * scale, 12 * scale), paint);
|
|
788
|
+
} else {
|
|
789
|
+
const path = CanvasKit.Path.MakeFromSVGString(
|
|
790
|
+
`M ${iconX} ${middle - 7 * scale} L ${iconX + 11 * scale} ${middle} L ${iconX} ${middle + 7 * scale} Z`);
|
|
791
|
+
if (path) { canvas.drawPath(path, paint); path.delete(); }
|
|
792
|
+
}
|
|
793
|
+
const layout = mediaBarLayout(width);
|
|
794
|
+
const duration = resource?.duration;
|
|
795
|
+
const time = Math.max(0, Math.min(Number.isFinite(duration) ? duration : Infinity, resource?.time ?? 0));
|
|
796
|
+
if (layout.timeWidth) {
|
|
797
|
+
const text = `${formatMediaTime(time)} / ${Number.isFinite(duration) ? formatMediaTime(duration) : "--:--"}`;
|
|
798
|
+
const paragraph = makeParagraph(text, { color: overlay ? "rgba(255, 255, 255, 1)" : "rgba(32, 33, 36, 1)",
|
|
799
|
+
fontFamily: ["sans-serif"], fontWeight: 400 }, 11, scale);
|
|
800
|
+
paragraph.layout(layout.timeWidth * scale);
|
|
801
|
+
canvas.drawParagraph(paragraph, (x + layout.timeStart) * scale, middle - paragraph.getHeight() / 2);
|
|
802
|
+
paragraph.delete();
|
|
803
|
+
}
|
|
804
|
+
const trackStart = (x + layout.trackStart) * scale;
|
|
805
|
+
const trackWidth = (layout.trackEnd - layout.trackStart) * scale;
|
|
806
|
+
paint.setColor(overlay ? CanvasKit.Color(255, 255, 255, 0.35) : CanvasKit.Color(0, 0, 0, 0.18));
|
|
807
|
+
canvas.drawRRect(CanvasKit.RRectXY(CanvasKit.XYWHRect(trackStart, middle - 2 * scale, trackWidth, 4 * scale), 2 * scale, 2 * scale), paint);
|
|
808
|
+
if (Number.isFinite(duration) && duration > 0) {
|
|
809
|
+
const played = trackWidth * time / duration;
|
|
810
|
+
paint.setColor(overlay ? CanvasKit.Color(255, 255, 255, 1) : CanvasKit.Color(26, 115, 232, 1));
|
|
811
|
+
canvas.drawRRect(CanvasKit.RRectXY(CanvasKit.XYWHRect(trackStart, middle - 2 * scale, played, 4 * scale), 2 * scale, 2 * scale), paint);
|
|
812
|
+
canvas.drawCircle(trackStart + played, middle, 5 * scale, paint);
|
|
813
|
+
}
|
|
814
|
+
paint.delete();
|
|
815
|
+
}
|
|
816
|
+
|
|
697
817
|
function drawHints(canvas, region) {
|
|
698
818
|
if (!page.hintsVisible) return;
|
|
699
819
|
const paint = new CanvasKit.Paint();
|
|
@@ -733,7 +853,311 @@ export async function createPageRenderer({
|
|
|
733
853
|
replaceParsedDocument(parseHTMLDocument(html, url), styleSheets, imageResources);
|
|
734
854
|
}
|
|
735
855
|
|
|
736
|
-
|
|
856
|
+
/** `linkSheets` (link element -> { url, text }) makes the sheet list follow the DOM; without it the given sheets are fixed. */
|
|
857
|
+
/** Marks a node whose own state changed (its subtree may restyle); null asks for a full restyle. */
|
|
858
|
+
function markStyleDirty(node, features = [":state"]) {
|
|
859
|
+
// Documents and fragments carry no state selectors read; their children's
|
|
860
|
+
// changes arrive as mutation records.
|
|
861
|
+
if (node?.nodeType === 9 || node?.nodeType === 11) return;
|
|
862
|
+
const element = node?.nodeType === 1 ? node : node?.parentElement;
|
|
863
|
+
if (!element) styleChanges.full ||= node ? `touched ${node.nodeName}` : "unknown change";
|
|
864
|
+
else {
|
|
865
|
+
styleChanges.roots.add(element);
|
|
866
|
+
const previous = styleChanges.rootFeatures.get(element);
|
|
867
|
+
styleChanges.rootFeatures.set(element, previous ? [...new Set([...previous, ...features])] : features);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function observeStyleChanges(document) {
|
|
872
|
+
styleChanges.observer?.disconnect();
|
|
873
|
+
styleChanges.records = [];
|
|
874
|
+
styleChanges.roots = new Set();
|
|
875
|
+
styleChanges.rootFeatures = new Map();
|
|
876
|
+
styleChanges.full = "new document";
|
|
877
|
+
styleChanges.activeElement = null;
|
|
878
|
+
const observer = new page.window.MutationObserver((records) => { styleChanges.records.push(...records); });
|
|
879
|
+
observer.observe(document, { subtree: true, childList: true, attributes: true, attributeOldValue: true, characterData: true });
|
|
880
|
+
styleChanges.observer = observer;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* What the stylesheets' selectors make sensitive: sibling combinators let
|
|
885
|
+
* a change restyle the following siblings; :has() lets one change the
|
|
886
|
+
* match of an ancestor (or, with + and ~, of an earlier sibling), so the
|
|
887
|
+
* compounds that contain it are re-checked there (`hasCompounds`).
|
|
888
|
+
*/
|
|
889
|
+
function selectorTraitsFor(sheets) {
|
|
890
|
+
if (selectorTraits.sheets === sheets) return selectorTraits;
|
|
891
|
+
const parts = sheets.map(sheetTraitsFor);
|
|
892
|
+
// Only the sheets in use stay cached.
|
|
893
|
+
if (sheetTraitsCache.size > sheets.length) for (const sheet of new Set(sheetTraitsCache.keys()).difference(new Set(sheets))) sheetTraitsCache.delete(sheet);
|
|
894
|
+
const union = (key) => new Set(parts.flatMap((part) => [...part[key]]));
|
|
895
|
+
const any = (key) => parts.some((part) => part[key]);
|
|
896
|
+
selectorTraits = {
|
|
897
|
+
sheets, sibling: any("sibling"), hasSiblings: any("hasSiblings"),
|
|
898
|
+
hasCompounds: [...union("hasCompounds")], structuralCompounds: [...union("structuralCompounds")],
|
|
899
|
+
descendantFeatures: union("descendantFeatures"), siblingFeatures: union("siblingFeatures"),
|
|
900
|
+
descendantStateCompounds: parts.flatMap((part) => part.descendantStateCompounds),
|
|
901
|
+
siblingTargetCompounds: [...union("siblingTargetCompounds")],
|
|
902
|
+
forward: any("forward"), firstChild: any("firstChild"), backward: any("backward"), lastChild: any("lastChild"),
|
|
903
|
+
defined: any("defined"), target: any("target"),
|
|
904
|
+
};
|
|
905
|
+
// Checked against the old compounds this time, re-recorded after the restyle.
|
|
906
|
+
hasStateStale = true;
|
|
907
|
+
return selectorTraits;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* One sheet's share of `selectorTraitsFor`, cached by its text (sheets come
|
|
912
|
+
* and go one at a time, and parsing all of them again is slow).
|
|
913
|
+
*/
|
|
914
|
+
function sheetTraitsFor(sheet) {
|
|
915
|
+
let traits = sheetTraitsCache.get(sheet);
|
|
916
|
+
if (traits) return traits;
|
|
917
|
+
traits = sheetTraits(sheet);
|
|
918
|
+
sheetTraitsCache.set(sheet, traits);
|
|
919
|
+
return traits;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/**
|
|
923
|
+
* Which elements match each :has() and non-subject structural compound, as
|
|
924
|
+
* of the last restyle. After an incremental round the kept maps are
|
|
925
|
+
* current, so only compounds new to the sheets are looked up.
|
|
926
|
+
*/
|
|
927
|
+
function recordHasState(traits) {
|
|
928
|
+
const reuse = hasState !== null;
|
|
929
|
+
if (compiledCompounds.size >= MAX_COMPILED_COMPOUNDS) compiledCompounds.clear();
|
|
930
|
+
const record = (compounds, previous) => {
|
|
931
|
+
const missing = compounds.filter((compound) => !(reuse && previous?.has(compound)));
|
|
932
|
+
const matched = missing.length ? matchSelectors(page.document, missing, compiledCompounds) : null;
|
|
933
|
+
const state = new Map();
|
|
934
|
+
for (const compound of compounds) {
|
|
935
|
+
if (reuse && previous?.has(compound)) state.set(compound, previous.get(compound));
|
|
936
|
+
else if (matched.has(compound)) state.set(compound, matched.get(compound)); // invalid ones never match
|
|
937
|
+
}
|
|
938
|
+
return state;
|
|
939
|
+
};
|
|
940
|
+
hasState = record(traits.hasCompounds, hasState);
|
|
941
|
+
structuralState = record(traits.structuralCompounds, structuralState);
|
|
942
|
+
siblingState = record(traits.siblingTargetCompounds, siblingState);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/** Brings the recorded matches of an inserted subtree up to date (it may have been in the document before). */
|
|
946
|
+
function recordInserted(root) {
|
|
947
|
+
const states = [hasState, structuralState, siblingState].filter(Boolean);
|
|
948
|
+
const now = matchSelectors(root, states.flatMap((state) => [...state.keys()]), compiledCompounds);
|
|
949
|
+
for (const state of states) {
|
|
950
|
+
for (const [compound, matched] of state) {
|
|
951
|
+
const inside = now.get(compound);
|
|
952
|
+
if (!inside) continue;
|
|
953
|
+
for (const element of [root, ...root.getElementsByTagName("*")]) {
|
|
954
|
+
if (inside.has(element)) matched.add(element); else matched.delete(element);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
/** element.matches(compound), with the compound compiled once (see matchSelectors). */
|
|
962
|
+
function matchesCompound(element, compound) {
|
|
963
|
+
let compiled = compiledCompounds.get(compound);
|
|
964
|
+
if (compiled === undefined) {
|
|
965
|
+
compiled = compileSelector(element, compound);
|
|
966
|
+
if (compiled === undefined) return element.matches(compound);
|
|
967
|
+
if (compiledCompounds.size >= MAX_COMPILED_COMPOUNDS) compiledCompounds.clear();
|
|
968
|
+
compiledCompounds.set(compound, compiled);
|
|
969
|
+
}
|
|
970
|
+
return compiled !== null && matchesCompiled(element, compiled);
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/** Re-checks `element` against recorded compounds; whether any match changed (the state is updated). */
|
|
974
|
+
function matchesChanged(state, element) {
|
|
975
|
+
let changed = false;
|
|
976
|
+
for (const [compound, matched] of state ?? []) {
|
|
977
|
+
let now = false;
|
|
978
|
+
try { now = matchesCompound(element, compound); } catch {}
|
|
979
|
+
if (now === matched.has(element)) continue;
|
|
980
|
+
if (now) matched.add(element); else matched.delete(element);
|
|
981
|
+
changed = true;
|
|
982
|
+
}
|
|
983
|
+
return changed;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* Elements whose :has() matches changed around a mutated node: its
|
|
988
|
+
* ancestors (and their earlier siblings for :has(+ / ~)), re-checked
|
|
989
|
+
* against the recorded state, which is updated as it goes.
|
|
990
|
+
*/
|
|
991
|
+
function hasChanges(traits, node, add, checked) {
|
|
992
|
+
if (!hasState || hasState.size === 0) return;
|
|
993
|
+
for (let element = node?.nodeType === 1 ? node : node?.parentElement; element; element = element.parentElement) {
|
|
994
|
+
// The records describe one final DOM, so each element is checked once a
|
|
995
|
+
// round; its ancestors were too when it was.
|
|
996
|
+
if (checked.has(element)) return;
|
|
997
|
+
const candidates = [element];
|
|
998
|
+
if (traits.hasSiblings) for (let sibling = element.previousElementSibling; sibling; sibling = sibling.previousElementSibling) candidates.push(sibling);
|
|
999
|
+
for (const candidate of candidates) {
|
|
1000
|
+
if (candidate !== element && checked.has(candidate)) continue;
|
|
1001
|
+
checked.add(candidate);
|
|
1002
|
+
for (const [compound, matched] of hasState) {
|
|
1003
|
+
let now = false;
|
|
1004
|
+
try { now = matchesCompound(candidate, compound); } catch {}
|
|
1005
|
+
if (now === matched.has(candidate)) continue;
|
|
1006
|
+
if (now) matched.add(candidate); else matched.delete(candidate);
|
|
1007
|
+
add(candidate);
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/**
|
|
1014
|
+
* The style computation's dirty set, or null for a full restyle. Pending
|
|
1015
|
+
* mutation records become subtree roots: an attribute change restyles the
|
|
1016
|
+
* element's subtree (descendant selectors), a child list change its
|
|
1017
|
+
* parent's (structural pseudo-classes), with siblings included when the
|
|
1018
|
+
* sheets use sibling combinators; a change :has() may observe restyles all.
|
|
1019
|
+
*/
|
|
1020
|
+
function takeStyleChanges(sheets) {
|
|
1021
|
+
const traits = selectorTraitsFor(sheets);
|
|
1022
|
+
const records = styleChanges.records;
|
|
1023
|
+
if (styleChanges.observer) records.push(...styleChanges.observer.takeRecords());
|
|
1024
|
+
const roots = styleChanges.roots;
|
|
1025
|
+
// Explicit roots mean that state-dependent selectors may need restyling;
|
|
1026
|
+
// they do not by themselves change render-tree content. DOM mutation
|
|
1027
|
+
// records below identify structural/content changes separately.
|
|
1028
|
+
let treeDirty = false;
|
|
1029
|
+
let full = styleChanges.full;
|
|
1030
|
+
const active = page.document.activeElement;
|
|
1031
|
+
if (active !== styleChanges.activeElement) {
|
|
1032
|
+
// :focus, :focus-within and :focus-visible follow the focused element.
|
|
1033
|
+
if (styleChanges.activeElement !== null) full ||= "focus moved";
|
|
1034
|
+
styleChanges.activeElement = active;
|
|
1035
|
+
}
|
|
1036
|
+
// Elements whose subtree restyles (`roots`), and elements whose own
|
|
1037
|
+
// matches may change but whose subtree restyles only if their style does
|
|
1038
|
+
// (`self`, decided by the style engine).
|
|
1039
|
+
const self = new Set();
|
|
1040
|
+
const add = (element) => roots.add(element);
|
|
1041
|
+
// Elements already re-checked this round against the :has(), sibling and structural state.
|
|
1042
|
+
const hasChecked = new Set();
|
|
1043
|
+
const siblingChecked = new Set();
|
|
1044
|
+
const structuralChecked = new Set();
|
|
1045
|
+
const inserted = new Set();
|
|
1046
|
+
// Siblings after a change: their own matches may differ; their subtrees
|
|
1047
|
+
// only when a non-subject compound right of + / ~ now matches differently.
|
|
1048
|
+
const laterSibling = (element) => {
|
|
1049
|
+
self.add(element);
|
|
1050
|
+
if (siblingChecked.has(element)) return;
|
|
1051
|
+
siblingChecked.add(element);
|
|
1052
|
+
if (matchesChanged(siblingState, element)) roots.add(element);
|
|
1053
|
+
};
|
|
1054
|
+
const later = (element) => { for (let next = element?.nextElementSibling; next; next = next.nextElementSibling) laterSibling(next); };
|
|
1055
|
+
const featureMatches = (used, changedFeatures) => changedFeatures.some((feature) => feature === ":state"
|
|
1056
|
+
? [...used].some((candidate) => candidate.startsWith(":"))
|
|
1057
|
+
: used.has(feature));
|
|
1058
|
+
const stateAffectsDescendants = (element, changedFeatures) => {
|
|
1059
|
+
const states = changedFeatures.filter((feature) => feature.startsWith(":"));
|
|
1060
|
+
if (!states.length) return false;
|
|
1061
|
+
const available = new Set(elementFeatures(element));
|
|
1062
|
+
return traits.descendantStateCompounds.some((entry) => (states.includes(":state") || entry.states.some((state) => states.includes(state)))
|
|
1063
|
+
&& entry.required.every((feature) => available.has(feature)));
|
|
1064
|
+
};
|
|
1065
|
+
/** An element whose features changed: itself, and wider where non-subject compounds use them. */
|
|
1066
|
+
const changed = (element, features) => {
|
|
1067
|
+
self.add(element);
|
|
1068
|
+
const ordinary = features.filter((feature) => !feature.startsWith(":"));
|
|
1069
|
+
if (featureMatches(traits.descendantFeatures, ordinary) || stateAffectsDescendants(element, features)) roots.add(element);
|
|
1070
|
+
if (traits.siblingFeatures.has("*") || featureMatches(traits.siblingFeatures, features)) later(element);
|
|
1071
|
+
hasChanges(traits, element, add, hasChecked);
|
|
1072
|
+
};
|
|
1073
|
+
const touchSibling = (element) => {
|
|
1074
|
+
self.add(element);
|
|
1075
|
+
if (structuralChecked.has(element)) return;
|
|
1076
|
+
structuralChecked.add(element);
|
|
1077
|
+
if (matchesChanged(structuralState, element)) roots.add(element);
|
|
1078
|
+
};
|
|
1079
|
+
if (!full && !hasState) full = "first restyle";
|
|
1080
|
+
// Marked elements: state outside the DOM (checked, value) or a DOM change
|
|
1081
|
+
// the records also describe; both start as a change of the element's state.
|
|
1082
|
+
for (const element of [...roots]) {
|
|
1083
|
+
roots.delete(element);
|
|
1084
|
+
if (!full) changed(element, styleChanges.rootFeatures.get(element) ?? [":state"]);
|
|
1085
|
+
}
|
|
1086
|
+
for (const record of records) {
|
|
1087
|
+
if (full) break;
|
|
1088
|
+
const target = record.target;
|
|
1089
|
+
if (record.type === "attributes") {
|
|
1090
|
+
if (target.nodeType !== 1) continue;
|
|
1091
|
+
const name = record.attributeName;
|
|
1092
|
+
// data/ARIA/title changes have no direct render-tree representation;
|
|
1093
|
+
// selectors that observe them are handled by the style diff below.
|
|
1094
|
+
// Other attributes may alter controls, replaced elements or visibility.
|
|
1095
|
+
if (!name.startsWith("data-") && !name.startsWith("aria-") && name !== "title") treeDirty = true;
|
|
1096
|
+
const features = ["[" + name];
|
|
1097
|
+
if (name === "class") {
|
|
1098
|
+
const before = new Set(String(record.oldValue ?? "").split(/\s+/).filter(Boolean));
|
|
1099
|
+
const after = new Set(target.classList);
|
|
1100
|
+
for (const value of before) if (!after.has(value)) features.push("." + value);
|
|
1101
|
+
for (const value of after) if (!before.has(value)) features.push("." + value);
|
|
1102
|
+
} else if (name === "id") {
|
|
1103
|
+
if (record.oldValue) features.push("#" + record.oldValue);
|
|
1104
|
+
const id = target.getAttribute("id");
|
|
1105
|
+
if (id) features.push("#" + id);
|
|
1106
|
+
}
|
|
1107
|
+
changed(target, features);
|
|
1108
|
+
} else if (record.type === "childList") {
|
|
1109
|
+
treeDirty = true;
|
|
1110
|
+
if (target.nodeType !== 1) { full = `children of ${target.nodeName}`; break; }
|
|
1111
|
+
// Inserted elements restyle; the parent itself may change (:empty);
|
|
1112
|
+
// siblings around the change point may match structural
|
|
1113
|
+
// pseudo-classes differently, and the ones after it sibling
|
|
1114
|
+
// combinators whose left side the inserted or removed nodes match.
|
|
1115
|
+
for (const node of record.addedNodes) {
|
|
1116
|
+
if (node.nodeType === 1 && node.parentElement === target) {
|
|
1117
|
+
add(node);
|
|
1118
|
+
inserted.add(node);
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
self.add(target);
|
|
1122
|
+
hasChanges(traits, target, add, hasChecked);
|
|
1123
|
+
let next = record.nextSibling;
|
|
1124
|
+
while (next && next.nodeType !== 1) next = next.nextSibling;
|
|
1125
|
+
let previous = record.previousSibling;
|
|
1126
|
+
while (previous && previous.nodeType !== 1) previous = previous.previousSibling;
|
|
1127
|
+
if (next?.parentElement !== target) next = null;
|
|
1128
|
+
if (previous?.parentElement !== target) previous = null;
|
|
1129
|
+
if (traits.forward) for (let element = next; element; element = element.nextElementSibling) touchSibling(element);
|
|
1130
|
+
else if (traits.firstChild && next) touchSibling(next);
|
|
1131
|
+
if (traits.backward) for (let element = previous; element; element = element.previousElementSibling) touchSibling(element);
|
|
1132
|
+
else if (traits.lastChild && previous) touchSibling(previous);
|
|
1133
|
+
if (next && traits.siblingFeatures.size) {
|
|
1134
|
+
const moved = [...record.addedNodes, ...record.removedNodes, previous].filter((node) => node?.nodeType === 1);
|
|
1135
|
+
if (traits.siblingFeatures.has("*") || moved.some((node) => elementFeatures(node).some((feature) => traits.siblingFeatures.has(feature)))) {
|
|
1136
|
+
for (let element = next; element; element = element.nextElementSibling) laterSibling(element);
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
} else if (record.type === "characterData") {
|
|
1140
|
+
treeDirty = true;
|
|
1141
|
+
// Only :empty and :has() can see text: the parent itself.
|
|
1142
|
+
const parent = target.parentElement;
|
|
1143
|
+
if (parent) {
|
|
1144
|
+
self.add(parent);
|
|
1145
|
+
hasChanges(traits, parent, add, hasChecked);
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
if (!full) for (const node of topmostConnected(inserted, page.document)) recordInserted(node);
|
|
1150
|
+
styleChanges.records = [];
|
|
1151
|
+
styleChanges.roots = new Set();
|
|
1152
|
+
styleChanges.rootFeatures = new Map();
|
|
1153
|
+
styleChanges.full = false;
|
|
1154
|
+
lastStyleReason = full || `${roots.size} root(s), ${self.size} self`;
|
|
1155
|
+
if (full) hasState = null; // recorded again after the full restyle
|
|
1156
|
+
return full ? null : { roots, self, treeDirty };
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
function replaceParsedDocument(parsed, styleSheets = [], imageResources = emptyImageResources(), linkSheets = null) {
|
|
1160
|
+
closePiano();
|
|
737
1161
|
disposeVideos();
|
|
738
1162
|
page.realm?.dispose();
|
|
739
1163
|
page.realm = null;
|
|
@@ -741,11 +1165,14 @@ export async function createPageRenderer({
|
|
|
741
1165
|
for (const image of page.decodedImages) image.delete();
|
|
742
1166
|
page.window = parsed.window;
|
|
743
1167
|
page.document = parsed.document;
|
|
1168
|
+
observeStyleChanges(parsed.document);
|
|
744
1169
|
page.title = parsed.document.title;
|
|
745
1170
|
page.styleSheets = styleSheets;
|
|
1171
|
+
page.linkSheets = linkSheets;
|
|
746
1172
|
page.resources = imageResources.map;
|
|
747
1173
|
initializeVideos(parsed.document, page.resources);
|
|
748
1174
|
page.decodedImages = imageResources.decoded;
|
|
1175
|
+
page.imagesLoaded = null;
|
|
749
1176
|
imageRequests = new WeakMap();
|
|
750
1177
|
const timing = styleAndLayout();
|
|
751
1178
|
trace?.(`${page.url}: first style ${timing.style}ms, layout ${timing.layout}ms`);
|
|
@@ -759,27 +1186,94 @@ export async function createPageRenderer({
|
|
|
759
1186
|
/** Style, render tree, layout and display list of the current document. */
|
|
760
1187
|
function styleAndLayout() {
|
|
761
1188
|
const started = hostPerformance.now();
|
|
762
|
-
|
|
1189
|
+
const ms = (from, to) => Math.round(to - from);
|
|
1190
|
+
const sheets = currentStyleSheets();
|
|
1191
|
+
const environment = { width: page.width, height: page.height, mobile };
|
|
1192
|
+
const changes = takeStyleChanges(sheets); // drained even when unused, so records do not pile up
|
|
1193
|
+
const taken = hostPerformance.now();
|
|
1194
|
+
// A rendering opportunity can be scheduled by lifecycle work which did
|
|
1195
|
+
// not actually change DOM, state, resources, or styles. Keep the retained
|
|
1196
|
+
// layout/display list in that case instead of laying the whole page out.
|
|
1197
|
+
if (page.renderTree && !page.resourceLayoutDirty && sheets === laidOutSheets && changes
|
|
1198
|
+
&& changes.roots.size === 0 && changes.self.size === 0) {
|
|
1199
|
+
lastStyleParts = `changes ${Math.round(taken - started)}, no invalidation`;
|
|
1200
|
+
return { style: Math.round(taken - started), layout: 0, skipped: true };
|
|
1201
|
+
}
|
|
1202
|
+
styleEngine.compute(page.document, sheets, environment, fullStyle ? null : changes?.roots ?? null, changes?.self ?? null);
|
|
1203
|
+
const computed = hostPerformance.now();
|
|
1204
|
+
if (!hasState || hasStateStale) {
|
|
1205
|
+
recordHasState(selectorTraitsFor(sheets));
|
|
1206
|
+
hasStateStale = false;
|
|
1207
|
+
}
|
|
1208
|
+
const recorded = hostPerformance.now();
|
|
1209
|
+
// Attribute mutations used by application bookkeeping often match no
|
|
1210
|
+
// different rule and do not alter render-tree content. Once style has
|
|
1211
|
+
// proved that no element changed, retain layout and paint unchanged.
|
|
1212
|
+
if (page.renderTree && !page.resourceLayoutDirty && sheets === laidOutSheets && changes
|
|
1213
|
+
&& !changes.treeDirty && styleEngine.incremental && styleEngine.restyled === 0) {
|
|
1214
|
+
lastStyleParts = `changes ${ms(started, taken)}, compute ${ms(taken, computed)}, record ${ms(computed, recorded)}, no visual change`;
|
|
1215
|
+
return { style: Math.round(recorded - started), layout: 0, skipped: true };
|
|
1216
|
+
}
|
|
1217
|
+
if (verifyStyle && styleEngine.incremental) verifyIncrementalStyle(sheets, environment);
|
|
1218
|
+
const verified = hostPerformance.now();
|
|
763
1219
|
page.backgroundColor = documentBackground(page.document, styleEngine);
|
|
764
|
-
|
|
1220
|
+
const nextRenderTree = renderTreeBuilder.build(page.document, styleEngine, page.resources);
|
|
765
1221
|
const styled = hostPerformance.now();
|
|
766
|
-
|
|
1222
|
+
lastStyleParts = `changes ${ms(started, taken)}, compute ${ms(taken, computed)}, record ${ms(computed, recorded)}, tree ${ms(verified, styled)}`;
|
|
1223
|
+
// MutationObserver also reports changes under non-rendered nodes such as
|
|
1224
|
+
// script and metadata elements. Compare the actual immutable layout input
|
|
1225
|
+
// after rebuilding it, so those changes do not trigger a full reflow.
|
|
1226
|
+
if (page.renderTree && !page.resourceLayoutDirty && sheets === laidOutSheets
|
|
1227
|
+
&& sameLayoutInput(page.renderTree.root, nextRenderTree.root, {
|
|
1228
|
+
ignoreResources: true,
|
|
1229
|
+
// A zero-element incremental pass retained every computed style.
|
|
1230
|
+
// Control placeholders may still wrap that style in a fresh object.
|
|
1231
|
+
ignoreStyles: styleEngine.incremental && styleEngine.restyled === 0,
|
|
1232
|
+
})) {
|
|
1233
|
+
lastStyleParts += ", unchanged layout input";
|
|
1234
|
+
return { style: Math.round(styled - started), layout: 0, skipped: true };
|
|
1235
|
+
}
|
|
1236
|
+
page.renderTree = nextRenderTree;
|
|
1237
|
+
page.layout = layoutEngine.layout(page.renderTree, {
|
|
767
1238
|
measureText, fontMetrics, width: page.width, viewportHeight: page.height, x: 0, y: 0,
|
|
768
1239
|
});
|
|
769
|
-
page.displayList = buildDisplayList(page.layout, page.renderTree);
|
|
1240
|
+
page.displayList = buildDisplayList(page.layout, page.renderTree, page.resources);
|
|
1241
|
+
page.resourceLayoutDirty = false;
|
|
1242
|
+
laidOutSheets = sheets;
|
|
770
1243
|
viewport.setContentSize(page.layout.width, page.layout.height);
|
|
771
1244
|
return { style: Math.round(styled - started), layout: Math.round(hostPerformance.now() - styled) };
|
|
772
1245
|
}
|
|
773
1246
|
|
|
1247
|
+
/** BUNINU_VERIFY_STYLE: compare an incremental restyle with a full one, element by element. */
|
|
1248
|
+
function verifyIncrementalStyle(sheets, environment) {
|
|
1249
|
+
const reference = new StyleEngine().compute(page.document, sheets, environment);
|
|
1250
|
+
const serialize = (style) => JSON.stringify(style, (key, value) => typeof value === "bigint" ? String(value) : value);
|
|
1251
|
+
let mismatches = 0;
|
|
1252
|
+
for (const element of page.document.querySelectorAll("*")) {
|
|
1253
|
+
const ours = serialize(styleEngine.get(element));
|
|
1254
|
+
const expected = serialize(reference.get(element));
|
|
1255
|
+
const pseudo = ["before", "after"].some((name) =>
|
|
1256
|
+
serialize(styleEngine.getPseudo(element, name)) !== serialize(reference.getPseudo(element, name)));
|
|
1257
|
+
if (ours === expected && !pseudo) continue;
|
|
1258
|
+
if (mismatches++ < 3) {
|
|
1259
|
+
const a = styleEngine.get(element);
|
|
1260
|
+
const b = reference.get(element);
|
|
1261
|
+
const fields = Object.keys(b).filter((key) => serialize(a[key]) !== serialize(b[key]));
|
|
1262
|
+
reportError(`buninu-browser: incremental style mismatch at <${element.localName} class="${element.getAttribute("class") ?? ""}">: ${pseudo ? "pseudo " : ""}${fields.join(", ")}`);
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
if (mismatches) reportError(`buninu-browser: incremental style: ${mismatches} element(s) differ from a full restyle`);
|
|
1266
|
+
}
|
|
1267
|
+
|
|
774
1268
|
function rebuildRenderTree(reason = "update") {
|
|
775
1269
|
const started = hostPerformance.now();
|
|
776
1270
|
page.dirty = false;
|
|
777
|
-
page.needsPaint = true;
|
|
778
1271
|
page.title = page.document?.title ?? page.title;
|
|
779
1272
|
const timing = styleAndLayout();
|
|
1273
|
+
if (!timing.skipped) page.needsPaint = true;
|
|
780
1274
|
lastRebuildMs = hostPerformance.now() - started;
|
|
781
1275
|
trace?.(`${page.url}: restyle and relayout (${reason}) ${Math.round(lastRebuildMs)}ms`
|
|
782
|
-
+ ` (style ${timing.style}ms, layout ${timing.layout}ms)`);
|
|
1276
|
+
+ ` (style ${timing.style}ms for ${styleEngine.incremental ? styleEngine.restyled : "all"} elements: ${lastStyleReason}; ${lastStyleParts}, layout ${timing.layout}ms)`);
|
|
783
1277
|
}
|
|
784
1278
|
|
|
785
1279
|
// Each loadDocument starts a generation; stages of a superseded load stop.
|
|
@@ -849,37 +1343,41 @@ export async function createPageRenderer({
|
|
|
849
1343
|
const isHtml = contentType.toLowerCase().includes("html");
|
|
850
1344
|
const parsed = parseHTMLDocument(isHtml ? source : "<body></body>", url);
|
|
851
1345
|
phase("parse");
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
1346
|
+
// Stylesheets block rendering and scripts; images do not (only the
|
|
1347
|
+
// window load event waits for them, see whenImagesLoaded): one slow image
|
|
1348
|
+
// must not hold back the page's scripts. Images that are already here
|
|
1349
|
+
// when the styles are join the first paint; the rest are drawn as they come.
|
|
1350
|
+
const linkSheets = isHtml ? new WeakMap() : null;
|
|
1351
|
+
const styleSheets = isHtml ? loadStyleSheets(parsed.document, url, linkSheets) : Promise.resolve([]);
|
|
1352
|
+
const images = isHtml ? loadImageResources(parsed.document, url) : Promise.resolve(emptyImageResources());
|
|
1353
|
+
page.preloadedScripts = isHtml && page.scripting !== "off" ? preloadScripts(parsed.document, url) : new Map();
|
|
1354
|
+
let imagesReady = null;
|
|
1355
|
+
images.then((loaded) => { imagesReady = loaded; });
|
|
855
1356
|
let timer;
|
|
856
|
-
let
|
|
857
|
-
|
|
1357
|
+
let sheets = await Promise.race([
|
|
1358
|
+
styleSheets,
|
|
858
1359
|
new Promise((resolve) => (timer = hostSetTimeout(resolve, PREVIEW_DELAY_MS, null))),
|
|
859
1360
|
]);
|
|
860
1361
|
hostClearTimeout(timer);
|
|
861
|
-
if (!current()) return discard(
|
|
862
|
-
if (!
|
|
863
|
-
//
|
|
1362
|
+
if (!current()) return discard(images, parsed);
|
|
1363
|
+
if (!sheets) {
|
|
1364
|
+
// Stylesheets are slow: show the document with the user-agent
|
|
864
1365
|
// defaults and its inline <style> sheets meanwhile.
|
|
865
|
-
replaceParsedDocument(parsed, inlineStyleSheets(parsed.document), emptyImageResources());
|
|
1366
|
+
replaceParsedDocument(parsed, inlineStyleSheets(parsed.document), emptyImageResources(), linkSheets);
|
|
1367
|
+
awaitImages(images, generation);
|
|
866
1368
|
render();
|
|
867
1369
|
phase("preview");
|
|
868
1370
|
commit.resolve();
|
|
869
|
-
|
|
870
|
-
if (!current()) return
|
|
871
|
-
phase(`${
|
|
1371
|
+
sheets = await styleSheets;
|
|
1372
|
+
if (!current()) return;
|
|
1373
|
+
phase(`${sheets.length} stylesheets`);
|
|
872
1374
|
// 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();
|
|
1375
|
+
page.styleSheets = sheets;
|
|
879
1376
|
rebuildRenderTree("stylesheets loaded");
|
|
880
1377
|
} else {
|
|
881
|
-
phase(`${
|
|
882
|
-
replaceParsedDocument(parsed,
|
|
1378
|
+
phase(`${sheets.length} stylesheets${imagesReady ? ` and ${imagesReady.decoded.length} images` : ""}`);
|
|
1379
|
+
replaceParsedDocument(parsed, sheets, imagesReady ?? emptyImageResources(), linkSheets);
|
|
1380
|
+
if (!imagesReady) awaitImages(images, generation);
|
|
883
1381
|
}
|
|
884
1382
|
phase("style and layout");
|
|
885
1383
|
if (!isHtml) {
|
|
@@ -905,22 +1403,79 @@ export async function createPageRenderer({
|
|
|
905
1403
|
phase("scripts");
|
|
906
1404
|
page.loading = false;
|
|
907
1405
|
refresh();
|
|
908
|
-
void hydrateGitHubBranchInfo(generation);
|
|
909
1406
|
phase("style, layout and raster after scripts");
|
|
910
1407
|
} else {
|
|
911
1408
|
finish();
|
|
912
1409
|
}
|
|
913
1410
|
}
|
|
914
1411
|
|
|
1412
|
+
/**
|
|
1413
|
+
* Like a browser's preload scanner: starts fetching the document's external
|
|
1414
|
+
* scripts and declared module preloads (<link rel=modulepreload>) as soon
|
|
1415
|
+
* as it is parsed, while its stylesheets load and it is styled, instead of
|
|
1416
|
+
* when the scripts' turn comes and a module graph is discovered level by
|
|
1417
|
+
* level. Only the bytes are fetched early; fetchScript() takes them.
|
|
1418
|
+
*/
|
|
1419
|
+
function preloadScripts(document, documentUrl) {
|
|
1420
|
+
const preloaded = new Map();
|
|
1421
|
+
const preload = (href) => {
|
|
1422
|
+
let target;
|
|
1423
|
+
try { target = new URL(href, documentUrl); } catch { return; }
|
|
1424
|
+
if (!["http:", "https:"].includes(target.protocol) || preloaded.has(target.href)) return;
|
|
1425
|
+
const response = fetchResource(target.href, "script");
|
|
1426
|
+
response.catch(() => {}); // reported when the script runs
|
|
1427
|
+
preloaded.set(target.href, response);
|
|
1428
|
+
};
|
|
1429
|
+
for (const script of document.querySelectorAll("script[src]")) {
|
|
1430
|
+
const type = (script.getAttribute("type") ?? "").trim().toLowerCase();
|
|
1431
|
+
if (type === "module" || (CLASSIC_SCRIPT_TYPES.has(type) && !script.hasAttribute("nomodule"))) preload(script.getAttribute("src"));
|
|
1432
|
+
}
|
|
1433
|
+
for (const link of document.querySelectorAll("link[rel~='modulepreload'][href]")) preload(link.getAttribute("href"));
|
|
1434
|
+
return preloaded;
|
|
1435
|
+
}
|
|
1436
|
+
|
|
915
1437
|
/** Frees what a superseded load fetched. */
|
|
916
|
-
async function discard(
|
|
1438
|
+
async function discard(images, parsed = null) {
|
|
917
1439
|
parsed?.window?.happyDOM?.abort?.();
|
|
918
|
-
const
|
|
919
|
-
for (const image of
|
|
1440
|
+
const loaded = await images.catch(() => emptyImageResources());
|
|
1441
|
+
for (const image of loaded.decoded) image.delete();
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
/**
|
|
1445
|
+
* The document's initial images, still on their way: they are drawn when
|
|
1446
|
+
* they arrive (unless a script changed the src meanwhile), and the load
|
|
1447
|
+
* event waits for them.
|
|
1448
|
+
*/
|
|
1449
|
+
function awaitImages(images, generation) {
|
|
1450
|
+
const document = page.document;
|
|
1451
|
+
// queueImageResources must not fetch these again.
|
|
1452
|
+
for (const element of document.querySelectorAll("img")) {
|
|
1453
|
+
const src = element.getAttribute("src");
|
|
1454
|
+
if (src) imageRequests.set(element, src);
|
|
1455
|
+
}
|
|
1456
|
+
page.imagesLoaded = images.then((loaded) => {
|
|
1457
|
+
if (generation !== loadGeneration || document !== page.document) {
|
|
1458
|
+
for (const image of loaded.decoded) image.delete();
|
|
1459
|
+
return;
|
|
1460
|
+
}
|
|
1461
|
+
for (const { element, src, resource } of loaded.entries) {
|
|
1462
|
+
if (element.getAttribute("src") !== src || page.resources.get(element)) {
|
|
1463
|
+
resource.image.delete();
|
|
1464
|
+
continue;
|
|
1465
|
+
}
|
|
1466
|
+
page.resources.set(element, resource);
|
|
1467
|
+
page.decodedImages.push(resource.image);
|
|
1468
|
+
}
|
|
1469
|
+
page.dirty = true;
|
|
1470
|
+
page.resourceLayoutDirty = true;
|
|
1471
|
+
scheduleFrame();
|
|
1472
|
+
}, () => {});
|
|
920
1473
|
}
|
|
921
1474
|
|
|
922
1475
|
function inlineStyleSheets(document) {
|
|
923
|
-
return [...document.querySelectorAll("style")]
|
|
1476
|
+
return [...document.querySelectorAll("style")]
|
|
1477
|
+
.filter((element) => !element.closest("noscript"))
|
|
1478
|
+
.map((element) => element.textContent ?? "");
|
|
924
1479
|
}
|
|
925
1480
|
|
|
926
1481
|
/** Brings style, layout and raster up to date; returns whether it repainted. */
|
|
@@ -944,6 +1499,15 @@ export async function createPageRenderer({
|
|
|
944
1499
|
const element = page.document.activeElement;
|
|
945
1500
|
if (!element || !["INPUT", "TEXTAREA"].includes(element.tagName) || element.disabled || element.readOnly) return;
|
|
946
1501
|
if (element.tagName === "INPUT" && !["text", "search", "url", "email", "tel", "password"].includes(element.type)) return;
|
|
1502
|
+
// Mobile IMEs often send committed text without a usable keydown (key 229
|
|
1503
|
+
// or Unidentified). Handle that before the normal beforeinput insertion.
|
|
1504
|
+
if (!backspace && element.getAttribute("id") === "buninu-piano-input" && audio && text.length === 1) {
|
|
1505
|
+
const note = pianoNote(text);
|
|
1506
|
+
if (note !== null) {
|
|
1507
|
+
playPiano(note);
|
|
1508
|
+
return;
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
947
1511
|
const value = String(element.value ?? "");
|
|
948
1512
|
let start = Number.isInteger(element.selectionStart) ? element.selectionStart : value.length;
|
|
949
1513
|
const end = Number.isInteger(element.selectionEnd) ? element.selectionEnd : start;
|
|
@@ -957,23 +1521,73 @@ export async function createPageRenderer({
|
|
|
957
1521
|
if (prevented) return;
|
|
958
1522
|
}
|
|
959
1523
|
element.value = value.slice(0, start) + text + value.slice(end);
|
|
1524
|
+
markStyleDirty(element); // :placeholder-shown, :valid / :invalid
|
|
960
1525
|
element.setSelectionRange?.(start + text.length, start + text.length);
|
|
961
1526
|
if (page.realm) page.realm.dispatch(element, new page.window.Event("input", { bubbles: true }));
|
|
962
1527
|
page.dirty = true;
|
|
963
1528
|
}
|
|
964
1529
|
|
|
1530
|
+
let pianoStreamId = null;
|
|
1531
|
+
let pianoMixer = null;
|
|
1532
|
+
let pianoIdleTimer = null;
|
|
1533
|
+
const PIANO_IDLE_MS = 2000;
|
|
1534
|
+
|
|
1535
|
+
function playPiano(note) {
|
|
1536
|
+
pianoMixer ??= new PianoMixer();
|
|
1537
|
+
pianoMixer.trigger(note);
|
|
1538
|
+
hostClearTimeout(pianoIdleTimer);
|
|
1539
|
+
pianoIdleTimer = hostSetTimeout(closePiano, PIANO_IDLE_MS);
|
|
1540
|
+
if (pianoStreamId !== null) return;
|
|
1541
|
+
const id = audio.open({ rate: 48000, channels: 1, name: "piano" }, (event) => {
|
|
1542
|
+
if (pianoStreamId !== id) return;
|
|
1543
|
+
if (event.event === "request") audio.write(id, pianoMixer.render(event.bytes));
|
|
1544
|
+
else if (event.event === "error" || event.event === "ended") {
|
|
1545
|
+
pianoStreamId = null;
|
|
1546
|
+
pianoMixer = null;
|
|
1547
|
+
if (event.event === "error") reportError(new Error(`piano audio: ${event.message}`));
|
|
1548
|
+
}
|
|
1549
|
+
});
|
|
1550
|
+
pianoStreamId = id;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
function closePiano() {
|
|
1554
|
+
hostClearTimeout(pianoIdleTimer);
|
|
1555
|
+
pianoIdleTimer = null;
|
|
1556
|
+
if (pianoStreamId !== null) audio?.close(pianoStreamId);
|
|
1557
|
+
pianoStreamId = null;
|
|
1558
|
+
pianoMixer = null;
|
|
1559
|
+
}
|
|
1560
|
+
|
|
965
1561
|
function realmHooks(generation) {
|
|
966
1562
|
return {
|
|
967
|
-
|
|
1563
|
+
definedChanged(name) {
|
|
1564
|
+
// An upgrade changes :defined for that tag's elements only.
|
|
1565
|
+
if (selectorTraits.defined) {
|
|
1566
|
+
for (const element of page.document.getElementsByTagName(String(name))) markStyleDirty(element, [":defined"]);
|
|
1567
|
+
}
|
|
1568
|
+
page.dirty = true;
|
|
1569
|
+
scheduleFrame();
|
|
1570
|
+
},
|
|
1571
|
+
touch(node = null, stateFeatures = []) {
|
|
1572
|
+
if (stateFeatures.length) markStyleDirty(node, stateFeatures);
|
|
968
1573
|
page.dirty = true;
|
|
969
1574
|
scheduleFrame();
|
|
970
1575
|
},
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
1576
|
+
mediaState,
|
|
1577
|
+
playMedia,
|
|
1578
|
+
pauseMedia,
|
|
1579
|
+
seekMedia,
|
|
974
1580
|
lifecycle(name) {
|
|
975
|
-
if (generation
|
|
1581
|
+
if (generation !== loadGeneration) return;
|
|
1582
|
+
onLifecycle?.(name, generation);
|
|
1583
|
+
// Parsing and the deferred scripts are done: changes from async
|
|
1584
|
+
// scripts and late images get frames, without waiting for load.
|
|
1585
|
+
if (name === "DOMContentLoaded") {
|
|
1586
|
+
page.loading = false;
|
|
1587
|
+
scheduleFrame();
|
|
1588
|
+
}
|
|
976
1589
|
},
|
|
1590
|
+
whenImagesLoaded: () => page.imagesLoaded,
|
|
977
1591
|
afterTask() {},
|
|
978
1592
|
boundsOf(element) {
|
|
979
1593
|
if (page.dirty) rebuildRenderTree("script read geometry");
|
|
@@ -997,9 +1611,23 @@ export async function createPageRenderer({
|
|
|
997
1611
|
if (page.dirty) rebuildRenderTree("getComputedStyle");
|
|
998
1612
|
return styleEngine.get(element);
|
|
999
1613
|
},
|
|
1000
|
-
navigate: (url) =>
|
|
1614
|
+
navigate: (url) => navigateTo(String(url)),
|
|
1615
|
+
activateLink(anchor) {
|
|
1616
|
+
const href = new URL(anchor.getAttribute("href"), page.url).href;
|
|
1617
|
+
const name = anchor.hasAttribute("download") ? anchor.getAttribute("download") ?? "" : null;
|
|
1618
|
+
if (href.startsWith("blob:") && name !== null) void downloadBlob(href, name);
|
|
1619
|
+
else navigateTo(href, name);
|
|
1620
|
+
},
|
|
1621
|
+
submitForm,
|
|
1001
1622
|
async fetchScript(url) {
|
|
1002
|
-
|
|
1623
|
+
if (/^(?:blob|data):/i.test(url)) {
|
|
1624
|
+
const response = await fetch(url);
|
|
1625
|
+
if (!response.ok) throw new Error(`script ${url} failed with HTTP ${response.status}`);
|
|
1626
|
+
return response.text();
|
|
1627
|
+
}
|
|
1628
|
+
const preloaded = page.preloadedScripts.get(url);
|
|
1629
|
+
if (preloaded) page.preloadedScripts.delete(url);
|
|
1630
|
+
const response = await (preloaded ?? fetchResource(url, "script"));
|
|
1003
1631
|
if (!response.ok) throw new Error(`script ${url} failed with HTTP ${response.status}`);
|
|
1004
1632
|
return new TextDecoder().decode(response.body);
|
|
1005
1633
|
},
|
|
@@ -1020,6 +1648,18 @@ export async function createPageRenderer({
|
|
|
1020
1648
|
};
|
|
1021
1649
|
}
|
|
1022
1650
|
|
|
1651
|
+
function submitForm(form, submitter = null) {
|
|
1652
|
+
const method = (submitter?.getAttribute("formmethod") ?? form.getAttribute("method") ?? "get").toLowerCase();
|
|
1653
|
+
if (method !== "get") return;
|
|
1654
|
+
const action = submitter?.getAttribute("formaction") ?? form.getAttribute("action") ?? page.url;
|
|
1655
|
+
const url = new URL(action || page.url, page.url);
|
|
1656
|
+
url.search = "";
|
|
1657
|
+
for (const [name, value] of new page.window.FormData(form, submitter)) {
|
|
1658
|
+
if (typeof value === "string") url.searchParams.append(name, value);
|
|
1659
|
+
}
|
|
1660
|
+
navigateTo(url.href);
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1023
1663
|
/** The border box of an element's layout box in viewport coordinates. */
|
|
1024
1664
|
function viewportBounds(element) {
|
|
1025
1665
|
let fixed = false;
|
|
@@ -1044,27 +1684,66 @@ export async function createPageRenderer({
|
|
|
1044
1684
|
}
|
|
1045
1685
|
|
|
1046
1686
|
/**
|
|
1047
|
-
*
|
|
1048
|
-
*
|
|
1687
|
+
* Navigation a page starts (a link, location.href = ...). A fragment of
|
|
1688
|
+
* the current document is navigated to at once, as HTML specifies, so the
|
|
1689
|
+
* page's own click and popstate handlers already see the new location;
|
|
1690
|
+
* the controller then only records the history entry (with the scroll
|
|
1691
|
+
* position being left). Anything else goes to the controller.
|
|
1692
|
+
*/
|
|
1693
|
+
function navigateTo(url, downloadName = null) {
|
|
1694
|
+
const from = downloadName === null ? fragmentNavigation(url) : null;
|
|
1695
|
+
requestNavigation(url, downloadName, from);
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
/**
|
|
1699
|
+
* Navigates to `url` at once if it is a fragment of the current document;
|
|
1700
|
+
* returns the scroll offset it left, or null when it is not such a URL.
|
|
1049
1701
|
*/
|
|
1050
|
-
function
|
|
1702
|
+
function fragmentNavigation(url) {
|
|
1703
|
+
const target = new URL(url, page.url);
|
|
1704
|
+
const current = new URL(page.url);
|
|
1705
|
+
target.hash = current.hash = "";
|
|
1706
|
+
if (!page.realm || !String(url).includes("#") || target.href !== current.href) return null;
|
|
1707
|
+
const from = { x: viewport.x, y: viewport.y };
|
|
1708
|
+
void showFragment(new URL(url, page.url).href);
|
|
1709
|
+
return from;
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
async function showFragment(url) {
|
|
1713
|
+
// :target moves from the old fragment's element to the new one's.
|
|
1714
|
+
if (selectorTraits.target) {
|
|
1715
|
+
for (const href of [page.url, url]) {
|
|
1716
|
+
let id = "";
|
|
1717
|
+
try { id = decodeURIComponent(new URL(href).hash.slice(1)); } catch {}
|
|
1718
|
+
const target = id && page.document.getElementById(id);
|
|
1719
|
+
if (target) markStyleDirty(target);
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
page.url = url;
|
|
1723
|
+
scrollToFragment(url);
|
|
1724
|
+
// Pages that map fragments to their own targets scroll from popstate or hashchange listeners.
|
|
1725
|
+
await page.realm?.navigateToFragment(url);
|
|
1726
|
+
render();
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
/** Scrolls to the fragment's target (HTML §7.4.6.4). */
|
|
1730
|
+
function scrollToFragment(href) {
|
|
1051
1731
|
const url = new URL(href);
|
|
1052
1732
|
if (!url.hash || url.hash === "#") {
|
|
1053
1733
|
viewport.scrollTo(0, 0);
|
|
1054
|
-
return
|
|
1734
|
+
return;
|
|
1055
1735
|
}
|
|
1056
1736
|
let identifier;
|
|
1057
1737
|
try {
|
|
1058
|
-
identifier =
|
|
1738
|
+
identifier = decodeURIComponent(url.hash.slice(1));
|
|
1059
1739
|
} catch {
|
|
1060
|
-
identifier =
|
|
1740
|
+
identifier = url.hash.slice(1);
|
|
1061
1741
|
}
|
|
1062
1742
|
const target = page.document.getElementById(identifier)
|
|
1063
1743
|
?? [...page.document.getElementsByTagName("a")]
|
|
1064
1744
|
.find((element) => element.getAttribute("name") === identifier);
|
|
1065
1745
|
const bounds = target && elementBounds(page.renderTree, page.layout, target);
|
|
1066
1746
|
if (bounds) viewport.scrollTo(viewport.x, bounds.top);
|
|
1067
|
-
return Boolean(target);
|
|
1068
1747
|
}
|
|
1069
1748
|
|
|
1070
1749
|
function collectHints() {
|
|
@@ -1102,6 +1781,18 @@ export async function createPageRenderer({
|
|
|
1102
1781
|
render();
|
|
1103
1782
|
}
|
|
1104
1783
|
|
|
1784
|
+
async function downloadBlob(url, name) {
|
|
1785
|
+
try {
|
|
1786
|
+
const response = await fetch(url);
|
|
1787
|
+
if (!response.ok) throw new Error(`blob download failed: HTTP ${response.status}`);
|
|
1788
|
+
const data = new Uint8Array(await response.arrayBuffer());
|
|
1789
|
+
if (data.byteLength > 64 * 1024 * 1024) throw new Error("blob download exceeds 64 MB");
|
|
1790
|
+
requestDownload({ url, name, data });
|
|
1791
|
+
} catch (error) {
|
|
1792
|
+
reportError(error);
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1105
1796
|
const api = {
|
|
1106
1797
|
loadDocument,
|
|
1107
1798
|
/** Enables page scripts once the sandbox is ready: "ses", "host", or "off". */
|
|
@@ -1116,18 +1807,7 @@ export async function createPageRenderer({
|
|
|
1116
1807
|
return page.realm?.console ?? [];
|
|
1117
1808
|
},
|
|
1118
1809
|
/** 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
|
-
},
|
|
1810
|
+
showFragment,
|
|
1131
1811
|
url() {
|
|
1132
1812
|
return page.url;
|
|
1133
1813
|
},
|
|
@@ -1235,6 +1915,14 @@ export async function createPageRenderer({
|
|
|
1235
1915
|
},
|
|
1236
1916
|
press(key, options = {}) {
|
|
1237
1917
|
refresh();
|
|
1918
|
+
const pianoInput = page.document?.activeElement;
|
|
1919
|
+
if (audio && pianoInput?.getAttribute?.("id") === "buninu-piano-input") {
|
|
1920
|
+
const note = pianoNote(String(key), String(options.code ?? ""));
|
|
1921
|
+
if (note !== null && !options.repeat && !options.modifiers) {
|
|
1922
|
+
playPiano(note);
|
|
1923
|
+
return { x: viewport.x, y: viewport.y };
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1238
1926
|
// Pages see keydown/keyup first; preventDefault() cancels the default scroll.
|
|
1239
1927
|
if (page.realm) {
|
|
1240
1928
|
const target = page.document.activeElement ?? page.document.body;
|
|
@@ -1248,7 +1936,15 @@ export async function createPageRenderer({
|
|
|
1248
1936
|
const prevented = page.realm.dispatch(target, new page.window.KeyboardEvent("keydown", init));
|
|
1249
1937
|
if (!prevented && !init.ctrlKey && !init.metaKey && !init.altKey) {
|
|
1250
1938
|
if (key === "Backspace") editFocusedText("", true);
|
|
1251
|
-
else if (key === "Enter" && target.tagName === "TEXTAREA")
|
|
1939
|
+
else if (key === "Enter" && target.tagName === "TEXTAREA") {
|
|
1940
|
+
// A search IME's action key submits a search form. Other textareas
|
|
1941
|
+
// keep their normal newline behavior.
|
|
1942
|
+
const form = target.form;
|
|
1943
|
+
if (target.getAttribute("inputmode")?.toLowerCase() === "search" && form && form.checkValidity()) {
|
|
1944
|
+
const event = new page.window.SubmitEvent("submit", { bubbles: true, cancelable: true, submitter: form });
|
|
1945
|
+
if (!page.realm.dispatch(form, event)) submitForm(form);
|
|
1946
|
+
} else editFocusedText("\n");
|
|
1947
|
+
}
|
|
1252
1948
|
else if (String(key).length === 1) editFocusedText(String(key));
|
|
1253
1949
|
}
|
|
1254
1950
|
page.realm.dispatch(target, new page.window.KeyboardEvent("keyup", init));
|
|
@@ -1283,8 +1979,33 @@ export async function createPageRenderer({
|
|
|
1283
1979
|
*/
|
|
1284
1980
|
click(x, y) {
|
|
1285
1981
|
refresh();
|
|
1286
|
-
|
|
1982
|
+
// Activation can toggle checked state (radios in a group, through a
|
|
1983
|
+
// label) without a DOM mutation, so the next restyle is a full one.
|
|
1984
|
+
markStyleDirty(null); // full: click
|
|
1985
|
+
let region = hitTest(page.renderTree, page.layout, x, y, viewport, viewport);
|
|
1986
|
+
if (!region) {
|
|
1987
|
+
// Some search forms draw a wide control around a narrow textarea and
|
|
1988
|
+
// rely on script to forward clicks. Preserve that affordance when the
|
|
1989
|
+
// form has exactly one search field, even if its script did not run.
|
|
1990
|
+
for (const form of page.document.querySelectorAll('form[role="search"]')) {
|
|
1991
|
+
const bounds = viewportBounds(form);
|
|
1992
|
+
if (!bounds || x < bounds.left || x >= bounds.left + bounds.width
|
|
1993
|
+
|| y < bounds.top || y >= bounds.top + bounds.height) continue;
|
|
1994
|
+
const fields = form.querySelectorAll('textarea[inputmode="search"], input[type="search"]');
|
|
1995
|
+
if (fields.length === 1) region = { element: fields[0] };
|
|
1996
|
+
break;
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1287
1999
|
if (!region) return null;
|
|
2000
|
+
if (page.url === "about:audio") {
|
|
2001
|
+
const key = region.element.closest?.("[data-piano-note]");
|
|
2002
|
+
const note = Number(key?.getAttribute("data-piano-note"));
|
|
2003
|
+
if (key && Number.isInteger(note) && note >= 0 && note <= 127 && audio) {
|
|
2004
|
+
playPiano(note);
|
|
2005
|
+
page.document.getElementById("buninu-piano-input")?.focus();
|
|
2006
|
+
return null;
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
1288
2009
|
if (["INPUT", "TEXTAREA", "SELECT", "BUTTON"].includes(region.element.tagName)) {
|
|
1289
2010
|
region.element.focus();
|
|
1290
2011
|
}
|
|
@@ -1296,10 +2017,8 @@ export async function createPageRenderer({
|
|
|
1296
2017
|
refresh();
|
|
1297
2018
|
if (prevented) return null;
|
|
1298
2019
|
}
|
|
1299
|
-
if (region.element.tagName === "VIDEO") {
|
|
1300
|
-
|
|
1301
|
-
if (state?.playing) pauseVideo(region.element);
|
|
1302
|
-
else void playVideo(region.element);
|
|
2020
|
+
if (region.element.tagName === "AUDIO" || region.element.tagName === "VIDEO") {
|
|
2021
|
+
clickMedia(region.element, x, y);
|
|
1303
2022
|
return null;
|
|
1304
2023
|
}
|
|
1305
2024
|
const anchor = region.element.closest?.("a[href]");
|
|
@@ -1309,7 +2028,13 @@ export async function createPageRenderer({
|
|
|
1309
2028
|
this.runJavaScriptURL(href);
|
|
1310
2029
|
return null;
|
|
1311
2030
|
}
|
|
1312
|
-
|
|
2031
|
+
if (href.startsWith("blob:") && anchor.hasAttribute("download")) {
|
|
2032
|
+
void downloadBlob(href, anchor.getAttribute("download") ?? "");
|
|
2033
|
+
return null;
|
|
2034
|
+
}
|
|
2035
|
+
if (anchor.hasAttribute("download")) return { url: href, download: anchor.getAttribute("download") ?? "" };
|
|
2036
|
+
const fragmentFrom = fragmentNavigation(href);
|
|
2037
|
+
return fragmentFrom ? { url: href, fragmentFrom } : href;
|
|
1313
2038
|
},
|
|
1314
2039
|
/**
|
|
1315
2040
|
* Navigating to a javascript: URL runs its percent-decoded source as a
|
|
@@ -1383,6 +2108,7 @@ export async function createPageRenderer({
|
|
|
1383
2108
|
hostClearTimeout(prepaintTimer);
|
|
1384
2109
|
hostClearTimeout(frameTimer);
|
|
1385
2110
|
page.realm?.dispose();
|
|
2111
|
+
closePiano();
|
|
1386
2112
|
disposeVideos();
|
|
1387
2113
|
page.window?.happyDOM?.abort?.();
|
|
1388
2114
|
for (const image of page.decodedImages) image.delete();
|
|
@@ -1395,26 +2121,66 @@ export async function createPageRenderer({
|
|
|
1395
2121
|
render();
|
|
1396
2122
|
return api;
|
|
1397
2123
|
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
const
|
|
1401
|
-
|
|
2124
|
+
/** Fetches the document's linked stylesheets (in parallel), then lists all its sheets. */
|
|
2125
|
+
async function loadStyleSheets(document, documentUrl, linkSheets) {
|
|
2126
|
+
const pending = [];
|
|
2127
|
+
collectStyleSheets(document, documentUrl, linkSheets, pending);
|
|
2128
|
+
await Promise.all(pending);
|
|
2129
|
+
return collectStyleSheets(document, documentUrl, linkSheets);
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
/**
|
|
2133
|
+
* The document's style sheets as they are now, in document order: each
|
|
2134
|
+
* <style>'s current text and each loaded <link rel=stylesheet>. Sheets that
|
|
2135
|
+
* scripts add later count too; a link not fetched yet is requested (its
|
|
2136
|
+
* promise goes to `pending`) and redraws the page when it arrives.
|
|
2137
|
+
*/
|
|
2138
|
+
function collectStyleSheets(document, documentUrl, linkSheets, pending = null) {
|
|
2139
|
+
const sheets = [];
|
|
2140
|
+
for (const element of document.querySelectorAll("style, link[rel~='stylesheet']")) {
|
|
2141
|
+
if (element.closest("noscript")) continue;
|
|
2142
|
+
if (element.tagName === "STYLE") {
|
|
2143
|
+
sheets.push(element.textContent ?? "");
|
|
2144
|
+
continue;
|
|
2145
|
+
}
|
|
1402
2146
|
const href = element.getAttribute("href");
|
|
1403
|
-
|
|
1404
|
-
try {
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
2147
|
+
let url;
|
|
2148
|
+
try { url = href ? new URL(href, documentUrl).href : null; } catch { url = null; }
|
|
2149
|
+
if (!url) continue;
|
|
2150
|
+
const loaded = linkSheets.get(element);
|
|
2151
|
+
if (loaded?.url === url) {
|
|
2152
|
+
if (loaded.text !== null) sheets.push(loaded.text);
|
|
2153
|
+
continue;
|
|
1410
2154
|
}
|
|
1411
|
-
|
|
1412
|
-
|
|
2155
|
+
const entry = { url, text: null };
|
|
2156
|
+
linkSheets.set(element, entry);
|
|
2157
|
+
const request = fetchResource(url, "stylesheet").then((response) => {
|
|
2158
|
+
if (response.ok) entry.text = new TextDecoder().decode(response.body);
|
|
2159
|
+
}, () => {
|
|
2160
|
+
// A failed stylesheet must not prevent the document itself from loading.
|
|
2161
|
+
}).then(() => {
|
|
2162
|
+
if (entry.text === null || document !== page.document || linkSheets.get(element) !== entry) return;
|
|
2163
|
+
page.dirty = true;
|
|
2164
|
+
scheduleFrame();
|
|
2165
|
+
});
|
|
2166
|
+
pending?.push(request);
|
|
2167
|
+
}
|
|
2168
|
+
return sheets;
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
/** The current sheet list, the same array while nothing changed (the style engine caches parsed rules by it). */
|
|
2172
|
+
function currentStyleSheets() {
|
|
2173
|
+
if (!page.linkSheets) return page.styleSheets;
|
|
2174
|
+
const sheets = collectStyleSheets(page.document, page.url, page.linkSheets);
|
|
2175
|
+
const previous = page.styleSheets;
|
|
2176
|
+
if (previous.length === sheets.length && sheets.every((sheet, index) => sheet === previous[index])) return previous;
|
|
2177
|
+
return page.styleSheets = sheets;
|
|
1413
2178
|
}
|
|
1414
2179
|
|
|
1415
2180
|
async function loadImageResources(document, documentUrl) {
|
|
1416
2181
|
const map = new WeakMap();
|
|
1417
2182
|
const decoded = [];
|
|
2183
|
+
const entries = [];
|
|
1418
2184
|
await Promise.all([...document.querySelectorAll("img")].map(async (element) => {
|
|
1419
2185
|
const src = element.getAttribute("src");
|
|
1420
2186
|
if (!src) return;
|
|
@@ -1429,20 +2195,22 @@ export async function createPageRenderer({
|
|
|
1429
2195
|
const resource = Object.freeze({ width, height, image, url: response.url });
|
|
1430
2196
|
map.set(element, resource);
|
|
1431
2197
|
decoded.push(image);
|
|
2198
|
+
entries.push({ element, src, resource });
|
|
1432
2199
|
} catch {
|
|
1433
2200
|
// Broken images keep their fallback replaced-element dimensions.
|
|
1434
2201
|
}
|
|
1435
2202
|
}));
|
|
1436
|
-
return { map, decoded };
|
|
2203
|
+
return { map, decoded, entries };
|
|
1437
2204
|
}
|
|
1438
2205
|
|
|
2206
|
+
/** Every <video> and <audio> gets its (painted) resource: size, frame, playing, time. */
|
|
1439
2207
|
function initializeVideos(document, resources) {
|
|
1440
|
-
for (const element of document.querySelectorAll("video")) {
|
|
2208
|
+
for (const element of document.querySelectorAll("video, audio")) {
|
|
1441
2209
|
if (resources.get(element)) continue;
|
|
1442
|
-
const
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
image: null, playing: false,
|
|
2210
|
+
const audioOnly = element.tagName === "AUDIO";
|
|
2211
|
+
resources.set(element, { width: audioOnly ? 300 : Number(element.getAttribute("width")) || 300,
|
|
2212
|
+
height: audioOnly ? 32 : Number(element.getAttribute("height")) || 150,
|
|
2213
|
+
bitmapWidth: 0, bitmapHeight: 0, image: null, playing: false, time: 0, duration: NaN });
|
|
1446
2214
|
}
|
|
1447
2215
|
}
|
|
1448
2216
|
|
|
@@ -1451,155 +2219,412 @@ export async function createPageRenderer({
|
|
|
1451
2219
|
try { return src ? new URL(src, page.url).href : null; } catch { return null; }
|
|
1452
2220
|
}
|
|
1453
2221
|
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
2222
|
+
// ---- media: <video> and <audio> share one pipeline. An <audio> is media
|
|
2223
|
+
// whose picture is not shown (its box is only the 300x32 control bar), and
|
|
2224
|
+
// a video's own sound plays too. wasmpeg decodes (frames and PCM); PCM goes
|
|
2225
|
+
// to the controller, which plays it through PulseAudio paced to real time.
|
|
2226
|
+
// One clock: `base` is the position where the current run started and
|
|
2227
|
+
// `clockStart` when it did. Frames are drawn when the clock reaches them.
|
|
2228
|
+
// wasmpeg seeks video (to a keyframe) but not audio, so an audio seek
|
|
2229
|
+
// decodes forward and skips (from the start again when going back).
|
|
2230
|
+
|
|
2231
|
+
/** HTMLMediaElement state for page script. */
|
|
2232
|
+
function mediaState(element) {
|
|
2233
|
+
const state = page.media.get(element);
|
|
2234
|
+
return { paused: !state?.playing, ended: Boolean(state?.ended), currentTime: state ? mediaTime(state) : 0,
|
|
2235
|
+
duration: state?.duration ?? NaN };
|
|
1457
2236
|
}
|
|
1458
2237
|
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
2238
|
+
/** The element's playback state, created (not loaded) on first use. */
|
|
2239
|
+
function mediaStateFor(element) {
|
|
2240
|
+
const resource = page.resources.get(element);
|
|
2241
|
+
const url = videoSource(element);
|
|
2242
|
+
if (!videoDecoder || !resource || !url) return null;
|
|
2243
|
+
let state = page.media.get(element);
|
|
2244
|
+
if (state && state.url !== url) {
|
|
2245
|
+
closeMedia(state);
|
|
2246
|
+
state = null;
|
|
1468
2247
|
}
|
|
1469
|
-
state
|
|
1470
|
-
|
|
1471
|
-
|
|
2248
|
+
if (!state) {
|
|
2249
|
+
state = { element, url, resource, bytes: null, loading: null, showsPicture: element.tagName === "VIDEO",
|
|
2250
|
+
video: null, sound: null, playing: false, ended: false, duration: NaN, base: 0, clockStart: null,
|
|
2251
|
+
timer: null, lastTimeUpdate: -1, generation: loadGeneration, document: page.document };
|
|
2252
|
+
page.media.set(element, state);
|
|
2253
|
+
}
|
|
2254
|
+
return state;
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
/** Current position in seconds. */
|
|
2258
|
+
function mediaTime(state) {
|
|
2259
|
+
const running = state.playing && state.clockStart !== null ? (hostPerformance.now() - state.clockStart) / 1000 : 0;
|
|
2260
|
+
const time = state.base + running;
|
|
2261
|
+
return Number.isFinite(state.duration) ? Math.min(state.duration, time) : time;
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
/** Fires a media event (play, pause, ended, seeked, timeupdate...) at the element. */
|
|
2265
|
+
function mediaEvent(state, type) {
|
|
2266
|
+
if (state.document !== page.document || !page.realm) return;
|
|
2267
|
+
try {
|
|
2268
|
+
page.realm.dispatch(state.element, new page.window.Event(type));
|
|
2269
|
+
} catch {}
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
/** The control bar and frame redraw without a relayout. */
|
|
2273
|
+
function repaintMedia() {
|
|
1472
2274
|
rasterized.list = null;
|
|
1473
2275
|
render();
|
|
1474
2276
|
}
|
|
1475
2277
|
|
|
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
|
-
|
|
2278
|
+
/** Fetches (once), probes, and opens the decoders for what the file holds. */
|
|
2279
|
+
function loadMedia(state) {
|
|
2280
|
+
state.loading ??= (async () => {
|
|
2281
|
+
if (!state.bytes) {
|
|
2282
|
+
const response = await fetchResource(state.url, state.showsPicture ? "video" : "audio");
|
|
2283
|
+
if (!response.ok) throw new Error(`media ${state.url} failed with HTTP ${response.status}`);
|
|
2284
|
+
state.bytes = response.body;
|
|
2285
|
+
}
|
|
2286
|
+
if (!Number.isFinite(state.duration) && !state.probed) {
|
|
2287
|
+
const probe = await videoDecoder.probe(state.bytes);
|
|
2288
|
+
state.probed = true;
|
|
2289
|
+
state.duration = probe.duration ?? NaN;
|
|
2290
|
+
state.hasVideo = state.showsPicture && probe.video?.width > 0;
|
|
2291
|
+
state.hasAudio = probe.audio?.sampleRate > 0;
|
|
2292
|
+
state.resource.duration = state.duration;
|
|
2293
|
+
mediaEvent(state, "loadedmetadata");
|
|
2294
|
+
}
|
|
2295
|
+
if (state.hasVideo && !state.video) await openVideoDecoder(state);
|
|
2296
|
+
if (state.hasAudio && audio && !state.sound) await openAudioDecoder(state);
|
|
2297
|
+
})().finally(() => { state.loading = null; });
|
|
2298
|
+
return state.loading;
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
// FFmpeg probes the container; formats without magic bytes take a hint from the extension.
|
|
2302
|
+
function decodeOptions(state) {
|
|
2303
|
+
const hint = formatHint(new URL(state.url).pathname);
|
|
2304
|
+
return hint ? { format: hint } : {};
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
async function openVideoDecoder(state) {
|
|
2308
|
+
state.video?.decoder.close();
|
|
2309
|
+
const decoder = await videoDecoder.decode(state.bytes, decodeOptions(state));
|
|
2310
|
+
state.video = { decoder, frameTime: 0, done: false };
|
|
2311
|
+
trace?.(`video decoded ${state.url}: ${decoder.width}x${decoder.height}`);
|
|
2312
|
+
if (state.resource.width !== decoder.width || state.resource.height !== decoder.height) {
|
|
2313
|
+
state.resource.width = decoder.width;
|
|
2314
|
+
state.resource.height = decoder.height;
|
|
2315
|
+
page.dirty = true;
|
|
2316
|
+
refresh();
|
|
2317
|
+
}
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
async function openAudioDecoder(state) {
|
|
2321
|
+
state.sound?.decoder.close();
|
|
2322
|
+
const decoder = await videoDecoder.decodeAudio(state.bytes, decodeOptions(state));
|
|
2323
|
+
state.sound = { decoder, id: null, position: 0, pending: null, history: [], finishing: false, done: false };
|
|
2324
|
+
trace?.(`audio decoded ${state.url}: ${decoder.sampleRate} Hz, ${decoder.channels} channels`);
|
|
2325
|
+
}
|
|
2326
|
+
|
|
2327
|
+
/** A click on the media: on its progress track it seeks, anywhere else it plays or pauses. */
|
|
2328
|
+
function clickMedia(element, x, y) {
|
|
2329
|
+
const box = viewportBounds(element);
|
|
2330
|
+
const state = page.media.get(element);
|
|
2331
|
+
const duration = state?.duration;
|
|
2332
|
+
if (box && Number.isFinite(duration) && duration > 0) {
|
|
2333
|
+
const layout = mediaBarLayout(box.width);
|
|
2334
|
+
const onBar = element.tagName === "AUDIO" || y - box.top >= box.height - MEDIA_BAR_HEIGHT;
|
|
2335
|
+
const relative = x - box.left;
|
|
2336
|
+
if (onBar && relative >= layout.trackStart - 6) {
|
|
2337
|
+
const fraction = Math.min(1, Math.max(0, (relative - layout.trackStart) / (layout.trackEnd - layout.trackStart)));
|
|
2338
|
+
void seekMedia(element, fraction * duration);
|
|
1515
2339
|
return;
|
|
1516
2340
|
}
|
|
1517
|
-
} else {
|
|
1518
|
-
state.playing = true;
|
|
1519
|
-
resource.playing = true;
|
|
1520
2341
|
}
|
|
1521
|
-
|
|
2342
|
+
if (state?.playing) pauseMedia(element);
|
|
2343
|
+
else void playMedia(element);
|
|
1522
2344
|
}
|
|
1523
2345
|
|
|
1524
|
-
function
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
state.
|
|
1534
|
-
|
|
1535
|
-
|
|
2346
|
+
async function playMedia(element) {
|
|
2347
|
+
const state = mediaStateFor(element);
|
|
2348
|
+
if (!state || state.playing) return;
|
|
2349
|
+
setMediaPlaying(state, true);
|
|
2350
|
+
try {
|
|
2351
|
+
await loadMedia(state);
|
|
2352
|
+
if (state.ended) {
|
|
2353
|
+
state.ended = false;
|
|
2354
|
+
await positionMedia(state, 0);
|
|
2355
|
+
} else if (state.needsPosition) {
|
|
2356
|
+
// After a pause the audio decoder is ahead of where playing resumes.
|
|
2357
|
+
await positionMedia(state, state.base, { videoToo: false });
|
|
2358
|
+
}
|
|
2359
|
+
state.needsPosition = false;
|
|
2360
|
+
} catch (error) {
|
|
2361
|
+
if (state.document === page.document) reportError(error);
|
|
2362
|
+
closeMedia(state);
|
|
2363
|
+
page.media.delete(element);
|
|
2364
|
+
repaintMedia();
|
|
2365
|
+
return;
|
|
2366
|
+
}
|
|
2367
|
+
if (!state.playing || state.document !== page.document) return;
|
|
2368
|
+
if (!state.video && !state.sound) {
|
|
2369
|
+
// Nothing this engine can play (audio with no audio output).
|
|
2370
|
+
setMediaPlaying(state, false);
|
|
2371
|
+
reportError(new Error(`media ${state.url}: nothing to play`));
|
|
2372
|
+
return;
|
|
2373
|
+
}
|
|
2374
|
+
startMedia(state);
|
|
2375
|
+
mediaEvent(state, "play");
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
function pauseMedia(element) {
|
|
2379
|
+
const state = page.media.get(element);
|
|
2380
|
+
if (!state?.playing) return;
|
|
2381
|
+
state.base = mediaTime(state);
|
|
2382
|
+
stopClock(state);
|
|
2383
|
+
state.needsPosition = true;
|
|
2384
|
+
setMediaPlaying(state, false);
|
|
2385
|
+
mediaEvent(state, "pause");
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
async function seekMedia(element, seconds) {
|
|
2389
|
+
const state = mediaStateFor(element);
|
|
2390
|
+
if (!state) return;
|
|
2391
|
+
const wasPlaying = state.playing;
|
|
2392
|
+
if (wasPlaying) stopClock(state);
|
|
2393
|
+
try {
|
|
2394
|
+
await loadMedia(state);
|
|
2395
|
+
const target = Math.max(0, Math.min(Number.isFinite(state.duration) ? state.duration : Infinity, Number(seconds) || 0));
|
|
2396
|
+
state.ended = false;
|
|
2397
|
+
await positionMedia(state, target);
|
|
2398
|
+
state.needsPosition = false;
|
|
2399
|
+
// A paused video shows the frame it landed on.
|
|
2400
|
+
if (state.video && !wasPlaying) drawVideoFrame(state, true);
|
|
2401
|
+
} catch (error) {
|
|
2402
|
+
reportError(error);
|
|
1536
2403
|
return;
|
|
1537
2404
|
}
|
|
2405
|
+
if (wasPlaying && state.playing && state.document === page.document) startMedia(state);
|
|
2406
|
+
state.resource.time = mediaTime(state);
|
|
2407
|
+
repaintMedia();
|
|
2408
|
+
mediaEvent(state, "seeked");
|
|
2409
|
+
mediaEvent(state, "timeupdate");
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
/** Moves the decoders (and the clock's base) to `seconds`. */
|
|
2413
|
+
async function positionMedia(state, seconds, { videoToo = true } = {}) {
|
|
2414
|
+
state.base = seconds;
|
|
2415
|
+
if (state.video && videoToo) {
|
|
2416
|
+
if (state.video.done || seconds === 0) await openVideoDecoder(state);
|
|
2417
|
+
if (seconds > 0) state.video.decoder.seek(seconds * 1000);
|
|
2418
|
+
state.video.frameTime = seconds;
|
|
2419
|
+
state.video.done = false;
|
|
2420
|
+
}
|
|
2421
|
+
if (state.sound) await positionAudio(state, seconds);
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
/** Puts the audio decoder at `seconds`: from the kept history, by decoding ahead, or from the start. */
|
|
2425
|
+
async function positionAudio(state, seconds) {
|
|
2426
|
+
const sound = state.sound;
|
|
2427
|
+
const { sampleRate, channels } = sound.decoder;
|
|
2428
|
+
const target = Math.max(0, Math.round(seconds * sampleRate));
|
|
2429
|
+
sound.done = false;
|
|
2430
|
+
const historyStart = sound.history[0]?.start ?? Infinity;
|
|
2431
|
+
if (target >= historyStart && target < sound.position) {
|
|
2432
|
+
// Resume inside audio already decoded: take it from the history.
|
|
2433
|
+
const parts = [];
|
|
2434
|
+
for (const chunk of sound.history) {
|
|
2435
|
+
const frames = chunk.samples.length / channels;
|
|
2436
|
+
if (chunk.start + frames <= target) continue;
|
|
2437
|
+
parts.push(chunk.start >= target ? chunk.samples : chunk.samples.subarray((target - chunk.start) * channels));
|
|
2438
|
+
}
|
|
2439
|
+
if (sound.pending) parts.push(sound.pending);
|
|
2440
|
+
sound.pending = concatSamples(parts);
|
|
2441
|
+
sound.position = target;
|
|
2442
|
+
sound.history = [];
|
|
2443
|
+
return;
|
|
2444
|
+
}
|
|
2445
|
+
if (target < sound.position) await openAudioDecoder(state);
|
|
2446
|
+
const current = state.sound;
|
|
2447
|
+
current.history = [];
|
|
2448
|
+
if (current.pending) {
|
|
2449
|
+
const frames = current.pending.length / channels;
|
|
2450
|
+
if (current.position + frames > target) {
|
|
2451
|
+
current.pending = current.pending.subarray((target - current.position) * channels);
|
|
2452
|
+
current.position = target;
|
|
2453
|
+
return;
|
|
2454
|
+
}
|
|
2455
|
+
current.position += frames;
|
|
2456
|
+
current.pending = null;
|
|
2457
|
+
}
|
|
2458
|
+
while (current.position < target) {
|
|
2459
|
+
const samples = current.decoder.nextSamples();
|
|
2460
|
+
if (!samples) return;
|
|
2461
|
+
const frames = samples.length / channels;
|
|
2462
|
+
if (current.position + frames > target) {
|
|
2463
|
+
current.pending = samples.subarray((target - current.position) * channels);
|
|
2464
|
+
current.position = target;
|
|
2465
|
+
return;
|
|
2466
|
+
}
|
|
2467
|
+
current.position += frames;
|
|
2468
|
+
}
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
/** Starts the clock, the audio stream and the frame/time ticks. */
|
|
2472
|
+
function startMedia(state) {
|
|
2473
|
+
state.clockStart = hostPerformance.now();
|
|
2474
|
+
if (state.sound && !state.sound.done) openAudioStream(state);
|
|
2475
|
+
mediaTick(state);
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
function stopClock(state) {
|
|
2479
|
+
hostClearTimeout(state.timer);
|
|
2480
|
+
state.timer = null;
|
|
2481
|
+
state.clockStart = null;
|
|
2482
|
+
if (state.sound?.id != null) {
|
|
2483
|
+
audio.close(state.sound.id);
|
|
2484
|
+
state.sound.id = null;
|
|
2485
|
+
state.sound.finishing = false;
|
|
2486
|
+
}
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2489
|
+
/** Draws the frames the clock has reached, updates time, ends when everything has played. */
|
|
2490
|
+
function mediaTick(state) {
|
|
2491
|
+
if (!state.playing || state.generation !== loadGeneration || state.document !== page.document) return;
|
|
2492
|
+
const now = mediaTime(state);
|
|
2493
|
+
const video = state.video;
|
|
2494
|
+
if (video && !video.done && video.frameTime <= now) {
|
|
2495
|
+
// Late frames are decoded but only the newest is shown.
|
|
2496
|
+
while (video.frameTime <= now && !video.done) {
|
|
2497
|
+
const nextIsLate = video.frameTime + 1 / (video.decoder.fps || 30) <= now;
|
|
2498
|
+
if (!drawVideoFrame(state, !nextIsLate)) video.done = true;
|
|
2499
|
+
}
|
|
2500
|
+
}
|
|
2501
|
+
state.resource.time = now;
|
|
2502
|
+
repaintMedia();
|
|
2503
|
+
if (Math.floor(now * 4) !== state.lastTimeUpdate) {
|
|
2504
|
+
state.lastTimeUpdate = Math.floor(now * 4);
|
|
2505
|
+
mediaEvent(state, "timeupdate");
|
|
2506
|
+
}
|
|
2507
|
+
const videoFinished = !video || video.done;
|
|
2508
|
+
const soundFinished = !state.sound || state.sound.done;
|
|
2509
|
+
// The clock at the duration ends it too: after a seek, frames restart at a
|
|
2510
|
+
// keyframe before the target, and wasmpeg does not tell their timestamps.
|
|
2511
|
+
const pastEnd = Number.isFinite(state.duration) && state.clockStart !== null
|
|
2512
|
+
&& state.base + (hostPerformance.now() - state.clockStart) / 1000 >= state.duration;
|
|
2513
|
+
if ((videoFinished && soundFinished) || (pastEnd && soundFinished)) return endMedia(state);
|
|
2514
|
+
const delay = video && !video.done ? Math.max(5, (video.frameTime - mediaTime(state)) * 1000) : 250;
|
|
2515
|
+
state.timer = hostSetTimeout(() => mediaTick(state), delay);
|
|
2516
|
+
}
|
|
2517
|
+
|
|
2518
|
+
/** Decodes the next frame; shows it when `show`. False at the end of the stream. */
|
|
2519
|
+
function drawVideoFrame(state, show) {
|
|
2520
|
+
const decoder = state.video.decoder;
|
|
2521
|
+
const width = Math.min(480, decoder.width);
|
|
2522
|
+
const height = Math.max(1, Math.round(width * decoder.height / decoder.width));
|
|
2523
|
+
const pixels = decoder.nextFrame(width, height);
|
|
2524
|
+
if (!pixels) return false;
|
|
2525
|
+
state.video.frameTime += 1 / (decoder.fps || 30);
|
|
2526
|
+
if (!show) return true;
|
|
1538
2527
|
const image = CanvasKit.MakeImage({ width, height, alphaType: CanvasKit.AlphaType.Unpremul,
|
|
1539
2528
|
colorType: CanvasKit.ColorType.RGBA_8888, colorSpace: CanvasKit.ColorSpace.SRGB }, pixels, width * 4);
|
|
1540
|
-
if (!image) return
|
|
1541
|
-
|
|
1542
|
-
state.image = image;
|
|
2529
|
+
if (!image) return true;
|
|
2530
|
+
state.resource.image?.delete();
|
|
1543
2531
|
state.resource.image = image;
|
|
1544
2532
|
state.resource.bitmapWidth = width;
|
|
1545
2533
|
state.resource.bitmapHeight = height;
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
2534
|
+
return true;
|
|
2535
|
+
}
|
|
2536
|
+
|
|
2537
|
+
function openAudioStream(state) {
|
|
2538
|
+
const sound = state.sound;
|
|
2539
|
+
sound.finishing = false;
|
|
2540
|
+
const name = new URL(state.url).pathname.split("/").pop() || "audio";
|
|
2541
|
+
const id = audio.open({ rate: sound.decoder.sampleRate, channels: sound.decoder.channels, name }, (event) => {
|
|
2542
|
+
if (sound.id !== id) return;
|
|
2543
|
+
if (event.event === "request") feedAudio(state, event.bytes);
|
|
2544
|
+
else if (event.event === "ended") {
|
|
2545
|
+
sound.id = null;
|
|
2546
|
+
sound.done = true;
|
|
2547
|
+
mediaTick(state);
|
|
2548
|
+
} else if (event.event === "error") {
|
|
2549
|
+
sound.id = null;
|
|
2550
|
+
sound.done = true;
|
|
2551
|
+
reportError(new Error(`audio: ${event.message}`));
|
|
2552
|
+
// Video plays on without sound; audio alone stops.
|
|
2553
|
+
if (!state.video) pauseMedia(state.element);
|
|
2554
|
+
}
|
|
2555
|
+
});
|
|
2556
|
+
sound.id = id;
|
|
2557
|
+
}
|
|
2558
|
+
|
|
2559
|
+
/** Sends about `bytes` of s16le audio (pending samples first, then decoding); at the end, lets it drain. */
|
|
2560
|
+
function feedAudio(state, bytes) {
|
|
2561
|
+
const sound = state.sound;
|
|
2562
|
+
if (!state.playing || sound.id === null || sound.finishing) return;
|
|
2563
|
+
const { channels, sampleRate } = sound.decoder;
|
|
2564
|
+
const frameBytes = channels * 2;
|
|
2565
|
+
for (let sent = 0; sent + frameBytes <= bytes;) {
|
|
2566
|
+
let samples = sound.pending;
|
|
2567
|
+
sound.pending = null;
|
|
2568
|
+
samples ??= sound.decoder.nextSamples();
|
|
2569
|
+
if (!samples) {
|
|
2570
|
+
sound.finishing = true;
|
|
2571
|
+
audio.finish(sound.id);
|
|
2572
|
+
return;
|
|
2573
|
+
}
|
|
2574
|
+
const [taken, remaining] = splitSamples(samples, channels, bytes - sent);
|
|
2575
|
+
if (remaining.length) sound.pending = remaining;
|
|
2576
|
+
sound.history.push({ start: sound.position, samples: taken });
|
|
2577
|
+
sound.position += taken.length / channels;
|
|
2578
|
+
while (sound.history.length > 1 && sound.position - sound.history[1].start > AUDIO_HISTORY_SECONDS * sampleRate) {
|
|
2579
|
+
sound.history.shift();
|
|
2580
|
+
}
|
|
2581
|
+
const pcm = floatToS16(taken);
|
|
2582
|
+
audio.write(sound.id, pcm);
|
|
2583
|
+
sent += pcm.length;
|
|
2584
|
+
}
|
|
2585
|
+
}
|
|
2586
|
+
|
|
2587
|
+
function endMedia(state) {
|
|
2588
|
+
stopClock(state);
|
|
2589
|
+
state.ended = true;
|
|
2590
|
+
state.base = Number.isFinite(state.duration) ? state.duration : state.base;
|
|
2591
|
+
setMediaPlaying(state, false);
|
|
2592
|
+
mediaEvent(state, "pause");
|
|
2593
|
+
mediaEvent(state, "ended");
|
|
2594
|
+
}
|
|
2595
|
+
|
|
2596
|
+
function setMediaPlaying(state, playing) {
|
|
2597
|
+
state.playing = playing;
|
|
2598
|
+
state.resource.playing = playing;
|
|
2599
|
+
state.resource.time = mediaTime(state);
|
|
2600
|
+
if (state.document === page.document) repaintMedia();
|
|
1552
2601
|
}
|
|
1553
2602
|
|
|
1554
|
-
function
|
|
2603
|
+
function closeMedia(state) {
|
|
2604
|
+
stopClock(state);
|
|
1555
2605
|
state.playing = false;
|
|
1556
|
-
|
|
1557
|
-
state.
|
|
1558
|
-
state.
|
|
2606
|
+
state.video?.decoder.close();
|
|
2607
|
+
state.video = null;
|
|
2608
|
+
state.sound?.decoder.close();
|
|
2609
|
+
state.sound = null;
|
|
2610
|
+
state.resource.image?.delete();
|
|
1559
2611
|
state.resource.image = null;
|
|
1560
2612
|
state.resource.playing = false;
|
|
1561
2613
|
}
|
|
1562
2614
|
|
|
1563
2615
|
function disposeVideos() {
|
|
1564
|
-
for (const state of page.
|
|
1565
|
-
page.
|
|
2616
|
+
for (const state of page.media.values()) closeMedia(state);
|
|
2617
|
+
page.media.clear();
|
|
1566
2618
|
}
|
|
1567
2619
|
|
|
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.
|
|
2620
|
+
function concatSamples(parts) {
|
|
2621
|
+
const out = new Float32Array(parts.reduce((sum, part) => sum + part.length, 0));
|
|
2622
|
+
let offset = 0;
|
|
2623
|
+
for (const part of parts) {
|
|
2624
|
+
out.set(part, offset);
|
|
2625
|
+
offset += part.length;
|
|
1602
2626
|
}
|
|
2627
|
+
return out;
|
|
1603
2628
|
}
|
|
1604
2629
|
|
|
1605
2630
|
function queueImageResources() {
|
|
@@ -1631,7 +2656,25 @@ export async function createPageRenderer({
|
|
|
1631
2656
|
}
|
|
1632
2657
|
page.resources.set(element, Object.freeze({ width, height, image, url: response.url }));
|
|
1633
2658
|
page.decodedImages.push(image);
|
|
1634
|
-
page.
|
|
2659
|
+
const node = [...page.renderTree.nodesById.values()].find((candidate) => candidate.domNode === element);
|
|
2660
|
+
const run = page.layout.fragments.flatMap((fragment) => fragment.runs)
|
|
2661
|
+
.find((candidate) => candidate.type === "image" && candidate.nodeId === node?.id);
|
|
2662
|
+
const cssWidth = node?.style?.width;
|
|
2663
|
+
const cssHeight = node?.style?.height;
|
|
2664
|
+
const specifiedWidth = typeof cssWidth === "number" ? cssWidth : node?.widthAttribute;
|
|
2665
|
+
const specifiedHeight = typeof cssHeight === "number" ? cssHeight : node?.heightAttribute;
|
|
2666
|
+
// When both used dimensions were already fixed, decoded pixels only
|
|
2667
|
+
// change paint. The retained layout remains authoritative and the
|
|
2668
|
+
// display list resolves the current resource by DOM node.
|
|
2669
|
+
const paintOnly = run && Number.isFinite(specifiedWidth) && Number.isFinite(specifiedHeight)
|
|
2670
|
+
&& Math.abs(run.width - specifiedWidth) < 0.01 && Math.abs(run.height - specifiedHeight) < 0.01;
|
|
2671
|
+
if (paintOnly) {
|
|
2672
|
+
page.displayList = buildDisplayList(page.layout, page.renderTree, page.resources);
|
|
2673
|
+
page.needsPaint = true;
|
|
2674
|
+
} else {
|
|
2675
|
+
page.dirty = true;
|
|
2676
|
+
page.resourceLayoutDirty = true;
|
|
2677
|
+
}
|
|
1635
2678
|
refresh();
|
|
1636
2679
|
}).catch(() => {
|
|
1637
2680
|
// A failed image retains its fallback replaced-element dimensions.
|
|
@@ -1687,5 +2730,5 @@ function documentBackground(document, styleEngine) {
|
|
|
1687
2730
|
}
|
|
1688
2731
|
|
|
1689
2732
|
function emptyImageResources() {
|
|
1690
|
-
return { map: new WeakMap(), decoded: [] };
|
|
2733
|
+
return { map: new WeakMap(), decoded: [], entries: [] };
|
|
1691
2734
|
}
|