buninu-browser 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -1
- package/frontends/wayland/README.md +50 -21
- package/frontends/wayland/index.js +14 -0
- package/frontends/wayland/input.js +24 -5
- package/frontends/wayland/urlbar.js +9 -5
- package/lib/audio/shared-connection.js +31 -0
- package/lib/cdp-server.js +15 -2
- package/lib/headless-shell.js +7 -4
- package/lib/paint/display-list.js +2 -1
- package/lib/qml/README.md +456 -0
- package/lib/qml/builtins.js +76 -5
- package/lib/qml/canvas.js +76 -0
- package/lib/qml/document.js +47 -24
- package/lib/qml/fonts.js +69 -0
- package/lib/qml/host.js +28 -0
- package/lib/qml/loader.js +7 -3
- package/lib/qml/models.js +1 -1
- package/lib/qml/reactive.js +26 -3
- package/lib/qml/runtime.js +263 -41
- package/lib/qml/shader/fragment.js +83 -0
- package/lib/qml/shader/qsb.js +27 -0
- package/lib/qml/shader/renderer.js +39 -0
- package/lib/qml/shader/runtime.js +61 -0
- package/lib/qml/shapes.js +9 -1
- package/lib/qml/shortcuts.js +65 -0
- package/lib/qml/textures.js +4 -1
- package/lib/qml/window-constraints.js +16 -0
- package/lib/renderer/bindings.js +5 -1
- package/lib/renderer/canvas-2d.js +16 -0
- package/lib/renderer/document-selection.js +19 -0
- package/lib/renderer/host.js +14 -11
- package/lib/renderer/main.js +2 -0
- package/lib/renderer/page-realm.js +4 -9
- package/lib/renderer/page-renderer.js +273 -35
- package/lib/renderer/pointer-move.js +5 -3
- package/lib/renderer/render-interval.js +9 -0
- package/lib/style/computed-style.js +8 -2
- package/package.json +1 -1
- package/test/audio-connection-lifetime.test.js +46 -0
- package/test/canvas-paint.test.js +8 -0
- package/test/computed-style.test.js +12 -0
- package/test/document-selection.test.js +27 -0
- package/test/double-click.test.js +85 -0
- package/test/fixtures/canvas-damage.js +40 -0
- package/test/fixtures/fake-renderer.js +1 -0
- package/test/fixtures/omastorm-helpers/Dockerfile +35 -0
- package/test/fixtures/omastorm-helpers/check.mjs +56 -0
- package/test/fixtures/omastorm-helpers/launch.mjs +24 -0
- package/test/fixtures/omastorm-helpers/prepare.mjs +17 -0
- package/test/fixtures/omastorm-helpers/runtime.mjs +32 -0
- package/test/fixtures/pointer-paint-batching.js +23 -0
- package/test/fixtures/qml-fonts/main.qml +10 -0
- package/test/fixtures/qml-locale/check.mjs +6 -0
- package/test/fixtures/qml-locale/main.qml +8 -0
- package/test/fixtures/qml-shader/grab.qml +14 -0
- package/test/fixtures/qml-shader/gradient.frag +5 -0
- package/test/fixtures/qml-shader/main.qml +14 -0
- package/test/fixtures/qml-shader/texture-grab.qml +25 -0
- package/test/fixtures/qml-shader/texture-size.frag +5 -0
- package/test/fixtures/qml-shader-check.mjs +24 -0
- package/test/fixtures/title-no-paint.js +20 -0
- package/test/manual-double-click.html +21 -0
- package/test/manual-mouse-buttons.html +18 -0
- package/test/manual-omastorm-engine.mjs +19 -0
- package/test/manual-omastorm-shaders.mjs +28 -0
- package/test/manual-omastorm-software.mjs +25 -0
- package/test/manual-pointer-scroll.html +43 -0
- package/test/pointer-drag.test.js +104 -0
- package/test/qml-canvas.test.js +21 -0
- package/test/qml-connections-timing.test.js +22 -0
- package/test/qml-controls-window.test.js +182 -0
- package/test/qml-fonts.test.js +31 -0
- package/test/qml-grid-multiline.test.js +48 -0
- package/test/qml-locale.test.js +45 -0
- package/test/qml-nonvisual-visible.test.js +19 -0
- package/test/qml-shader-runtime.test.js +6 -0
- package/test/qml-shortcuts.test.js +39 -0
- package/test/qml-socket.test.js +60 -0
- package/test/qml-software-shader.test.js +55 -0
- package/test/qml-window-update.test.js +36 -0
- package/test/render-interval.test.js +33 -0
- package/test/title-no-paint.test.js +6 -0
- package/test/wayland-scroll.test.js +40 -0
- package/test/wayland-urlbar.test.js +23 -0
|
@@ -29,10 +29,16 @@ import { setCanvasKit, canvasResource, observeDocumentCanvases } from "./canvas-
|
|
|
29
29
|
import { createCanvasTextRenderer, parseCanvasFont } from "./canvas-text.js";
|
|
30
30
|
import { DESKTOP_USER_AGENT } from "../user-agent.js";
|
|
31
31
|
|
|
32
|
+
// qml handle begin: optional software shader canvas bridge
|
|
33
|
+
import {paintQmlCanvas} from './canvas-2d.js';
|
|
34
|
+
import {createShaderRenderer} from '../qml/shader/renderer.js';
|
|
35
|
+
// qml handle end
|
|
36
|
+
|
|
32
37
|
// qml handle begin: optional document frontend
|
|
33
38
|
import {createQmlMediaController,applyQmlMediaGain} from '../qml/media-renderer.js';
|
|
34
39
|
import {INTERFACES as qmlBindings} from "./bindings.js";
|
|
35
40
|
import { enableQmlDomImages, hasQmlDomImages, captureQmlDomImage, filterQmlTextureSources, qmlTextureVisibilityChanged } from "../qml/canvas-dom.js";
|
|
41
|
+
import {loadQmlFonts} from '../qml/fonts.js';
|
|
36
42
|
import {createQmlHost} from "../qml/host.js";
|
|
37
43
|
import { loadQmlDocument } from "../qml/document.js";
|
|
38
44
|
import { isQmlDocument } from "../qml/format.js";
|
|
@@ -257,6 +263,7 @@ function buildPageRenderer({
|
|
|
257
263
|
mobile = false,
|
|
258
264
|
verifyStyle = false,
|
|
259
265
|
fullStyle = false,
|
|
266
|
+
renderMinInterval = 16,
|
|
260
267
|
// An iframe's document (see syncFrames): frames go to the embedding page,
|
|
261
268
|
// which needs only the viewport, so nothing is painted ahead.
|
|
262
269
|
embedded = false,
|
|
@@ -267,6 +274,10 @@ function buildPageRenderer({
|
|
|
267
274
|
CanvasKit, loadedFamilies, fontCollection, fontFamilies, sansSerifFamily, monospaceFamily, monospaceBoldFamily,
|
|
268
275
|
genericFamilies, builtinTypefaces,
|
|
269
276
|
}) {
|
|
277
|
+
// qml handle begin: preserve ordinary page fonts across QML navigation
|
|
278
|
+
const standardFonts = {CanvasKit, loadedFamilies, fontCollection, fontFamilies, builtinTypefaces};
|
|
279
|
+
// qml handle end
|
|
280
|
+
|
|
270
281
|
let documentFonts = null;
|
|
271
282
|
let fontSheets = null;
|
|
272
283
|
let fontEnvironment = "";
|
|
@@ -330,6 +341,10 @@ function buildPageRenderer({
|
|
|
330
341
|
let framePaintTimer = null;
|
|
331
342
|
// The frame keyboard input goes to, after a click into it.
|
|
332
343
|
let focusedFrame = null;
|
|
344
|
+
let pointerButtons = 0, suppressPointerMouse = false;
|
|
345
|
+
const pointerPresses = new Map();
|
|
346
|
+
let selectionDrag = null;
|
|
347
|
+
let selectionVersion = 0;
|
|
333
348
|
const viewport = new ScrollViewport(800, 600);
|
|
334
349
|
const page = {
|
|
335
350
|
url: "about:blank",
|
|
@@ -589,6 +604,7 @@ function buildPageRenderer({
|
|
|
589
604
|
let rasterSurface = null;
|
|
590
605
|
let rasterSurfaceWidth = 0;
|
|
591
606
|
let rasterSurfaceHeight = 0;
|
|
607
|
+
let rasterRegion = null;
|
|
592
608
|
const rasterReadback = new RasterReadback(CanvasKit, { shared: Boolean(onFrame), allocate: allocateFramePixels });
|
|
593
609
|
// A scroll or unrelated DOM update does not change text shaping. Keep a
|
|
594
610
|
// bounded set of visible paragraphs keyed by their complete shaping input.
|
|
@@ -656,7 +672,8 @@ function buildPageRenderer({
|
|
|
656
672
|
const hints = page.hintsVisible
|
|
657
673
|
? page.hints.map((hint) => `${hint.label}@${hint.x + hint.scrollX},${hint.y + hint.scrollY}`).join(";")
|
|
658
674
|
: "";
|
|
659
|
-
|
|
675
|
+
const selection=page.window?.getSelection();
|
|
676
|
+
return hints + selectionVersion + (selection?.rangeCount ? `:${selection.anchorOffset}:${selection.focusOffset}:${selection.toString()}` : "");
|
|
660
677
|
}
|
|
661
678
|
|
|
662
679
|
function sameRasterInput() {
|
|
@@ -701,6 +718,36 @@ function buildPageRenderer({
|
|
|
701
718
|
schedulePrepaint();
|
|
702
719
|
}
|
|
703
720
|
|
|
721
|
+
/** Conservative canvas-only damage, in document CSS pixels. */
|
|
722
|
+
function canvasDamage(region) {
|
|
723
|
+
const old = rasterized.list, next = page.displayList;
|
|
724
|
+
// Reuse pixels only when geometry and every non-canvas input are stable.
|
|
725
|
+
// Transformed/fixed canvases conservatively take the full-paint path.
|
|
726
|
+
if (!rasterSurface || !Bun.deepEquals(rasterRegion, region)
|
|
727
|
+
|| rasterized.scale !== page.deviceScaleFactor || rasterized.background !== page.backgroundColor
|
|
728
|
+
|| rasterized.overlay !== overlayKey() || rasterized.frames !== framesVersion
|
|
729
|
+
|| rasterized.scrollX !== viewport.x || rasterized.scrollY !== viewport.y
|
|
730
|
+
|| !old || !next || old.images.length !== next.images.length
|
|
731
|
+
|| !Bun.deepEquals(old.items, next.items)
|
|
732
|
+
|| next.items.some(item => item.op === 'drawVideo')) return null;
|
|
733
|
+
let damage = null;
|
|
734
|
+
for (let i = 0; i < next.images.length; i++) {
|
|
735
|
+
if (old.images[i] === next.images[i]) continue;
|
|
736
|
+
const uses = next.items.filter(item => item.image === i);
|
|
737
|
+
if (!uses.length) return null;
|
|
738
|
+
for (const item of uses) {
|
|
739
|
+
if (item.op !== 'drawImage' || item.fixed || item.transform
|
|
740
|
+
|| page.renderTree.nodesById.get(item.nodeId)?.domNode?.tagName !== 'CANVAS') return null;
|
|
741
|
+
const r = item.rect;
|
|
742
|
+
if (![r.x, r.y, r.width, r.height].every(Number.isFinite)) return null;
|
|
743
|
+
const left = r.x - 1, top = r.y - 1, right = r.x + r.width + 1, bottom = r.y + r.height + 1;
|
|
744
|
+
damage = damage ? {x:Math.min(damage.x,left),y:Math.min(damage.y,top),right:Math.max(damage.right,right),bottom:Math.max(damage.bottom,bottom)}
|
|
745
|
+
: {x:left,y:top,right,bottom};
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
return damage;
|
|
749
|
+
}
|
|
750
|
+
|
|
704
751
|
/** Rasterizes a page rectangle (CSS pixels) into RGBA pixels. */
|
|
705
752
|
function rasterTile(region) {
|
|
706
753
|
// Prepaint can run before a queued canvas/image repaint. Refresh native
|
|
@@ -712,6 +759,8 @@ function buildPageRenderer({
|
|
|
712
759
|
const scale = page.deviceScaleFactor;
|
|
713
760
|
const pixelWidth = Math.max(1, Math.round(region.width * scale));
|
|
714
761
|
const pixelHeight = Math.max(1, Math.round(region.height * scale));
|
|
762
|
+
const damage = canvasDamage(region);
|
|
763
|
+
if (damage) trace?.(`${page.url}: canvas damage ${damage.right-damage.x}x${damage.bottom-damage.y}`);
|
|
715
764
|
if (!rasterSurface || rasterSurfaceWidth !== pixelWidth || rasterSurfaceHeight !== pixelHeight) {
|
|
716
765
|
// MakeSurface owns a malloc-backed pixel buffer; delete() alone leaks it.
|
|
717
766
|
rasterSurface?.dispose();
|
|
@@ -722,9 +771,19 @@ function buildPageRenderer({
|
|
|
722
771
|
const surface = rasterSurface;
|
|
723
772
|
if (!surface) throw new Error(`Unable to create a ${pixelWidth}x${pixelHeight} CanvasKit surface`);
|
|
724
773
|
const canvas = surface.getCanvas();
|
|
774
|
+
canvas.save();
|
|
775
|
+
if (damage) canvas.clipRect(CanvasKit.LTRBRect(
|
|
776
|
+
Math.floor((damage.x-region.x)*scale), Math.floor((damage.y-region.y)*scale),
|
|
777
|
+
Math.ceil((damage.right-region.x)*scale), Math.ceil((damage.bottom-region.y)*scale),
|
|
778
|
+
), CanvasKit.ClipOp.Intersect, false);
|
|
779
|
+
// Clear and replay all layers inside the damage, including backgrounds
|
|
780
|
+
// and foreground occluders. Transparent canvas pixels must not leave trails.
|
|
725
781
|
canvas.clear(canvasColor(CanvasKit, page.backgroundColor));
|
|
726
|
-
paintDisplayList(canvas, page.displayList, region, scale
|
|
782
|
+
paintDisplayList(canvas, page.displayList, region, scale, null, damage ? {
|
|
783
|
+
x:damage.x, y:damage.y, width:damage.right-damage.x, height:damage.bottom-damage.y,
|
|
784
|
+
} : region);
|
|
727
785
|
drawHints(canvas, region);
|
|
786
|
+
canvas.restore();
|
|
728
787
|
surface.flush();
|
|
729
788
|
const image = surface.makeImageSnapshot();
|
|
730
789
|
let pixels;
|
|
@@ -732,6 +791,7 @@ function buildPageRenderer({
|
|
|
732
791
|
finally { image.delete(); }
|
|
733
792
|
const videoLayer = makeVideoLayer(canvas, surface, region, scale, pixelWidth, pixelHeight);
|
|
734
793
|
page.needsPaint = false;
|
|
794
|
+
rasterRegion = {...region};
|
|
735
795
|
rasterized = {
|
|
736
796
|
list: page.displayList, overlay: overlayKey(), background: page.backgroundColor, scale: page.deviceScaleFactor,
|
|
737
797
|
scrollX: viewport.x, scrollY: viewport.y, frames: framesVersion,
|
|
@@ -807,10 +867,10 @@ function buildPageRenderer({
|
|
|
807
867
|
frameTimer = hostSetTimeout(() => {
|
|
808
868
|
frameTimer = null;
|
|
809
869
|
refresh();
|
|
810
|
-
// Coalesce ordinary mutations for
|
|
811
|
-
// retain a longer idle window so related script mutations can settle
|
|
870
|
+
// Coalesce ordinary mutations for the configured minimum interval.
|
|
871
|
+
// Expensive style/layout rebuilds retain a longer idle window so related script mutations can settle
|
|
812
872
|
// instead of continuously rebuilding a large document.
|
|
813
|
-
}, Math.max(
|
|
873
|
+
}, Math.max(renderMinInterval, lastRebuildMs * 2));
|
|
814
874
|
}
|
|
815
875
|
|
|
816
876
|
/** The viewport as an encoded image (base64), cut from the latest tile. */
|
|
@@ -830,7 +890,7 @@ function buildPageRenderer({
|
|
|
830
890
|
/** Rasterizes display items intersecting the viewport onto a CanvasKit canvas. */
|
|
831
891
|
const paintFailures = new Set();
|
|
832
892
|
|
|
833
|
-
function paintDisplayList(canvas, displayList, view, scale, videoOverride = null) {
|
|
893
|
+
function paintDisplayList(canvas, displayList, view, scale, videoOverride = null, dirtyView = view) {
|
|
834
894
|
if (!displayList) return;
|
|
835
895
|
const pixel = (value) => Math.round(value * scale);
|
|
836
896
|
const deviceRect = (rect) => CanvasKit.XYWHRect(
|
|
@@ -857,7 +917,7 @@ function buildPageRenderer({
|
|
|
857
917
|
if (rounded) canvas.drawRRect(rounded, paint);
|
|
858
918
|
else canvas.drawRect(deviceRect(rect), paint);
|
|
859
919
|
};
|
|
860
|
-
for (const item of visibleItems(displayList,
|
|
920
|
+
for (const item of visibleItems(displayList, dirtyView, viewport)) {
|
|
861
921
|
const saved = item.fixed || item.clipRect || item.opacity < 1 || item.transform ? canvas.save() : null;
|
|
862
922
|
try {
|
|
863
923
|
if (item.fixed) canvas.translate(viewport.x * scale, viewport.y * scale);
|
|
@@ -1046,6 +1106,17 @@ function buildPageRenderer({
|
|
|
1046
1106
|
} else {
|
|
1047
1107
|
canvas.drawParagraph(entry.paragraph, pixel(item.x - view.x), pixel(item.y - view.y));
|
|
1048
1108
|
}
|
|
1109
|
+
const selected=selectedTextRange(item);
|
|
1110
|
+
if(selected){
|
|
1111
|
+
const boxes=entry.paragraph.getRectsForRange(selected.from,selected.to,CanvasKit.RectHeightStyle.Max,CanvasKit.RectWidthStyle.Tight);
|
|
1112
|
+
const white=paragraphForPaint({...item,color:'rgba(255, 255, 255, 1)',gradient:null},scale,pixel(Math.max(1,item.textWidth+1)));
|
|
1113
|
+
const paint=new CanvasKit.Paint();paint.setColor(canvasColor(CanvasKit,'rgba(56, 116, 242, 1)'));
|
|
1114
|
+
try{for(const box of boxes){
|
|
1115
|
+
const r=box.rect,ox=pixel(item.x-view.x),oy=pixel(item.y-view.y);
|
|
1116
|
+
const rect=CanvasKit.LTRBRect(r[0]+ox,r[1]+oy,r[2]+ox,r[3]+oy);
|
|
1117
|
+
canvas.save();canvas.clipRect(rect,CanvasKit.ClipOp.Intersect,false);canvas.drawRect(rect,paint);canvas.drawParagraph(white.paragraph,ox,oy);canvas.restore();
|
|
1118
|
+
}}finally{paint.delete();if(!white.retained)white.paragraph.delete();}
|
|
1119
|
+
}
|
|
1049
1120
|
} finally {
|
|
1050
1121
|
if (!entry.retained) entry.paragraph.delete();
|
|
1051
1122
|
}
|
|
@@ -1592,6 +1663,7 @@ function buildPageRenderer({
|
|
|
1592
1663
|
for (const image of page.decodedImages) image.delete();
|
|
1593
1664
|
disposeBackgrounds();
|
|
1594
1665
|
page.window = parsed.window;
|
|
1666
|
+
pointerButtons = 0; suppressPointerMouse = false; pointerPresses.clear(); selectionDrag=null;
|
|
1595
1667
|
page.document = parsed.document;
|
|
1596
1668
|
observeDocumentCanvases(parsed.document, (node) => {
|
|
1597
1669
|
if (disposedRenderer || node.ownerDocument !== page.document || !node.isConnected) return;
|
|
@@ -1793,6 +1865,16 @@ function buildPageRenderer({
|
|
|
1793
1865
|
onLifecycle?.("DOMContentLoaded", generation);
|
|
1794
1866
|
onLifecycle?.("load", generation);
|
|
1795
1867
|
};
|
|
1868
|
+
// qml handle begin: optional local Qt font directory, never inherited by HTML
|
|
1869
|
+
const selectedFonts = page.scripting === "host" && url.startsWith("file:") && isQmlDocument(url, contentType)
|
|
1870
|
+
? await loadQmlFonts(standardFonts, message => reportError(new Error(message))) : standardFonts;
|
|
1871
|
+
if (!current()) return;
|
|
1872
|
+
if (fontCollection !== selectedFonts.fontCollection) {
|
|
1873
|
+
({loadedFamilies, fontCollection, fontFamilies, builtinTypefaces} = selectedFonts);
|
|
1874
|
+
clearFontCaches();
|
|
1875
|
+
}
|
|
1876
|
+
// qml handle end
|
|
1877
|
+
|
|
1796
1878
|
page.url = url;
|
|
1797
1879
|
page.cookie = typeof cookie === "string" ? cookie : "";
|
|
1798
1880
|
if (error) {
|
|
@@ -1931,6 +2013,16 @@ function buildPageRenderer({
|
|
|
1931
2013
|
}
|
|
1932
2014
|
// qml handle end
|
|
1933
2015
|
|
|
2016
|
+
// qml handle begin: local QML fragment shader capability
|
|
2017
|
+
if (page.document.__qmlHost) {
|
|
2018
|
+
const host=page.document.__qmlHost;
|
|
2019
|
+
host.shader=createShaderRenderer({kit:CanvasKit,unwrap:e=>qmlBindings.Node.unwrap(e),
|
|
2020
|
+
valid:e=>e?.ownerDocument===page.document,
|
|
2021
|
+
resource:e=>e.tagName==='CANVAS'?canvasResource(e):page.resources.get(e),
|
|
2022
|
+
paint:paintQmlCanvas,track:fn=>host.trackResource(fn)});
|
|
2023
|
+
}
|
|
2024
|
+
// qml handle end
|
|
2025
|
+
|
|
1934
2026
|
await page.realm.runDocumentScripts();
|
|
1935
2027
|
if (!current()) return;
|
|
1936
2028
|
phase("scripts");
|
|
@@ -2129,6 +2221,54 @@ function buildPageRenderer({
|
|
|
2129
2221
|
return Math.max(0,Math.min(String(element.value).length,position));
|
|
2130
2222
|
}
|
|
2131
2223
|
|
|
2224
|
+
function selectableText(node){
|
|
2225
|
+
for(let el=node.parentElement;el;el=el.parentElement){
|
|
2226
|
+
const value=styleEngine.get(el)?.userSelect;
|
|
2227
|
+
if(value==='none')return false;
|
|
2228
|
+
if(value==='text'||value==='all')return true;
|
|
2229
|
+
}
|
|
2230
|
+
return true;
|
|
2231
|
+
}
|
|
2232
|
+
function documentTextPoint(x,y,target=null) {
|
|
2233
|
+
let best=null;
|
|
2234
|
+
for(const hit of page.displayList?.hits??[]){
|
|
2235
|
+
const run=hit.textRun,node=page.renderTree.nodesById.get(hit.nodeId)?.domNode;
|
|
2236
|
+
if(!run||node?.nodeType!==3||!node.isConnected||!selectableText(node))continue;
|
|
2237
|
+
if(target && !target.contains(node))continue;
|
|
2238
|
+
if(node.parentElement?.closest('button,input,textarea,select,canvas,script,style'))continue;
|
|
2239
|
+
let px=x+(hit.fixed?0:viewport.x),py=y+(hit.fixed?0:viewport.y);
|
|
2240
|
+
if(hit.transform){const {a,d,e,f}=hit.transform;if(!a||!d)continue;px=(px-e)/a;py=(py-f)/d;}
|
|
2241
|
+
if(hit.clipRect && (px<hit.clipRect.x||py<hit.clipRect.y||px>hit.clipRect.x+hit.clipRect.width||py>hit.clipRect.y+hit.clipRect.height))continue;
|
|
2242
|
+
const height=parseFloat(run.style.lineHeight)||parseFloat(run.style.fontSize)||16;
|
|
2243
|
+
const dy=Math.max(run.y-py,py-run.y-height,0),dx=Math.max(run.x-px,px-run.x-run.width,0);
|
|
2244
|
+
if(!best||dy<best.dy||dy===best.dy&&dx<best.dx)best={run,node,px,dy,dx};
|
|
2245
|
+
}
|
|
2246
|
+
if(!best)return null;
|
|
2247
|
+
const {run,node,px}=best,p=makeParagraph(run.text,run.style);
|
|
2248
|
+
try{p.layout(Math.max(1,run.width+1));return {node,offset:Math.max(0,Math.min(node.textContent.length,(run.sourceStart??0)+p.getGlyphPositionAtCoordinate(px-run.x,0).pos))};}
|
|
2249
|
+
finally{p.delete();}
|
|
2250
|
+
}
|
|
2251
|
+
function extendDrag(x,y){
|
|
2252
|
+
if(selectionDrag.element){
|
|
2253
|
+
const el=selectionDrag.element;if(!el.isConnected||page.document.activeElement!==el){selectionDrag=null;return;}
|
|
2254
|
+
selectInput(el,selectionDrag.anchor,inputPositionAtPoint(el,x,y));syncInputCaret();invalidateInput();
|
|
2255
|
+
}else{
|
|
2256
|
+
const point=documentTextPoint(x,y);if(!point||!selectionDrag.node.isConnected)return;
|
|
2257
|
+
page.window.getSelection().setBaseAndExtent(selectionDrag.node,selectionDrag.offset,point.node,point.offset);
|
|
2258
|
+
selectionVersion++;
|
|
2259
|
+
page.needsPaint=true;
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2262
|
+
function selectedTextRange(item){
|
|
2263
|
+
const sel=page.window.getSelection(),node=page.renderTree.nodesById.get(item.nodeId)?.domNode;
|
|
2264
|
+
if(!sel?.rangeCount||sel.isCollapsed||node?.nodeType!==3||!node.isConnected||!selectableText(node))return null;
|
|
2265
|
+
const range=sel.getRangeAt(0);if(!range.intersectsNode(node))return null;
|
|
2266
|
+
const base=item.sourceStart??0;
|
|
2267
|
+
const from=Math.max(0,(range.startContainer===node?range.startOffset:0)-base);
|
|
2268
|
+
const to=Math.min(item.text.length,(range.endContainer===node?range.endOffset:node.textContent.length)-base);
|
|
2269
|
+
return to>from?{from,to}:null;
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2132
2272
|
function clipboardForInput(element,operation,backend={read:clipboardReadInput,write:clipboardWriteInput}) {
|
|
2133
2273
|
return inputClipboard(element,operation,{...backend,replace:(text,type)=>{
|
|
2134
2274
|
if(page.document.activeElement!==element)return;
|
|
@@ -2298,6 +2438,7 @@ function buildPageRenderer({
|
|
|
2298
2438
|
}
|
|
2299
2439
|
},
|
|
2300
2440
|
whenImagesLoaded: () => Promise.all([page.imagesLoaded, documentFonts.ready(), ...[...page.backgrounds.values()].map((entry) => entry.loaded)]),
|
|
2441
|
+
selectionChanged(){selectionVersion++;page.needsPaint=true;scheduleFrame();},
|
|
2301
2442
|
afterTask() {},
|
|
2302
2443
|
boundsOf(element) {
|
|
2303
2444
|
if (page.dirty) rebuildRenderTree("script read geometry");
|
|
@@ -2727,9 +2868,9 @@ function buildPageRenderer({
|
|
|
2727
2868
|
schedulePrepaint();
|
|
2728
2869
|
},
|
|
2729
2870
|
title() {
|
|
2730
|
-
|
|
2731
|
-
//
|
|
2732
|
-
return page.document?.title
|
|
2871
|
+
// DOM title changes are synchronous and do not require layout or paint.
|
|
2872
|
+
// An empty document title is also a live value, not a cached-title fallback.
|
|
2873
|
+
return page.document?.title ?? page.title;
|
|
2733
2874
|
},
|
|
2734
2875
|
scroll(deltaX, deltaY) {
|
|
2735
2876
|
if (viewport.scrollBy(deltaX, deltaY)) render();
|
|
@@ -2755,6 +2896,9 @@ function buildPageRenderer({
|
|
|
2755
2896
|
const prevented = page.realm.dispatch(target, new page.window.KeyboardEvent("keydown", init));
|
|
2756
2897
|
let activated = false;
|
|
2757
2898
|
const inputHandled=!prevented&&inputDefault(target,init);
|
|
2899
|
+
if(!prevented&&!inputHandled&&(init.ctrlKey||init.metaKey)&&init.key.toLowerCase()==='c'){
|
|
2900
|
+
const text=page.window.getSelection().toString();if(text)clipboardWriteInput(text).catch(reportError);
|
|
2901
|
+
}
|
|
2758
2902
|
if (!inputHandled && !prevented && !init.ctrlKey && !init.metaKey && !init.altKey) {
|
|
2759
2903
|
if ((key === "Enter" || key === " ") && isDisclosureControl(target)) {
|
|
2760
2904
|
activated = true;
|
|
@@ -2824,18 +2968,29 @@ function buildPageRenderer({
|
|
|
2824
2968
|
return wheel(Number(x), Number(y), Number(deltaX) || 0, Number(deltaY) || 0, options);
|
|
2825
2969
|
},
|
|
2826
2970
|
/** Move the pointer in viewport coordinates, including boundary events. */
|
|
2827
|
-
move(x, y) {
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2971
|
+
move(x, y, options = {}) {
|
|
2972
|
+
// Hit testing needs current geometry, not a rasterized frame. Dispatch
|
|
2973
|
+
// every motion (drawing needs every point), but let the frame scheduler
|
|
2974
|
+
// combine their paints instead of rasterizing the whole page per event.
|
|
2975
|
+
if (page.dirty) rebuildRenderTree("pointer hit test");
|
|
2976
|
+
const target = elementAtPoint(page.renderTree, page.displayList, Number(x), Number(y), viewport) ?? (pointerButtons ? page.document.documentElement : null);
|
|
2977
|
+
const inside = target?.tagName === 'IFRAME' ? frameAt(target, x, y) : null;
|
|
2978
|
+
if (inside) inside.frame.renderer.move(inside.x, inside.y, options);
|
|
2979
|
+
dispatchPointerMove(page.window, page.realm, target, Number(x), Number(y), {...options, buttons: pointerButtons, suppressMouse:suppressPointerMouse});
|
|
2980
|
+
if(selectionDrag && (pointerButtons&1)) extendDrag(x,y);
|
|
2981
|
+
if (page.dirty || page.needsPaint) scheduleFrame();
|
|
2832
2982
|
},
|
|
2833
2983
|
/**
|
|
2834
2984
|
* Clicks at a viewport point: the page receives a click event first, and
|
|
2835
2985
|
* unless it calls preventDefault() a link under the point is returned for
|
|
2836
2986
|
* the controller to navigate to.
|
|
2837
2987
|
*/
|
|
2838
|
-
|
|
2988
|
+
pointerButton(x, y, button, state, options = {}) {
|
|
2989
|
+
if (button < 272 || button > 274) return null;
|
|
2990
|
+
return this.click(x, y, {...options, button: button === 273 ? 'right' : button === 274 ? 'middle' : 'left', phase: state ? 'down' : 'up'});
|
|
2991
|
+
},
|
|
2992
|
+
click(x, y, options = {}) {
|
|
2993
|
+
const detail = Number.isInteger(options.clickCount) && options.clickCount > 0 ? options.clickCount : 1;
|
|
2839
2994
|
refresh();
|
|
2840
2995
|
let region = hitTest(page.renderTree, page.layout, x, y, viewport, viewport);
|
|
2841
2996
|
let forwarded = false;
|
|
@@ -2861,7 +3016,7 @@ function buildPageRenderer({
|
|
|
2861
3016
|
// and a search form's forwarded field takes the click on the form.
|
|
2862
3017
|
const hitNode = page.displayList ? renderNodeAtPoint(page.renderTree, page.displayList, x, y, viewport) : null;
|
|
2863
3018
|
const precise = hitNode?.domNode?.nodeType === 1 ? hitNode.domNode : hitNode?.domNode?.parentElement;
|
|
2864
|
-
const target = forwarded ? region.element : inputOwner.get(precise) ?? precise ?? region?.element ?? null;
|
|
3019
|
+
const target = options.activationTarget ?? (forwarded ? region.element : inputOwner.get(precise) ?? precise ?? region?.element ?? (options.phase ? page.document.documentElement : null));
|
|
2865
3020
|
const generatedDisclosure = hitNode?.pseudo === "summary" && target?.tagName === "DETAILS";
|
|
2866
3021
|
if (!target) {
|
|
2867
3022
|
focusedFrame = null;
|
|
@@ -2874,11 +3029,97 @@ function buildPageRenderer({
|
|
|
2874
3029
|
const inFrame = target.tagName === "IFRAME" ? frameAt(target, x, y) : null;
|
|
2875
3030
|
focusedFrame = inFrame?.frame ?? null;
|
|
2876
3031
|
if (inFrame) {
|
|
2877
|
-
const followed = inFrame.frame.renderer.click(inFrame.x, inFrame.y);
|
|
3032
|
+
const followed = inFrame.frame.renderer.click(inFrame.x, inFrame.y, options);
|
|
2878
3033
|
const href = typeof followed === "string" ? followed : followed?.fragmentFrom ? null : followed?.url;
|
|
2879
3034
|
if (href && !followed?.download) void loadFrame(inFrame.frame, href);
|
|
2880
3035
|
return null;
|
|
2881
3036
|
}
|
|
3037
|
+
const mouseButton = options.button === 'right' || options.button === 2 ? 2
|
|
3038
|
+
: options.button === 'middle' || options.button === 1 ? 1 : 0;
|
|
3039
|
+
// The press focuses the focusable element it lands in, unless mousedown was canceled.
|
|
3040
|
+
const focus = () => {
|
|
3041
|
+
selectionDrag=null;
|
|
3042
|
+
const focusable = generatedDisclosure ? target : target.closest?.("input, textarea, select, button, summary, [tabindex]");
|
|
3043
|
+
if (focusable && !focusable.disabled) {
|
|
3044
|
+
const position=isTextInput(focusable)&&!forwarded?inputPositionAtPoint(focusable,x,y):null;
|
|
3045
|
+
focusable.focus();
|
|
3046
|
+
if(position!==null){selectInput(focusable,position);syncInputCaret();invalidateInput();if(options.phase==='down')selectionDrag={element:focusable,anchor:position};}
|
|
3047
|
+
} else if(mouseButton===0){
|
|
3048
|
+
const point=documentTextPoint(x,y,target);
|
|
3049
|
+
if(point)page.document.activeElement?.blur?.();
|
|
3050
|
+
selectionVersion++;page.window.getSelection().removeAllRanges();
|
|
3051
|
+
if(point){page.window.getSelection().collapse(point.node,point.offset);if(options.phase==='down')selectionDrag=point;}
|
|
3052
|
+
page.needsPaint=true;
|
|
3053
|
+
}
|
|
3054
|
+
};
|
|
3055
|
+
const modifiers = options.modifiers ?? 0;
|
|
3056
|
+
const mouseInit = { bubbles:true, cancelable:true, composed:true,
|
|
3057
|
+
clientX:Number(x), clientY:Number(y), button:mouseButton, detail,
|
|
3058
|
+
altKey:!!(modifiers&1), ctrlKey:!!(modifiers&2),
|
|
3059
|
+
metaKey:!!(modifiers&4), shiftKey:!!(modifiers&8) };
|
|
3060
|
+
if (options.phase) {
|
|
3061
|
+
const mask = mouseButton === 0 ? 1 : mouseButton === 1 ? 4 : 2;
|
|
3062
|
+
const before = pointerButtons;
|
|
3063
|
+
const pressed = pointerPresses.get(mouseButton);
|
|
3064
|
+
pointerButtons = options.phase === 'down' ? before | mask : before & ~mask;
|
|
3065
|
+
const Pointer = page.window.PointerEvent ?? page.window.MouseEvent;
|
|
3066
|
+
const pointer = {...mouseInit, detail:0, buttons:pointerButtons,
|
|
3067
|
+
pointerId:1, pointerType:'mouse', isPrimary:true};
|
|
3068
|
+
if (options.phase === 'down') {
|
|
3069
|
+
const record = {target, document:page.document, suppressMouse:false};
|
|
3070
|
+
pointerPresses.set(mouseButton, record);
|
|
3071
|
+
if (page.realm) {
|
|
3072
|
+
if (before === 0) suppressPointerMouse = page.realm.dispatch(target,new Pointer('pointerdown',pointer));
|
|
3073
|
+
record.suppressMouse = suppressPointerMouse;
|
|
3074
|
+
if (before !== 0) page.realm.dispatch(target,new Pointer('pointermove',pointer));
|
|
3075
|
+
if (!record.suppressMouse) {
|
|
3076
|
+
const prevented = page.realm.dispatch(target,new page.window.MouseEvent('mousedown',{...mouseInit,buttons:pointerButtons}));
|
|
3077
|
+
if (mouseButton === 0 && !prevented) focus();
|
|
3078
|
+
}
|
|
3079
|
+
if (mouseButton === 2) page.realm.dispatch(target,new Pointer('contextmenu',pointer));
|
|
3080
|
+
} else if (mouseButton === 0) focus();
|
|
3081
|
+
refresh();
|
|
3082
|
+
return null;
|
|
3083
|
+
}
|
|
3084
|
+
let selectedByDrag=false;
|
|
3085
|
+
if(mouseButton===0){
|
|
3086
|
+
if(selectionDrag){extendDrag(x,y);selectedByDrag=!selectionDrag?.element&&!page.window.getSelection().isCollapsed;}
|
|
3087
|
+
selectionDrag=null;
|
|
3088
|
+
}
|
|
3089
|
+
pointerPresses.delete(mouseButton);
|
|
3090
|
+
if (page.realm) {
|
|
3091
|
+
page.realm.dispatch(target,new Pointer(pointerButtons ? 'pointermove' : 'pointerup',pointer));
|
|
3092
|
+
if (!suppressPointerMouse) page.realm.dispatch(target,new page.window.MouseEvent('mouseup',{...mouseInit,buttons:pointerButtons}));
|
|
3093
|
+
}
|
|
3094
|
+
if (!pointerButtons) suppressPointerMouse = false;
|
|
3095
|
+
// Click/auxclick targets the common ancestor of the press and release.
|
|
3096
|
+
// Removed press targets and releases without a press do not activate.
|
|
3097
|
+
let activation = target;
|
|
3098
|
+
if (!pressed || pressed.document !== page.document || !pressed.target.isConnected) activation = null;
|
|
3099
|
+
while (activation && !activation.contains(pressed.target)) activation = activation.parentElement;
|
|
3100
|
+
refresh();
|
|
3101
|
+
return activation ? this.click(x,y,{...options,phase:undefined,activationOnly:true,activationTarget:activation,selectionDragged:selectedByDrag}) : null;
|
|
3102
|
+
}
|
|
3103
|
+
if (mouseButton !== 0) {
|
|
3104
|
+
// Non-primary clicks reach DOM handlers without primary activation
|
|
3105
|
+
// (navigation, form submission, controls, text-caret placement).
|
|
3106
|
+
if (page.realm) {
|
|
3107
|
+
const init = mouseInit;
|
|
3108
|
+
const Pointer = page.window.PointerEvent ?? page.window.MouseEvent;
|
|
3109
|
+
const pointer = {...init, detail:0, pointerId:1, pointerType:'mouse', isPrimary:true};
|
|
3110
|
+
const buttons = mouseButton === 2 ? 2 : 4;
|
|
3111
|
+
if (!options.activationOnly) {
|
|
3112
|
+
page.realm.dispatch(target,new Pointer('pointerdown',{...pointer,buttons}));
|
|
3113
|
+
page.realm.dispatch(target,new page.window.MouseEvent('mousedown',{...init,buttons}));
|
|
3114
|
+
if(mouseButton===2)page.realm.dispatch(target,new Pointer('contextmenu',{...pointer,buttons}));
|
|
3115
|
+
page.realm.dispatch(target,new Pointer('pointerup',{...pointer,buttons:0}));
|
|
3116
|
+
page.realm.dispatch(target,new page.window.MouseEvent('mouseup',{...init,buttons:0}));
|
|
3117
|
+
}
|
|
3118
|
+
page.realm.dispatch(target,new Pointer('auxclick',{...pointer,detail,buttons:pointerButtons}));
|
|
3119
|
+
refresh();
|
|
3120
|
+
}
|
|
3121
|
+
return null;
|
|
3122
|
+
}
|
|
2882
3123
|
if (page.url === "about:audio") {
|
|
2883
3124
|
const key = target.closest?.("[data-piano-note]");
|
|
2884
3125
|
const note = Number(key?.getAttribute("data-piano-note"));
|
|
@@ -2888,31 +3129,27 @@ function buildPageRenderer({
|
|
|
2888
3129
|
return null;
|
|
2889
3130
|
}
|
|
2890
3131
|
}
|
|
2891
|
-
|
|
2892
|
-
const focus = () => {
|
|
2893
|
-
const focusable = generatedDisclosure ? target : target.closest?.("input, textarea, select, button, summary, [tabindex]");
|
|
2894
|
-
if (focusable && !focusable.disabled) {
|
|
2895
|
-
const position=isTextInput(focusable)&&!forwarded?inputPositionAtPoint(focusable,x,y):null;
|
|
2896
|
-
focusable.focus();
|
|
2897
|
-
if(position!==null){selectInput(focusable,position);syncInputCaret();invalidateInput();}
|
|
2898
|
-
}
|
|
2899
|
-
};
|
|
2900
|
-
if (!page.realm) focus();
|
|
3132
|
+
if (!page.realm) { if (!options.activationOnly) focus(); }
|
|
2901
3133
|
else {
|
|
2902
3134
|
const controlStates = controlStatesBeforeActivation();
|
|
2903
3135
|
// A press and release, then the click (UI Events §3.5).
|
|
2904
|
-
const init = {
|
|
3136
|
+
const init = { ...mouseInit, buttons: pointerButtons };
|
|
2905
3137
|
const PointerEvent = page.window.PointerEvent ?? page.window.MouseEvent;
|
|
2906
|
-
const pointer = { ...init, pointerId: 1, pointerType: "mouse", isPrimary: true };
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
3138
|
+
const pointer = { ...init, detail: 0, pointerId: 1, pointerType: "mouse", isPrimary: true };
|
|
3139
|
+
if (!options.activationOnly) {
|
|
3140
|
+
page.realm.dispatch(target, new PointerEvent("pointerdown", { ...pointer, buttons: 1 }));
|
|
3141
|
+
if (!page.realm.dispatch(target, new page.window.MouseEvent("mousedown", { ...init, buttons: 1 }))) focus();
|
|
3142
|
+
page.realm.dispatch(target, new PointerEvent("pointerup", pointer));
|
|
3143
|
+
page.realm.dispatch(target, new page.window.MouseEvent("mouseup", init));
|
|
3144
|
+
}
|
|
3145
|
+
const prevented = page.realm.dispatch(target, new page.window.MouseEvent("click", init));
|
|
3146
|
+
// dblclick follows the second click even when its default action was cancelled.
|
|
3147
|
+
if (detail === 2) page.realm.dispatch(target, new page.window.MouseEvent("dblclick", init));
|
|
2912
3148
|
invalidateActivatedControls(controlStates);
|
|
2913
3149
|
refresh();
|
|
2914
3150
|
if (prevented) return null;
|
|
2915
3151
|
}
|
|
3152
|
+
if (options.selectionDragged) return null;
|
|
2916
3153
|
if (generatedDisclosure) {
|
|
2917
3154
|
target.open = !target.open;
|
|
2918
3155
|
refresh();
|
|
@@ -3176,6 +3413,7 @@ function buildPageRenderer({
|
|
|
3176
3413
|
// Made at once, as is its initial about:blank document (HTML §4.8.5): a
|
|
3177
3414
|
// script can use its contentWindow as soon as the iframe is inserted.
|
|
3178
3415
|
const renderer = buildPageRenderer({
|
|
3416
|
+
renderMinInterval,
|
|
3179
3417
|
fetchResource: rawFetchResource,
|
|
3180
3418
|
requestNavigation: (url, downloadName) => {
|
|
3181
3419
|
if (downloadName === null) void loadFrame(frame, url);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const previousTargets = new WeakMap();
|
|
2
2
|
|
|
3
3
|
/** Deliver pointer/mouse boundary events before movement, as a real pointer does. */
|
|
4
|
-
export function dispatchPointerMove(window, realm, target, x, y) {
|
|
4
|
+
export function dispatchPointerMove(window, realm, target, x, y, options = {}) {
|
|
5
5
|
const document = window.document;
|
|
6
6
|
const previous = previousTargets.get(document) ?? null;
|
|
7
7
|
previousTargets.set(document, target);
|
|
@@ -12,7 +12,9 @@ export function dispatchPointerMove(window, realm, target, x, y) {
|
|
|
12
12
|
const pointer = type.startsWith('pointer');
|
|
13
13
|
const Constructor = pointer ? window.PointerEvent : window.MouseEvent;
|
|
14
14
|
const event = new Constructor(type, { clientX: x, clientY: y, relatedTarget, bubbles,
|
|
15
|
-
composed: bubbles, cancelable: bubbles, button: pointer ? -1 : 0, buttons: 0,
|
|
15
|
+
composed: bubbles, cancelable: bubbles, button: pointer ? -1 : 0, buttons: options.buttons ?? 0,
|
|
16
|
+
altKey:!!(options.modifiers&1),ctrlKey:!!(options.modifiers&2),
|
|
17
|
+
metaKey:!!(options.modifiers&4),shiftKey:!!(options.modifiers&8),
|
|
16
18
|
pointerId: 1, pointerType: 'mouse', isPrimary: true });
|
|
17
19
|
if (realm) realm.dispatch(node, event); else node.dispatchEvent(event);
|
|
18
20
|
};
|
|
@@ -25,5 +27,5 @@ export function dispatchPointerMove(window, realm, target, x, y) {
|
|
|
25
27
|
}
|
|
26
28
|
}
|
|
27
29
|
send(target, 'pointermove', null);
|
|
28
|
-
send(target, 'mousemove', null);
|
|
30
|
+
if (!options.suppressMouse) send(target, 'mousemove', null);
|
|
29
31
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export const RENDER_INTERVAL_FLAG = "--buninu-render-min-interval=";
|
|
2
|
+
|
|
3
|
+
// Validate in the controller, then pass only this number to the sandboxed child.
|
|
4
|
+
export function renderMinInterval(value, warn = console.warn) {
|
|
5
|
+
if (value === undefined) return 16;
|
|
6
|
+
if (/^\d+$/.test(value) && Number(value) <= 60000) return Number(value);
|
|
7
|
+
warn("buninu-browser: BUNINU_RENDER_MIN_INTERVAL must be an integer from 0 to 60000 ms; using 16 ms");
|
|
8
|
+
return 16;
|
|
9
|
+
}
|
|
@@ -211,6 +211,7 @@ const INITIAL = Object.freeze({
|
|
|
211
211
|
visibility: "visible",
|
|
212
212
|
// Hit testing only (inherited): "none" lets clicks pass through.
|
|
213
213
|
pointerEvents: "auto",
|
|
214
|
+
userSelect: "auto",
|
|
214
215
|
// Breaking inside words (CSS Text 3 §5): only with these set.
|
|
215
216
|
overflowWrap: "normal",
|
|
216
217
|
wordBreak: "normal",
|
|
@@ -281,8 +282,8 @@ const UA_DECLARATIONS = new Map(Object.entries({
|
|
|
281
282
|
* fills them in when checked.
|
|
282
283
|
*/
|
|
283
284
|
const CONTROL_FONT = "font-size: 13.333px; font-family: sans-serif";
|
|
284
|
-
const FIELD = `${CONTROL_FONT}; border: 1px solid #767676; background-color: white`;
|
|
285
|
-
const BUTTON_LIKE = `${CONTROL_FONT}; border: 1px solid #767676; border-radius: 2px; padding: 1px 6px; background-color: #efefef; text-align: center`;
|
|
285
|
+
const FIELD = `${CONTROL_FONT}; color: black; border: 1px solid #767676; background-color: white`;
|
|
286
|
+
const BUTTON_LIKE = `${CONTROL_FONT}; color: black; border: 1px solid #767676; border-radius: 2px; padding: 1px 6px; background-color: #efefef; text-align: center`;
|
|
286
287
|
const UA_CONTROL_DECLARATIONS = Object.freeze({
|
|
287
288
|
textarea: parseDeclarations(`${FIELD}; font-family: monospace; padding: 2px; white-space: pre-wrap; overflow-wrap: break-word; overflow: auto`),
|
|
288
289
|
text: parseDeclarations(`${FIELD}; padding: 1px 2px; white-space: pre; overflow: hidden`),
|
|
@@ -1413,6 +1414,10 @@ function setProperty(style, name, value, parentStyle = null) {
|
|
|
1413
1414
|
if (["visible", "hidden", "collapse"].includes(value)) style.visibility = value === "collapse" ? "hidden" : value;
|
|
1414
1415
|
break;
|
|
1415
1416
|
// SVG-only values (visiblePainted, fill, ...) hit like auto for HTML boxes.
|
|
1417
|
+
case "user-select":
|
|
1418
|
+
case "-webkit-user-select":
|
|
1419
|
+
if (["auto", "none", "text", "all"].includes(value)) style.userSelect = value;
|
|
1420
|
+
break;
|
|
1416
1421
|
case "pointer-events":
|
|
1417
1422
|
if (/^[a-z]+$/i.test(value)) style.pointerEvents = value === "none" ? "none" : "auto";
|
|
1418
1423
|
break;
|
|
@@ -2517,6 +2522,7 @@ function cssValue(style, name) {
|
|
|
2517
2522
|
: style.lineHeight == null ? "normal" : `${style.lineHeight}px`,
|
|
2518
2523
|
"list-style-type": style.listStyleType,
|
|
2519
2524
|
"pointer-events": style.pointerEvents,
|
|
2525
|
+
"user-select": style.userSelect,
|
|
2520
2526
|
"text-align": style.textAlign,
|
|
2521
2527
|
"white-space": style.whiteSpace,
|
|
2522
2528
|
})[name] ?? style[name];
|
package/package.json
CHANGED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import {test, expect} from 'bun:test';
|
|
2
|
+
import {SharedAudioConnection} from '../lib/audio/shared-connection.js';
|
|
3
|
+
|
|
4
|
+
test('audio connection survives one renderer closing, ends with the last owner, and reconnects', async () => {
|
|
5
|
+
let opened = 0, closed = 0;
|
|
6
|
+
const pool = new SharedAudioConnection(async () => ({id:++opened, close(){closed++;}}));
|
|
7
|
+
const a={}, b={};
|
|
8
|
+
expect(await pool.acquire(a)).toBe(await pool.acquire(b));
|
|
9
|
+
pool.release(a); await Promise.resolve();
|
|
10
|
+
expect(closed).toBe(0);
|
|
11
|
+
pool.release(b); await Promise.resolve();
|
|
12
|
+
expect(closed).toBe(1);
|
|
13
|
+
expect((await pool.acquire(a)).id).toBe(2);
|
|
14
|
+
pool.release(a); await Promise.resolve();
|
|
15
|
+
expect(closed).toBe(2);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('closing during authentication does not close or clear a newer connection', async () => {
|
|
19
|
+
let resolveOld, closed = 0, calls = 0;
|
|
20
|
+
const pool = new SharedAudioConnection(() => ++calls===1
|
|
21
|
+
? new Promise(resolve=>{resolveOld=resolve;})
|
|
22
|
+
: {close(){closed++;}});
|
|
23
|
+
const a={},b={};
|
|
24
|
+
const old=pool.acquire(a); await Promise.resolve();
|
|
25
|
+
pool.release(a);
|
|
26
|
+
const current=await pool.acquire(b);
|
|
27
|
+
resolveOld({close(){closed++;}}); await old; await Promise.resolve();
|
|
28
|
+
expect(closed).toBe(1);
|
|
29
|
+
expect(await pool.acquire(b)).toBe(current);
|
|
30
|
+
pool.release(b); await Promise.resolve();
|
|
31
|
+
expect(closed).toBe(2);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('late authentication failure does not invalidate a newer connection', async () => {
|
|
35
|
+
let rejectOld, calls=0;
|
|
36
|
+
const pool=new SharedAudioConnection(()=>++calls===1
|
|
37
|
+
? new Promise((_,reject)=>{rejectOld=reject;}) : {close(){}});
|
|
38
|
+
const a={},b={};
|
|
39
|
+
const old=pool.acquire(a); await Promise.resolve();
|
|
40
|
+
pool.release(a);
|
|
41
|
+
const current=await pool.acquire(b);
|
|
42
|
+
rejectOld(Error('old auth failed'));
|
|
43
|
+
await expect(old).rejects.toThrow('old auth failed');
|
|
44
|
+
expect(await pool.acquire(b)).toBe(current);
|
|
45
|
+
pool.release(b);
|
|
46
|
+
});
|
|
@@ -7,3 +7,11 @@ test("canvas bitmaps paint, update without DOM mutations, scale, reset and survi
|
|
|
7
7
|
expect(stderr).toBe("");
|
|
8
8
|
expect(exit).toBe(0);
|
|
9
9
|
}, 15_000);
|
|
10
|
+
|
|
11
|
+
test("canvas damage matches full repaint with transparency, clipping and overlapping content", async () => {
|
|
12
|
+
const child = Bun.spawn([process.execPath, new URL("./fixtures/canvas-damage.js", import.meta.url).pathname],
|
|
13
|
+
{ stdout: "pipe", stderr: "pipe" });
|
|
14
|
+
const [exit, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()]);
|
|
15
|
+
expect(stderr).toBe("");
|
|
16
|
+
expect(exit).toBe(0);
|
|
17
|
+
}, 15_000);
|