react-x11 2.22.11 → 2.22.12
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/package.json +1 -1
- package/src/Reconciler.js +4 -0
- package/src/atspi.js +169 -25
- package/src/components/Menu.js +5 -2
- package/src/faces.js +24 -0
- package/src/nodes/damage.js +7 -0
- package/src/nodes/layout.js +19 -4
- package/src/nodes/node.js +3 -0
- package/src/nodes/scrollable.js +47 -14
- package/src/nodes/window/floors.js +22 -2
- package/src/nodes/window/flush.js +7 -3
- package/src/nodes/window/size.js +20 -1
- package/src/nodes/window/spine.js +32 -5
- package/src/nodes/window/window.js +3 -0
- package/src/styles.js +41 -14
- package/src/yoga.js +51 -5
package/package.json
CHANGED
package/src/Reconciler.js
CHANGED
|
@@ -75,6 +75,7 @@ import {
|
|
|
75
75
|
} from './registry.js';
|
|
76
76
|
import { SvgNode, SvgChildNode } from './svgnodes.js';
|
|
77
77
|
import { loadLayout } from './yoga.js';
|
|
78
|
+
import { warmWidgetFaces } from './faces.js';
|
|
78
79
|
|
|
79
80
|
// The renderer name and version DevTools shows. Read from package.json so
|
|
80
81
|
// they cannot drift — but **guarded**, because a single-file bundle has no
|
|
@@ -880,6 +881,9 @@ export async function createRoot(options = {}) {
|
|
|
880
881
|
const layout = loadLayout();
|
|
881
882
|
const integrations = loadIntegrations(); // null when there is nothing to install
|
|
882
883
|
const [app] = await Promise.all([connecting, layout, integrations]);
|
|
884
|
+
// The faces the widgets set text in beyond a family's four, matched now
|
|
885
|
+
// rather than in the first frame that sets one (src/faces.js).
|
|
886
|
+
warmWidgetFaces(app);
|
|
883
887
|
|
|
884
888
|
const container = Renderer.createContainer(
|
|
885
889
|
app,
|
package/src/atspi.js
CHANGED
|
@@ -71,6 +71,9 @@ const NODE_PATH = '/org/a11y/atspi/accessible/';
|
|
|
71
71
|
const NULL_PATH = '/org/a11y/atspi/null';
|
|
72
72
|
const CACHE_PATH = '/org/a11y/atspi/cache';
|
|
73
73
|
const REGISTRY_NAME = 'org.a11y.atspi.Registry';
|
|
74
|
+
const REGISTRY_PATH = '/org/a11y/atspi/registry';
|
|
75
|
+
const REGISTRY_IFACE = 'org.a11y.atspi.Registry';
|
|
76
|
+
const DBUS_NAME = 'org.freedesktop.DBus';
|
|
74
77
|
|
|
75
78
|
const IFACE = {
|
|
76
79
|
ACCESSIBLE: 'org.a11y.atspi.Accessible',
|
|
@@ -754,6 +757,18 @@ const EditableTextImpl = {
|
|
|
754
757
|
// The bridge
|
|
755
758
|
// --------------------------------------------------------------------------
|
|
756
759
|
|
|
760
|
+
/** What the renderer has said since the last push. */
|
|
761
|
+
function emptyPending() {
|
|
762
|
+
return {
|
|
763
|
+
attach: new Set(),
|
|
764
|
+
remove: [],
|
|
765
|
+
props: new Set(),
|
|
766
|
+
text: new Set(),
|
|
767
|
+
focus: [],
|
|
768
|
+
window: [],
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
|
|
757
772
|
class AtspiBridge {
|
|
758
773
|
constructor(bus, toolkitVersion) {
|
|
759
774
|
this.bus = bus;
|
|
@@ -770,25 +785,32 @@ class AtspiBridge {
|
|
|
770
785
|
this._unsubscribes = [];
|
|
771
786
|
// batched renderer notifications, flushed in a microtask once the pacer
|
|
772
787
|
// says so (src/a11ypace.js), and at once for what the user waits on
|
|
773
|
-
this._pending =
|
|
774
|
-
attach: new Set(),
|
|
775
|
-
remove: [],
|
|
776
|
-
props: new Set(),
|
|
777
|
-
text: new Set(),
|
|
778
|
-
focus: [],
|
|
779
|
-
window: [],
|
|
780
|
-
};
|
|
788
|
+
this._pending = emptyPending();
|
|
781
789
|
this._flushScheduled = false;
|
|
782
790
|
this._pacer = new A11yPacer();
|
|
783
791
|
/** The focused node, whose own changes are not paced. */
|
|
784
792
|
this._focused = null;
|
|
793
|
+
/**
|
|
794
|
+
* Whether anything may be listening, and so whether the bridge speaks
|
|
795
|
+
* at all (`watchListeners`). True until the registry says nobody is.
|
|
796
|
+
*/
|
|
797
|
+
this.audience = true;
|
|
798
|
+
/** Something has listened or asked, and the bridge speaks for good. */
|
|
799
|
+
this._heard = false;
|
|
800
|
+
/** The registry's unique name, once known: its calls are not an AT's. */
|
|
801
|
+
this._registryOwner = null;
|
|
802
|
+
/** Who has called in before the registry was asked who listens. */
|
|
803
|
+
this._callers = new Set();
|
|
785
804
|
// Pushes are paced, reads are not: whatever the pacer is holding is
|
|
786
805
|
// flushed before dbus-native dispatches an AT's call, so the objects and
|
|
787
806
|
// interfaces it asks about exist as the tree has them now. A node React
|
|
788
807
|
// reused as a slider still exported a button's interfaces until the
|
|
789
|
-
// next push, and a Value read of it failed.
|
|
808
|
+
// next push, and a Value read of it failed. A call is also how an AT
|
|
809
|
+
// that registered for nothing shows it is there.
|
|
790
810
|
bus.connection?.prependListener?.('message', (msg) => {
|
|
791
|
-
if (msg.type
|
|
811
|
+
if (msg.type !== METHOD_CALL) return;
|
|
812
|
+
if (msg.sender) this._calledBy(msg.sender);
|
|
813
|
+
if (this._hasPending()) this.flush();
|
|
792
814
|
});
|
|
793
815
|
}
|
|
794
816
|
|
|
@@ -1116,7 +1138,7 @@ class AtspiBridge {
|
|
|
1116
1138
|
// ---- signals ---------------------------------------------------------
|
|
1117
1139
|
|
|
1118
1140
|
_signal(path, iface, member, detail, detail1, detail2, anyData) {
|
|
1119
|
-
if (this.dead) return;
|
|
1141
|
+
if (this.dead || !this.audience) return;
|
|
1120
1142
|
try {
|
|
1121
1143
|
this.bus.sendSignal(path, iface, member, EVENT_SIGNATURE, [
|
|
1122
1144
|
detail,
|
|
@@ -1172,7 +1194,7 @@ class AtspiBridge {
|
|
|
1172
1194
|
}
|
|
1173
1195
|
|
|
1174
1196
|
emitCacheAdd(node) {
|
|
1175
|
-
if (this.dead) return;
|
|
1197
|
+
if (this.dead || !this.audience) return;
|
|
1176
1198
|
try {
|
|
1177
1199
|
this.bus.sendSignal(
|
|
1178
1200
|
CACHE_PATH,
|
|
@@ -1187,7 +1209,7 @@ class AtspiBridge {
|
|
|
1187
1209
|
}
|
|
1188
1210
|
|
|
1189
1211
|
emitCacheRemove(ref) {
|
|
1190
|
-
if (this.dead) return;
|
|
1212
|
+
if (this.dead || !this.audience) return;
|
|
1191
1213
|
try {
|
|
1192
1214
|
this.bus.sendSignal(CACHE_PATH, IFACE.CACHE, 'RemoveAccessible', '(so)', [
|
|
1193
1215
|
ref,
|
|
@@ -1207,7 +1229,7 @@ class AtspiBridge {
|
|
|
1207
1229
|
* finds them gone, and the removal finds nothing the AT ever saw.
|
|
1208
1230
|
*/
|
|
1209
1231
|
_schedule() {
|
|
1210
|
-
if (this._flushScheduled || this.dead) return;
|
|
1232
|
+
if (this._flushScheduled || this.dead || !this.audience) return;
|
|
1211
1233
|
if (this._pacer.ready(() => this.flush())) this._flushSoon();
|
|
1212
1234
|
}
|
|
1213
1235
|
|
|
@@ -1240,34 +1262,43 @@ class AtspiBridge {
|
|
|
1240
1262
|
return false;
|
|
1241
1263
|
}
|
|
1242
1264
|
|
|
1265
|
+
// Nothing is queued with nobody to tell: the tree an AT reads when it
|
|
1266
|
+
// arrives is the live one, and it starts from there (`_silence`).
|
|
1267
|
+
|
|
1243
1268
|
queueAttach(node) {
|
|
1269
|
+
if (!this.audience) return;
|
|
1244
1270
|
this._pending.attach.add(node);
|
|
1245
1271
|
this._schedule();
|
|
1246
1272
|
}
|
|
1247
1273
|
|
|
1248
1274
|
queueRemove(parent, child, index) {
|
|
1275
|
+
if (!this.audience) return;
|
|
1249
1276
|
this._pending.remove.push({ parent, child, index });
|
|
1250
1277
|
this._schedule();
|
|
1251
1278
|
}
|
|
1252
1279
|
|
|
1253
1280
|
queueProps(node) {
|
|
1281
|
+
if (!this.audience) return;
|
|
1254
1282
|
this._pending.props.add(node);
|
|
1255
1283
|
if (node === this._focused) this._urgent();
|
|
1256
1284
|
else this._schedule();
|
|
1257
1285
|
}
|
|
1258
1286
|
|
|
1259
1287
|
queueText(node) {
|
|
1288
|
+
if (!this.audience) return;
|
|
1260
1289
|
this._pending.text.add(node);
|
|
1261
1290
|
if (node === this._focused) this._urgent();
|
|
1262
1291
|
else this._schedule();
|
|
1263
1292
|
}
|
|
1264
1293
|
|
|
1265
1294
|
queueFocus(previous, next) {
|
|
1295
|
+
if (!this.audience) return;
|
|
1266
1296
|
this._pending.focus.push({ previous, next });
|
|
1267
1297
|
this._urgent();
|
|
1268
1298
|
}
|
|
1269
1299
|
|
|
1270
1300
|
queueWindowFocus(win, focused) {
|
|
1301
|
+
if (!this.audience) return;
|
|
1271
1302
|
this._pending.window.push({ win, focused });
|
|
1272
1303
|
this._urgent();
|
|
1273
1304
|
}
|
|
@@ -1288,19 +1319,12 @@ class AtspiBridge {
|
|
|
1288
1319
|
if (this.dead) return;
|
|
1289
1320
|
this._flushScheduled = false;
|
|
1290
1321
|
const p = this._pending;
|
|
1291
|
-
if (!this._hasPending()) return;
|
|
1322
|
+
if (!this.audience || !this._hasPending()) return;
|
|
1292
1323
|
// this push takes what a stream was owed, and the next interval runs
|
|
1293
1324
|
// from here
|
|
1294
1325
|
this._pacer.cancel();
|
|
1295
1326
|
this._pacer.pushed();
|
|
1296
|
-
this._pending =
|
|
1297
|
-
attach: new Set(),
|
|
1298
|
-
remove: [],
|
|
1299
|
-
props: new Set(),
|
|
1300
|
-
text: new Set(),
|
|
1301
|
-
focus: [],
|
|
1302
|
-
window: [],
|
|
1303
|
-
};
|
|
1327
|
+
this._pending = emptyPending();
|
|
1304
1328
|
|
|
1305
1329
|
// Removals first: a node that left and came back in one batch reads as
|
|
1306
1330
|
// its add, never as a stale remove.
|
|
@@ -1723,10 +1747,120 @@ class AtspiBridge {
|
|
|
1723
1747
|
);
|
|
1724
1748
|
this.bus.signals.on(key, (body) => {
|
|
1725
1749
|
const newOwner = body?.[2];
|
|
1726
|
-
if (newOwner)
|
|
1750
|
+
if (!newOwner) return;
|
|
1751
|
+
// a restarted registry calls in under its new name, and is still
|
|
1752
|
+
// not an AT
|
|
1753
|
+
if (this._registryOwner !== null) this._registryOwner = newOwner;
|
|
1754
|
+
this.embed().catch(() => {});
|
|
1727
1755
|
});
|
|
1728
1756
|
}
|
|
1729
1757
|
|
|
1758
|
+
// ---- nobody listening ------------------------------------------------
|
|
1759
|
+
|
|
1760
|
+
/**
|
|
1761
|
+
* Speak only with somebody to hear it. An accessibility bus runs in most
|
|
1762
|
+
* Linux sessions whether or not an assistive technology does, and with
|
|
1763
|
+
* none there every signal the bridge sends, and every object it exports
|
|
1764
|
+
* to name in them, is paid for by nobody: a quarter of the frame rate of
|
|
1765
|
+
* a thumb dragged down a 100,000-row `<Table>`.
|
|
1766
|
+
*
|
|
1767
|
+
* The registry knows. An AT registers the events it wants with it
|
|
1768
|
+
* (`RegisterEvent`), and it answers `GetRegisteredEvents` and announces
|
|
1769
|
+
* each new listener (`EventListenerRegistered`), which is how GTK 4 and
|
|
1770
|
+
* at-spi2-atk hold back the events nobody asked for. Both still send the
|
|
1771
|
+
* cache's signals and children-changed regardless, because libatspi
|
|
1772
|
+
* listens for those in every client without registering. So an empty list
|
|
1773
|
+
* is not the whole answer. An AT that registered for nothing still has to
|
|
1774
|
+
* call in to read anything, and a call from anybody but the registry
|
|
1775
|
+
* counts as well.
|
|
1776
|
+
*
|
|
1777
|
+
* With neither, the bridge falls silent: nothing queued, nothing pushed,
|
|
1778
|
+
* nothing exported. It speaks again for good at the first listener or the
|
|
1779
|
+
* first call, and an AT arriving then reads the tree as it is, which is
|
|
1780
|
+
* where it would start anyway. The one thing silence costs is an
|
|
1781
|
+
* announcement, which `announce()` then reports as unheard.
|
|
1782
|
+
*/
|
|
1783
|
+
async watchListeners() {
|
|
1784
|
+
const bus = this.bus;
|
|
1785
|
+
await bus.addMatch(
|
|
1786
|
+
`type='signal',interface='${REGISTRY_IFACE}',` +
|
|
1787
|
+
"member='EventListenerRegistered'",
|
|
1788
|
+
);
|
|
1789
|
+
bus.signals.on(
|
|
1790
|
+
bus.mangle(REGISTRY_PATH, REGISTRY_IFACE, 'EventListenerRegistered'),
|
|
1791
|
+
() => this._hearFrom(),
|
|
1792
|
+
);
|
|
1793
|
+
const owner = await bus.invoke({
|
|
1794
|
+
destination: DBUS_NAME,
|
|
1795
|
+
path: '/org/freedesktop/DBus',
|
|
1796
|
+
interface: DBUS_NAME,
|
|
1797
|
+
member: 'GetNameOwner',
|
|
1798
|
+
signature: 's',
|
|
1799
|
+
body: [REGISTRY_NAME],
|
|
1800
|
+
});
|
|
1801
|
+
const events = await bus.invoke({
|
|
1802
|
+
destination: REGISTRY_NAME,
|
|
1803
|
+
path: REGISTRY_PATH,
|
|
1804
|
+
interface: REGISTRY_IFACE,
|
|
1805
|
+
member: 'GetRegisteredEvents',
|
|
1806
|
+
});
|
|
1807
|
+
// A listener that registered after the answer was sent may already
|
|
1808
|
+
// have been heard, in the same read as the reply.
|
|
1809
|
+
if (this._heard || this.dead) return;
|
|
1810
|
+
this._registryOwner = owner;
|
|
1811
|
+
const asked = [...this._callers].some((sender) => this._isPeer(sender));
|
|
1812
|
+
this._callers.clear();
|
|
1813
|
+
if (asked || !Array.isArray(events) || events.length > 0) {
|
|
1814
|
+
this._hearFrom();
|
|
1815
|
+
return;
|
|
1816
|
+
}
|
|
1817
|
+
this._silence();
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
/** A method call has come in from `sender`. */
|
|
1821
|
+
_calledBy(sender) {
|
|
1822
|
+
if (this._heard) return;
|
|
1823
|
+
if (this._registryOwner === null) this._callers.add(sender);
|
|
1824
|
+
else if (this._isPeer(sender)) this._hearFrom();
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
/** Anybody but the registry, the bus, or this connection. */
|
|
1828
|
+
_isPeer(sender) {
|
|
1829
|
+
return (
|
|
1830
|
+
sender !== this._registryOwner &&
|
|
1831
|
+
sender !== DBUS_NAME &&
|
|
1832
|
+
sender !== this.bus.name
|
|
1833
|
+
);
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
/** Somebody listens or asks: speak, from now on. */
|
|
1837
|
+
_hearFrom() {
|
|
1838
|
+
this._heard = true;
|
|
1839
|
+
this._callers.clear();
|
|
1840
|
+
if (!this.audience && process.env.REACT_X11_A11Y) {
|
|
1841
|
+
console.warn('react-x11: accessibility bridge speaking — heard an AT');
|
|
1842
|
+
}
|
|
1843
|
+
this.audience = true;
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
/**
|
|
1847
|
+
* Nobody listens: stop. What the bus was shown before the registry
|
|
1848
|
+
* answered goes too, so silence has nothing exported to keep in step and
|
|
1849
|
+
* nothing to take back.
|
|
1850
|
+
*/
|
|
1851
|
+
_silence() {
|
|
1852
|
+
if (process.env.REACT_X11_A11Y) {
|
|
1853
|
+
console.warn(
|
|
1854
|
+
'react-x11: accessibility bridge quiet — nothing listens or asks yet',
|
|
1855
|
+
);
|
|
1856
|
+
}
|
|
1857
|
+
this.audience = false;
|
|
1858
|
+
this._pacer.cancel();
|
|
1859
|
+
this._flushScheduled = false;
|
|
1860
|
+
this._pending = emptyPending();
|
|
1861
|
+
for (const node of [...this.exported.keys()]) this._unexport(node);
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1730
1864
|
// ---- wiring into the renderer ---------------------------------------
|
|
1731
1865
|
|
|
1732
1866
|
install() {
|
|
@@ -1741,11 +1875,14 @@ class AtspiBridge {
|
|
|
1741
1875
|
this.toplevels.splice(index, 1);
|
|
1742
1876
|
this.queueRemove(null, win, index);
|
|
1743
1877
|
};
|
|
1878
|
+
// The walks below are skipped with nobody listening: silence exports
|
|
1879
|
+
// nothing, so there is nothing a detach has to take back either.
|
|
1744
1880
|
hooks.attached = (parent, child) => {
|
|
1745
|
-
if (!this._live(parent)) return;
|
|
1881
|
+
if (!this.audience || !this._live(parent)) return;
|
|
1746
1882
|
this.queueAttach(child);
|
|
1747
1883
|
};
|
|
1748
1884
|
hooks.detach = (parent, child) => {
|
|
1885
|
+
if (!this.audience) return;
|
|
1749
1886
|
const exported = this.exported.has(child);
|
|
1750
1887
|
if (!exported && !this._live(parent)) return;
|
|
1751
1888
|
// the index is for the AT's children-changed, which only a child it
|
|
@@ -1760,6 +1897,7 @@ class AtspiBridge {
|
|
|
1760
1897
|
if (this.exported.has(node)) this.queueProps(node);
|
|
1761
1898
|
};
|
|
1762
1899
|
hooks.textContent = (chunk) => {
|
|
1900
|
+
if (!this.audience) return;
|
|
1763
1901
|
let text = chunk.parent;
|
|
1764
1902
|
while (text && text.kind === 'text' && text.isSpan) text = text.parent;
|
|
1765
1903
|
if (!text) return;
|
|
@@ -1784,6 +1922,9 @@ class AtspiBridge {
|
|
|
1784
1922
|
// hook above has already asked for the one it needs
|
|
1785
1923
|
hooks.commit = () => this._schedule();
|
|
1786
1924
|
hooks.announce = (text, opts) => {
|
|
1925
|
+
// `announce()`'s false: nobody is listening, and the app may want to
|
|
1926
|
+
// show it instead
|
|
1927
|
+
if (!this.audience) return false;
|
|
1787
1928
|
// spoken from the active window, which is the context the user is in
|
|
1788
1929
|
const target =
|
|
1789
1930
|
this.toplevels.find((win) => win.events?.windowFocused) ??
|
|
@@ -1989,6 +2130,9 @@ export async function start() {
|
|
|
1989
2130
|
}
|
|
1990
2131
|
}
|
|
1991
2132
|
b.watchRegistry().catch(() => {});
|
|
2133
|
+
// A registry that cannot say who listens is taken to have somebody
|
|
2134
|
+
// listening, as GTK takes one it cannot ask.
|
|
2135
|
+
b.watchListeners().catch(() => b._hearFrom());
|
|
1992
2136
|
b.install();
|
|
1993
2137
|
bridge = b;
|
|
1994
2138
|
if (loud) {
|
package/src/components/Menu.js
CHANGED
|
@@ -28,6 +28,7 @@ import { useGlobalMenu } from '../globalmenu.js';
|
|
|
28
28
|
import { acceleratedItem } from '../accelerators.js';
|
|
29
29
|
import { useAcceleratorEntry } from '../acceleratorhooks.js';
|
|
30
30
|
import { useTopLevelWindow } from '../windowid.js';
|
|
31
|
+
import { MENU_TEXT_WEIGHT } from '../faces.js';
|
|
31
32
|
import {
|
|
32
33
|
ariaKeyShortcuts,
|
|
33
34
|
checkShortcut,
|
|
@@ -212,8 +213,10 @@ const MENU_GUTTER = MENU_ICON_SIZE + MENU_ICON_GAP;
|
|
|
212
213
|
//
|
|
213
214
|
// Every label measured for a popup's width is measured at this weight too
|
|
214
215
|
// (`measureLabel` takes it), since a menu sized in regular for rows drawn in
|
|
215
|
-
// medium is a menu whose own labels run into its shortcuts.
|
|
216
|
-
|
|
216
|
+
// medium is a menu whose own labels run into its shortcuts. The weight,
|
|
217
|
+
// `MENU_TEXT_WEIGHT`, is in ../faces.js, which a root warms as it connects:
|
|
218
|
+
// a face outside a family's four is otherwise matched in the first frame
|
|
219
|
+
// that sets it.
|
|
217
220
|
|
|
218
221
|
const MENU_SHORTCUT_GAP = 24;
|
|
219
222
|
// menus size to their content rather than scrolling, so a page is a fixed
|
package/src/faces.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// The faces the widgets set text in beyond the four a font source warms for
|
|
2
|
+
// a family by itself — regular, bold, italic and bold italic — so that a
|
|
3
|
+
// root can have them matched while it connects (`warmWidgetFaces`, from
|
|
4
|
+
// createRoot) rather than in the first frame that sets one.
|
|
5
|
+
//
|
|
6
|
+
// With fontconfig, a face nobody warmed is a synchronous `fc-match` inside
|
|
7
|
+
// that frame: a menu bar's titles, set in the medium below, cost 34 ms of a
|
|
8
|
+
// Linux desktop's first frame, and the matcher alone takes 80–150 ms on
|
|
9
|
+
// XQuartz. The weight is here rather than in components/Menu.js because the
|
|
10
|
+
// renderer is what warms it, and the renderer does not import widgets.
|
|
11
|
+
import { DefaultTheme } from './palette.js';
|
|
12
|
+
|
|
13
|
+
/** The weight a menu's titles and rows are set in (components/Menu.js). */
|
|
14
|
+
export const MENU_TEXT_WEIGHT = 500;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Start matching the widget faces in the default family, off the event loop
|
|
18
|
+
* (ntk's `FontManager#prewarm`). Fonts with nothing to look up — CoreText,
|
|
19
|
+
* DirectWrite, faces handed over in memory — ignore it, as does an ntk older
|
|
20
|
+
* than the method, and it never throws.
|
|
21
|
+
*/
|
|
22
|
+
export function warmWidgetFaces(app) {
|
|
23
|
+
app.fonts?.prewarm?.(DefaultTheme.fontFamily, [{ weight: MENU_TEXT_WEIGHT }]);
|
|
24
|
+
}
|
package/src/nodes/damage.js
CHANGED
|
@@ -66,6 +66,13 @@ export const layoutDiff = {
|
|
|
66
66
|
// asked to: a scroll moves boxes without a pass, and the walk is what
|
|
67
67
|
// carries the offset down to them (`WindowNode._walkWhole`).
|
|
68
68
|
skipUnreached: false,
|
|
69
|
+
|
|
70
|
+
// The boxes a scroll pane's measure read on this walk (`contentReach`),
|
|
71
|
+
// left for the `absolutize` that places each one later in the same walk
|
|
72
|
+
// (`laidBox`): no pass runs in between, so what yoga said is what it
|
|
73
|
+
// says. A Map for the length of the window's walk and null outside it,
|
|
74
|
+
// where a pane that re-measures (`_placeNodes` moving one) reads its own.
|
|
75
|
+
boxes: null,
|
|
69
76
|
};
|
|
70
77
|
|
|
71
78
|
// What an invalidate() may name as its reason — a small closed set, so the
|
package/src/nodes/layout.js
CHANGED
|
@@ -83,6 +83,20 @@ export function offsetInParent(node) {
|
|
|
83
83
|
};
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Where layout put `node`'s box — yoga's `{ left, top, width, height }`,
|
|
88
|
+
* in one call where four getters cost four crossings into the wasm module —
|
|
89
|
+
* or the box a scroll pane's measure already read on this walk
|
|
90
|
+
* (`layoutDiff.boxes`), which no pass has touched since. A pane that
|
|
91
|
+
* measures its content reads every box in it, and the walk that places
|
|
92
|
+
* them read each one again: on an edit to a 600 KB document, 1,704 blocks'
|
|
93
|
+
* worth of numbers read twice every frame.
|
|
94
|
+
*/
|
|
95
|
+
export function laidBox(node) {
|
|
96
|
+
const box = layoutDiff.boxes?.get(node);
|
|
97
|
+
return box === undefined ? node.yoga.getComputedLayout() : box;
|
|
98
|
+
}
|
|
99
|
+
|
|
86
100
|
/** What a leaf a width pass reads nothing from answers with. */
|
|
87
101
|
const UNREAD_SIZE = Object.freeze({ width: 0, height: 0 });
|
|
88
102
|
|
|
@@ -206,10 +220,11 @@ export class NodeLayout {
|
|
|
206
220
|
// its own — counts as on screen too
|
|
207
221
|
this._placed = true;
|
|
208
222
|
if (!this.yoga) return;
|
|
209
|
-
const
|
|
210
|
-
const
|
|
211
|
-
const
|
|
212
|
-
const
|
|
223
|
+
const box = laidBox(this);
|
|
224
|
+
const x = originX + box.left;
|
|
225
|
+
const y = originY + box.top;
|
|
226
|
+
const width = box.width;
|
|
227
|
+
const height = box.height;
|
|
213
228
|
const wasX = this.abs.x;
|
|
214
229
|
const wasY = this.abs.y;
|
|
215
230
|
const move = this._beginRigidMove(x, y, width, height);
|
package/src/nodes/node.js
CHANGED
|
@@ -108,6 +108,9 @@ export class Node {
|
|
|
108
108
|
this._floorAtW = undefined;
|
|
109
109
|
this._floorMinW = undefined;
|
|
110
110
|
this._floorMinH = undefined;
|
|
111
|
+
// this box's top and bottom margins as a column's floor sums them
|
|
112
|
+
// (`marginsDown`), with the style object they were read under
|
|
113
|
+
this._marginsDown = null;
|
|
111
114
|
// the width mode yoga last measured this leaf in with no height on
|
|
112
115
|
// offer — the question `_heightForWidth` repeats
|
|
113
116
|
this._floorMeasureMode = null;
|
package/src/nodes/scrollable.js
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
XK_SPACE,
|
|
18
18
|
} from '../keysyms.js';
|
|
19
19
|
import { DAMAGE_SLOP, layoutDiff } from './damage.js';
|
|
20
|
-
import { describeSize, offsetInParent } from './layout.js';
|
|
20
|
+
import { describeSize, laidBox, offsetInParent } from './layout.js';
|
|
21
21
|
import { insetRect, intersectRects, rectsOverlap } from './rects.js';
|
|
22
22
|
import {
|
|
23
23
|
SCROLLBAR_WIDTH,
|
|
@@ -55,31 +55,63 @@ function contentReach(node, top) {
|
|
|
55
55
|
cached !== undefined &&
|
|
56
56
|
node._host === null &&
|
|
57
57
|
node.parent?._host === null &&
|
|
58
|
-
!node.yoga.hasNewLayout()
|
|
58
|
+
(!node.yoga.hasNewLayout() || childrenSettled(node, cached))
|
|
59
59
|
) {
|
|
60
60
|
return cached;
|
|
61
61
|
}
|
|
62
62
|
let left = Infinity;
|
|
63
63
|
let right = -Infinity;
|
|
64
64
|
let bottom = -Infinity;
|
|
65
|
+
// a child a layout algorithm placed sits in its slot (`offsetInParent`)
|
|
66
|
+
const hosted = node._host !== null;
|
|
67
|
+
const boxes = layoutDiff.boxes;
|
|
65
68
|
for (const child of node.children) {
|
|
66
69
|
if (child.isWindow || !child.yoga || child.hidden) continue;
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
70
|
+
const box = child.yoga.getComputedLayout();
|
|
71
|
+
// …for the walk that places this child, later in the same one
|
|
72
|
+
boxes?.set(child, box);
|
|
73
|
+
const slot = hosted ? child._hostSlot : null;
|
|
74
|
+
const x = slot === null ? box.left : box.left + slot.x;
|
|
75
|
+
const y = slot === null ? box.top : box.top + slot.y;
|
|
76
|
+
left = Math.min(left, x);
|
|
77
|
+
right = Math.max(right, x + box.width);
|
|
78
|
+
bottom = Math.max(bottom, y + box.height);
|
|
71
79
|
if (!child.clipsChildren()) {
|
|
72
80
|
const inner = contentReach(child, false);
|
|
73
|
-
left = Math.min(left,
|
|
74
|
-
right = Math.max(right,
|
|
75
|
-
bottom = Math.max(bottom,
|
|
81
|
+
left = Math.min(left, x + inner.left);
|
|
82
|
+
right = Math.max(right, x + inner.right);
|
|
83
|
+
bottom = Math.max(bottom, y + inner.bottom);
|
|
76
84
|
}
|
|
77
85
|
}
|
|
78
|
-
const reach = { left, right, bottom };
|
|
86
|
+
const reach = { left, right, bottom, children: node.children.length };
|
|
79
87
|
if (!top) node._contentReach = reach;
|
|
80
88
|
return reach;
|
|
81
89
|
}
|
|
82
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Whether what `node` holds sits where it did, relative to `node`: the same
|
|
93
|
+
* children, none of them laid out again. The flag on `node` itself says only
|
|
94
|
+
* that the pass reached it, and a pass reaches every child of a box it lays
|
|
95
|
+
* out — a column laid out again for one block that grew flags all of its
|
|
96
|
+
* blocks, though every one of them holds what it held, where it held it. Each
|
|
97
|
+
* child's own flag is the witness for that: a pass that moved, resized,
|
|
98
|
+
* showed, hid or clipped a child laid it out, and one that added or took a
|
|
99
|
+
* child away laid out its parent, which flags every child left, or changed
|
|
100
|
+
* the count when none was.
|
|
101
|
+
*
|
|
102
|
+
* It saves reading each child's place and size back out of yoga, four
|
|
103
|
+
* calls a child, to find the answer `node` already has: an edit to a 600 KB
|
|
104
|
+
* document was 1,704 blocks' worth of them every frame.
|
|
105
|
+
*/
|
|
106
|
+
function childrenSettled(node, cached) {
|
|
107
|
+
const children = node.children;
|
|
108
|
+
if (children.length !== cached.children) return false;
|
|
109
|
+
for (const child of children) {
|
|
110
|
+
if (child.yoga && child.yoga.hasNewLayout()) return false;
|
|
111
|
+
}
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
|
|
83
115
|
/**
|
|
84
116
|
* One axis of a scroll request held for a pass
|
|
85
117
|
* (`Scrollable._holdScrollTo`): `{ base, steps }`, the request the extent in
|
|
@@ -194,10 +226,11 @@ export const Scrollable = (Base) =>
|
|
|
194
226
|
absolutize(originX, originY) {
|
|
195
227
|
this._placed = true;
|
|
196
228
|
if (!this.yoga) return;
|
|
197
|
-
const
|
|
198
|
-
const
|
|
199
|
-
const
|
|
200
|
-
const
|
|
229
|
+
const box = laidBox(this);
|
|
230
|
+
const x = originX + box.left;
|
|
231
|
+
const y = originY + box.top;
|
|
232
|
+
const width = box.width;
|
|
233
|
+
const height = box.height;
|
|
201
234
|
// a box that moved and changed nothing else claims where it was and
|
|
202
235
|
// where it went, once — not once per node inside it (`Node`'s)
|
|
203
236
|
const wasX = this.abs.x;
|
|
@@ -340,8 +340,7 @@ export function columnHeightSpan(node) {
|
|
|
340
340
|
if (!inFlow(child)) continue;
|
|
341
341
|
const extent = child._floorH;
|
|
342
342
|
if (extent === undefined) return undefined;
|
|
343
|
-
const top =
|
|
344
|
-
const bottom = marginDown(child.yoga, Yoga.EDGE_BOTTOM);
|
|
343
|
+
const { top, bottom } = marginsDown(child);
|
|
345
344
|
if (!(top >= 0 && bottom >= 0)) return undefined;
|
|
346
345
|
span += count === 0 ? extent : trailing + top + extent;
|
|
347
346
|
trailing = bottom;
|
|
@@ -360,6 +359,27 @@ export function columnHeightSpan(node) {
|
|
|
360
359
|
);
|
|
361
360
|
}
|
|
362
361
|
|
|
362
|
+
/**
|
|
363
|
+
* `marginDown` for both edges of a box, kept on the box with the style object
|
|
364
|
+
* it was read under. A margin reaches yoga only from the style
|
|
365
|
+
* (`applyLayoutStyle`), and a style that changes is a new object, so while
|
|
366
|
+
* the object is the same, so are the margins. Asking yoga is up to six calls
|
|
367
|
+
* that each build an object, for every child of every column a spine sums,
|
|
368
|
+
* on every pass: 2 ms of an edit to a document of 1,700 blocks, for margins
|
|
369
|
+
* that had not changed.
|
|
370
|
+
*/
|
|
371
|
+
function marginsDown(child) {
|
|
372
|
+
const kept = child._marginsDown;
|
|
373
|
+
if (kept !== null && kept.style === child.style) return kept;
|
|
374
|
+
const read = {
|
|
375
|
+
style: child.style,
|
|
376
|
+
top: marginDown(child.yoga, Yoga.EDGE_TOP),
|
|
377
|
+
bottom: marginDown(child.yoga, Yoga.EDGE_BOTTOM),
|
|
378
|
+
};
|
|
379
|
+
child._marginsDown = read;
|
|
380
|
+
return read;
|
|
381
|
+
}
|
|
382
|
+
|
|
363
383
|
/**
|
|
364
384
|
* A box's margin on one edge down the page, from its style as yoga resolves
|
|
365
385
|
* it — the edge, else the vertical pair, else all four — rather than from a
|
|
@@ -161,9 +161,11 @@ export class WindowFlush {
|
|
|
161
161
|
if (this._loopNodes.size && !this._loopWatch) this._watchLoops();
|
|
162
162
|
this._advanceAnimations(now());
|
|
163
163
|
if (this.needsLayout) {
|
|
164
|
-
// before
|
|
165
|
-
// which subtrees changed, and the floors are measured from that record
|
|
166
|
-
|
|
164
|
+
// before anything lays the tree out: a pass clears yoga's record of
|
|
165
|
+
// which subtrees changed, and the floors are measured from that record.
|
|
166
|
+
// Owed to that pass rather than taken here (`_collectOwedFloorStale`),
|
|
167
|
+
// so a frame whose first pass is the floors' own collects once.
|
|
168
|
+
this._floorsCollectOwed = true;
|
|
167
169
|
this._refit();
|
|
168
170
|
}
|
|
169
171
|
const width = this.window.width ?? this._requestedSize?.width ?? 0;
|
|
@@ -215,6 +217,7 @@ export class WindowFlush {
|
|
|
215
217
|
// a subtree no pass reached and nothing scrolled in is where the last
|
|
216
218
|
// walk left it (`_followParent`)
|
|
217
219
|
layoutDiff.skipUnreached = !this._walkWhole;
|
|
220
|
+
layoutDiff.boxes = new Map();
|
|
218
221
|
try {
|
|
219
222
|
// `_absolutizeChildren`, not the loop it wraps: a `<window
|
|
220
223
|
// style={{overflow: 'scroll'}}>` is a scroll container like any box,
|
|
@@ -223,6 +226,7 @@ export class WindowFlush {
|
|
|
223
226
|
} finally {
|
|
224
227
|
layoutDiff.sink = null;
|
|
225
228
|
layoutDiff.skipUnreached = false;
|
|
229
|
+
layoutDiff.boxes = null;
|
|
226
230
|
}
|
|
227
231
|
this._walkWhole = false;
|
|
228
232
|
// …and placed nodes against the arrangement that walk produced: where
|
package/src/nodes/window/size.js
CHANGED
|
@@ -148,11 +148,12 @@ export class WindowSize {
|
|
|
148
148
|
* marks the first one left.
|
|
149
149
|
*/
|
|
150
150
|
_collectFloorStale() {
|
|
151
|
+
this._floorsCollectOwed = false;
|
|
151
152
|
this._sweepLayoutHosts();
|
|
152
153
|
const found = { width: false, height: false };
|
|
153
154
|
this._floorsStale.clear();
|
|
154
155
|
// Decided afresh on every call rather than once a frame: something
|
|
155
|
-
// between the two calls a frame
|
|
156
|
+
// between the two calls a frame can make — a size query resolving — can
|
|
156
157
|
// turn a scoped change into one that is not, and the wider collection
|
|
157
158
|
// is the one that is always right.
|
|
158
159
|
const scope = this._floorsScope();
|
|
@@ -204,6 +205,21 @@ export class WindowSize {
|
|
|
204
205
|
return found;
|
|
205
206
|
}
|
|
206
207
|
|
|
208
|
+
/**
|
|
209
|
+
* The collection a flush owes (`_floorsCollectOwed`), taken just before
|
|
210
|
+
* the first pass that would clear what it reads: a natural-size
|
|
211
|
+
* measurement (`_refit`), or a layout that measures no floors. The floors'
|
|
212
|
+
* own measurement collects anyway (`_applyContentFloors`), and nothing in
|
|
213
|
+
* a flush before it lays anything out — a size query that resolves only
|
|
214
|
+
* restyles, which adds to yoga's record — so that frame collects once.
|
|
215
|
+
* Collecting on the way in as well read every block of a long column
|
|
216
|
+
* twice: 1,704 of them, a millisecond a frame, on an edit to a 600 KB
|
|
217
|
+
* document.
|
|
218
|
+
*/
|
|
219
|
+
_collectOwedFloorStale() {
|
|
220
|
+
if (this._floorsCollectOwed) this._collectFloorStale();
|
|
221
|
+
}
|
|
222
|
+
|
|
207
223
|
/**
|
|
208
224
|
* The boxes the changes since the last measurement are confined to, or
|
|
209
225
|
* null when a measurement has to look at the whole tree.
|
|
@@ -485,6 +501,7 @@ export class WindowSize {
|
|
|
485
501
|
|
|
486
502
|
/** The real layout pass: the tree at the window's size, on the pixel grid. */
|
|
487
503
|
_layoutRoot(width, height) {
|
|
504
|
+
this._collectOwedFloorStale();
|
|
488
505
|
this._sweepLayoutHosts();
|
|
489
506
|
this._layoutPasses += 1;
|
|
490
507
|
this.yoga.setWidth(width);
|
|
@@ -915,6 +932,8 @@ export class WindowSize {
|
|
|
915
932
|
isContentBound(props[key]),
|
|
916
933
|
);
|
|
917
934
|
if (!tracking && !bounded) return;
|
|
935
|
+
// the natural size is a pass over the tree
|
|
936
|
+
this._collectOwedFloorStale();
|
|
918
937
|
const asked = this._requestedSize;
|
|
919
938
|
const measured = this._measure();
|
|
920
939
|
this._sendSizeHints(props, measured.hints);
|
|
@@ -166,11 +166,11 @@ export function spineScope(root, sources, listOnly) {
|
|
|
166
166
|
// Every other child of a summed column answers from the extent it carries.
|
|
167
167
|
for (const column of spine) {
|
|
168
168
|
for (const child of column.children) {
|
|
169
|
-
if (!
|
|
170
|
-
if (child
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
169
|
+
if (!child.yoga || child.isWindow) continue;
|
|
170
|
+
if (roots.has(child) || spine.has(child)) continue;
|
|
171
|
+
const flags = styleFlags(child.style);
|
|
172
|
+
if (flags & OUT_OF_FLOW) continue;
|
|
173
|
+
if (child._floorH === undefined || flags & RELATIVE_MARGIN) return null;
|
|
174
174
|
}
|
|
175
175
|
}
|
|
176
176
|
const depth = (node) => {
|
|
@@ -185,6 +185,33 @@ export function spineScope(root, sources, listOnly) {
|
|
|
185
185
|
};
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
+
const OUT_OF_FLOW = 1;
|
|
189
|
+
const RELATIVE_MARGIN = 2;
|
|
190
|
+
const STYLE_FLAGS = new WeakMap();
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* What the summing asks of a block's style — out of its column's flow
|
|
194
|
+
* (`inFlow`'s half that is style), or a margin that is not a length — kept
|
|
195
|
+
* per style object. A resolved style is replaced when it changes, never
|
|
196
|
+
* edited (`_retarget`), and a column of 1,704 blocks is a handful of
|
|
197
|
+
* styles asked a dozen names each, most of them unset: 20,000 lookups of
|
|
198
|
+
* properties no block has, on every edit to a long document.
|
|
199
|
+
*/
|
|
200
|
+
function styleFlags(style) {
|
|
201
|
+
let flags = STYLE_FLAGS.get(style);
|
|
202
|
+
if (flags === undefined) {
|
|
203
|
+
flags =
|
|
204
|
+
(style.position === 'absolute' || style.display === 'none'
|
|
205
|
+
? OUT_OF_FLOW
|
|
206
|
+
: 0) |
|
|
207
|
+
(MARGINS.some((key) => typeof style[key] === 'string')
|
|
208
|
+
? RELATIVE_MARGIN
|
|
209
|
+
: 0);
|
|
210
|
+
STYLE_FLAGS.set(style, flags);
|
|
211
|
+
}
|
|
212
|
+
return flags;
|
|
213
|
+
}
|
|
214
|
+
|
|
188
215
|
/** The children of a node that gained or lost some: the ones yoga has a
|
|
189
216
|
* change on record for, and the ones never measured. */
|
|
190
217
|
function staleChildren(node) {
|
|
@@ -287,6 +287,9 @@ export class WindowNode extends Scrollable(Node) {
|
|
|
287
287
|
this._readEveryWidth = false;
|
|
288
288
|
this._floorsScopeNow = null;
|
|
289
289
|
this._floorsSpineNow = null;
|
|
290
|
+
// whether a flush's collection of what changed is still owed to the
|
|
291
|
+
// first pass that lays the tree out (`_collectOwedFloorStale`)
|
|
292
|
+
this._floorsCollectOwed = false;
|
|
290
293
|
// whether the next layout walk must visit every box (`invalidate`)
|
|
291
294
|
this._walkWhole = true;
|
|
292
295
|
this._scopedFloorPasses = 0;
|
package/src/styles.js
CHANGED
|
@@ -199,8 +199,8 @@ const LAYOUT_APPLIERS = {
|
|
|
199
199
|
paddingStart: (n, v) => n.setPadding(Yoga.EDGE_START, v),
|
|
200
200
|
paddingEnd: (n, v) => n.setPadding(Yoga.EDGE_END, v),
|
|
201
201
|
gap: (n, v) => n.setGap(Yoga.GUTTER_ALL, v ?? 0),
|
|
202
|
-
rowGap: (n, v) => n.setGap(Yoga.GUTTER_ROW, v
|
|
203
|
-
columnGap: (n, v) => n.setGap(Yoga.GUTTER_COLUMN, v
|
|
202
|
+
rowGap: (n, v) => n.setGap(Yoga.GUTTER_ROW, v),
|
|
203
|
+
columnGap: (n, v) => n.setGap(Yoga.GUTTER_COLUMN, v),
|
|
204
204
|
aspectRatio: (n, v) => n.setAspectRatio(v),
|
|
205
205
|
display: (n, v) =>
|
|
206
206
|
n.setDisplay(pick(DISPLAY, v, 'display') ?? Yoga.DISPLAY_FLEX),
|
|
@@ -1995,32 +1995,59 @@ export function applyLayoutDefaults(yogaNode) {
|
|
|
1995
1995
|
yogaNode.setFlexShrink(1);
|
|
1996
1996
|
}
|
|
1997
1997
|
|
|
1998
|
+
/** Each layout prop's applier, by name — `LAYOUT_APPLIERS` without its
|
|
1999
|
+
* prototype, so a style key that is not a layout prop finds nothing. */
|
|
2000
|
+
const LAYOUT_APPLIER_OF = new Map(Object.entries(LAYOUT_APPLIERS));
|
|
2001
|
+
|
|
2002
|
+
const NO_STYLE = Object.freeze({});
|
|
2003
|
+
|
|
1998
2004
|
/**
|
|
1999
2005
|
* Apply changed layout props to a yoga node.
|
|
2006
|
+
*
|
|
2007
|
+
* Walks the keys the two styles carry rather than every property yoga
|
|
2008
|
+
* knows: a style names a handful of the fifty-odd, and a property neither
|
|
2009
|
+
* names is `undefined` in both, which is no change. Every applier sets a
|
|
2010
|
+
* field of its own, so the order they run in does not matter. Asking every
|
|
2011
|
+
* name of both styles was most of the cost of a node that mounts — a
|
|
2012
|
+
* virtualized list replaces its rows at input rate.
|
|
2013
|
+
*
|
|
2000
2014
|
* @returns true if any layout-affecting prop changed
|
|
2001
2015
|
*/
|
|
2002
|
-
export function applyLayoutStyle(yogaNode, props, oldProps =
|
|
2016
|
+
export function applyLayoutStyle(yogaNode, props, oldProps = NO_STYLE) {
|
|
2003
2017
|
let changed = false;
|
|
2004
2018
|
// A placed node's insets — sticky's thresholds, or whatever a registered
|
|
2005
2019
|
// position reads them as — are the placement's, never offsets: yoga gets
|
|
2006
|
-
// `undefined` for them, and a node that becomes placed (or stops) has
|
|
2007
|
-
//
|
|
2008
|
-
// change: the pass it asks for is what places the node.
|
|
2020
|
+
// `undefined` for them, and a node that becomes placed (or stops) has
|
|
2021
|
+
// every inset it names re-sent whether or not it changed.
|
|
2009
2022
|
const placed = isPlacedPosition(props.position);
|
|
2010
2023
|
const flipped = placed !== isPlacedPosition(oldProps.position);
|
|
2011
|
-
for (const key
|
|
2024
|
+
for (const key in props) {
|
|
2025
|
+
const applier = LAYOUT_APPLIER_OF.get(key);
|
|
2026
|
+
if (applier === undefined) continue;
|
|
2027
|
+
const inset = INSETS.has(key);
|
|
2012
2028
|
const moved =
|
|
2013
2029
|
key === 'position'
|
|
2014
2030
|
? !samePosition(props.position, oldProps.position)
|
|
2015
2031
|
: props[key] !== oldProps[key];
|
|
2016
|
-
if (moved
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2032
|
+
if (!moved && !(flipped && inset)) continue;
|
|
2033
|
+
applier(yogaNode, placed && inset ? undefined : props[key]);
|
|
2034
|
+
changed = true;
|
|
2035
|
+
}
|
|
2036
|
+
// …and the ones the new style dropped, which go back to yoga's default
|
|
2037
|
+
for (const key in oldProps) {
|
|
2038
|
+
if (key in props) continue;
|
|
2039
|
+
const applier = LAYOUT_APPLIER_OF.get(key);
|
|
2040
|
+
if (applier === undefined) continue;
|
|
2041
|
+
const moved =
|
|
2042
|
+
key === 'position'
|
|
2043
|
+
? !samePosition(undefined, oldProps.position)
|
|
2044
|
+
: oldProps[key] !== undefined;
|
|
2045
|
+
if (!moved && !(flipped && INSETS.has(key))) continue;
|
|
2046
|
+
applier(yogaNode, undefined);
|
|
2047
|
+
changed = true;
|
|
2023
2048
|
}
|
|
2049
|
+
// An inset neither style names is unset in yoga already, and a flip is a
|
|
2050
|
+
// change to `position` itself, so it is counted above.
|
|
2024
2051
|
return changed;
|
|
2025
2052
|
}
|
|
2026
2053
|
|
package/src/yoga.js
CHANGED
|
@@ -109,6 +109,50 @@ for (const name of ['Node', 'Config']) {
|
|
|
109
109
|
|
|
110
110
|
let loading = null;
|
|
111
111
|
|
|
112
|
+
/**
|
|
113
|
+
* The assembly, compiled from the bytes yoga-layout embeds — or null where
|
|
114
|
+
* they cannot be read off the disk (a bundle, a single executable: see
|
|
115
|
+
* docs/packaging.md), and `loadYoga()` loads it the stock way.
|
|
116
|
+
*
|
|
117
|
+
* yoga-layout ships its WebAssembly only as a base64 data URL inside its
|
|
118
|
+
* loader, and the loader, handed nothing, fetches that URL: Node loads undici
|
|
119
|
+
* for `fetch` and compiles the module through a streamed Response. That was
|
|
120
|
+
* 36–50 ms of every app's startup, where the same bytes handed over as
|
|
121
|
+
* `wasmBinary` compile in 9–13.
|
|
122
|
+
*/
|
|
123
|
+
async function embeddedAssembly() {
|
|
124
|
+
try {
|
|
125
|
+
const [{ createRequire }, { readFileSync }, path, { pathToFileURL }] =
|
|
126
|
+
await Promise.all([
|
|
127
|
+
import('node:module'),
|
|
128
|
+
import('node:fs'),
|
|
129
|
+
import('node:path'),
|
|
130
|
+
import('node:url'),
|
|
131
|
+
]);
|
|
132
|
+
const src = path.dirname(
|
|
133
|
+
createRequire(import.meta.url).resolve('yoga-layout/load'),
|
|
134
|
+
);
|
|
135
|
+
const loader = path.join(src, '..', 'binaries', 'yoga-wasm-base64-esm.js');
|
|
136
|
+
const embedded =
|
|
137
|
+
/"data:application\/octet-stream;base64,([A-Za-z0-9+/=]+)"/.exec(
|
|
138
|
+
readFileSync(loader, 'utf8'),
|
|
139
|
+
);
|
|
140
|
+
if (!embedded) return null;
|
|
141
|
+
const [{ default: factory }, { default: wrapAssembly }] = await Promise.all(
|
|
142
|
+
[
|
|
143
|
+
import(pathToFileURL(loader).href),
|
|
144
|
+
import(pathToFileURL(path.join(src, 'wrapAssembly.js')).href),
|
|
145
|
+
],
|
|
146
|
+
);
|
|
147
|
+
return wrapAssembly(
|
|
148
|
+
await factory({ wasmBinary: Buffer.from(embedded[1], 'base64') }),
|
|
149
|
+
);
|
|
150
|
+
} catch {
|
|
151
|
+
// a layout the next yoga-layout moved, or no disk to read it from
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
112
156
|
/**
|
|
113
157
|
* Load the layout engine's WebAssembly. Idempotent, and resolves with the
|
|
114
158
|
* same `Yoga` object this module exports — `createRoot()` awaits it, so
|
|
@@ -117,11 +161,13 @@ let loading = null;
|
|
|
117
161
|
*/
|
|
118
162
|
export function loadLayout() {
|
|
119
163
|
if (!loading) {
|
|
120
|
-
loading =
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
164
|
+
loading = embeddedAssembly()
|
|
165
|
+
.then((assembly) => assembly ?? loadYoga())
|
|
166
|
+
.then((assembly) => {
|
|
167
|
+
for (const name of ['Node', 'Config']) delete Yoga[name]; // drop the throwing getters
|
|
168
|
+
Object.assign(Yoga, assembly);
|
|
169
|
+
return Yoga;
|
|
170
|
+
});
|
|
125
171
|
}
|
|
126
172
|
return loading;
|
|
127
173
|
}
|