react-x11 2.40.0 → 2.41.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/app.js +19 -8
- package/src/cocoa/keymap.js +69 -12
- package/src/cocoa/panehost.js +44 -12
- package/src/cocoa/paneplayer.js +101 -24
- package/src/cocoa/panewindow.js +325 -44
- package/src/cocoa/window.js +9 -1
- package/src/events.js +12 -0
- package/src/frame/index.js +63 -17
- package/src/nodes/textarea.js +85 -15
- package/src/nodes/textinput.js +14 -3
- package/src/nodes/textkeys.js +322 -0
- package/src/types/events.d.ts +6 -2
- package/src/win32/fonts.js +521 -44
- package/src/win32/panewindow.js +25 -2
package/src/win32/fonts.js
CHANGED
|
@@ -72,15 +72,77 @@ const GENERIC = new Set([
|
|
|
72
72
|
'system-ui',
|
|
73
73
|
'cursive',
|
|
74
74
|
]);
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
//
|
|
76
|
+
// Two more ways a name reaches a face, both what Chrome does on Windows:
|
|
77
|
+
//
|
|
78
|
+
// - **A face of a family filed under another name.** DirectWrite groups
|
|
79
|
+
// faces by weight, width and slope, so "Arial Black", "Segoe UI Light" and
|
|
80
|
+
// "Franklin Gothic Medium" are faces of "Arial", "Segoe UI" and "Franklin
|
|
81
|
+
// Gothic", and no family by those names exists. The bridge looks such a
|
|
82
|
+
// name up as GDI does (`fontFamilyOf`) and answers the family and the
|
|
83
|
+
// face's weight, width and slope, which then stand in for the request's
|
|
84
|
+
// weight and width, as Blink has them (`font_cache_skia_win.cc`, the weight
|
|
85
|
+
// and width suffixes): `font-weight: bold` on "Arial Black" is still Arial
|
|
86
|
+
// Black. A slope is kept from either.
|
|
87
|
+
// - **Blink's alternate names** (`AlternateFamilyName`, font_cache.cc):
|
|
88
|
+
// Courier and Courier New, Times and Times New Roman, Arial and Helvetica,
|
|
89
|
+
// each tried for the other where it is missing. Windows has a Courier only
|
|
90
|
+
// as a bitmap font DirectWrite does not read, and no Times or Helvetica,
|
|
91
|
+
// and these three head a good many stacks on the web.
|
|
92
|
+
const ALTERNATES = new Map([
|
|
93
|
+
['courier', 'Courier New'],
|
|
94
|
+
['courier new', 'Courier'],
|
|
95
|
+
['times', 'Times New Roman'],
|
|
96
|
+
['times new roman', 'Times'],
|
|
97
|
+
['arial', 'Helvetica'],
|
|
98
|
+
['helvetica', 'Arial'],
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A stack resolved: `{ family, face }`, the family the bridge is asked for
|
|
103
|
+
* and, where the name reached a face of a family by another name, `face`:
|
|
104
|
+
* its `weight`, `stretch` and `italic`. `face` is null otherwise.
|
|
105
|
+
*/
|
|
106
|
+
function resolveStack(native, stack) {
|
|
107
|
+
if (!stack) return { family: 'sans-serif', face: null };
|
|
77
108
|
for (const raw of String(stack).split(',')) {
|
|
78
109
|
const name = raw.trim().replace(/^["']|["']$/g, '');
|
|
79
110
|
if (!name) continue;
|
|
80
|
-
if (GENERIC.has(name)) return name;
|
|
81
|
-
if (native.fontExists(name)) return name;
|
|
111
|
+
if (GENERIC.has(name)) return { family: name, face: null };
|
|
112
|
+
if (native.fontExists(name)) return { family: name, face: null };
|
|
113
|
+
const face = native.fontFamilyOf?.(name);
|
|
114
|
+
if (face?.family) {
|
|
115
|
+
return {
|
|
116
|
+
family: face.family,
|
|
117
|
+
face: {
|
|
118
|
+
weight: weightOf(face.weight),
|
|
119
|
+
stretch: face.stretch,
|
|
120
|
+
italic: face.italic === true,
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const alternate = ALTERNATES.get(name.toLowerCase());
|
|
125
|
+
if (alternate && native.fontExists(alternate)) {
|
|
126
|
+
return { family: alternate, face: null };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return { family: 'sans-serif', face: null };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** What a span or a base asks for, with its stack resolved: the family,
|
|
133
|
+
* weight, width and slope the bridge is handed. */
|
|
134
|
+
function requestOf(resolved, weight, style) {
|
|
135
|
+
const italic = style === 'italic' || style === 'oblique';
|
|
136
|
+
const { family, face } = resolved;
|
|
137
|
+
if (!face) {
|
|
138
|
+
return { family, weight: weightOf(weight ?? 400), stretch: 5, italic };
|
|
82
139
|
}
|
|
83
|
-
return
|
|
140
|
+
return {
|
|
141
|
+
family,
|
|
142
|
+
weight: face.weight,
|
|
143
|
+
stretch: face.stretch,
|
|
144
|
+
italic: italic || face.italic,
|
|
145
|
+
};
|
|
84
146
|
}
|
|
85
147
|
|
|
86
148
|
function colorOf(value) {
|
|
@@ -93,6 +155,112 @@ function colorOf(value) {
|
|
|
93
155
|
}
|
|
94
156
|
}
|
|
95
157
|
|
|
158
|
+
/**
|
|
159
|
+
* A span's letter spacing and OpenType features, as the bridge takes them:
|
|
160
|
+
* `letterSpacing` in pixels after each character, `features` by tag. Letter
|
|
161
|
+
* spacing turns the optional ligatures off, as ntk and the Cocoa engine have
|
|
162
|
+
* it, so the three agree on what a spaced word is made of. Left out, a
|
|
163
|
+
* `letter-spacing` was set tight on Windows, and <Html>, which justifies a
|
|
164
|
+
* line through letter spacing where an engine cannot, laid a justified
|
|
165
|
+
* line's words over each other.
|
|
166
|
+
*/
|
|
167
|
+
const OPTIONAL_LIGATURES_OFF = Object.freeze({
|
|
168
|
+
liga: 0,
|
|
169
|
+
clig: 0,
|
|
170
|
+
dlig: 0,
|
|
171
|
+
hlig: 0,
|
|
172
|
+
});
|
|
173
|
+
function spacingOf(span, base) {
|
|
174
|
+
const raw = span.letterSpacing ?? base.letterSpacing;
|
|
175
|
+
const spacing = typeof raw === 'number' && Number.isFinite(raw) ? raw : 0;
|
|
176
|
+
const own = span.features ?? base.features;
|
|
177
|
+
const features = spacing
|
|
178
|
+
? own
|
|
179
|
+
? { ...OPTIONAL_LIGATURES_OFF, ...own }
|
|
180
|
+
: OPTIONAL_LIGATURES_OFF
|
|
181
|
+
: own;
|
|
182
|
+
return {
|
|
183
|
+
...(spacing ? { letterSpacing: spacing } : {}),
|
|
184
|
+
...(features && typeof features === 'object' ? { features } : {}),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** What an eliding paragraph ends in. */
|
|
189
|
+
const ELLIPSIS = '…';
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The spans' text over `[from, to)` in code units, each span cut where the
|
|
193
|
+
* range cuts it and a span the range takes whole kept as the caller's own
|
|
194
|
+
* object; `mark` goes on the end of the last, in its style.
|
|
195
|
+
*/
|
|
196
|
+
function spansBetween(spans, from, to, mark = '') {
|
|
197
|
+
const out = [];
|
|
198
|
+
let at = 0;
|
|
199
|
+
for (const span of spans) {
|
|
200
|
+
const text = String(span.text);
|
|
201
|
+
const start = at;
|
|
202
|
+
at += text.length;
|
|
203
|
+
if (at <= from) continue;
|
|
204
|
+
if (start >= to) break;
|
|
205
|
+
const piece = text.slice(Math.max(0, from - start), to - start);
|
|
206
|
+
if (piece) out.push(piece === text ? span : { ...span, text: piece });
|
|
207
|
+
}
|
|
208
|
+
if (mark) {
|
|
209
|
+
if (out.length) {
|
|
210
|
+
const last = out[out.length - 1];
|
|
211
|
+
out[out.length - 1] = { ...last, text: String(last.text) + mark };
|
|
212
|
+
} else if (spans.length) {
|
|
213
|
+
out.push({ ...spans[0], text: mark });
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* A line's width without the white space it ends on, from where the layout
|
|
221
|
+
* puts the caret before that white space. Left to right only: on a line a
|
|
222
|
+
* right-to-left run is on, where the space goes is the bidi algorithm's,
|
|
223
|
+
* and the line keeps the bridge's width.
|
|
224
|
+
*/
|
|
225
|
+
function inkWidth(native, handle, text, line) {
|
|
226
|
+
const end = trimmedEnd(text, line.start, line.end);
|
|
227
|
+
if (end === line.end) return line.width;
|
|
228
|
+
if (end === line.start) return 0;
|
|
229
|
+
if ((line.runs ?? []).some((run) => run.rtl)) return line.width;
|
|
230
|
+
const caret = native.layoutCaret(handle, end);
|
|
231
|
+
if (!caret || !Number.isFinite(caret.x)) return line.width;
|
|
232
|
+
return Math.max(0, Math.min(line.width, caret.x - line.x));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** The spans up to code unit `to`, with `mark` after them. */
|
|
236
|
+
function spansTo(spans, to, mark = '') {
|
|
237
|
+
return spansBetween(spans, 0, to, mark);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** The white space a line may end on and leaves out of its width: the
|
|
241
|
+
* spaces a line breaks at, and the line separator <Html> asks for a break
|
|
242
|
+
* with — not a no-break space, which is part of the line as a letter is. */
|
|
243
|
+
const BREAKING_SPACE =
|
|
244
|
+
/[\t\n\v\f\r \u1680\u2000-\u200a\u2028\u2029\u205f\u3000]/;
|
|
245
|
+
|
|
246
|
+
/** `end` moved back over the white space before it, not past `start`. */
|
|
247
|
+
function trimmedEnd(text, start, end) {
|
|
248
|
+
let at = end;
|
|
249
|
+
while (at > start && BREAKING_SPACE.test(text[at - 1])) at--;
|
|
250
|
+
return at;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** A code-unit offset not inside a surrogate pair: `at`, or one before. */
|
|
254
|
+
function pointAtOrBefore(text, at) {
|
|
255
|
+
const unit = text.charCodeAt(at - 1);
|
|
256
|
+
return unit >= 0xd800 && unit <= 0xdbff ? at - 1 : at;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** The code-unit offset one code point before `at`. */
|
|
260
|
+
function previousPoint(text, at) {
|
|
261
|
+
return Math.max(0, pointAtOrBefore(text, at - 1));
|
|
262
|
+
}
|
|
263
|
+
|
|
96
264
|
/** Code-unit offset of each code point, so the two index spaces can be
|
|
97
265
|
* converted without walking the string twice per query. */
|
|
98
266
|
function codeUnitOffsets(text) {
|
|
@@ -119,6 +287,107 @@ class Win32TextLayout {
|
|
|
119
287
|
// rather than from inside the layout — so a <text> whose colour is a
|
|
120
288
|
// gradient reaches drawLayoutGradient.
|
|
121
289
|
this._contextInk = true;
|
|
290
|
+
// ntk's: whether a `maxLines` cap dropped any of the text
|
|
291
|
+
this.truncated = false;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Hang each run off the span it was laid out from, as ntk's runs hang:
|
|
296
|
+
* `span` is the caller's own object, markers and all — what a painter
|
|
297
|
+
* reads a link's underline or a code chip's background off — and `run`
|
|
298
|
+
* the face and size it was set in, and its direction. The bridge makes a
|
|
299
|
+
* run of each stretch of one direction, a link beside plain text in
|
|
300
|
+
* one, so a run is cut where two spans meet, at the offset the layout
|
|
301
|
+
* puts that index at. Without it a document drew no underline, chip or
|
|
302
|
+
* highlight on Windows and could find no link under the pointer.
|
|
303
|
+
*
|
|
304
|
+
* `laid` is what was laid out — `sources` cut at a cap, an ellipsis on
|
|
305
|
+
* the last — span for span, so the offsets are its and the objects the
|
|
306
|
+
* caller's.
|
|
307
|
+
*/
|
|
308
|
+
_hangRuns(sources, laid, runOf) {
|
|
309
|
+
const starts = [];
|
|
310
|
+
let at = 0;
|
|
311
|
+
for (const span of laid) {
|
|
312
|
+
starts.push(at);
|
|
313
|
+
at += String(span.text).length;
|
|
314
|
+
}
|
|
315
|
+
for (const line of this.lines) {
|
|
316
|
+
const runs = [];
|
|
317
|
+
for (const r of line.runs) {
|
|
318
|
+
this._cutRun(
|
|
319
|
+
r,
|
|
320
|
+
line,
|
|
321
|
+
starts,
|
|
322
|
+
(i, rtl) => {
|
|
323
|
+
const span = sources[i];
|
|
324
|
+
return { span, run: runOf(span, rtl) };
|
|
325
|
+
},
|
|
326
|
+
runs,
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
line.runs = runs;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
_cutRun(r, line, starts, hangOf, out) {
|
|
334
|
+
if (!starts.length) {
|
|
335
|
+
out.push(r);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
// the span holding the run's first unit
|
|
339
|
+
let lo = 0;
|
|
340
|
+
let hi = starts.length - 1;
|
|
341
|
+
while (lo < hi) {
|
|
342
|
+
const mid = (lo + hi + 1) >> 1;
|
|
343
|
+
if (starts[mid] <= r.start) lo = mid;
|
|
344
|
+
else hi = mid - 1;
|
|
345
|
+
}
|
|
346
|
+
const hang = (run, source) => {
|
|
347
|
+
const { span, run: set } = hangOf(source, run.rtl);
|
|
348
|
+
run.span = span;
|
|
349
|
+
run.run = set;
|
|
350
|
+
return run;
|
|
351
|
+
};
|
|
352
|
+
let next = lo + 1;
|
|
353
|
+
if (next >= starts.length || starts[next] >= r.end) {
|
|
354
|
+
out.push(hang(r, lo));
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
// where each span that starts inside the run begins, from the line's
|
|
358
|
+
// origin, as the run's own `x` is measured
|
|
359
|
+
const edges = [r.start];
|
|
360
|
+
const at = [r.rtl ? r.x + r.width : r.x];
|
|
361
|
+
const sources = [lo];
|
|
362
|
+
for (; next < starts.length && starts[next] < r.end; next += 1) {
|
|
363
|
+
edges.push(starts[next]);
|
|
364
|
+
const caret = this._native.layoutCaret(this._handle, starts[next]);
|
|
365
|
+
at.push((caret?.x ?? 0) - line.x);
|
|
366
|
+
sources.push(next);
|
|
367
|
+
}
|
|
368
|
+
edges.push(r.end);
|
|
369
|
+
at.push(r.rtl ? r.x : r.x + r.width);
|
|
370
|
+
const pieces = [];
|
|
371
|
+
for (let i = 0; i < sources.length; i += 1) {
|
|
372
|
+
const a = at[i];
|
|
373
|
+
const b = at[i + 1];
|
|
374
|
+
pieces.push(
|
|
375
|
+
hang(
|
|
376
|
+
{
|
|
377
|
+
...r,
|
|
378
|
+
start: edges[i],
|
|
379
|
+
end: edges[i + 1],
|
|
380
|
+
x: Math.min(a, b),
|
|
381
|
+
width: Math.abs(b - a),
|
|
382
|
+
},
|
|
383
|
+
sources[i],
|
|
384
|
+
),
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
// a line's runs go left to right, and a right-to-left run's first span
|
|
388
|
+
// is its rightmost piece
|
|
389
|
+
if (r.rtl) pieces.reverse();
|
|
390
|
+
out.push(...pieces);
|
|
122
391
|
}
|
|
123
392
|
|
|
124
393
|
get width() {
|
|
@@ -198,12 +467,19 @@ class Win32TextLayout {
|
|
|
198
467
|
/** A face, as far as a renderer that positions glyphs itself reads one.
|
|
199
468
|
* Size is deferred the way ntk's is. */
|
|
200
469
|
class Win32Face {
|
|
201
|
-
constructor(
|
|
470
|
+
constructor(
|
|
471
|
+
manager,
|
|
472
|
+
family,
|
|
473
|
+
{ weight = 400, style = 'normal', stretch = 5 } = {},
|
|
474
|
+
) {
|
|
202
475
|
this._manager = manager;
|
|
203
476
|
this.family = family;
|
|
204
477
|
this.weight = weightOf(weight);
|
|
205
478
|
this.style = style;
|
|
206
479
|
this.italic = style === 'italic' || style === 'oblique';
|
|
480
|
+
// DirectWrite's width, 1–9 with 5 normal: a face such as Arial Narrow is
|
|
481
|
+
// reached through it (`resolveStack`)
|
|
482
|
+
this.stretch = stretch;
|
|
207
483
|
}
|
|
208
484
|
|
|
209
485
|
/**
|
|
@@ -221,7 +497,15 @@ class Win32Face {
|
|
|
221
497
|
size,
|
|
222
498
|
this.weight,
|
|
223
499
|
this.italic,
|
|
500
|
+
this.stretch,
|
|
224
501
|
);
|
|
502
|
+
// ntk's `lineHeight` beside DirectWrite's names, as the Cocoa engine
|
|
503
|
+
// gives CoreText's, so a caller written against ntk reads a finite
|
|
504
|
+
// line height: <Html>'s `line-height: normal` read `NaN` here, and a
|
|
505
|
+
// `NaN` in a block's height culled the whole document from the paint.
|
|
506
|
+
if (m && typeof m === 'object' && !Number.isFinite(m.lineHeight)) {
|
|
507
|
+
m = { ...m, lineHeight: m.ascent + m.descent + (m.lineGap || 0) };
|
|
508
|
+
}
|
|
225
509
|
this._metrics.set(size, m);
|
|
226
510
|
}
|
|
227
511
|
return m;
|
|
@@ -294,12 +578,18 @@ export class Win32FontManager {
|
|
|
294
578
|
*/
|
|
295
579
|
_handleFor(face, size) {
|
|
296
580
|
const px = Math.max(1, Math.round((size ?? 0) * 64)) / 64;
|
|
297
|
-
const
|
|
581
|
+
const stretch = face.stretch ?? 5;
|
|
582
|
+
const key = `${face.family}|${face.weight}|${face.italic ? 1 : 0}|${stretch}|${px}`;
|
|
298
583
|
let handle = this._handles.get(key);
|
|
299
584
|
if (handle === undefined) {
|
|
300
585
|
handle =
|
|
301
|
-
this._native.fontHandle(
|
|
302
|
-
|
|
586
|
+
this._native.fontHandle(
|
|
587
|
+
face.family,
|
|
588
|
+
px,
|
|
589
|
+
face.weight,
|
|
590
|
+
face.italic,
|
|
591
|
+
stretch,
|
|
592
|
+
) || null;
|
|
303
593
|
this._handles.set(key, handle);
|
|
304
594
|
}
|
|
305
595
|
return handle;
|
|
@@ -370,9 +660,10 @@ export class Win32FontManager {
|
|
|
370
660
|
}
|
|
371
661
|
// Faces matched before this one existed were matched against a smaller
|
|
372
662
|
// set of fonts, and one of them may have fallen back to what this
|
|
373
|
-
// replaces.
|
|
663
|
+
// replaces — a stack resolved past its name among them.
|
|
374
664
|
this._faces.clear();
|
|
375
665
|
this._handles.clear();
|
|
666
|
+
this._stacks?.clear();
|
|
376
667
|
return null;
|
|
377
668
|
}
|
|
378
669
|
|
|
@@ -429,10 +720,142 @@ export class Win32FontManager {
|
|
|
429
720
|
* layout rather than one per chunk — and therefore what makes line breaking
|
|
430
721
|
* work *across* them, which is the whole reason the contract takes spans
|
|
431
722
|
* rather than a string.
|
|
723
|
+
*
|
|
724
|
+
* The cap and the ellipsis are ntk's and are worked out here, since the
|
|
725
|
+
* bridge has neither: the paragraph is broken at the width, the lines past
|
|
726
|
+
* `maxLines` are dropped (`truncated` says whether any were), and an
|
|
727
|
+
* eliding one ends its last line in `…`, as much of the line kept as fits
|
|
728
|
+
* beside it. The bridge's own `maxLines` is never asked: it reads 1 as "do
|
|
729
|
+
* not wrap", so a one-line `<text>` came back as the whole paragraph on
|
|
730
|
+
* one line, as wide as all of it and with no ellipsis, and every paragraph
|
|
731
|
+
* a caller laid out a line at a time through it overran its box.
|
|
432
732
|
*/
|
|
433
733
|
layout(spans, base = {}, options = {}) {
|
|
434
734
|
const list =
|
|
435
735
|
typeof spans === 'string' ? [{ text: spans, ...base }] : (spans ?? []);
|
|
736
|
+
// the caller's spans with text, which the runs hang off (`_hangRuns`)
|
|
737
|
+
const sources = list.filter((span) => String(span?.text ?? ''));
|
|
738
|
+
const { maxWidth, maxLines, overflow } = options;
|
|
739
|
+
const cap = Number.isFinite(maxLines)
|
|
740
|
+
? Math.max(1, Math.floor(maxLines))
|
|
741
|
+
: Infinity;
|
|
742
|
+
const elides = overflow === 'ellipsis' && cap < Infinity;
|
|
743
|
+
// A width offer of zero is yoga's min-content question (nodes/window/
|
|
744
|
+
// floors.js), which the bridge answered with a layout built unbounded —
|
|
745
|
+
// the whole paragraph on one line — so no `<text>` in a flex row could
|
|
746
|
+
// give way below its full length. Its `minWidth` is DirectWrite's own
|
|
747
|
+
// answer (`DetermineMinWidth`), and the paragraph laid out at it is the
|
|
748
|
+
// floor. An eliding line's floor is the mark alone, as the Cocoa engine
|
|
749
|
+
// has it (react-x11#512): it gives way, and says so with `…`.
|
|
750
|
+
if (Number.isFinite(maxWidth) && maxWidth <= 0) {
|
|
751
|
+
const open = { ...options, maxWidth: undefined };
|
|
752
|
+
if (elides && cap === 1 && sources.length) {
|
|
753
|
+
return this._laidOut([{ ...sources[0], text: ELLIPSIS }], base, open);
|
|
754
|
+
}
|
|
755
|
+
const wide = this._laidOut(sources, base, open);
|
|
756
|
+
const least = wide.minWidth;
|
|
757
|
+
if (!(least > 0) || least >= wide.width) return wide;
|
|
758
|
+
wide.destroy();
|
|
759
|
+
return this._laidOut(sources, base, { ...options, maxWidth: least });
|
|
760
|
+
}
|
|
761
|
+
let laid = sources;
|
|
762
|
+
let layout = this._laidOut(laid, base, options);
|
|
763
|
+
// With no width the bridge lays a paragraph out in a box a million
|
|
764
|
+
// pixels wide, and aligns it there: a centred line's `x` was half a
|
|
765
|
+
// million, an RTL one's a million, and <Html> measured a centred
|
|
766
|
+
// heading in a shrink-to-fit box as wide as all the room it had. ntk
|
|
767
|
+
// aligns the lines of an unbounded paragraph within its widest, and so
|
|
768
|
+
// does a paragraph laid out again at that width.
|
|
769
|
+
if (
|
|
770
|
+
!(maxWidth > 0 && Number.isFinite(maxWidth)) &&
|
|
771
|
+
layout.width > 0 &&
|
|
772
|
+
layout.lines.some((line) => line.x > 0.5)
|
|
773
|
+
) {
|
|
774
|
+
const widest = layout.width;
|
|
775
|
+
layout.destroy();
|
|
776
|
+
layout = this._laidOut(laid, base, { ...options, maxWidth: widest });
|
|
777
|
+
}
|
|
778
|
+
layout.truncated = layout.lines.length > cap;
|
|
779
|
+
if (layout.truncated) {
|
|
780
|
+
const last = layout.lines[cap - 1];
|
|
781
|
+
const text = layout._text;
|
|
782
|
+
layout.destroy();
|
|
783
|
+
let end = last.end;
|
|
784
|
+
if (elides) {
|
|
785
|
+
end = this._ellipsisAt(sources, base, options, text, last.start, end);
|
|
786
|
+
}
|
|
787
|
+
laid = spansTo(sources, end, elides ? ELLIPSIS : '');
|
|
788
|
+
layout = this._laidOut(laid, base, options);
|
|
789
|
+
// where the mark wrapped after all — a width rounded the other way
|
|
790
|
+
// than the probe's — the line gives up a character more, a few times
|
|
791
|
+
for (let i = 0; elides && layout.lines.length > cap && i < 4; i++) {
|
|
792
|
+
end = trimmedEnd(text, last.start, previousPoint(text, end));
|
|
793
|
+
laid = spansTo(sources, end, ELLIPSIS);
|
|
794
|
+
layout.destroy();
|
|
795
|
+
layout = this._laidOut(laid, base, options);
|
|
796
|
+
}
|
|
797
|
+
layout.truncated = true;
|
|
798
|
+
}
|
|
799
|
+
layout._hangRuns(sources, laid, this._runOf(base));
|
|
800
|
+
return layout;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Where the last line kept of an eliding paragraph ends: as far into
|
|
805
|
+
* `[start, end)` as fits beside the mark in `maxWidth`, white space before
|
|
806
|
+
* the mark left out. Measured as that line alone, unwrapped.
|
|
807
|
+
*/
|
|
808
|
+
_ellipsisAt(sources, base, options, text, start, end) {
|
|
809
|
+
const room = options.maxWidth;
|
|
810
|
+
if (!(room > 0) || !Number.isFinite(room)) return end;
|
|
811
|
+
const open = { ...options, maxWidth: undefined, align: undefined };
|
|
812
|
+
const fits = (at) => {
|
|
813
|
+
const probe = this._laidOut(
|
|
814
|
+
spansBetween(sources, start, trimmedEnd(text, start, at), ELLIPSIS),
|
|
815
|
+
base,
|
|
816
|
+
open,
|
|
817
|
+
);
|
|
818
|
+
const width = probe.width;
|
|
819
|
+
probe.destroy();
|
|
820
|
+
return width <= room;
|
|
821
|
+
};
|
|
822
|
+
if (fits(end)) return trimmedEnd(text, start, end);
|
|
823
|
+
let lo = start;
|
|
824
|
+
let hi = end;
|
|
825
|
+
while (hi - lo > 1) {
|
|
826
|
+
const mid = pointAtOrBefore(text, (lo + hi) >> 1);
|
|
827
|
+
if (mid <= lo) break;
|
|
828
|
+
if (fits(mid)) lo = mid;
|
|
829
|
+
else hi = mid;
|
|
830
|
+
}
|
|
831
|
+
return trimmedEnd(text, start, lo);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/** What a run is set in — its face, size and direction, ntk's `run` — by
|
|
835
|
+
* the span it was laid out from, made once a span and direction. */
|
|
836
|
+
_runOf(base) {
|
|
837
|
+
const made = new Map();
|
|
838
|
+
return (span, rtl) => {
|
|
839
|
+
let byDirection = made.get(span);
|
|
840
|
+
if (!byDirection) {
|
|
841
|
+
byDirection = [null, null];
|
|
842
|
+
made.set(span, byDirection);
|
|
843
|
+
}
|
|
844
|
+
const at = rtl ? 1 : 0;
|
|
845
|
+
byDirection[at] ??= {
|
|
846
|
+
font: this.match(span.family ?? base.family, {
|
|
847
|
+
weight: span.weight ?? base.weight,
|
|
848
|
+
style: span.style ?? base.style,
|
|
849
|
+
}),
|
|
850
|
+
size: sizeOf(span.size ?? base.size),
|
|
851
|
+
direction: rtl ? 'rtl' : 'ltr',
|
|
852
|
+
};
|
|
853
|
+
return byDirection[at];
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/** The bridge's layout of `list`, as it lays it out: no cap, no mark. */
|
|
858
|
+
_laidOut(list, base, options) {
|
|
436
859
|
let text = '';
|
|
437
860
|
const ranges = [];
|
|
438
861
|
for (const span of list) {
|
|
@@ -441,32 +864,40 @@ export class Win32FontManager {
|
|
|
441
864
|
const start = text.length;
|
|
442
865
|
text += piece;
|
|
443
866
|
const ink = colorOf(span.color ?? base.color);
|
|
867
|
+
const set = requestOf(
|
|
868
|
+
this._stack(span.family ?? base.family),
|
|
869
|
+
span.weight ?? base.weight,
|
|
870
|
+
span.style ?? base.style,
|
|
871
|
+
);
|
|
444
872
|
ranges.push({
|
|
445
873
|
start,
|
|
446
874
|
length: piece.length,
|
|
447
|
-
family:
|
|
875
|
+
family: set.family,
|
|
448
876
|
size: sizeOf(span.size ?? base.size),
|
|
449
|
-
weight:
|
|
450
|
-
italic:
|
|
877
|
+
weight: set.weight,
|
|
878
|
+
italic: set.italic,
|
|
879
|
+
stretch: set.stretch,
|
|
451
880
|
// A variable face's axes. Passed per span as well as on the base,
|
|
452
881
|
// because a span carries its own and a paragraph is one layout.
|
|
453
882
|
variations: span.variations ?? base.variations ?? undefined,
|
|
883
|
+
...spacingOf(span, base),
|
|
454
884
|
...(ink ? ink : {}),
|
|
455
885
|
});
|
|
456
886
|
}
|
|
457
887
|
|
|
888
|
+
const own = requestOf(this._stack(base.family), base.weight, base.style);
|
|
458
889
|
const handle = this._native.layoutCreate(
|
|
459
890
|
text,
|
|
460
891
|
{
|
|
461
|
-
family:
|
|
892
|
+
family: own.family,
|
|
462
893
|
size: sizeOf(base.size),
|
|
463
|
-
weight:
|
|
464
|
-
italic:
|
|
894
|
+
weight: own.weight,
|
|
895
|
+
italic: own.italic,
|
|
896
|
+
stretch: own.stretch,
|
|
465
897
|
variations: base.variations ?? undefined,
|
|
466
898
|
maxWidth: options.maxWidth,
|
|
467
899
|
align: options.align,
|
|
468
900
|
lineHeight: options.lineHeight,
|
|
469
|
-
maxLines: options.maxLines,
|
|
470
901
|
rtl: options.direction === 'rtl',
|
|
471
902
|
},
|
|
472
903
|
ranges,
|
|
@@ -475,7 +906,7 @@ export class Win32FontManager {
|
|
|
475
906
|
const raw = this._native.layoutMetrics(handle);
|
|
476
907
|
if (DEBUG) {
|
|
477
908
|
// The family is in here because the one way this goes quietly wrong is
|
|
478
|
-
// `
|
|
909
|
+
// `resolveStack` not recognising a name and answering `sans-serif`: the
|
|
479
910
|
// text still draws, in the wrong face, at the wrong metrics, and the
|
|
480
911
|
// only tell is a width that does not match the face the app thinks it
|
|
481
912
|
// asked for.
|
|
@@ -486,37 +917,83 @@ export class Win32FontManager {
|
|
|
486
917
|
);
|
|
487
918
|
}
|
|
488
919
|
|
|
489
|
-
// Every engine answers a descent per line — a field and the
|
|
490
|
-
// measure a line's ink as
|
|
491
|
-
//
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
920
|
+
// Every engine answers an ascent and a descent per line — a field and the
|
|
921
|
+
// edit menu measure a line's ink as their sum — and the bridge measures
|
|
922
|
+
// both off the faces on the line. A line it measured nothing on (an
|
|
923
|
+
// empty one) takes them from its baseline. Worked out from the baseline
|
|
924
|
+
// always, as they were before the bridge said, a `lineHeight` put the
|
|
925
|
+
// descent below the line box: the bridge sets that baseline at 0.8 of
|
|
926
|
+
// the face's, and a line at `lineHeight: 1` read as shorter than its
|
|
927
|
+
// own strut, so <Html> laid every paragraph out a line at a time.
|
|
928
|
+
//
|
|
929
|
+
// And a baseline is ntk's: from the top of the layout. DirectWrite's is
|
|
930
|
+
// from the top of its line, so every line but the first was read as
|
|
931
|
+
// though its baseline were on the first — <Html> drew a link's
|
|
932
|
+
// underline on the line above the link.
|
|
933
|
+
//
|
|
934
|
+
// A line's `width` is ntk's too: without the white space it ends on,
|
|
935
|
+
// which its `advance` keeps. The bridge measures the line's runs, the
|
|
936
|
+
// space a wrap ended it on among them, so a line that fitted read as
|
|
937
|
+
// wider than its box: <Html> took each line beside a float for one that
|
|
938
|
+
// did not fit there and moved it below the float, a line at a time.
|
|
939
|
+
const lines = (raw.lines ?? []).map((line) => {
|
|
940
|
+
const measured =
|
|
941
|
+
line.ascent > 0 && Number.isFinite(line.descent) && line.descent >= 0;
|
|
942
|
+
return {
|
|
943
|
+
...line,
|
|
944
|
+
width: inkWidth(this._native, handle, text, line),
|
|
945
|
+
advance: line.width,
|
|
946
|
+
baseline: line.y + line.baseline,
|
|
947
|
+
ascent: measured ? line.ascent : line.baseline,
|
|
948
|
+
descent: measured
|
|
949
|
+
? line.descent
|
|
950
|
+
: Math.max(0, line.height - line.baseline),
|
|
951
|
+
// `rangeBands` walks a line's runs to build a selection highlight,
|
|
952
|
+
// and reads each one's direction off a nested `run` object — ntk's
|
|
953
|
+
// shape. A line with no runs is not an empty line here, it is a
|
|
954
|
+
// crash: `line.runs is not iterable`.
|
|
955
|
+
runs: (line.runs ?? []).map((run) => ({
|
|
956
|
+
...run,
|
|
957
|
+
run: { direction: run.rtl ? 'rtl' : 'ltr' },
|
|
958
|
+
})),
|
|
959
|
+
};
|
|
960
|
+
});
|
|
961
|
+
// DirectWrite's width leaves out a no-break space a line ends on, as it
|
|
962
|
+
// leaves out a space, where ntk's counts it as the letter it is to a
|
|
963
|
+
// line: a lone one measured nothing, and <Html>, which spaces an inline
|
|
964
|
+
// box's edges with one, made every edge a space too wide. The lines'
|
|
965
|
+
// own widths keep it, in the whole pixels the bridge answers in.
|
|
966
|
+
let width = raw.width;
|
|
967
|
+
for (const line of lines) {
|
|
968
|
+
width = Math.max(width, Math.ceil(line.width - 1e-3));
|
|
969
|
+
}
|
|
970
|
+
return new Win32TextLayout(this, handle, text, { ...raw, width, lines });
|
|
505
971
|
}
|
|
506
972
|
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
this.
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
this.
|
|
973
|
+
/** A stack resolved once (`resolveStack`): a named family is a
|
|
974
|
+
* DirectWrite lookup, and every layout asks for every span. */
|
|
975
|
+
_stack(stack) {
|
|
976
|
+
this._stacks ??= new Map();
|
|
977
|
+
const key = stack ?? '';
|
|
978
|
+
let resolved = this._stacks.get(key);
|
|
979
|
+
if (resolved === undefined) {
|
|
980
|
+
resolved = resolveStack(this._native, stack);
|
|
981
|
+
this._stacks.set(key, resolved);
|
|
515
982
|
}
|
|
516
|
-
|
|
983
|
+
return resolved;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
match(family, options = {}) {
|
|
987
|
+
const set = requestOf(this._stack(family), options.weight, options.style);
|
|
988
|
+
const style = set.italic ? 'italic' : 'normal';
|
|
989
|
+
const key = `${set.family}|${set.weight}|${style}|${set.stretch}`;
|
|
517
990
|
let face = this._faces.get(key);
|
|
518
991
|
if (!face) {
|
|
519
|
-
face = new Win32Face(this,
|
|
992
|
+
face = new Win32Face(this, set.family, {
|
|
993
|
+
weight: set.weight,
|
|
994
|
+
style,
|
|
995
|
+
stretch: set.stretch,
|
|
996
|
+
});
|
|
520
997
|
this._faces.set(key, face);
|
|
521
998
|
}
|
|
522
999
|
return face;
|