react-x11 2.3.0 → 2.4.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 +2 -2
- package/src/cocoa/context2d.js +127 -0
- package/src/cocoa/fonts.js +311 -22
- package/src/cocoa/window.js +16 -1
- package/src/node.d.ts +19 -6
- package/src/nodes.js +94 -10
- package/src/testing/index.d.ts +8 -0
- package/src/textselection.js +21 -3
- package/src/types/events.d.ts +5 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-x11",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"description": "react renderer with X11 as a target",
|
|
5
5
|
"main": "./src/index.js",
|
|
6
6
|
"files": [
|
|
@@ -90,7 +90,7 @@
|
|
|
90
90
|
"yoga-layout": "^3.2.1"
|
|
91
91
|
},
|
|
92
92
|
"optionalDependencies": {
|
|
93
|
-
"@windowkit/appkit": "^0.
|
|
93
|
+
"@windowkit/appkit": "^0.3.0",
|
|
94
94
|
"dbus-native": "^0.15.1",
|
|
95
95
|
"x11-dri": "^0.7.0"
|
|
96
96
|
},
|
package/src/cocoa/context2d.js
CHANGED
|
@@ -65,6 +65,32 @@ class LinearGradient {
|
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
const clamp01 = (v) => Math.min(1, Math.max(0, Number(v) || 0));
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The Render ops text draws with, numbered as XRender numbers them so a
|
|
72
|
+
* caller's `ctx.Render?.PictOp?.Over ?? 3` reads the same on both
|
|
73
|
+
* backends. Every op draws as Over here: the bridge composites glyph
|
|
74
|
+
* coverage with the context's fill and offers no blend-mode switch, and
|
|
75
|
+
* for the opaque inks text uses Src and Over agree.
|
|
76
|
+
*/
|
|
77
|
+
const PICT_OP = Object.freeze({ Src: 1, Over: 3 });
|
|
78
|
+
const RENDER = Object.freeze({ PictOp: PICT_OP });
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A solid ink for `drawGlyphs` — ntk's `createSolidPicture` answers an
|
|
82
|
+
* XRender picture; here it is the colour itself. Premultiplied 0..1 in, as
|
|
83
|
+
* XRender solids are (the identity for the opaque inks text uses), straight
|
|
84
|
+
* for CoreGraphics inside.
|
|
85
|
+
*/
|
|
86
|
+
class SolidPicture {
|
|
87
|
+
constructor(r, g, b, a) {
|
|
88
|
+
const alpha = clamp01(a);
|
|
89
|
+
const straight = (c) => (alpha > 0 ? clamp01(c / alpha) : 0);
|
|
90
|
+
this._rgba = [straight(r), straight(g), straight(b), alpha];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
68
94
|
export class CocoaContext2D {
|
|
69
95
|
/**
|
|
70
96
|
* @param native the @windowkit/appkit module
|
|
@@ -617,6 +643,107 @@ export class CocoaContext2D {
|
|
|
617
643
|
return promise;
|
|
618
644
|
}
|
|
619
645
|
|
|
646
|
+
// --- glyph runs (ntk's documented run contract) --------------------------
|
|
647
|
+
|
|
648
|
+
/** ntk's Render extension object, as much of it as text needs. */
|
|
649
|
+
get Render() {
|
|
650
|
+
return RENDER;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
createSolidPicture(r, g, b, a) {
|
|
654
|
+
return new SolidPicture(r, g, b, a);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Composite glyph runs — ntk's contract (its docs/text.md#glyph-runs),
|
|
659
|
+
* so a renderer written against ntk's context runs here unchanged:
|
|
660
|
+
* `positioned` is `[{ run: { font, size, glyphs: [{ id, ax, dx, dy }] },
|
|
661
|
+
* x, y }]`, `x`/`y` the run's baseline origin in user space, the pen
|
|
662
|
+
* starting at `x` and each glyph inking at `(pen + dx, y - dy)` — `dy`
|
|
663
|
+
* y-up — before advancing by `ax`. `op` is `Render.PictOp.Over` or
|
|
664
|
+
* `.Src`; `src` a `createSolidPicture` ink.
|
|
665
|
+
*
|
|
666
|
+
* The glyphs are grouped by face and size and go out as one native call
|
|
667
|
+
* — `CTFontDrawGlyphs` per group, with the fill set to `src`'s colour —
|
|
668
|
+
* so a frame of terminal text is one call per foreground colour.
|
|
669
|
+
* `run.font` is a face from `fonts.match()`/`fallbackFor()`, or an ntk
|
|
670
|
+
* `Font` from `openFont()` (resolved to CoreText from the same bytes, so
|
|
671
|
+
* its glyph ids hold); a glyph carrying a `font` of its own — what
|
|
672
|
+
* `shape()` produces when CoreText substituted a face — draws with that
|
|
673
|
+
* face.
|
|
674
|
+
*
|
|
675
|
+
* One difference from ntk, stated: the transform applies to the glyphs as
|
|
676
|
+
* well as to their origins, because CoreGraphics draws text through the
|
|
677
|
+
* CTM like everything else, where ntk moves the origins and keeps the
|
|
678
|
+
* advances in device pixels. Under a translate, which is what a node's
|
|
679
|
+
* paint runs in, the two agree.
|
|
680
|
+
*/
|
|
681
|
+
drawGlyphs(op, src, positioned) {
|
|
682
|
+
if (!Array.isArray(positioned) || positioned.length === 0) return;
|
|
683
|
+
const fonts = this._fonts;
|
|
684
|
+
if (typeof fonts?._runHandle !== 'function') return;
|
|
685
|
+
const batches = new Map(); // CTFont handle -> { font, glyphs, positions }
|
|
686
|
+
for (const placed of positioned) {
|
|
687
|
+
const run = placed?.run;
|
|
688
|
+
const glyphs = run?.glyphs;
|
|
689
|
+
if (!glyphs?.length) continue;
|
|
690
|
+
const size = run.size;
|
|
691
|
+
const runHandle = fonts._runHandle(run.font, size);
|
|
692
|
+
let pen = 0;
|
|
693
|
+
for (const g of glyphs) {
|
|
694
|
+
const handle = g.font ? fonts._runHandle(g.font, size) : runHandle;
|
|
695
|
+
if (handle) {
|
|
696
|
+
let batch = batches.get(handle);
|
|
697
|
+
if (!batch) {
|
|
698
|
+
batch = { font: handle, glyphs: [], positions: [] };
|
|
699
|
+
batches.set(handle, batch);
|
|
700
|
+
}
|
|
701
|
+
batch.glyphs.push(g.id);
|
|
702
|
+
batch.positions.push(
|
|
703
|
+
placed.x + pen + (g.dx || 0),
|
|
704
|
+
placed.y - (g.dy || 0),
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
pen += g.ax || 0;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
if (batches.size === 0) return;
|
|
711
|
+
const [r, g, b, a] = this._inkOf(src);
|
|
712
|
+
const surface = this._s();
|
|
713
|
+
this._native.ctxSetFillColor(surface, r, g, b, a);
|
|
714
|
+
const runs = [];
|
|
715
|
+
for (const batch of batches.values()) {
|
|
716
|
+
runs.push({
|
|
717
|
+
font: batch.font,
|
|
718
|
+
glyphs: Uint16Array.from(batch.glyphs),
|
|
719
|
+
positions: Float64Array.from(batch.positions),
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
this._native.ctxDrawGlyphs(surface, runs);
|
|
723
|
+
this._dirty();
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* The straight colour a `drawGlyphs` source paints with: a solid ink, a
|
|
728
|
+
* CSS colour string, a straight `[r, g, b, a]` — or, for anything else
|
|
729
|
+
* (a gradient, which glyph runs do not fill through here), the fill
|
|
730
|
+
* style in force.
|
|
731
|
+
*/
|
|
732
|
+
_inkOf(src) {
|
|
733
|
+
if (src instanceof SolidPicture) return src._rgba;
|
|
734
|
+
if (typeof src === 'string') return parseColor(src);
|
|
735
|
+
if (Array.isArray(src) && src.length >= 3) {
|
|
736
|
+
return [
|
|
737
|
+
clamp01(src[0]),
|
|
738
|
+
clamp01(src[1]),
|
|
739
|
+
clamp01(src[2]),
|
|
740
|
+
src.length > 3 ? clamp01(src[3]) : 1,
|
|
741
|
+
];
|
|
742
|
+
}
|
|
743
|
+
const style = this._state.fillStyle;
|
|
744
|
+
return style instanceof LinearGradient ? BLACK : parseColor(style);
|
|
745
|
+
}
|
|
746
|
+
|
|
620
747
|
// --- text (minimal: enough for <canvas onDraw> users) --------------------
|
|
621
748
|
|
|
622
749
|
_drawLayout(layout, x, y) {
|
package/src/cocoa/fonts.js
CHANGED
|
@@ -6,7 +6,15 @@
|
|
|
6
6
|
// overflow, direction })
|
|
7
7
|
// -> { width, height, lines, draw(ctx, x, y),
|
|
8
8
|
// indexAt(x, y), caretPosition(cp) }
|
|
9
|
-
// fonts.match(family, { weight, style }) ->
|
|
9
|
+
// fonts.match(family, { weight, style }) -> face
|
|
10
|
+
// fonts.fallbackFor(codepoint, family, { weight, style }) -> face | null
|
|
11
|
+
//
|
|
12
|
+
// and a face is ntk's `Font` as far as a renderer that positions glyphs
|
|
13
|
+
// itself reads it — `metrics(size)`, `hasGlyph(cp)`, `glyphIdFor(cp)`,
|
|
14
|
+
// `advanceOf(id, size)`, `shape(text, size)` — so a run built here draws
|
|
15
|
+
// through `ctx.drawGlyphs` here (issue #432, over @windowkit/appkit 0.3's
|
|
16
|
+
// fontGlyphForCodepoint / fontGlyphAdvances / fontFallbackFor /
|
|
17
|
+
// fontShapeText / fontWithSize / ctxDrawGlyphs).
|
|
10
18
|
//
|
|
11
19
|
// Index spaces, because two meet here: `lines[].start/end` and
|
|
12
20
|
// `runs[].start/end` are UTF-16 code units (what `rangeBands` in nodes.js
|
|
@@ -156,15 +164,199 @@ const GENERIC_FAMILIES = new Set([
|
|
|
156
164
|
'ui-monospace',
|
|
157
165
|
]);
|
|
158
166
|
|
|
167
|
+
/** The size a face is probed at when the question has no size in it. */
|
|
168
|
+
const PROBE_SIZE = 14;
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* A face — what `match()` and `fallbackFor()` answer — in the shape of
|
|
172
|
+
* ntk's `Font` as far as a renderer that positions glyphs itself reads it
|
|
173
|
+
* (ntk docs/text.md#glyph-runs): `metrics`, `hasGlyph`, `glyphIdFor`,
|
|
174
|
+
* `advanceOf`, `shape`. The face defers size the way ntk's does: `_handle`
|
|
175
|
+
* is the CTFont at a concrete pixel size, and every member that needs one
|
|
176
|
+
* asks for it, so one face serves a terminal at 13px and a label at 24px.
|
|
177
|
+
*
|
|
178
|
+
* Glyph ids are the font's own indices in both engines, so a run built
|
|
179
|
+
* from `glyphIdFor` here draws through `ctx.drawGlyphs` here with no
|
|
180
|
+
* translation; `run.font` is simply this object.
|
|
181
|
+
*/
|
|
182
|
+
export class CocoaFace {
|
|
183
|
+
/**
|
|
184
|
+
* @param manager the CocoaFontManager
|
|
185
|
+
* @param key stable identity (the match key, or the PostScript name of a
|
|
186
|
+
* face that arrived by substitution)
|
|
187
|
+
* @param sizedHandle (size) => CTFont handle for this face at that size
|
|
188
|
+
*/
|
|
189
|
+
constructor(manager, key, sizedHandle) {
|
|
190
|
+
this._manager = manager;
|
|
191
|
+
this.key = key;
|
|
192
|
+
this._sizedHandle = sizedHandle;
|
|
193
|
+
this._sized = new Map(); // size -> handle
|
|
194
|
+
this._covers = new Map(); // code point -> face that covers it | null
|
|
195
|
+
this._names = null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
_handle(size) {
|
|
199
|
+
const s = Number.isFinite(size) && size > 0 ? size : PROBE_SIZE;
|
|
200
|
+
let handle = this._sized.get(s);
|
|
201
|
+
if (handle === undefined) {
|
|
202
|
+
handle = this._sizedHandle(s) ?? null;
|
|
203
|
+
this._sized.set(s, handle);
|
|
204
|
+
}
|
|
205
|
+
return handle;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
_name() {
|
|
209
|
+
if (!this._names) {
|
|
210
|
+
const m = this._manager._native.fontMetrics(this._handle(PROBE_SIZE));
|
|
211
|
+
this._names = {
|
|
212
|
+
familyName: m.familyName ?? '',
|
|
213
|
+
postscriptName: m.postScriptName ?? '',
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
return this._names;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** The family CoreText resolved — `Menlo`, `Apple Color Emoji`. */
|
|
220
|
+
get familyName() {
|
|
221
|
+
return this._name().familyName;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** ntk's spelling of the PostScript name, for callers written against
|
|
225
|
+
* its `Font`. */
|
|
226
|
+
get postscriptName() {
|
|
227
|
+
return this._name().postscriptName;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Scaled to a pixel size. CoreText's names (`leading`) and ntk's
|
|
232
|
+
* (`lineGap`, `lineHeight`) side by side, so a caller written against
|
|
233
|
+
* either reads a finite line height.
|
|
234
|
+
*/
|
|
235
|
+
metrics(size) {
|
|
236
|
+
const m = this._manager._native.fontMetrics(this._handle(size));
|
|
237
|
+
return {
|
|
238
|
+
...m,
|
|
239
|
+
lineGap: m.leading,
|
|
240
|
+
lineHeight: m.ascent + m.descent + m.leading,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Coverage. A code point (ntk's contract) or a string — the two forms
|
|
246
|
+
* `hasGlyph` has been asked in.
|
|
247
|
+
*/
|
|
248
|
+
hasGlyph(codepoint) {
|
|
249
|
+
if (typeof codepoint === 'number')
|
|
250
|
+
return this.glyphIdFor(codepoint) !== null;
|
|
251
|
+
return this._manager._native.fontHasGlyph(
|
|
252
|
+
this._handle(PROBE_SIZE),
|
|
253
|
+
String(codepoint),
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Glyph id for a code point, or `null` when this face does not map it —
|
|
259
|
+
* the lookup twin of `hasGlyph` (ntk#254). A cmap lookup only: no
|
|
260
|
+
* shaping, so text that needs ligatures or marks goes through `shape()`.
|
|
261
|
+
*/
|
|
262
|
+
glyphIdFor(codepoint) {
|
|
263
|
+
if (typeof codepoint !== 'number') return null;
|
|
264
|
+
return this._manager._native.fontGlyphForCodepoint(
|
|
265
|
+
this._handle(PROBE_SIZE),
|
|
266
|
+
codepoint,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Nominal (unshaped) horizontal advance of a glyph id, in pixels at
|
|
271
|
+
* `size`. */
|
|
272
|
+
advanceOf(glyphId, size) {
|
|
273
|
+
const advances = this._manager._native.fontGlyphAdvances(
|
|
274
|
+
this._handle(size),
|
|
275
|
+
[glyphId],
|
|
276
|
+
);
|
|
277
|
+
return advances[0] ?? 0;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Shape a run of text at a pixel size: `{ font, size, direction, width,
|
|
282
|
+
* glyphs: [{ id, ax, dx, dy }] }`, glyphs in visual order with ntk's
|
|
283
|
+
* pen contract — `ax` the advance, `dx`/`dy` the drawing offset from the
|
|
284
|
+
* pen (y up). One CTLine through the typesetter (`fontShapeText`),
|
|
285
|
+
* which is what a cluster — a base with combining marks, an emoji
|
|
286
|
+
* sequence, a variation selector — needs and a grid renderer bypasses
|
|
287
|
+
* for everything else; the terminal shapes a cluster at a time and
|
|
288
|
+
* caches the run, so the typesetter runs once per distinct cluster.
|
|
289
|
+
*
|
|
290
|
+
* One thing ntk's `shape()` never does happens here: CoreText substitutes
|
|
291
|
+
* a face for characters this one lacks instead of shaping them as
|
|
292
|
+
* `.notdef`, and a glyph from a substituted run carries that face as
|
|
293
|
+
* `font` — an extra field ntk's contract ignores and this backend's
|
|
294
|
+
* `drawGlyphs` honours, so `☺️` in Menlo comes out as the emoji rather
|
|
295
|
+
* than Menlo's glyph at the emoji font's index.
|
|
296
|
+
*/
|
|
297
|
+
shape(text, size, opts = {}) {
|
|
298
|
+
const manager = this._manager;
|
|
299
|
+
const raw = manager._native.fontShapeText(this._handle(size), String(text));
|
|
300
|
+
const glyphs = [];
|
|
301
|
+
let pen = 0;
|
|
302
|
+
for (const run of raw.runs) {
|
|
303
|
+
const face = run.font ? manager._faceOfHandle(run.font, size) : null;
|
|
304
|
+
for (let i = 0; i < run.glyphs.length; i++) {
|
|
305
|
+
const ax = run.advances[i];
|
|
306
|
+
const glyph = {
|
|
307
|
+
id: run.glyphs[i],
|
|
308
|
+
ax,
|
|
309
|
+
dx: run.positions[i * 2] - pen,
|
|
310
|
+
dy: run.positions[i * 2 + 1],
|
|
311
|
+
};
|
|
312
|
+
if (face) glyph.font = face;
|
|
313
|
+
glyphs.push(glyph);
|
|
314
|
+
pen += ax;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return {
|
|
318
|
+
font: this,
|
|
319
|
+
size,
|
|
320
|
+
direction: opts.direction ?? 'ltr',
|
|
321
|
+
width: raw.width,
|
|
322
|
+
glyphs,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* A face that covers `codepoint` when this one does not — this face
|
|
328
|
+
* itself when it does, `null` when nothing on the system does. Faces the
|
|
329
|
+
* app loaded first, then CoreText's cascade off this face
|
|
330
|
+
* (`fontFallbackFor`). Cached per code point.
|
|
331
|
+
*/
|
|
332
|
+
_coverFor(codepoint) {
|
|
333
|
+
if (this._covers.has(codepoint)) return this._covers.get(codepoint);
|
|
334
|
+
const found = this._manager._fallbackFrom(this, codepoint);
|
|
335
|
+
this._covers.set(codepoint, found);
|
|
336
|
+
return found;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
159
340
|
export class CocoaFontManager {
|
|
160
|
-
|
|
161
|
-
|
|
341
|
+
/** @param native the @windowkit/appkit module; the tests hand in a fake */
|
|
342
|
+
constructor(native = loadNative()) {
|
|
343
|
+
this._native = native;
|
|
162
344
|
this._fonts = new Map(); // family|weight|italic|size -> handle
|
|
163
345
|
this._faces = new Map(); // family|weight|italic -> face wrapper
|
|
164
346
|
this._registered = new Map(); // lowercase family -> [{cg, weight, italic}]
|
|
165
347
|
this._byKey = new Map(); // ntk Font key -> { cg } | { ps }
|
|
166
348
|
this._sized = new Map(); // face key|size|variations -> CTFont handle
|
|
167
349
|
this._layouts = new Map(); // layout signature -> CocoaTextLayout (LRU)
|
|
350
|
+
this._faceByPs = new Map(); // PostScript name -> face CoreText chose itself
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** A face the app loaded joins every fallback chain: what the faces that
|
|
354
|
+
* outlive `_faces.clear()` remembered about coverage is stale. */
|
|
355
|
+
_forgetCovers() {
|
|
356
|
+
for (const face of this._faceByPs.values()) face._covers.clear();
|
|
357
|
+
for (const faces of this._registered.values()) {
|
|
358
|
+
for (const reg of faces) reg.face?._covers.clear();
|
|
359
|
+
}
|
|
168
360
|
}
|
|
169
361
|
|
|
170
362
|
/**
|
|
@@ -211,6 +403,7 @@ export class CocoaFontManager {
|
|
|
211
403
|
this._fonts.clear();
|
|
212
404
|
this._faces.clear();
|
|
213
405
|
this._sized.clear();
|
|
406
|
+
this._forgetCovers();
|
|
214
407
|
}
|
|
215
408
|
}
|
|
216
409
|
} catch {
|
|
@@ -354,8 +547,10 @@ export class CocoaFontManager {
|
|
|
354
547
|
|
|
355
548
|
/**
|
|
356
549
|
* A face by family/weight/style — what `textBoxTrim` reads `capHeight`
|
|
357
|
-
* from
|
|
358
|
-
* concrete pixel size
|
|
550
|
+
* from and what a terminal builds its glyph runs on. The face defers
|
|
551
|
+
* size, like ntk's: `metrics(size)` answers for a concrete pixel size,
|
|
552
|
+
* and the family re-resolves per size through `_font` (SF's optical
|
|
553
|
+
* sizing is a different face at 13px and at 28px).
|
|
359
554
|
*/
|
|
360
555
|
match(family, { weight, style } = {}) {
|
|
361
556
|
const w = numericWeight(weight);
|
|
@@ -363,25 +558,116 @@ export class CocoaFontManager {
|
|
|
363
558
|
const key = `${family}|${w}|${italic}`;
|
|
364
559
|
let face = this._faces.get(key);
|
|
365
560
|
if (!face) {
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
return manager._native.fontMetrics(
|
|
370
|
-
manager._font(family, w, italic, size ?? 14),
|
|
371
|
-
);
|
|
372
|
-
},
|
|
373
|
-
hasGlyph(char) {
|
|
374
|
-
return manager._native.fontHasGlyph(
|
|
375
|
-
manager._font(family, w, italic, 14),
|
|
376
|
-
String(char),
|
|
377
|
-
);
|
|
378
|
-
},
|
|
379
|
-
};
|
|
561
|
+
face = new CocoaFace(this, key, (size) =>
|
|
562
|
+
this._font(family, w, italic, size),
|
|
563
|
+
);
|
|
380
564
|
this._faces.set(key, face);
|
|
381
565
|
}
|
|
382
566
|
return face;
|
|
383
567
|
}
|
|
384
568
|
|
|
569
|
+
/**
|
|
570
|
+
* A face that covers `codepoint` when the one for `family` does not, or
|
|
571
|
+
* `null` when nothing on the system does — ntk's
|
|
572
|
+
* `FontManager.fallbackFor`, in its order: faces the app loaded first (an
|
|
573
|
+
* app that ships a font means the one it ships), then CoreText's cascade
|
|
574
|
+
* off the matched face (`CTFontCreateForString`), the same substitution
|
|
575
|
+
* `layout()` gets from the typesetter. Cached per family, weight, style
|
|
576
|
+
* and code point; the matched face itself when it already covers the
|
|
577
|
+
* code point, so runs group with the base's.
|
|
578
|
+
*/
|
|
579
|
+
fallbackFor(codepoint, family = 'sans-serif', opts = {}) {
|
|
580
|
+
if (typeof codepoint !== 'number') {
|
|
581
|
+
codepoint = String(codepoint).codePointAt(0) ?? -1;
|
|
582
|
+
}
|
|
583
|
+
return this.match(family, opts)._coverFor(codepoint);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** `fallbackFor` from a face rather than a family — see `_coverFor`. */
|
|
587
|
+
_fallbackFrom(base, codepoint) {
|
|
588
|
+
let found = null;
|
|
589
|
+
for (const faces of this._registered.values()) {
|
|
590
|
+
for (const reg of faces) {
|
|
591
|
+
const face = this._registeredFace(reg);
|
|
592
|
+
if (face.glyphIdFor(codepoint) !== null) {
|
|
593
|
+
found = face;
|
|
594
|
+
break;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
if (found) break;
|
|
598
|
+
}
|
|
599
|
+
if (!found) {
|
|
600
|
+
let text = null;
|
|
601
|
+
try {
|
|
602
|
+
text = String.fromCodePoint(codepoint);
|
|
603
|
+
} catch {
|
|
604
|
+
text = null; // not a scalar value: nothing covers it
|
|
605
|
+
}
|
|
606
|
+
if (text !== null) {
|
|
607
|
+
const probe = base._handle(PROBE_SIZE);
|
|
608
|
+
const handle = this._native.fontFallbackFor(probe, text);
|
|
609
|
+
// the bridge answers the probe handle itself when the face covers
|
|
610
|
+
// the text: no substitution needed
|
|
611
|
+
if (handle === probe) found = base;
|
|
612
|
+
else if (handle) found = this._faceOfHandle(handle, PROBE_SIZE);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
if (
|
|
616
|
+
found &&
|
|
617
|
+
found !== base &&
|
|
618
|
+
found.postscriptName === base.postscriptName
|
|
619
|
+
) {
|
|
620
|
+
found = base;
|
|
621
|
+
}
|
|
622
|
+
return found;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** The face of a loaded file, over its own CGFont handle. */
|
|
626
|
+
_registeredFace(reg) {
|
|
627
|
+
if (!reg.face) {
|
|
628
|
+
reg.face = new CocoaFace(this, 'registered', (size) =>
|
|
629
|
+
this._native.cgFontWithSize(reg.cg, size),
|
|
630
|
+
);
|
|
631
|
+
reg.face.key = `registered:${reg.face.postscriptName}`;
|
|
632
|
+
}
|
|
633
|
+
return reg.face;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* The face behind a CTFont handle CoreText chose itself — a fallback, or
|
|
638
|
+
* a substituted run inside `shape()` — one object per PostScript name, so
|
|
639
|
+
* runs from either route group together in `drawGlyphs`. The handle seeds
|
|
640
|
+
* the size it came at; other sizes are copies of it (`fontWithSize`),
|
|
641
|
+
* which is how a face with no family to re-match by answers
|
|
642
|
+
* `metrics(size)` and advances everywhere, as itself.
|
|
643
|
+
*/
|
|
644
|
+
_faceOfHandle(handle, size) {
|
|
645
|
+
const ps = this._native.fontMetrics(handle).postScriptName;
|
|
646
|
+
let face = this._faceByPs.get(ps);
|
|
647
|
+
if (!face) {
|
|
648
|
+
face = new CocoaFace(this, `ps:${ps}`, (s) =>
|
|
649
|
+
this._native.fontWithSize(handle, s),
|
|
650
|
+
);
|
|
651
|
+
this._faceByPs.set(ps, face);
|
|
652
|
+
}
|
|
653
|
+
if (!face._sized.has(size)) face._sized.set(size, handle);
|
|
654
|
+
return face;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* The CTFont a glyph run draws with (`CocoaContext2D.drawGlyphs`): a face
|
|
659
|
+
* of this engine's at the run's size, or an ntk `Font` — `openFont()`'s —
|
|
660
|
+
* resolved to CoreText from the same bytes, so the glyph ids it shaped
|
|
661
|
+
* with hold. Null for anything else, and the run is skipped.
|
|
662
|
+
*/
|
|
663
|
+
_runHandle(font, size) {
|
|
664
|
+
if (font instanceof CocoaFace) return font._handle(size);
|
|
665
|
+
if (font && (font.postscriptName || font.path || font.fk)) {
|
|
666
|
+
return this._faceFont(font, size);
|
|
667
|
+
}
|
|
668
|
+
return null;
|
|
669
|
+
}
|
|
670
|
+
|
|
385
671
|
/**
|
|
386
672
|
* `loadFont()`'s engine half: register the face with CoreText so the
|
|
387
673
|
* process can match it by family name (with real weight/italic traits —
|
|
@@ -444,6 +730,7 @@ export class CocoaFontManager {
|
|
|
444
730
|
this._faces.clear();
|
|
445
731
|
this._sized.clear();
|
|
446
732
|
this._layouts.clear();
|
|
733
|
+
this._forgetCovers();
|
|
447
734
|
// `null` on purpose: src/fonts.js keeps the fontkit face it already
|
|
448
735
|
// opened as the handle, which is the one whose metrics apps can read;
|
|
449
736
|
// the registry above is what rendering resolves against.
|
|
@@ -500,9 +787,11 @@ export class CocoaFontManager {
|
|
|
500
787
|
// which is how the fonts app renders exactly the file it opened.
|
|
501
788
|
const face = span.font ?? base.font;
|
|
502
789
|
let handle =
|
|
503
|
-
face
|
|
504
|
-
? this.
|
|
505
|
-
:
|
|
790
|
+
face instanceof CocoaFace
|
|
791
|
+
? this._withVariations(face._handle(size), variations)
|
|
792
|
+
: face && (face.postscriptName || face.path || face.fk)
|
|
793
|
+
? this._faceFont(face, size, variations)
|
|
794
|
+
: null;
|
|
506
795
|
handle ??= this._withVariations(
|
|
507
796
|
this._font(
|
|
508
797
|
span.family ?? base.family,
|
package/src/cocoa/window.js
CHANGED
|
@@ -321,7 +321,22 @@ export class CocoaWindow {
|
|
|
321
321
|
dx,
|
|
322
322
|
dy,
|
|
323
323
|
);
|
|
324
|
-
if (moved)
|
|
324
|
+
if (moved) {
|
|
325
|
+
this._dirty = true;
|
|
326
|
+
// The band moved inside the BACK buffer only. After the flip the
|
|
327
|
+
// other buffer still holds the band where it was, and the catch-up
|
|
328
|
+
// copy only covers what the flush painted — the strips the shift
|
|
329
|
+
// exposed — so the next frame would blit a band one frame stale.
|
|
330
|
+
// Record the shifted rect as painted, and the flip's copy carries it.
|
|
331
|
+
this.noteFrameDamage([
|
|
332
|
+
{
|
|
333
|
+
x: Math.round(rect.x),
|
|
334
|
+
y: Math.round(rect.y),
|
|
335
|
+
width: Math.round(rect.width),
|
|
336
|
+
height: Math.round(rect.height),
|
|
337
|
+
},
|
|
338
|
+
]);
|
|
339
|
+
}
|
|
325
340
|
return Boolean(moved);
|
|
326
341
|
}
|
|
327
342
|
|
package/src/node.d.ts
CHANGED
|
@@ -106,8 +106,9 @@ export interface A11ySceneItem {
|
|
|
106
106
|
* is destroyed and recreated under every screen reader holding it. */
|
|
107
107
|
id: string;
|
|
108
108
|
/** Where it is drawn, in the owning window's coordinates — the same
|
|
109
|
-
* space as `abs
|
|
110
|
-
*
|
|
109
|
+
* space as `abs`, which is device pixels; a mouse event's `x`/`y` are
|
|
110
|
+
* logical, `scale` apart (docs/scale.md). Where AT focus draws and what
|
|
111
|
+
* a magnifier follows. */
|
|
111
112
|
rect: Rect;
|
|
112
113
|
/** The ARIA role it plays. `'group'` when it says none, which is
|
|
113
114
|
* audible and promises nothing. A role that promises activation
|
|
@@ -186,8 +187,19 @@ export declare class Node {
|
|
|
186
187
|
/** The owning `<window>` node, once attached. */
|
|
187
188
|
readonly root: Node | null;
|
|
188
189
|
readonly destroyed: boolean;
|
|
189
|
-
/** Position and size within the owning window, valid after layout.
|
|
190
|
+
/** Position and size within the owning window, valid after layout. In
|
|
191
|
+
* device pixels, like everything painted (docs/scale.md). */
|
|
190
192
|
readonly abs: Rect;
|
|
193
|
+
/**
|
|
194
|
+
* Device pixels per logical pixel for this node's connection — resolved
|
|
195
|
+
* once by `createRoot` (docs/scale.md) and constant for the node's life.
|
|
196
|
+
* `this.style`, `this.abs` and `contentBox()` are already device pixels;
|
|
197
|
+
* a synthetic event's `x`/`y` are logical. This is for what never passes
|
|
198
|
+
* through a style — a paint constant such as a caret's width, or an
|
|
199
|
+
* event coordinate on its way to a comparison with `abs`. `1` on an
|
|
200
|
+
* ordinary display, `2` on a retina panel.
|
|
201
|
+
*/
|
|
202
|
+
readonly scale: number;
|
|
191
203
|
/** `abs` inset by the border and the padding — where the content goes,
|
|
192
204
|
* and where every built-in draws. The insets come off the layout, which
|
|
193
205
|
* is where percentages and the per-side overrides have already been
|
|
@@ -221,9 +233,10 @@ export declare class Node {
|
|
|
221
233
|
//
|
|
222
234
|
// Four questions in one index space and one coordinate space: characters
|
|
223
235
|
// are **code points**, rectangles are in the owning window's coordinates —
|
|
224
|
-
// the same ones `abs
|
|
225
|
-
//
|
|
226
|
-
//
|
|
236
|
+
// the same ones `abs` and `contentBox()` use, which are device pixels; a
|
|
237
|
+
// mouse event's `x`/`y` are the logical ones, `scale` apart. An element
|
|
238
|
+
// that answers them can be selected across by a `selectable` ancestor
|
|
239
|
+
// with no registration of any kind.
|
|
227
240
|
|
|
228
241
|
/** This element's text, or null when it has none. The default. */
|
|
229
242
|
textContent(): string | null;
|
package/src/nodes.js
CHANGED
|
@@ -486,6 +486,25 @@ function rectsBounds(rects) {
|
|
|
486
486
|
}
|
|
487
487
|
|
|
488
488
|
/** Do two rects share any area? Touching edges do not count. */
|
|
489
|
+
/** Does `rect` reach into any of the four `radius`-sized corner squares of
|
|
490
|
+
* `box` — the only part of a rounded border a translation cannot keep? */
|
|
491
|
+
function cornerSquaresOverlap(box, radius, rect) {
|
|
492
|
+
const r = Math.min(radius, box.width / 2, box.height / 2);
|
|
493
|
+
if (!(r > 0)) return false;
|
|
494
|
+
const corners = [
|
|
495
|
+
{ x: box.x, y: box.y, width: r, height: r },
|
|
496
|
+
{ x: box.x + box.width - r, y: box.y, width: r, height: r },
|
|
497
|
+
{ x: box.x, y: box.y + box.height - r, width: r, height: r },
|
|
498
|
+
{
|
|
499
|
+
x: box.x + box.width - r,
|
|
500
|
+
y: box.y + box.height - r,
|
|
501
|
+
width: r,
|
|
502
|
+
height: r,
|
|
503
|
+
},
|
|
504
|
+
];
|
|
505
|
+
return corners.some((square) => rectsOverlap(square, rect));
|
|
506
|
+
}
|
|
507
|
+
|
|
489
508
|
function rectsOverlap(a, b) {
|
|
490
509
|
return (
|
|
491
510
|
a.x < b.x + b.width &&
|
|
@@ -11127,12 +11146,51 @@ export class WindowNode extends Scrollable(Node) {
|
|
|
11127
11146
|
}
|
|
11128
11147
|
const keep = this._blitKeptDamage(vp, true);
|
|
11129
11148
|
if (!keep) return;
|
|
11130
|
-
|
|
11149
|
+
// An ancestor's rounded corners reach into the top and bottom rows of
|
|
11150
|
+
// the region (a graph pane inside a rounded card is the common shape):
|
|
11151
|
+
// those rows do not translate, so they leave the blit and get repainted
|
|
11152
|
+
// as bands — the same carve the element does for its own furniture —
|
|
11153
|
+
// and the band that shifts is what is left between them.
|
|
11154
|
+
const bands = this._cornerBands(node, vp);
|
|
11155
|
+
const shifted =
|
|
11156
|
+
bands.top || bands.bottom
|
|
11157
|
+
? {
|
|
11158
|
+
x: vp.x,
|
|
11159
|
+
y: vp.y + bands.top,
|
|
11160
|
+
width: vp.width,
|
|
11161
|
+
height: vp.height - bands.top - bands.bottom,
|
|
11162
|
+
}
|
|
11163
|
+
: vp;
|
|
11164
|
+
if (shifted.height <= 0 || Math.abs(dy) >= shifted.height) return;
|
|
11165
|
+
if (
|
|
11166
|
+
(shifted.width - Math.abs(dx)) * (shifted.height - Math.abs(dy)) <
|
|
11167
|
+
area * SCROLL_BLIT_MIN_KEEP
|
|
11168
|
+
) {
|
|
11169
|
+
return;
|
|
11170
|
+
}
|
|
11171
|
+
if (!this._scrollBlitSafe(node, shifted)) return;
|
|
11131
11172
|
// the element's deltas are already how far the pixels moved, the sense
|
|
11132
11173
|
// scrollRegion takes (0 + x rather than x: a caller's -0 would survive
|
|
11133
11174
|
// into request buffers and test comparisons)
|
|
11134
|
-
if (!this.window.scrollRegion({ ...
|
|
11175
|
+
if (!this.window.scrollRegion({ ...shifted }, 0 + dx, 0 + dy)) return;
|
|
11135
11176
|
let rects = keep;
|
|
11177
|
+
if (bands.top) {
|
|
11178
|
+
rects = addDamageRect(rects, {
|
|
11179
|
+
x: vp.x,
|
|
11180
|
+
y: vp.y,
|
|
11181
|
+
width: vp.width,
|
|
11182
|
+
height: bands.top,
|
|
11183
|
+
});
|
|
11184
|
+
}
|
|
11185
|
+
if (bands.bottom) {
|
|
11186
|
+
rects = addDamageRect(rects, {
|
|
11187
|
+
x: vp.x,
|
|
11188
|
+
y: shifted.y + shifted.height,
|
|
11189
|
+
width: vp.width,
|
|
11190
|
+
height: bands.bottom,
|
|
11191
|
+
});
|
|
11192
|
+
}
|
|
11193
|
+
vp = shifted;
|
|
11136
11194
|
// The strips the shift exposed, on the sides the pixels came from. The
|
|
11137
11195
|
// horizontal one takes the full width and the vertical one takes what
|
|
11138
11196
|
// is left, so a diagonal shift claims two rects that do not overlap —
|
|
@@ -11157,6 +11215,28 @@ export class WindowNode extends Scrollable(Node) {
|
|
|
11157
11215
|
this._damage = rects;
|
|
11158
11216
|
}
|
|
11159
11217
|
|
|
11218
|
+
/**
|
|
11219
|
+
* How many rows at the top and at the bottom of `vp` an ancestor's rounded
|
|
11220
|
+
* corners reach into — the rows an element blit has to leave behind and
|
|
11221
|
+
* repaint, so that what shifts stays clear of every corner square
|
|
11222
|
+
* (`_scrollBlitSafe`). Whole pixels, and zero when no corner reaches in.
|
|
11223
|
+
*/
|
|
11224
|
+
_cornerBands(node, vp) {
|
|
11225
|
+
let top = 0;
|
|
11226
|
+
let bottom = 0;
|
|
11227
|
+
for (let n = node.parent; n && n !== this; n = n.parent) {
|
|
11228
|
+
const radius = n.style?.borderRadius ?? 0;
|
|
11229
|
+
if (!(radius > 0) || !n.abs) continue;
|
|
11230
|
+
if (!cornerSquaresOverlap(n.abs, radius, vp)) continue;
|
|
11231
|
+
top = Math.max(top, Math.ceil(n.abs.y + radius - vp.y));
|
|
11232
|
+
bottom = Math.max(
|
|
11233
|
+
bottom,
|
|
11234
|
+
Math.ceil(vp.y + vp.height - (n.abs.y + n.abs.height - radius)),
|
|
11235
|
+
);
|
|
11236
|
+
}
|
|
11237
|
+
return { top: Math.max(0, top), bottom: Math.max(0, bottom) };
|
|
11238
|
+
}
|
|
11239
|
+
|
|
11160
11240
|
/**
|
|
11161
11241
|
* May the viewport's pixels be moved wholesale? Only if every pixel in it
|
|
11162
11242
|
* belongs to the scrolled content (or to a plain solid fill behind it):
|
|
@@ -11186,14 +11266,18 @@ export class WindowNode extends Scrollable(Node) {
|
|
|
11186
11266
|
child.style ?? EMPTY_STYLE,
|
|
11187
11267
|
child.direction,
|
|
11188
11268
|
);
|
|
11189
|
-
const
|
|
11190
|
-
|
|
11191
|
-
|
|
11192
|
-
|
|
11193
|
-
|
|
11194
|
-
|
|
11195
|
-
|
|
11196
|
-
|
|
11269
|
+
const ring = Math.max(bw.top, bw.right, bw.bottom, bw.left);
|
|
11270
|
+
if (ring > 0 && !rectContains(insetRect(child.abs, ring), vp)) {
|
|
11271
|
+
return false;
|
|
11272
|
+
}
|
|
11273
|
+
// A rounded corner is not a ring: the arc lives in the four
|
|
11274
|
+
// radius-sized squares at the corners, and the straight run of
|
|
11275
|
+
// the edge between them is the border ring already excluded
|
|
11276
|
+
// above. A viewport that reaches the edge but stays clear of the
|
|
11277
|
+
// squares — an element that carved the corner rows into bands it
|
|
11278
|
+
// repaints (`_cornerBands`) — is translation-safe.
|
|
11279
|
+
const radius = child.style?.borderRadius ?? 0;
|
|
11280
|
+
if (radius > 0 && cornerSquaresOverlap(child.abs, radius, vp)) {
|
|
11197
11281
|
return false;
|
|
11198
11282
|
}
|
|
11199
11283
|
if (typeof child._scrollbars === 'function') {
|
package/src/testing/index.d.ts
CHANGED
|
@@ -66,6 +66,14 @@ export interface RenderX11Options {
|
|
|
66
66
|
* D-Bus anywhere. See docs/accessibility.md.
|
|
67
67
|
*/
|
|
68
68
|
a11y?: boolean;
|
|
69
|
+
/**
|
|
70
|
+
* Pin the display scale — device pixels per logical pixel (docs/scale.md).
|
|
71
|
+
* The headless harnesses resolve to exactly 1 on their own, so every
|
|
72
|
+
* geometry assertion means its numbers literally; `2` renders the tree
|
|
73
|
+
* the way a retina panel does, which is the only way to catch an element
|
|
74
|
+
* that compares a logical event coordinate with its device `abs`.
|
|
75
|
+
*/
|
|
76
|
+
scale?: 'auto' | number;
|
|
69
77
|
}
|
|
70
78
|
|
|
71
79
|
/** One recorded assistive-technology fact, plus its one-line `summary`. */
|
package/src/textselection.js
CHANGED
|
@@ -169,7 +169,7 @@ export class TextSelection {
|
|
|
169
169
|
|
|
170
170
|
press(ev) {
|
|
171
171
|
if (ev.button !== 1) return;
|
|
172
|
-
const at = this.positionAt(
|
|
172
|
+
const at = this.positionAt(...this.devicePoint(ev));
|
|
173
173
|
if (!at) return;
|
|
174
174
|
this.granularity =
|
|
175
175
|
ev.detail >= 3 ? 'block' : ev.detail === 2 ? 'word' : 'char';
|
|
@@ -190,7 +190,7 @@ export class TextSelection {
|
|
|
190
190
|
|
|
191
191
|
drag(ev) {
|
|
192
192
|
if (!this.dragging) return;
|
|
193
|
-
const at = this.positionAt(
|
|
193
|
+
const at = this.positionAt(...this.devicePoint(ev));
|
|
194
194
|
if (!at) return;
|
|
195
195
|
this.focus = at;
|
|
196
196
|
this.apply();
|
|
@@ -221,9 +221,27 @@ export class TextSelection {
|
|
|
221
221
|
}
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
+
/**
|
|
225
|
+
* The pointer in the space `abs` and the four accessors are in — device
|
|
226
|
+
* pixels (docs/scale.md). A synthetic event's `x`/`y` are logical, so on
|
|
227
|
+
* a 2x panel they name a point half as far from the window's origin as the
|
|
228
|
+
* pointer is: a press landed on the wrong character and a drag stopped
|
|
229
|
+
* short at half its distance. The X event underneath already carries the
|
|
230
|
+
* device numbers; an event synthesized without one is multiplied up, the
|
|
231
|
+
* way every other pointer consumer in nodes.js does it.
|
|
232
|
+
*/
|
|
233
|
+
devicePoint(ev) {
|
|
234
|
+
const scale = this.node.scale > 0 ? this.node.scale : 1;
|
|
235
|
+
return [
|
|
236
|
+
ev.nativeEvent?.x ?? ev.x * scale,
|
|
237
|
+
ev.nativeEvent?.y ?? ev.y * scale,
|
|
238
|
+
];
|
|
239
|
+
}
|
|
240
|
+
|
|
224
241
|
// --- the selection itself ----------------------------------------------
|
|
225
242
|
|
|
226
|
-
/** The participant and index nearest a point in window coordinates
|
|
243
|
+
/** The participant and index nearest a point in window coordinates —
|
|
244
|
+
* device pixels, the unit `abs` and `textIndexAt` speak. */
|
|
227
245
|
positionAt(x, y) {
|
|
228
246
|
let best = null;
|
|
229
247
|
let bestScore = Infinity;
|
package/src/types/events.d.ts
CHANGED
|
@@ -13,7 +13,8 @@ export interface NativeEvent {
|
|
|
13
13
|
/** X event type number. */
|
|
14
14
|
type: number;
|
|
15
15
|
name?: string;
|
|
16
|
-
/** Window-relative pointer position
|
|
16
|
+
/** Window-relative pointer position, in device pixels — the numbers off
|
|
17
|
+
* the wire, and the ones to compare with a node's `abs` (docs/scale.md). */
|
|
17
18
|
x: number;
|
|
18
19
|
y: number;
|
|
19
20
|
/** Screen coordinates — what you anchor a `<popup>` at. */
|
|
@@ -33,7 +34,9 @@ export interface SyntheticEvent<T = DrawnNode> {
|
|
|
33
34
|
target: T;
|
|
34
35
|
/** The node whose handler is running. */
|
|
35
36
|
currentTarget: T | null;
|
|
36
|
-
/** Window coordinates
|
|
37
|
+
/** Window coordinates, in logical pixels — the unit styles are written
|
|
38
|
+
* in. `nativeEvent.x`/`y` are the same point in device pixels, which is
|
|
39
|
+
* what a node's `abs` is in (docs/scale.md). */
|
|
37
40
|
x: number;
|
|
38
41
|
y: number;
|
|
39
42
|
/** Coordinates relative to `target`'s box. */
|