buninu-browser 0.0.2 → 0.2.0

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