react-x11 2.41.0 → 2.42.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/package.json +3 -3
- package/src/cocoa/panehost.js +156 -54
- package/src/cocoa/sprites.js +272 -17
- package/src/node.d.ts +51 -3
- package/src/nodes/sprites.js +6 -0
- package/src/offscreen.js +8 -7
- package/src/win32/fonts.js +116 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-x11",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.42.0",
|
|
4
4
|
"description": "react renderer with X11 as a target",
|
|
5
5
|
"main": "./src/index.js",
|
|
6
6
|
"files": [
|
|
@@ -104,14 +104,14 @@
|
|
|
104
104
|
"ical.js": "^2.2.1",
|
|
105
105
|
"image-in-browser": "^3.6.0",
|
|
106
106
|
"linebreak": "^1.1.0",
|
|
107
|
-
"ntk": "^8.
|
|
107
|
+
"ntk": "^8.23.0",
|
|
108
108
|
"react-reconciler": "^0.34.0",
|
|
109
109
|
"yoga-layout": "^3.2.1"
|
|
110
110
|
},
|
|
111
111
|
"optionalDependencies": {
|
|
112
112
|
"@windowkit/appkit": "^0.25.0",
|
|
113
113
|
"@windowkit/wayland": "^3.1.1",
|
|
114
|
-
"@windowkit/win32": "^0.0.
|
|
114
|
+
"@windowkit/win32": "^0.0.9",
|
|
115
115
|
"dbus-native": "^0.16.0",
|
|
116
116
|
"x11-dri": "^0.9.0"
|
|
117
117
|
},
|
package/src/cocoa/panehost.js
CHANGED
|
@@ -31,6 +31,19 @@ const ASKED_KEPT = 16;
|
|
|
31
31
|
* drag itself. */
|
|
32
32
|
const GESTURE_GAP_MS = 500;
|
|
33
33
|
|
|
34
|
+
/** How far inside the budget a frame of a pane that fell behind has to
|
|
35
|
+
* come for its frames to be shown at their size again (`_answered`): a
|
|
36
|
+
* pane whose frames come near the budget keeps one look rather than taking
|
|
37
|
+
* the other at every frame. */
|
|
38
|
+
const KEPT_UP = 0.75;
|
|
39
|
+
|
|
40
|
+
/** How much of a frame's last pixel is carried over the layer past it
|
|
41
|
+
* (`_edgeProps`): its middle, a hundredth of a pixel wide. A centre the
|
|
42
|
+
* whole pixel wide stretches what the filter makes of it and the pixel
|
|
43
|
+
* before it, a blend of the two ramped across the gap, and Core Animation
|
|
44
|
+
* takes a centre of no width for none. */
|
|
45
|
+
const EDGE_SLIVER = 0.01;
|
|
46
|
+
|
|
34
47
|
/** One shared surface on glass: a sublayer of a window's root layer, its
|
|
35
48
|
* contents the IOSurface the pane last presented into it. */
|
|
36
49
|
class PaneLayer {
|
|
@@ -45,59 +58,126 @@ class PaneLayer {
|
|
|
45
58
|
});
|
|
46
59
|
this.destroyed = false;
|
|
47
60
|
this._rect = null;
|
|
48
|
-
// How the frame the layer holds
|
|
49
|
-
// layer was just given is on its way (`
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
|
|
61
|
+
// How the frame the layer holds is shown while one of the size the
|
|
62
|
+
// layer was just given is on its way (`_anchoredNow`, `_edgeProps`):
|
|
63
|
+
// the sizes asked of the pane with when each was asked, whether the
|
|
64
|
+
// pane has fallen behind them, the size of the frame it holds, and the
|
|
65
|
+
// contents rects last set — Core Animation's own, the whole, until any
|
|
66
|
+
// are.
|
|
53
67
|
this._contentsScale = null;
|
|
54
68
|
this._asked = [];
|
|
55
69
|
this._askedAt = null;
|
|
56
70
|
this._behind = false;
|
|
71
|
+
this._frame = null;
|
|
72
|
+
this._edgeKey = `${null}|${null}`;
|
|
73
|
+
// when a frame last answered a size, and how many frames in a row came
|
|
74
|
+
// later than the budget (`_answered`)
|
|
75
|
+
this._answeredAt = -Infinity;
|
|
76
|
+
this._late = 0;
|
|
57
77
|
}
|
|
58
78
|
|
|
59
79
|
/**
|
|
60
|
-
*
|
|
61
|
-
* the layer's new size lands. The host gives
|
|
62
|
-
* moment the window lays out, and the pane's
|
|
63
|
-
* pane frame later: Core Animation
|
|
64
|
-
* frame it has to the new bounds meanwhile, and a page's left column
|
|
65
|
-
* drawn scaled a little at every step of a window dragged wider, and
|
|
66
|
-
* at its size again. A pane that keeps up has its frame
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
80
|
+
* Whether a pane's last frame is shown at its size in its layer until a
|
|
81
|
+
* frame of the layer's new size lands, or stretched to it. The host gives
|
|
82
|
+
* the layer its new size the moment the window lays out, and the pane's
|
|
83
|
+
* frame of that size comes a pane frame later: Core Animation stretches
|
|
84
|
+
* the frame it has to the new bounds meanwhile, and a page's left column
|
|
85
|
+
* was drawn scaled a little at every step of a window dragged wider, and
|
|
86
|
+
* then at its size again. A pane that keeps up has its frame shown at its
|
|
87
|
+
* size instead, its edge carried over what it does not cover yet
|
|
88
|
+
* (`_edgeProps`); one that falls behind the budget is stretched, which is
|
|
89
|
+
* the better picture for the long wait — a pane repainting at three
|
|
90
|
+
* frames a second shows a squeezed page, not a page and a smeared strip.
|
|
91
|
+
* The budget is the window's live-resize handshake's
|
|
72
92
|
* (`createRoot({ cocoa: { resizeWait } })`), how long a frame of a new
|
|
73
93
|
* size may take before the old one is shown in its place: 0 stretches
|
|
74
|
-
* always, as before.
|
|
75
|
-
*
|
|
94
|
+
* always, as before.
|
|
95
|
+
*
|
|
96
|
+
* Behind is a pane whose frames come later than the budget, not one with
|
|
97
|
+
* a size waiting that long. `<Frame>` sends a pane the newest size once
|
|
98
|
+
* it has painted the last, so the sizes a drag passes through while a
|
|
99
|
+
* frame is being painted are never sent, and never answered: a size
|
|
100
|
+
* waits until a frame of a later one lands, two pane frames at most. A
|
|
101
|
+
* page that painted in 27 ms answered every size it was sent in 31, and
|
|
102
|
+
* still had one 52 ms old at every step of the drag — so its layer was
|
|
103
|
+
* stretched at every step and shown at its size again at every frame, the
|
|
104
|
+
* page scaling and coming back thirty times a second. Here a size waiting
|
|
105
|
+
* longer than the budget counts only when no frame has answered any size
|
|
106
|
+
* in that time, which is a pane that stopped, and a frame that lands
|
|
107
|
+
* decides the rest (`_answered`).
|
|
76
108
|
*/
|
|
77
|
-
|
|
109
|
+
_anchoredNow() {
|
|
78
110
|
const budget = this.app._resizeWait ?? RESIZE_WAIT_MS;
|
|
79
|
-
if (!(budget > 0)) return
|
|
80
|
-
// a size asked longer ago than the budget, and no frame of
|
|
111
|
+
if (!(budget > 0)) return false;
|
|
112
|
+
// a size asked longer ago than the budget, and no frame of any since
|
|
81
113
|
const oldest = this._asked[0];
|
|
82
|
-
if (oldest && now() - oldest.at > budget)
|
|
83
|
-
|
|
114
|
+
if (oldest && now() - Math.max(oldest.at, this._answeredAt) > budget) {
|
|
115
|
+
this._behind = true;
|
|
116
|
+
}
|
|
117
|
+
return !this._behind;
|
|
84
118
|
}
|
|
85
119
|
|
|
86
|
-
/**
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
120
|
+
/**
|
|
121
|
+
* The props that show the frame the layer holds in bounds that are not
|
|
122
|
+
* its size, where they changed. At its size and from the top left, as a
|
|
123
|
+
* page sits in a window: an axis the layer shrank on is cropped
|
|
124
|
+
* (`contentsRect`), and on one it grew on the frame's last pixel is
|
|
125
|
+
* carried over the rest (`contentsCenter`, the part of the contents that
|
|
126
|
+
* stretches while everything else keeps its size) — the last column
|
|
127
|
+
* across a window dragged wider, the last row down one dragged taller,
|
|
128
|
+
* and the corner pixel into the corner. Both rects are the whole when
|
|
129
|
+
* the frame is stretched, and when it is the layer's size.
|
|
130
|
+
*
|
|
131
|
+
* Anchored by gravity alone, the strip the frame did not cover showed
|
|
132
|
+
* whatever was under the pane, which is the `<Frame>`'s background: in
|
|
133
|
+
* the browser example's dark mode a dark band 14 to 38 points wide down
|
|
134
|
+
* the page's right edge, at every step of a fast drag. A page's edge is
|
|
135
|
+
* mostly its background, so its last pixels continue it: Zen Garden's
|
|
136
|
+
* about page has a vertical gradient there, which its last column carries
|
|
137
|
+
* on exactly, where one colour read off the edge put a flat band across
|
|
138
|
+
* it, three times as far from the page. Where the edge cuts through the
|
|
139
|
+
* page's content — the bottom row, in a window dragged taller, usually
|
|
140
|
+
* crosses a line of text — the glyphs run down the strip in thin lines
|
|
141
|
+
* for the pane frame it lasts, where the settled page has its next line
|
|
142
|
+
* of text: as far from it, measured, as the flat colour. A bridge that
|
|
143
|
+
* predates the rects ignores them, and stretches.
|
|
144
|
+
*/
|
|
145
|
+
_edgeProps() {
|
|
146
|
+
const frame = this._frame;
|
|
147
|
+
const rect = this._rect;
|
|
148
|
+
let crop = null;
|
|
149
|
+
let edge = null;
|
|
150
|
+
if (frame && rect && this._anchoredNow()) {
|
|
151
|
+
const w = Math.min(frame.width, rect.width);
|
|
152
|
+
const h = Math.min(frame.height, rect.height);
|
|
153
|
+
if (w < frame.width || h < frame.height) {
|
|
154
|
+
crop = [0, 0, w / frame.width, h / frame.height];
|
|
155
|
+
}
|
|
156
|
+
// in the shown part's own unit square; an axis that did not grow is
|
|
157
|
+
// all centre, stretched by one
|
|
158
|
+
const wider = rect.width > w;
|
|
159
|
+
const taller = rect.height > h;
|
|
160
|
+
if (wider || taller) {
|
|
161
|
+
edge = [
|
|
162
|
+
wider ? (w - 0.5) / w : 0,
|
|
163
|
+
taller ? (h - 0.5) / h : 0,
|
|
164
|
+
wider ? EDGE_SLIVER / w : 1,
|
|
165
|
+
taller ? EDGE_SLIVER / h : 1,
|
|
166
|
+
];
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const key = `${crop}|${edge}`;
|
|
170
|
+
if (key === this._edgeKey) return null;
|
|
171
|
+
this._edgeKey = key;
|
|
172
|
+
return { contentsRect: crop, contentsCenter: edge };
|
|
93
173
|
}
|
|
94
174
|
|
|
95
175
|
/**
|
|
96
176
|
* The props that say how many of the pane's pixels make a point of the
|
|
97
177
|
* layer, where that changed: the window's scale, which the pane draws
|
|
98
|
-
* at. A frame stretched to the bounds never asked, and one
|
|
99
|
-
*
|
|
100
|
-
*
|
|
178
|
+
* at. A frame stretched to the bounds never asked, and one shown at its
|
|
179
|
+
* size is shown at it — pixels over `contentsScale` points, so at the
|
|
180
|
+
* default of 1 a page at 2x came out twice its size.
|
|
101
181
|
*/
|
|
102
182
|
_scaleProps() {
|
|
103
183
|
const scale = this.wnd.scale ?? 1;
|
|
@@ -106,20 +186,35 @@ class PaneLayer {
|
|
|
106
186
|
return { contentsScale: scale };
|
|
107
187
|
}
|
|
108
188
|
|
|
109
|
-
/**
|
|
110
|
-
*
|
|
189
|
+
/**
|
|
190
|
+
* A frame of `width` by `height` device px landed: what it answers of
|
|
191
|
+
* the sizes asked, and how long it took. Two frames in a row later than
|
|
192
|
+
* the budget put the pane behind, and one well inside it — `KEPT_UP` of
|
|
193
|
+
* it — brings it back: a frame the collector held up stretches nothing,
|
|
194
|
+
* and a pane whose frames come near the budget is not stretched and
|
|
195
|
+
* shown at its size by turns.
|
|
196
|
+
*/
|
|
111
197
|
_answered(width, height) {
|
|
112
198
|
const asked = this._asked;
|
|
113
199
|
// the pane rounds the logical size it was sent back to device pixels
|
|
114
200
|
const slack = Math.max(2, this.wnd.scale ?? 1);
|
|
115
|
-
|
|
201
|
+
// the last time it was asked: a drag back and forth asks a size twice
|
|
202
|
+
const at = asked.findLastIndex(
|
|
116
203
|
(a) =>
|
|
117
204
|
Math.abs(a.width - width) <= slack &&
|
|
118
205
|
Math.abs(a.height - height) <= slack,
|
|
119
206
|
);
|
|
120
207
|
if (at < 0) return;
|
|
121
|
-
this.
|
|
122
|
-
|
|
208
|
+
const budget = this.app._resizeWait ?? RESIZE_WAIT_MS;
|
|
209
|
+
const took = now() - asked[at].at;
|
|
210
|
+
this._answeredAt = now();
|
|
211
|
+
if (took > budget) {
|
|
212
|
+
this._late += 1;
|
|
213
|
+
if (this._late >= 2) this._behind = true;
|
|
214
|
+
} else {
|
|
215
|
+
this._late = 0;
|
|
216
|
+
if (took <= budget * KEPT_UP) this._behind = false;
|
|
217
|
+
}
|
|
123
218
|
asked.splice(0, at + 1);
|
|
124
219
|
}
|
|
125
220
|
|
|
@@ -144,6 +239,7 @@ class PaneLayer {
|
|
|
144
239
|
if (at - (this._askedAt ?? -Infinity) >= GESTURE_GAP_MS) {
|
|
145
240
|
this._asked = [];
|
|
146
241
|
this._behind = false;
|
|
242
|
+
this._late = 0;
|
|
147
243
|
}
|
|
148
244
|
this._askedAt = at;
|
|
149
245
|
if (this._asked.length === ASKED_KEPT) this._asked.shift();
|
|
@@ -161,7 +257,7 @@ class PaneLayer {
|
|
|
161
257
|
zPosition: 1e7,
|
|
162
258
|
hidden: false,
|
|
163
259
|
...this._scaleProps(),
|
|
164
|
-
...this.
|
|
260
|
+
...this._edgeProps(),
|
|
165
261
|
});
|
|
166
262
|
} finally {
|
|
167
263
|
native.txCommit();
|
|
@@ -189,22 +285,28 @@ class PaneLayer {
|
|
|
189
285
|
*/
|
|
190
286
|
present(iosurfaceId, size = null) {
|
|
191
287
|
if (this.destroyed) return false;
|
|
288
|
+
const native = this._native;
|
|
289
|
+
// The frame and the rects that show it in one transaction: a frame of
|
|
290
|
+
// a new size shown by the last one's rects is cropped or carried over
|
|
291
|
+
// by the wrong amount for a refresh.
|
|
292
|
+
native.txBegin({ disableActions: true });
|
|
192
293
|
try {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
);
|
|
294
|
+
try {
|
|
295
|
+
native.setLayerContentsIOSurface(this.layer, iosurfaceId);
|
|
296
|
+
} catch (err) {
|
|
297
|
+
if (!/IOSurfaceLookup/.test(err?.message ?? '')) throw err;
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
// how long the pane took to a frame of a size it was asked for, which
|
|
301
|
+
// is what decides how the next is shown (`_anchoredNow`)
|
|
302
|
+
if (size) {
|
|
303
|
+
this._answered(size.width, size.height);
|
|
304
|
+
this._frame = { width: size.width, height: size.height };
|
|
305
|
+
const edge = this._edgeProps();
|
|
306
|
+
if (edge) native.setLayerProps(this.layer, edge);
|
|
207
307
|
}
|
|
308
|
+
} finally {
|
|
309
|
+
native.txCommit();
|
|
208
310
|
}
|
|
209
311
|
return true;
|
|
210
312
|
}
|
package/src/cocoa/sprites.js
CHANGED
|
@@ -39,6 +39,17 @@
|
|
|
39
39
|
// clip with round corners that a square ancestor cuts again is a box of its
|
|
40
40
|
// own inside a box of the square one's.
|
|
41
41
|
//
|
|
42
|
+
// Or a part is its **shadows** alone (`shadows`): CSS's outer box-shadow,
|
|
43
|
+
// which its element would blur a pixel at a time and the render server
|
|
44
|
+
// draws on the GPU. Each is a layer that casts it — the shape, its corners
|
|
45
|
+
// rounded, moved clear of what shows and its shadow offset back by as much,
|
|
46
|
+
// so that no spread shows the shape itself — in a box that shows everything
|
|
47
|
+
// but the part's own rect, as an outer shadow is never drawn under the box
|
|
48
|
+
// that casts it. Core Animation's `shadowRadius` is the Gaussian's
|
|
49
|
+
// deviation, which CSS makes half the blur radius: a glow of 200 CSS pixels
|
|
50
|
+
// is a radius of 100 points, and measured against `<Html>`'s own shadow a
|
|
51
|
+
// level apart. Their blur, colour and offset animate there too.
|
|
52
|
+
//
|
|
42
53
|
// A part may be inside another of its element's (`parent`): a spinner in a
|
|
43
54
|
// card that fades. Its layer is lifted inside the parent's, so it fades,
|
|
44
55
|
// turns and is cut with it, and the parent's raster leaves it out — the
|
|
@@ -134,18 +145,53 @@ const isCurve = (t) =>
|
|
|
134
145
|
t[2] >= 0 &&
|
|
135
146
|
t[2] <= 1);
|
|
136
147
|
|
|
148
|
+
const isColor = (c) => Array.isArray(c) && c.length === 4 && c.every(finite);
|
|
149
|
+
|
|
150
|
+
const isPair = (p) => Array.isArray(p) && p.length === 2 && p.every(finite);
|
|
151
|
+
|
|
152
|
+
const isBlur = (v) => finite(v) && v >= 0;
|
|
153
|
+
|
|
154
|
+
// what each animated property's keyframes are
|
|
155
|
+
const VALUE_OF = {
|
|
156
|
+
opacity: finite,
|
|
157
|
+
transform: isMatrix,
|
|
158
|
+
shadowBlur: isBlur,
|
|
159
|
+
shadowColor: isColor,
|
|
160
|
+
shadowOffset: isPair,
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const SHADOW_PROPERTIES = new Set([
|
|
164
|
+
'shadowBlur',
|
|
165
|
+
'shadowColor',
|
|
166
|
+
'shadowOffset',
|
|
167
|
+
]);
|
|
168
|
+
|
|
169
|
+
const isShadow = (sh) =>
|
|
170
|
+
sh != null &&
|
|
171
|
+
isRect(sh.rect) &&
|
|
172
|
+
isColor(sh.color) &&
|
|
173
|
+
(sh.radius === undefined || isBlur(sh.radius)) &&
|
|
174
|
+
(sh.x === undefined || finite(sh.x)) &&
|
|
175
|
+
(sh.y === undefined || finite(sh.y)) &&
|
|
176
|
+
(sh.blur === undefined || isBlur(sh.blur));
|
|
177
|
+
|
|
178
|
+
/** Is `sprite` its shadows alone? */
|
|
179
|
+
const castsOnly = (sprite) => Array.isArray(sprite.shadows);
|
|
180
|
+
|
|
137
181
|
/** Is `a` an animation the bridge will take as it is — so that a bad one
|
|
138
182
|
* is refused here rather than thrown from inside a frame? */
|
|
139
183
|
function isAnimation(a) {
|
|
140
184
|
if (a == null || typeof a.id !== 'string') return false;
|
|
141
|
-
|
|
185
|
+
const value = Object.hasOwn(VALUE_OF, a.property)
|
|
186
|
+
? VALUE_OF[a.property]
|
|
187
|
+
: null;
|
|
188
|
+
if (!value) return false;
|
|
142
189
|
if (!finite(a.duration) || !(a.duration > 0)) return false;
|
|
143
190
|
const values = a.values;
|
|
144
191
|
if (!Array.isArray(values) || values.length < 2) return false;
|
|
145
192
|
// an element hands the same keyframes over from frame to frame: they
|
|
146
193
|
// are read once
|
|
147
194
|
if (CHECKED.get(values) !== a.property) {
|
|
148
|
-
const value = a.property === 'opacity' ? finite : isMatrix;
|
|
149
195
|
if (!values.every(value)) return false;
|
|
150
196
|
CHECKED.set(values, a.property);
|
|
151
197
|
}
|
|
@@ -180,13 +226,22 @@ function isAnimation(a) {
|
|
|
180
226
|
function isSprite(sprite) {
|
|
181
227
|
if (sprite == null || typeof sprite.key !== 'string') return false;
|
|
182
228
|
if (!isRect(sprite.rect)) return false;
|
|
183
|
-
|
|
229
|
+
const shadows = sprite.shadows;
|
|
230
|
+
if (shadows !== undefined) {
|
|
231
|
+
// its shadows alone: nothing painted, nothing shown
|
|
232
|
+
if (sprite.paint !== undefined || sprite.contents != null) return false;
|
|
233
|
+
if (!Array.isArray(shadows) || shadows.length === 0) return false;
|
|
234
|
+
if (!shadows.every(isShadow)) return false;
|
|
235
|
+
} else if (
|
|
184
236
|
sprite.contents != null
|
|
185
237
|
? sprite.paint !== undefined
|
|
186
238
|
: typeof sprite.paint !== 'function'
|
|
187
239
|
) {
|
|
188
240
|
return false;
|
|
189
241
|
}
|
|
242
|
+
if (sprite.rectRadius !== undefined && !isBlur(sprite.rectRadius)) {
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
190
245
|
if (sprite.reach !== undefined && !isRect(sprite.reach)) return false;
|
|
191
246
|
if (sprite.clip !== undefined && !isRect(sprite.clip)) return false;
|
|
192
247
|
if (sprite.parent !== undefined && typeof sprite.parent !== 'string') {
|
|
@@ -217,6 +272,14 @@ function isSprite(sprite) {
|
|
|
217
272
|
if (!Array.isArray(animations) || !animations.every(isAnimation)) {
|
|
218
273
|
return false;
|
|
219
274
|
}
|
|
275
|
+
// a shadow's animation is of one of the part's shadows
|
|
276
|
+
for (const a of animations) {
|
|
277
|
+
if (!SHADOW_PROPERTIES.has(a.property)) continue;
|
|
278
|
+
const i = a.shadow ?? 0;
|
|
279
|
+
if (!Number.isInteger(i) || i < 0 || !(i < (shadows?.length ?? 0))) {
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
220
283
|
}
|
|
221
284
|
return true;
|
|
222
285
|
}
|
|
@@ -280,6 +343,63 @@ function unionRect(a, b) {
|
|
|
280
343
|
};
|
|
281
344
|
}
|
|
282
345
|
|
|
346
|
+
/**
|
|
347
|
+
* How far past its rect a shadow blurred by `blur` shows: three of its
|
|
348
|
+
* deviations, half the blur each (CSS Backgrounds 3, 7.1.1), past which the
|
|
349
|
+
* tail is a fraction of a level; and a pixel more for the antialiased edge
|
|
350
|
+
* of the shape.
|
|
351
|
+
*/
|
|
352
|
+
const shadowReach = (blur) => Math.ceil(1.5 * blur) + 1;
|
|
353
|
+
|
|
354
|
+
// where a shadow part's shadows can reach, by its shadows and keyframes
|
|
355
|
+
const SHADOW_REACH = new WeakMap();
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* What a part draws, untransformed: its `reach`, or its `rect` — and for a
|
|
359
|
+
* part that is its shadows, everywhere they can be through every keyframe
|
|
360
|
+
* of their blur and offset, with its own `reach` if it gives one.
|
|
361
|
+
*/
|
|
362
|
+
function reachOf(sprite) {
|
|
363
|
+
if (!castsOnly(sprite)) return sprite.reach ?? sprite.rect;
|
|
364
|
+
const shadows = sprite.shadows;
|
|
365
|
+
const animations = sprite.animations ?? [];
|
|
366
|
+
const kept = SHADOW_REACH.get(shadows);
|
|
367
|
+
if (kept && kept.animations === animations && kept.reach === sprite.reach) {
|
|
368
|
+
return kept.box;
|
|
369
|
+
}
|
|
370
|
+
let box = sprite.reach ?? null;
|
|
371
|
+
for (let i = 0; i < shadows.length; i++) {
|
|
372
|
+
const sh = shadows[i];
|
|
373
|
+
let blur = sh.blur ?? 0;
|
|
374
|
+
let x0 = sh.x ?? 0;
|
|
375
|
+
let x1 = x0;
|
|
376
|
+
let y0 = sh.y ?? 0;
|
|
377
|
+
let y1 = y0;
|
|
378
|
+
for (const a of animations) {
|
|
379
|
+
if ((a.shadow ?? 0) !== i) continue;
|
|
380
|
+
if (a.property === 'shadowBlur') {
|
|
381
|
+
for (const v of a.values) blur = Math.max(blur, v);
|
|
382
|
+
} else if (a.property === 'shadowOffset') {
|
|
383
|
+
for (const [x, y] of a.values) {
|
|
384
|
+
x0 = Math.min(x0, x);
|
|
385
|
+
x1 = Math.max(x1, x);
|
|
386
|
+
y0 = Math.min(y0, y);
|
|
387
|
+
y1 = Math.max(y1, y);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
const pad = shadowReach(blur);
|
|
392
|
+
box = unionRect(box, {
|
|
393
|
+
x: sh.rect.x + x0 - pad,
|
|
394
|
+
y: sh.rect.y + y0 - pad,
|
|
395
|
+
width: sh.rect.width + x1 - x0 + 2 * pad,
|
|
396
|
+
height: sh.rect.height + y1 - y0 + 2 * pad,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
SHADOW_REACH.set(shadows, { animations, reach: sprite.reach, box });
|
|
400
|
+
return box;
|
|
401
|
+
}
|
|
402
|
+
|
|
283
403
|
/**
|
|
284
404
|
* Everywhere `sprite` can draw while its animations run: its reach through
|
|
285
405
|
* its own transform and every transform it is animated through, out to
|
|
@@ -291,7 +411,7 @@ function unionRect(a, b) {
|
|
|
291
411
|
* curve overshoots, offers a `reach` that covers what is between them.
|
|
292
412
|
*/
|
|
293
413
|
function extentOf(sprite) {
|
|
294
|
-
const reach = sprite
|
|
414
|
+
const reach = reachOf(sprite);
|
|
295
415
|
const origin = originOf(sprite);
|
|
296
416
|
const m0 = sprite.transform ?? IDENTITY;
|
|
297
417
|
// About its origin, where it can be is the same for a part that only
|
|
@@ -347,7 +467,7 @@ function extentOf(sprite) {
|
|
|
347
467
|
* that the hole it leaves and the layer meet. */
|
|
348
468
|
function rasterRectOf(sprite) {
|
|
349
469
|
if (sprite.contents != null) return sprite.rect;
|
|
350
|
-
const reach = sprite
|
|
470
|
+
const reach = reachOf(sprite);
|
|
351
471
|
const x = Math.floor(reach.x) - RASTER_PAD;
|
|
352
472
|
const y = Math.floor(reach.y) - RASTER_PAD;
|
|
353
473
|
return {
|
|
@@ -404,6 +524,11 @@ export class SpriteLayers {
|
|
|
404
524
|
this.solid = Boolean(forms?.includes?.('matrix3d'));
|
|
405
525
|
// and a video's frames on a layer need its video surfaces
|
|
406
526
|
this.videos = canLiftVideo(this.native);
|
|
527
|
+
// and a part that is its shadows, a box masked by a shape: every
|
|
528
|
+
// bridge that lifts anything has both, but a fake one may not
|
|
529
|
+
this.shadows =
|
|
530
|
+
typeof this.native.createShapeLayer === 'function' &&
|
|
531
|
+
typeof this.native.setShapeProps === 'function';
|
|
407
532
|
this.hosts = new Map(); // element -> Map(key -> state)
|
|
408
533
|
this._warned = new WeakSet();
|
|
409
534
|
}
|
|
@@ -479,18 +604,27 @@ export class SpriteLayers {
|
|
|
479
604
|
let parent = null;
|
|
480
605
|
if (sprite.parent !== undefined) {
|
|
481
606
|
parent = now.get(sprite.parent) ?? null;
|
|
482
|
-
if (
|
|
607
|
+
if (
|
|
608
|
+
!parent ||
|
|
609
|
+
parent.contents !== null ||
|
|
610
|
+
parent.shadow ||
|
|
611
|
+
sprite.contents != null
|
|
612
|
+
) {
|
|
483
613
|
continue;
|
|
484
614
|
}
|
|
485
615
|
}
|
|
486
616
|
const contents = sprite.contents ?? null;
|
|
487
617
|
// a source this bridge cannot show: the element draws it
|
|
488
618
|
if (contents !== null && !this._canShow(contents)) continue;
|
|
619
|
+
// shadows this bridge cannot mask: the element draws them
|
|
620
|
+
const casts = castsOnly(sprite);
|
|
621
|
+
if (casts && (!this.shadows || parent)) continue;
|
|
489
622
|
const raster = rasterRectOf(sprite);
|
|
490
623
|
// a raster's limit; a source's surface is its own size, whatever
|
|
491
|
-
// the layer is scaled to
|
|
624
|
+
// the layer is scaled to, and shadows have none
|
|
492
625
|
if (
|
|
493
626
|
contents === null &&
|
|
627
|
+
!casts &&
|
|
494
628
|
(raster.width > MAX_SIDE || raster.height > MAX_SIDE)
|
|
495
629
|
) {
|
|
496
630
|
continue;
|
|
@@ -530,7 +664,7 @@ export class SpriteLayers {
|
|
|
530
664
|
// for a source, and past the budget of raster kept for such parts.
|
|
531
665
|
if (!shown) {
|
|
532
666
|
if (contents !== null) continue;
|
|
533
|
-
const px = raster.width * raster.height;
|
|
667
|
+
const px = casts ? 0 : raster.width * raster.height;
|
|
534
668
|
if (hidden + px > HIDDEN_BUDGET) continue;
|
|
535
669
|
hidden += px;
|
|
536
670
|
}
|
|
@@ -544,7 +678,8 @@ export class SpriteLayers {
|
|
|
544
678
|
(Boolean(state.clip) !== Boolean(cut.clip) ||
|
|
545
679
|
Boolean(state.outer) !== Boolean(cut.outer) ||
|
|
546
680
|
state.contents !== contents ||
|
|
547
|
-
state.parent !== parent
|
|
681
|
+
state.parent !== parent ||
|
|
682
|
+
Boolean(state.shadow) !== casts)
|
|
548
683
|
) {
|
|
549
684
|
this._drop(state, root);
|
|
550
685
|
state = null;
|
|
@@ -558,6 +693,7 @@ export class SpriteLayers {
|
|
|
558
693
|
contents,
|
|
559
694
|
parent,
|
|
560
695
|
Boolean(cut.outer),
|
|
696
|
+
casts,
|
|
561
697
|
);
|
|
562
698
|
}
|
|
563
699
|
state.sprite = sprite;
|
|
@@ -661,7 +797,15 @@ export class SpriteLayers {
|
|
|
661
797
|
return isVideoFrames(contents) && this.videos;
|
|
662
798
|
}
|
|
663
799
|
|
|
664
|
-
_lift(
|
|
800
|
+
_lift(
|
|
801
|
+
host,
|
|
802
|
+
key,
|
|
803
|
+
clipped,
|
|
804
|
+
contents = null,
|
|
805
|
+
parent = null,
|
|
806
|
+
outered = false,
|
|
807
|
+
casts = false,
|
|
808
|
+
) {
|
|
665
809
|
const layer = this.native.createLayer();
|
|
666
810
|
// a part cut to a clip is in a box of the clip's size that masks to it,
|
|
667
811
|
// and the box is what stands among the layers above the bitmap — or
|
|
@@ -714,6 +858,8 @@ export class SpriteLayers {
|
|
|
714
858
|
order: null,
|
|
715
859
|
// what shows itself on the layer, for a part with a source
|
|
716
860
|
contents,
|
|
861
|
+
// the box the casters of a part that is its shadows are in
|
|
862
|
+
shadow: casts ? this._shadowBox(layer) : null,
|
|
717
863
|
lift:
|
|
718
864
|
contents === null
|
|
719
865
|
? null
|
|
@@ -756,6 +902,7 @@ export class SpriteLayers {
|
|
|
756
902
|
// source paints nothing: it shows itself.
|
|
757
903
|
if (
|
|
758
904
|
!state.lift &&
|
|
905
|
+
!state.shadow &&
|
|
759
906
|
(state.painted === UNPAINTED ||
|
|
760
907
|
sprite.version !== state.painted ||
|
|
761
908
|
state.rect?.width !== rect.width ||
|
|
@@ -862,6 +1009,8 @@ export class SpriteLayers {
|
|
|
862
1009
|
state.props = next;
|
|
863
1010
|
// a source that fills the layer with a layer of its own
|
|
864
1011
|
state.lift?.layout?.(next.bounds);
|
|
1012
|
+
// shadows, cast in the layer's coordinates
|
|
1013
|
+
if (state.shadow) this._syncShadows(state, sprite, rect);
|
|
865
1014
|
// after the model went out, in the same transaction
|
|
866
1015
|
this._syncAnimations(state, sprite);
|
|
867
1016
|
// the parts inside it, in the element's order, over its raster
|
|
@@ -870,6 +1019,97 @@ export class SpriteLayers {
|
|
|
870
1019
|
}
|
|
871
1020
|
}
|
|
872
1021
|
|
|
1022
|
+
/** The box a shadow part's casters are in, on its layer, and the mask
|
|
1023
|
+
* that shows everything in it but the part's own rect. */
|
|
1024
|
+
_shadowBox(layer) {
|
|
1025
|
+
const box = this.native.createLayer();
|
|
1026
|
+
const mask = this.native.createShapeLayer();
|
|
1027
|
+
this.native.addSublayer(layer, box);
|
|
1028
|
+
this.native.setLayerProps(box, { mask });
|
|
1029
|
+
return { box, mask, casters: [], shape: '', props: [] };
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
/**
|
|
1033
|
+
* A shadow part's casters, as its shadows say, in the layer's points:
|
|
1034
|
+
* each the shadow's shape, rounded, moved left past the layer's edge —
|
|
1035
|
+
* out of the box's mask, which only shows inside the layer — and its
|
|
1036
|
+
* shadow offset back by as much, so the shape itself never shows,
|
|
1037
|
+
* whatever its spread; blurred by a radius half the CSS blur, which is
|
|
1038
|
+
* the deviation of the Gaussian CSS blurs by (calibrated against
|
|
1039
|
+
* `<Html>`'s own: a level apart). The first shadow is on top: added last.
|
|
1040
|
+
*/
|
|
1041
|
+
_syncShadows(state, sprite, rect) {
|
|
1042
|
+
const s = this.scale;
|
|
1043
|
+
const sh = state.shadow;
|
|
1044
|
+
const shadows = sprite.shadows;
|
|
1045
|
+
const w = rect.width / s;
|
|
1046
|
+
const h = rect.height / s;
|
|
1047
|
+
// the box, and its mask: all of the layer but the part's rect
|
|
1048
|
+
const hole = sprite.rect;
|
|
1049
|
+
const hw = hole.width / s;
|
|
1050
|
+
const hh = hole.height / s;
|
|
1051
|
+
const hr = Math.min((sprite.rectRadius ?? 0) / s, hw / 2, hh / 2);
|
|
1052
|
+
const shape = [
|
|
1053
|
+
['rect', 0, 0, w, h],
|
|
1054
|
+
['roundRect', (hole.x - rect.x) / s, (hole.y - rect.y) / s, hw, hh, hr],
|
|
1055
|
+
];
|
|
1056
|
+
const key = JSON.stringify(shape);
|
|
1057
|
+
if (sh.shape !== key) {
|
|
1058
|
+
this.native.setLayerProps(sh.box, { frame: [0, 0, w, h] });
|
|
1059
|
+
this.native.setLayerProps(sh.mask, { frame: [0, 0, w, h] });
|
|
1060
|
+
this.native.setShapeProps(sh.mask, {
|
|
1061
|
+
path: shape,
|
|
1062
|
+
fillRule: 'evenodd',
|
|
1063
|
+
fillColor: [0, 0, 0, 1],
|
|
1064
|
+
});
|
|
1065
|
+
sh.shape = key;
|
|
1066
|
+
}
|
|
1067
|
+
// one caster a shadow, the first added last
|
|
1068
|
+
if (sh.casters.length !== shadows.length) {
|
|
1069
|
+
for (const caster of sh.casters) this.native.removeFromSuperlayer(caster);
|
|
1070
|
+
sh.casters = shadows.map(() => this.native.createLayer());
|
|
1071
|
+
for (let i = shadows.length - 1; i >= 0; i--) {
|
|
1072
|
+
this.native.addSublayer(sh.box, sh.casters[i]);
|
|
1073
|
+
}
|
|
1074
|
+
sh.props = [];
|
|
1075
|
+
}
|
|
1076
|
+
for (let i = 0; i < shadows.length; i++) {
|
|
1077
|
+
const shadow = shadows[i];
|
|
1078
|
+
const r = shadow.rect;
|
|
1079
|
+
const cw = r.width / s;
|
|
1080
|
+
const ch = r.height / s;
|
|
1081
|
+
const aside = this._aside(rect, r);
|
|
1082
|
+
const next = {
|
|
1083
|
+
frame: [(r.x - rect.x) / s - aside, (r.y - rect.y) / s, cw, ch],
|
|
1084
|
+
backgroundColor: [0, 0, 0, 1],
|
|
1085
|
+
cornerRadius: Math.min((shadow.radius ?? 0) / s, cw / 2, ch / 2),
|
|
1086
|
+
shadowColor: shadow.color,
|
|
1087
|
+
shadowOpacity: 1,
|
|
1088
|
+
shadowRadius: (shadow.blur ?? 0) / (2 * s),
|
|
1089
|
+
shadowOffset: [aside + (shadow.x ?? 0) / s, (shadow.y ?? 0) / s],
|
|
1090
|
+
};
|
|
1091
|
+
const was = sh.props[i] ?? {};
|
|
1092
|
+
const out = {};
|
|
1093
|
+
let any = false;
|
|
1094
|
+
for (const k of Object.keys(next)) {
|
|
1095
|
+
const v = next[k];
|
|
1096
|
+
if (Array.isArray(v) ? !sameMatrix(was[k], v) : was[k] !== v) {
|
|
1097
|
+
out[k] = v;
|
|
1098
|
+
any = true;
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
if (any) this.native.setLayerProps(sh.casters[i], out);
|
|
1102
|
+
sh.props[i] = next;
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/** How far, in points, a caster of a shape at `r` in a layer at `rect`
|
|
1107
|
+
* moves left to be clear of the layer: its right edge a point past the
|
|
1108
|
+
* layer's left. */
|
|
1109
|
+
_aside(rect, r) {
|
|
1110
|
+
return (r.x + r.width - rect.x) / this.scale + 1;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
873
1113
|
_paint(state, sprite, rect) {
|
|
874
1114
|
const ctx = state.raster.ensure(
|
|
875
1115
|
this.promotion,
|
|
@@ -909,7 +1149,7 @@ export class SpriteLayers {
|
|
|
909
1149
|
const listed = (id) => wanted.some((a) => a.id === id);
|
|
910
1150
|
for (const [id, run] of state.runs) {
|
|
911
1151
|
if (listed(id)) continue;
|
|
912
|
-
this.native.removeAnimation(
|
|
1152
|
+
this.native.removeAnimation(run.layer, run.caKey);
|
|
913
1153
|
this._ends().delete(run.caId);
|
|
914
1154
|
state.runs.delete(id);
|
|
915
1155
|
}
|
|
@@ -919,11 +1159,26 @@ export class SpriteLayers {
|
|
|
919
1159
|
if (state.runs.has(a.id) || state.done.has(a.id)) continue;
|
|
920
1160
|
const caId = `rxs${++spriteSeq}`;
|
|
921
1161
|
const caKey = `sprite:${caId}`;
|
|
1162
|
+
// a shadow's goes on its caster, in the caster's terms
|
|
1163
|
+
let layer = state.layer;
|
|
1164
|
+
let keyPath = a.property;
|
|
1165
|
+
let values = a.values;
|
|
1166
|
+
if (a.property === 'transform') {
|
|
1167
|
+
values = a.values.map((m) => inPoints(m, s));
|
|
1168
|
+
} else if (SHADOW_PROPERTIES.has(a.property)) {
|
|
1169
|
+
const i = a.shadow ?? 0;
|
|
1170
|
+
layer = state.shadow?.casters[i];
|
|
1171
|
+
if (!layer) continue;
|
|
1172
|
+
if (a.property === 'shadowBlur') {
|
|
1173
|
+
keyPath = 'shadowRadius';
|
|
1174
|
+
values = a.values.map((v) => v / (2 * s));
|
|
1175
|
+
} else if (a.property === 'shadowOffset') {
|
|
1176
|
+
const aside = this._aside(state.rect, sprite.shadows[i].rect);
|
|
1177
|
+
values = a.values.map(([x, y]) => [aside + x / s, y / s]);
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
922
1180
|
const opts = {
|
|
923
|
-
values
|
|
924
|
-
a.property === 'transform'
|
|
925
|
-
? a.values.map((m) => inPoints(m, s))
|
|
926
|
-
: a.values,
|
|
1181
|
+
values,
|
|
927
1182
|
duration: a.duration / 1000,
|
|
928
1183
|
id: caId,
|
|
929
1184
|
};
|
|
@@ -935,7 +1190,7 @@ export class SpriteLayers {
|
|
|
935
1190
|
// from the frame that asked: ahead, or already that far in
|
|
936
1191
|
if (a.delay) opts.delay = a.delay / 1000;
|
|
937
1192
|
try {
|
|
938
|
-
this.native.addAnimation(
|
|
1193
|
+
this.native.addAnimation(layer, keyPath, opts, caKey);
|
|
939
1194
|
} catch (err) {
|
|
940
1195
|
this._warn(
|
|
941
1196
|
state.host,
|
|
@@ -943,7 +1198,7 @@ export class SpriteLayers {
|
|
|
943
1198
|
);
|
|
944
1199
|
continue;
|
|
945
1200
|
}
|
|
946
|
-
state.runs.set(a.id, { caId, caKey, held: Boolean(a.hold) });
|
|
1201
|
+
state.runs.set(a.id, { caId, caKey, layer, held: Boolean(a.hold) });
|
|
947
1202
|
this._ends().set(caId, (ev) => this._ended(state, a.id, caId, ev));
|
|
948
1203
|
}
|
|
949
1204
|
}
|
package/src/node.d.ts
CHANGED
|
@@ -281,6 +281,30 @@ export type SpriteMatrix3D = [
|
|
|
281
281
|
* 0..1, or one of Core Animation's curve names. */
|
|
282
282
|
export type SpriteTiming = [number, number, number, number] | string;
|
|
283
283
|
|
|
284
|
+
/** A colour as a sprite's shadows take it: straight sRGB, red, green, blue
|
|
285
|
+
* and alpha each 0 to 1. */
|
|
286
|
+
export type SpriteColor = [number, number, number, number];
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* A shadow a part casts, as CSS's outer `box-shadow` casts one: the shape of
|
|
290
|
+
* `rect` with `radius` corners, `x` and `y` away, blurred by `blur` — CSS's
|
|
291
|
+
* blur radius, a Gaussian of half of it — in `color`. Device pixels, window
|
|
292
|
+
* coordinates, untransformed like the part's `rect`. A spread is the
|
|
293
|
+
* element's to fold into `rect` and `radius`, as CSS spells it out.
|
|
294
|
+
*/
|
|
295
|
+
export interface SpriteShadow {
|
|
296
|
+
rect: Rect;
|
|
297
|
+
/** The radius of `rect`'s corners: a circular arc at each of the four. 0
|
|
298
|
+
* when left out. */
|
|
299
|
+
radius?: number;
|
|
300
|
+
/** 0 when left out. */
|
|
301
|
+
x?: number;
|
|
302
|
+
y?: number;
|
|
303
|
+
/** 0 when left out: a hard shadow. */
|
|
304
|
+
blur?: number;
|
|
305
|
+
color: SpriteColor;
|
|
306
|
+
}
|
|
307
|
+
|
|
284
308
|
/**
|
|
285
309
|
* An animation the render server runs on a sprite: keyframes for one
|
|
286
310
|
* property, played as CSS plays an animation's iterations.
|
|
@@ -290,11 +314,22 @@ export interface SpriteAnimation {
|
|
|
290
314
|
* reports. An animation is attached once per id: to change one, give it
|
|
291
315
|
* a new id. */
|
|
292
316
|
id: string;
|
|
293
|
-
|
|
317
|
+
/** The layer's opacity or transform, or one of its shadows' blur, colour
|
|
318
|
+
* or offset (`shadow`). */
|
|
319
|
+
property:
|
|
320
|
+
'opacity' | 'transform' | 'shadowBlur' | 'shadowColor' | 'shadowOffset';
|
|
321
|
+
/** Which of the part's `shadows` a shadow's animation is of; 0 when left
|
|
322
|
+
* out. */
|
|
323
|
+
shadow?: number;
|
|
294
324
|
/** At least two: opacities, or matrices for a transform — sampled densely
|
|
295
325
|
* where the element interpolates in a way the render server would not.
|
|
296
|
-
* A transform's may be `matrix3d`s, in any mix with `matrix`es.
|
|
297
|
-
|
|
326
|
+
* A transform's may be `matrix3d`s, in any mix with `matrix`es. A
|
|
327
|
+
* shadow's are blurs and `[x, y]` offsets in device pixels, or colours. */
|
|
328
|
+
values:
|
|
329
|
+
| number[]
|
|
330
|
+
| (SpriteMatrix | SpriteMatrix3D)[]
|
|
331
|
+
| SpriteColor[]
|
|
332
|
+
| [number, number][];
|
|
298
333
|
/** One per value, 0 to 1, never decreasing; evenly spaced when left out. */
|
|
299
334
|
keyTimes?: number[];
|
|
300
335
|
/** One curve per segment, so one fewer than `values`; linear when left
|
|
@@ -356,6 +391,19 @@ export interface Sprite {
|
|
|
356
391
|
* the current frame itself (docs/extending.md).
|
|
357
392
|
*/
|
|
358
393
|
contents?: VideoFrames;
|
|
394
|
+
/**
|
|
395
|
+
* What the part casts, the first on top, as CSS lists them: a part with
|
|
396
|
+
* shadows, and no `paint` or `contents`, is its shadows alone, which the
|
|
397
|
+
* presenter draws on the GPU — a glow its element would blur a pixel at a
|
|
398
|
+
* time — and runs their animations. None of them falls inside `rect`,
|
|
399
|
+
* with `rectRadius` corners, as an outer shadow is never drawn under the
|
|
400
|
+
* box that casts it; where they reach is worked out from them, through
|
|
401
|
+
* every keyframe, so `reach` need not say.
|
|
402
|
+
*/
|
|
403
|
+
shadows?: SpriteShadow[];
|
|
404
|
+
/** The radius of `rect`'s corners, in device pixels, where its `shadows`
|
|
405
|
+
* stop. 0 when left out. */
|
|
406
|
+
rectRadius?: number;
|
|
359
407
|
/** Compared with `===` from frame to frame; a change paints the part
|
|
360
408
|
* again. Left out, it is painted once. */
|
|
361
409
|
version?: unknown;
|
package/src/nodes/sprites.js
CHANGED
|
@@ -37,6 +37,12 @@ export class NodeSprites {
|
|
|
37
37
|
* milliseconds and `delay` counted from the frame that asks — negative
|
|
38
38
|
* for one that started before it. An animation is attached once per
|
|
39
39
|
* `id`: to change one, give it a new id.
|
|
40
|
+
* - `shadows`, in place of `paint`: a part that is CSS's outer box
|
|
41
|
+
* shadows alone, each `{ rect, radius, x, y, blur, color }` in device
|
|
42
|
+
* pixels and straight sRGB, the first on top, none falling inside the
|
|
43
|
+
* part's `rect` (with `rectRadius` corners); their animations are of
|
|
44
|
+
* `shadowBlur`, `shadowColor` and `shadowOffset`, `shadow` saying
|
|
45
|
+
* which.
|
|
40
46
|
*
|
|
41
47
|
* Asked every frame, after layout and before the damage is taken, on a
|
|
42
48
|
* window whose presenter lifts sprites; nowhere else. An element whose
|
package/src/offscreen.js
CHANGED
|
@@ -5,13 +5,14 @@
|
|
|
5
5
|
// An app that makes its own surfaces answers `createSurface(options)` — the
|
|
6
6
|
// Cocoa app over a CG bitmap (src/cocoa/surface.js), the Windows app over a
|
|
7
7
|
// Direct2D bitmap, the Wayland app over a GL render target
|
|
8
|
-
// (src/wayland/surface.js)
|
|
9
|
-
// ntk
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// `
|
|
14
|
-
//
|
|
8
|
+
// (src/wayland/surface.js), and ntk's own app over an X connection with its
|
|
9
|
+
// pixmap (ntk >= 8.21). An app without the method — an older ntk deduped
|
|
10
|
+
// into the tree — gets that pixmap from ntk's root, which only an X
|
|
11
|
+
// connection loads (src/ntkroot.js). The result is whichever implementation
|
|
12
|
+
// answered, not an instance of this class: the contract is the shape —
|
|
13
|
+
// `width`/`height`, `getContext('2d')`, `render`, `clear`, `copyWithin`,
|
|
14
|
+
// `destroy`, and `ctx.drawImage(surface, …)` — (docs/extending.md "Scrolling
|
|
15
|
+
// the pixels, not just the offset"), and nothing needs `instanceof`.
|
|
15
16
|
//
|
|
16
17
|
// Part of that shape is that a context may be **held**: ntk tells a caller
|
|
17
18
|
// doing many draws to take `getContext('2d')` once rather than one per frame,
|
package/src/win32/fonts.js
CHANGED
|
@@ -407,20 +407,68 @@ class Win32TextLayout {
|
|
|
407
407
|
* where CoreText cannot and the Cocoa engine breaks at `linebreak`'s
|
|
408
408
|
* opportunities itself. */
|
|
409
409
|
get minWidth() {
|
|
410
|
+
// asked of the bridge the first time, where it left it out of the
|
|
411
|
+
// metrics: the line breaker over the whole text again, which a layout
|
|
412
|
+
// made to be drawn is never asked for
|
|
413
|
+
if (this._metrics.minWidth === undefined) {
|
|
414
|
+
this._metrics.minWidth =
|
|
415
|
+
this._handle != null && this._native.layoutMinWidth
|
|
416
|
+
? (this._native.layoutMinWidth(this._handle) ?? 0)
|
|
417
|
+
: 0;
|
|
418
|
+
}
|
|
410
419
|
return this._metrics.minWidth;
|
|
411
420
|
}
|
|
412
421
|
|
|
422
|
+
/**
|
|
423
|
+
* Keep the first `cap` lines of what was laid out: they are all this
|
|
424
|
+
* layout reports, answers for and draws (`_keptEnd`, `_keptHeight`). A
|
|
425
|
+
* capped paragraph is laid out past its cap to find where the cap falls,
|
|
426
|
+
* and keeping that layout's first lines is the same picture as laying the
|
|
427
|
+
* kept text out again, for one native layout where it took two.
|
|
428
|
+
*/
|
|
429
|
+
_keep(cap) {
|
|
430
|
+
const lines = this._metrics.lines.slice(0, cap);
|
|
431
|
+
const last = lines[lines.length - 1];
|
|
432
|
+
if (!last) return;
|
|
433
|
+
let width = 0;
|
|
434
|
+
for (const line of lines)
|
|
435
|
+
width = Math.max(width, Math.ceil(line.width - 1e-3));
|
|
436
|
+
this._metrics = {
|
|
437
|
+
...this._metrics,
|
|
438
|
+
lines,
|
|
439
|
+
width,
|
|
440
|
+
height: Math.ceil(last.y + last.height),
|
|
441
|
+
minWidth: undefined,
|
|
442
|
+
};
|
|
443
|
+
this._keptEnd = last.end;
|
|
444
|
+
this._keptHeight = last.y + last.height;
|
|
445
|
+
}
|
|
446
|
+
|
|
413
447
|
draw(ctx, x, y) {
|
|
448
|
+
if (this._keptHeight === undefined) {
|
|
449
|
+
ctx._drawLayout(this, x, y);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
// the lines past the cap are in the layout, and stay out of the picture
|
|
453
|
+
ctx.save();
|
|
454
|
+
ctx.beginPath();
|
|
455
|
+
ctx.rect(x - 1e4, y, 2e4 + this._metrics.width, this._keptHeight);
|
|
456
|
+
ctx.clip();
|
|
414
457
|
ctx._drawLayout(this, x, y);
|
|
458
|
+
ctx.restore();
|
|
415
459
|
}
|
|
416
460
|
|
|
417
461
|
indexAt(x, y) {
|
|
418
462
|
const unit = this._native.layoutIndexAt(this._handle, x, y);
|
|
419
|
-
return this._cpOf(unit);
|
|
463
|
+
return this._cpOf(Math.min(unit, this._keptEnd ?? unit));
|
|
420
464
|
}
|
|
421
465
|
|
|
422
466
|
caretPosition(cp) {
|
|
423
|
-
|
|
467
|
+
const unit = this._cuOf(cp);
|
|
468
|
+
return this._native.layoutCaret(
|
|
469
|
+
this._handle,
|
|
470
|
+
Math.min(unit, this._keptEnd ?? unit),
|
|
471
|
+
);
|
|
424
472
|
}
|
|
425
473
|
|
|
426
474
|
/**
|
|
@@ -567,6 +615,20 @@ export class Win32FontManager {
|
|
|
567
615
|
// family|weight|italic|size -> the bridge's handle, or null when there is
|
|
568
616
|
// no such face
|
|
569
617
|
this._handles = new Map();
|
|
618
|
+
// what this bridge's layouts take, asked once (`textFeatures`, from
|
|
619
|
+
// @windowkit/win32 0.0.9): an older one answers nothing and takes neither
|
|
620
|
+
this._features = native.textFeatures?.() ?? {};
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Which of ntk's `justify` modes `layout` takes. DirectWrite justifies
|
|
625
|
+
* every line but a paragraph's last, which is `justify: true`, and has no
|
|
626
|
+
* way to justify a last line: `'rest'` says so, and a caller justifies the
|
|
627
|
+
* other two itself. <Html>, which spaced every justified line through
|
|
628
|
+
* letter spacing on this engine, lays a justified paragraph out once.
|
|
629
|
+
*/
|
|
630
|
+
get justifies() {
|
|
631
|
+
return this._features.justify === true ? 'rest' : false;
|
|
570
632
|
}
|
|
571
633
|
|
|
572
634
|
/**
|
|
@@ -759,7 +821,9 @@ export class Win32FontManager {
|
|
|
759
821
|
return this._laidOut(sources, base, { ...options, maxWidth: least });
|
|
760
822
|
}
|
|
761
823
|
let laid = sources;
|
|
762
|
-
let layout =
|
|
824
|
+
let layout =
|
|
825
|
+
this._prefixLaidOut(sources, base, options, cap) ??
|
|
826
|
+
this._laidOut(laid, base, options);
|
|
763
827
|
// With no width the bridge lays a paragraph out in a box a million
|
|
764
828
|
// pixels wide, and aligns it there: a centred line's `x` was half a
|
|
765
829
|
// million, an RTL one's a million, and <Html> measured a centred
|
|
@@ -776,7 +840,11 @@ export class Win32FontManager {
|
|
|
776
840
|
layout = this._laidOut(laid, base, { ...options, maxWidth: widest });
|
|
777
841
|
}
|
|
778
842
|
layout.truncated = layout.lines.length > cap;
|
|
779
|
-
if (layout.truncated) {
|
|
843
|
+
if (layout.truncated && !elides) {
|
|
844
|
+
// what was laid out, a prefix or the whole, keeps its first lines
|
|
845
|
+
laid = spansTo(sources, layout._text.length);
|
|
846
|
+
layout._keep(cap);
|
|
847
|
+
} else if (layout.truncated) {
|
|
780
848
|
const last = layout.lines[cap - 1];
|
|
781
849
|
const text = layout._text;
|
|
782
850
|
layout.destroy();
|
|
@@ -800,6 +868,43 @@ export class Win32FontManager {
|
|
|
800
868
|
return layout;
|
|
801
869
|
}
|
|
802
870
|
|
|
871
|
+
/**
|
|
872
|
+
* A capped paragraph laid out only as far as the cap needs: a prefix of
|
|
873
|
+
* its text that breaks into more lines than `cap`, or null where the
|
|
874
|
+
* whole text is no longer than a prefix would be. The first lines of a
|
|
875
|
+
* prefix are the whole text's first lines wherever it reaches past them —
|
|
876
|
+
* a line's break waits only on the word after it, which then starts a
|
|
877
|
+
* line of the prefix — and the prefix ends at a break, its last word
|
|
878
|
+
* whole. <Html> lays a paragraph beside a float out a line at a time,
|
|
879
|
+
* each `maxLines: 1` over the rest of the paragraph; laid out whole, each
|
|
880
|
+
* line cost the whole rest of it, twice, and a paragraph cost the square
|
|
881
|
+
* of its length.
|
|
882
|
+
*/
|
|
883
|
+
_prefixLaidOut(sources, base, options, cap) {
|
|
884
|
+
const { maxWidth } = options;
|
|
885
|
+
if (!(cap < Infinity) || !(maxWidth > 0) || !Number.isFinite(maxWidth)) {
|
|
886
|
+
return null;
|
|
887
|
+
}
|
|
888
|
+
const text = sources.map((span) => String(span.text)).join('');
|
|
889
|
+
let size = Infinity;
|
|
890
|
+
for (const span of sources)
|
|
891
|
+
size = Math.min(size, sizeOf(span.size ?? base.size));
|
|
892
|
+
// a third of an em a character is narrower than nearly any text is set
|
|
893
|
+
// in, so a prefix that long nearly always reaches past the lines wanted;
|
|
894
|
+
// where it does not, one twice as long is tried
|
|
895
|
+
let take = Math.ceil(maxWidth / (Math.max(1, size) * 0.3)) * cap + 16;
|
|
896
|
+
while (take < text.length) {
|
|
897
|
+
let end = take;
|
|
898
|
+
while (end < text.length && !BREAKING_SPACE.test(text[end])) end++;
|
|
899
|
+
if (end >= text.length) return null;
|
|
900
|
+
const layout = this._laidOut(spansTo(sources, end), base, options);
|
|
901
|
+
if (layout.lines.length > cap) return layout;
|
|
902
|
+
layout.destroy();
|
|
903
|
+
take *= 2;
|
|
904
|
+
}
|
|
905
|
+
return null;
|
|
906
|
+
}
|
|
907
|
+
|
|
803
908
|
/**
|
|
804
909
|
* Where the last line kept of an eliding paragraph ends: as far into
|
|
805
910
|
* `[start, end)` as fits beside the mark in `maxWidth`, white space before
|
|
@@ -899,11 +1004,17 @@ export class Win32FontManager {
|
|
|
899
1004
|
align: options.align,
|
|
900
1005
|
lineHeight: options.lineHeight,
|
|
901
1006
|
rtl: options.direction === 'rtl',
|
|
1007
|
+
// the one mode DirectWrite has (`justifies`); 'last' and 'all' are
|
|
1008
|
+
// the caller's
|
|
1009
|
+
justify: options.justify === true || undefined,
|
|
902
1010
|
},
|
|
903
1011
|
ranges,
|
|
904
1012
|
);
|
|
905
1013
|
|
|
906
|
-
const raw = this._native.layoutMetrics(
|
|
1014
|
+
const raw = this._native.layoutMetrics(
|
|
1015
|
+
handle,
|
|
1016
|
+
this._features.lazyMinWidth ? { minWidth: false } : undefined,
|
|
1017
|
+
);
|
|
907
1018
|
if (DEBUG) {
|
|
908
1019
|
// The family is in here because the one way this goes quietly wrong is
|
|
909
1020
|
// `resolveStack` not recognising a name and answering `sans-serif`: the
|