ntk 3.2.0 → 3.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/lib/text/layout.js +342 -16
- package/lib/widgets/htmlview.js +16 -4
- package/lib/widgets/markdownview.js +16 -3
- package/package.json +1 -1
package/lib/text/layout.js
CHANGED
|
@@ -30,8 +30,13 @@ function isWsGlyph(g) {
|
|
|
30
30
|
* - `direction` — 'ltr' | 'rtl' | 'auto' base paragraph direction
|
|
31
31
|
*
|
|
32
32
|
* The result is inspectable before/without drawing: `width`, `height`, and
|
|
33
|
-
* `lines[] = { x, y, baseline, width, ascent, descent, runs }`
|
|
34
|
-
* `runs[] = { x, width, run, span }` in visual order
|
|
33
|
+
* `lines[] = { x, y, baseline, width, ascent, descent, runs, start, end }`
|
|
34
|
+
* with `runs[] = { x, width, run, span, start, end }` in visual order
|
|
35
|
+
* (`start`/`end` are logical UTF-16 ranges into the full text).
|
|
36
|
+
*
|
|
37
|
+
* `caretPosition(index)` / `indexAt(x, y)` map logical code-point indices
|
|
38
|
+
* to visual caret geometry and back (bidi/ligature/trailing-whitespace
|
|
39
|
+
* aware) — see docs/text.md.
|
|
35
40
|
*/
|
|
36
41
|
export class TextLayout {
|
|
37
42
|
constructor(fonts, content, style = {}, options = {}) {
|
|
@@ -59,6 +64,8 @@ export class TextLayout {
|
|
|
59
64
|
});
|
|
60
65
|
|
|
61
66
|
const text = spans.map((s) => s.text).join('');
|
|
67
|
+
this._text = text;
|
|
68
|
+
this._cpOffsets = null; // lazy code-point index -> code-unit offset table
|
|
62
69
|
const emb = embeddingLevels(text, options.direction);
|
|
63
70
|
const levels = emb.levels;
|
|
64
71
|
this.baseLevel = emb.paragraphs.length ? emb.paragraphs[0].level & 1 : 0;
|
|
@@ -139,16 +146,31 @@ export class TextLayout {
|
|
|
139
146
|
let layoutWidth = 0;
|
|
140
147
|
|
|
141
148
|
for (const toks of lineTokens) {
|
|
142
|
-
// entries carry .level so reorderRuns can order them (UAX#9 L2)
|
|
149
|
+
// entries carry .level so reorderRuns can order them (UAX#9 L2);
|
|
150
|
+
// .start/.end are absolute code-unit ranges into the full text, kept
|
|
151
|
+
// for caret positioning (caretPosition / indexAt)
|
|
143
152
|
let entries = [];
|
|
144
153
|
for (const token of toks) {
|
|
145
154
|
for (const frag of token.fragments) {
|
|
146
155
|
for (const run of frag.shaped.runs) {
|
|
147
|
-
entries.push({
|
|
156
|
+
entries.push({
|
|
157
|
+
run,
|
|
158
|
+
span: frag.span,
|
|
159
|
+
level: run.level,
|
|
160
|
+
start: frag.start + run.start,
|
|
161
|
+
end: frag.start + run.end
|
|
162
|
+
});
|
|
148
163
|
}
|
|
149
164
|
}
|
|
150
165
|
}
|
|
151
|
-
|
|
166
|
+
const lineStart = toks[0].start;
|
|
167
|
+
const lineEnd = toks[toks.length - 1].end;
|
|
168
|
+
const trailing = stripTrailingWhitespace(entries);
|
|
169
|
+
const contentEnd = trailing
|
|
170
|
+
? trailing.start
|
|
171
|
+
: entries.length
|
|
172
|
+
? entries[entries.length - 1].end
|
|
173
|
+
: lineStart;
|
|
152
174
|
entries = reorderRuns(entries);
|
|
153
175
|
|
|
154
176
|
let ascent = 0;
|
|
@@ -157,7 +179,7 @@ export class TextLayout {
|
|
|
157
179
|
const runs = [];
|
|
158
180
|
let x = 0;
|
|
159
181
|
for (const e of entries) {
|
|
160
|
-
runs.push({ x, width: e.run.width, run: e.run, span: e.span });
|
|
182
|
+
runs.push({ x, width: e.run.width, run: e.run, span: e.span, start: e.start, end: e.end });
|
|
161
183
|
x += e.run.width;
|
|
162
184
|
const m = e.run.font.metrics(e.run.size);
|
|
163
185
|
if (m.ascent > ascent) ascent = m.ascent;
|
|
@@ -172,7 +194,19 @@ export class TextLayout {
|
|
|
172
194
|
natural = m.lineHeight;
|
|
173
195
|
}
|
|
174
196
|
if (x > layoutWidth) layoutWidth = x;
|
|
175
|
-
this.lines.push({
|
|
197
|
+
this.lines.push({
|
|
198
|
+
x: 0,
|
|
199
|
+
y,
|
|
200
|
+
baseline: y + ascent,
|
|
201
|
+
width: x,
|
|
202
|
+
ascent,
|
|
203
|
+
descent,
|
|
204
|
+
runs,
|
|
205
|
+
start: lineStart,
|
|
206
|
+
end: lineEnd,
|
|
207
|
+
_contentEnd: contentEnd,
|
|
208
|
+
_trailing: trailing
|
|
209
|
+
});
|
|
176
210
|
y += natural * lineHeightMul;
|
|
177
211
|
}
|
|
178
212
|
|
|
@@ -204,7 +238,7 @@ export class TextLayout {
|
|
|
204
238
|
if (fragText.length > 0) {
|
|
205
239
|
const fragLevels = normalizedLevels(levels, pos, pos + fragText.length);
|
|
206
240
|
const shaped = this.fonts._shapeCached(fragText, span, fragLevels);
|
|
207
|
-
fragments.push({ text: fragText, span, shaped });
|
|
241
|
+
fragments.push({ text: fragText, span, shaped, start: pos });
|
|
208
242
|
width += shaped.width;
|
|
209
243
|
}
|
|
210
244
|
pos = fragEnd;
|
|
@@ -219,7 +253,7 @@ export class TextLayout {
|
|
|
219
253
|
wsWidth = m[0].length * spaceGlyph.advanceWidth * last.span.font.scale(last.span.size);
|
|
220
254
|
}
|
|
221
255
|
}
|
|
222
|
-
return { fragments, width, wsWidth, required };
|
|
256
|
+
return { fragments, width, wsWidth, required, start, end };
|
|
223
257
|
}
|
|
224
258
|
|
|
225
259
|
// split an over-wide token at the widest cluster boundary that fits
|
|
@@ -249,7 +283,9 @@ export class TextLayout {
|
|
|
249
283
|
hi = mid - 1;
|
|
250
284
|
}
|
|
251
285
|
}
|
|
252
|
-
if (best)
|
|
286
|
+
if (best) {
|
|
287
|
+
headFrags.push({ text: best.text, span: frag.span, shaped: best.shaped, start: frag.start });
|
|
288
|
+
}
|
|
253
289
|
|
|
254
290
|
const restFrags = [];
|
|
255
291
|
const restText = frag.text.slice(best ? best.len : 0);
|
|
@@ -257,26 +293,30 @@ export class TextLayout {
|
|
|
257
293
|
restFrags.push({
|
|
258
294
|
text: restText,
|
|
259
295
|
span: frag.span,
|
|
260
|
-
shaped: this.fonts._shapeCached(restText, frag.span, '0')
|
|
296
|
+
shaped: this.fonts._shapeCached(restText, frag.span, '0'),
|
|
297
|
+
start: frag.start + (best ? best.len : 0)
|
|
261
298
|
});
|
|
262
299
|
}
|
|
263
300
|
restFrags.push(...token.fragments.slice(i + 1));
|
|
264
301
|
const sum = (frags) => frags.reduce((w, f) => w + f.shaped.width, 0);
|
|
302
|
+
const splitAt = restFrags.length ? restFrags[0].start : token.end;
|
|
265
303
|
const head = headFrags.length
|
|
266
|
-
? { fragments: headFrags, width: sum(headFrags), wsWidth: 0, required: false }
|
|
304
|
+
? { fragments: headFrags, width: sum(headFrags), wsWidth: 0, required: false, start: token.start, end: splitAt }
|
|
267
305
|
: null;
|
|
268
306
|
const rest = {
|
|
269
307
|
fragments: restFrags,
|
|
270
308
|
width: sum(restFrags),
|
|
271
309
|
wsWidth: token.wsWidth,
|
|
272
|
-
required: token.required
|
|
310
|
+
required: token.required,
|
|
311
|
+
start: splitAt,
|
|
312
|
+
end: token.end
|
|
273
313
|
};
|
|
274
314
|
return [head, rest];
|
|
275
315
|
}
|
|
276
316
|
// everything fit after all (float rounding): no split needed
|
|
277
317
|
return [
|
|
278
318
|
{ ...token, required: false },
|
|
279
|
-
{ fragments: [], width: 0, wsWidth: 0, required: token.required }
|
|
319
|
+
{ fragments: [], width: 0, wsWidth: 0, required: token.required, start: token.end, end: token.end }
|
|
280
320
|
];
|
|
281
321
|
}
|
|
282
322
|
|
|
@@ -308,11 +348,283 @@ export class TextLayout {
|
|
|
308
348
|
ctx._markDirty();
|
|
309
349
|
return this;
|
|
310
350
|
}
|
|
351
|
+
|
|
352
|
+
// ---- caret positioning / hit testing ----------------------------------
|
|
353
|
+
//
|
|
354
|
+
// Both methods speak logical **code-point** indices (what you get from
|
|
355
|
+
// `Array.from(text)` / caret arithmetic on code points), converted
|
|
356
|
+
// internally to the code-unit ranges the shaped runs carry.
|
|
357
|
+
//
|
|
358
|
+
// Conventions (v1):
|
|
359
|
+
// - Direction boundaries: a single caret, placed at the trailing edge of
|
|
360
|
+
// the character logically before the index (the run containing the
|
|
361
|
+
// previous character wins). At a line start the leading edge of the
|
|
362
|
+
// run containing the index is used. Indices on both sides of a
|
|
363
|
+
// direction boundary may therefore map to the same visual x.
|
|
364
|
+
// - Ligature/cluster interior indices interpolate proportionally (by
|
|
365
|
+
// code-point count) across the cluster's advance.
|
|
366
|
+
// - An index just after a hard break belongs to the next line; the index
|
|
367
|
+
// of the break character itself sits at the end of its line. An index
|
|
368
|
+
// at a soft wrap boundary belongs to the start of the wrapped line.
|
|
369
|
+
// - Trailing whitespace stripped from a line end still advances the
|
|
370
|
+
// caret, extending past the line edge on the paragraph-direction side.
|
|
371
|
+
|
|
372
|
+
/** lazy code-point index -> code-unit offset table (n + 1 entries) */
|
|
373
|
+
_offsets() {
|
|
374
|
+
if (!this._cpOffsets) {
|
|
375
|
+
const offs = [];
|
|
376
|
+
let cu = 0;
|
|
377
|
+
for (const ch of this._text) {
|
|
378
|
+
offs.push(cu);
|
|
379
|
+
cu += ch.length;
|
|
380
|
+
}
|
|
381
|
+
offs.push(cu);
|
|
382
|
+
this._cpOffsets = offs;
|
|
383
|
+
}
|
|
384
|
+
return this._cpOffsets;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** code-unit offset -> code-point index (binary search) */
|
|
388
|
+
_cpOf(cu) {
|
|
389
|
+
const offs = this._offsets();
|
|
390
|
+
let lo = 0;
|
|
391
|
+
let hi = offs.length - 1;
|
|
392
|
+
while (lo < hi) {
|
|
393
|
+
const mid = (lo + hi + 1) >> 1;
|
|
394
|
+
if (offs[mid] <= cu) lo = mid;
|
|
395
|
+
else hi = mid - 1;
|
|
396
|
+
}
|
|
397
|
+
return lo;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Visual caret geometry for a logical code-point index in
|
|
402
|
+
* `[0, codePointCount]` (out-of-range indices clamp).
|
|
403
|
+
*
|
|
404
|
+
* @returns {{ x, y, height, line }} `x` is the caret's visual x within
|
|
405
|
+
* the layout box (alignment included), `y` the top of the line box,
|
|
406
|
+
* `height` = ascent + descent, `line` the line index.
|
|
407
|
+
*/
|
|
408
|
+
caretPosition(index) {
|
|
409
|
+
const offs = this._offsets();
|
|
410
|
+
const n = offs.length - 1;
|
|
411
|
+
const i = Math.max(0, Math.min(Math.floor(index), n));
|
|
412
|
+
const cu = offs[i];
|
|
413
|
+
let li = 0;
|
|
414
|
+
for (let l = this.lines.length - 1; l >= 0; l--) {
|
|
415
|
+
if (cu >= this.lines[l].start) {
|
|
416
|
+
li = l;
|
|
417
|
+
break;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
const line = this.lines[li];
|
|
421
|
+
return {
|
|
422
|
+
x: this._caretXInLine(line, cu),
|
|
423
|
+
y: line.y,
|
|
424
|
+
height: line.ascent + line.descent,
|
|
425
|
+
line: li
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Hit test: the logical code-point index of the caret boundary closest
|
|
431
|
+
* to layout-box coordinates (x, y). Picks the line by y (clamping above
|
|
432
|
+
* the first / below the last line), then the nearest boundary by x in
|
|
433
|
+
* visual order — a click past the midpoint of a cluster snaps to its far
|
|
434
|
+
* edge. Inverse of `caretPosition` up to bidi boundary ambiguity.
|
|
435
|
+
*/
|
|
436
|
+
indexAt(x, y) {
|
|
437
|
+
const lines = this.lines;
|
|
438
|
+
if (lines.length === 0) return 0;
|
|
439
|
+
let li = lines.length - 1;
|
|
440
|
+
for (let l = 0; l < lines.length; l++) {
|
|
441
|
+
const bottom = l + 1 < lines.length ? lines[l + 1].y : Infinity;
|
|
442
|
+
if (y < bottom) {
|
|
443
|
+
li = l;
|
|
444
|
+
break;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
const line = lines[li];
|
|
448
|
+
const lx = x - line.x;
|
|
449
|
+
const t = line._trailing;
|
|
450
|
+
const rightSide = !(this.baseLevel & 1);
|
|
451
|
+
if (t && (rightSide ? lx > line.width : lx < 0)) {
|
|
452
|
+
// in the stripped trailing-whitespace zone past the line edge
|
|
453
|
+
const dist = rightSide ? lx - line.width : -lx;
|
|
454
|
+
let pos = t.start;
|
|
455
|
+
let edge = 0;
|
|
456
|
+
for (const g of t.glyphs) {
|
|
457
|
+
if (dist < edge + g.ax / 2) return this._cpOf(pos);
|
|
458
|
+
edge += g.ax;
|
|
459
|
+
pos += g.len;
|
|
460
|
+
}
|
|
461
|
+
return this._cpOf(pos);
|
|
462
|
+
}
|
|
463
|
+
if (line.runs.length === 0) return this._cpOf(line.start);
|
|
464
|
+
const cx = Math.max(0, Math.min(lx, line.width));
|
|
465
|
+
let target = line.runs[line.runs.length - 1];
|
|
466
|
+
for (const r of line.runs) {
|
|
467
|
+
if (cx <= r.x + r.width) {
|
|
468
|
+
target = r;
|
|
469
|
+
break;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
return this._cpOf(this._runIndexAt(target, cx));
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** caret x (layout-box coords) for an absolute code-unit offset on `line` */
|
|
476
|
+
_caretXInLine(line, cu) {
|
|
477
|
+
const t = line._trailing;
|
|
478
|
+
if (t && cu > t.start) {
|
|
479
|
+
// inside (or past) the stripped trailing whitespace: extend beyond
|
|
480
|
+
// the visual line edge on the paragraph-direction side
|
|
481
|
+
let adv = 0;
|
|
482
|
+
let pos = t.start;
|
|
483
|
+
for (const g of t.glyphs) {
|
|
484
|
+
if (cu >= pos + g.len) {
|
|
485
|
+
adv += g.ax;
|
|
486
|
+
pos += g.len;
|
|
487
|
+
} else {
|
|
488
|
+
if (cu > pos) adv += (g.ax * (cu - pos)) / g.len;
|
|
489
|
+
break;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
return this.baseLevel & 1 ? line.x - adv : line.x + line.width + adv;
|
|
493
|
+
}
|
|
494
|
+
if (line.runs.length === 0) return line.x;
|
|
495
|
+
const cc = Math.min(cu, line._contentEnd);
|
|
496
|
+
// previous-character rule: the run containing the char before the index
|
|
497
|
+
let target = null;
|
|
498
|
+
if (cc > line.start) {
|
|
499
|
+
for (const r of line.runs) {
|
|
500
|
+
if (r.start < cc && cc <= r.end) {
|
|
501
|
+
target = r;
|
|
502
|
+
break;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
if (!target) {
|
|
507
|
+
for (const r of line.runs) {
|
|
508
|
+
if (r.start <= cc && cc < r.end) {
|
|
509
|
+
target = r;
|
|
510
|
+
break;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
if (!target) target = line.runs[this.baseLevel & 1 ? line.runs.length - 1 : 0];
|
|
515
|
+
return line.x + this._boundaryX(target, cc);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** x of logical boundary `cu` within a positioned line run (run-local + run.x) */
|
|
519
|
+
_boundaryX(lineRun, cu) {
|
|
520
|
+
const { run, start, end } = lineRun;
|
|
521
|
+
let x = lineRun.x;
|
|
522
|
+
if (run.direction === 'rtl') {
|
|
523
|
+
// glyphs stored in visual order; leftmost glyph is logically last
|
|
524
|
+
let pos = end;
|
|
525
|
+
for (const g of run.glyphs) {
|
|
526
|
+
const len = cuLength(g.codePoints);
|
|
527
|
+
if (len === 0) {
|
|
528
|
+
x += g.ax;
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
if (cu >= pos) return x;
|
|
532
|
+
if (cu > pos - len) {
|
|
533
|
+
// fraction of the cluster left of the caret = code points after cu
|
|
534
|
+
const cps = g.codePoints;
|
|
535
|
+
let k = 0;
|
|
536
|
+
let c = pos;
|
|
537
|
+
for (let j = cps.length - 1; j >= 0; j--) {
|
|
538
|
+
const l = cps[j] > 0xffff ? 2 : 1;
|
|
539
|
+
if (c - l < cu) break;
|
|
540
|
+
c -= l;
|
|
541
|
+
k++;
|
|
542
|
+
}
|
|
543
|
+
return x + (g.ax * k) / cps.length;
|
|
544
|
+
}
|
|
545
|
+
pos -= len;
|
|
546
|
+
x += g.ax;
|
|
547
|
+
}
|
|
548
|
+
return x;
|
|
549
|
+
}
|
|
550
|
+
let pos = start;
|
|
551
|
+
for (const g of run.glyphs) {
|
|
552
|
+
const len = cuLength(g.codePoints);
|
|
553
|
+
if (len === 0) {
|
|
554
|
+
x += g.ax;
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
if (cu <= pos) return x;
|
|
558
|
+
if (cu < pos + len) {
|
|
559
|
+
const cps = g.codePoints;
|
|
560
|
+
let k = 0;
|
|
561
|
+
let c = pos;
|
|
562
|
+
for (const cp of cps) {
|
|
563
|
+
const l = cp > 0xffff ? 2 : 1;
|
|
564
|
+
if (c + l > cu) break;
|
|
565
|
+
c += l;
|
|
566
|
+
k++;
|
|
567
|
+
}
|
|
568
|
+
return x + (g.ax * k) / cps.length;
|
|
569
|
+
}
|
|
570
|
+
pos += len;
|
|
571
|
+
x += g.ax;
|
|
572
|
+
}
|
|
573
|
+
return x;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** nearest caret boundary (code units) to line-local x within a line run */
|
|
577
|
+
_runIndexAt(lineRun, lx) {
|
|
578
|
+
const { run, start, end } = lineRun;
|
|
579
|
+
const rtl = run.direction === 'rtl';
|
|
580
|
+
let pos = rtl ? end : start;
|
|
581
|
+
let gx = lineRun.x;
|
|
582
|
+
for (const g of run.glyphs) {
|
|
583
|
+
const len = cuLength(g.codePoints);
|
|
584
|
+
if (len === 0 || g.ax <= 0) {
|
|
585
|
+
gx += g.ax;
|
|
586
|
+
if (g.ax <= 0) pos += rtl ? -len : len;
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
if (lx <= gx + g.ax) {
|
|
590
|
+
const cps = g.codePoints;
|
|
591
|
+
let k = Math.round(((lx - gx) / g.ax) * cps.length);
|
|
592
|
+
k = Math.max(0, Math.min(k, cps.length));
|
|
593
|
+
// move k code points into the cluster from its left edge
|
|
594
|
+
let c = pos;
|
|
595
|
+
if (rtl) {
|
|
596
|
+
for (let j = cps.length - 1; j >= cps.length - k; j--) {
|
|
597
|
+
c -= cps[j] > 0xffff ? 2 : 1;
|
|
598
|
+
}
|
|
599
|
+
} else {
|
|
600
|
+
for (let j = 0; j < k; j++) c += cps[j] > 0xffff ? 2 : 1;
|
|
601
|
+
}
|
|
602
|
+
return c;
|
|
603
|
+
}
|
|
604
|
+
pos += rtl ? -len : len;
|
|
605
|
+
gx += g.ax;
|
|
606
|
+
}
|
|
607
|
+
return pos;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// UTF-16 length of a glyph cluster's codePoints array
|
|
612
|
+
function cuLength(codePoints) {
|
|
613
|
+
let len = 0;
|
|
614
|
+
for (const cp of codePoints) len += cp > 0xffff ? 2 : 1;
|
|
615
|
+
return len;
|
|
311
616
|
}
|
|
312
617
|
|
|
313
618
|
// Drop trailing whitespace glyphs from the logical end of a line.
|
|
314
619
|
// Shaped runs are shared via the shaping cache — clone instead of mutating.
|
|
620
|
+
// Returns what was stripped — `{ start, glyphs: [{ ax, len }] }` in logical
|
|
621
|
+
// order (absolute code-unit start, per-glyph advance and code-unit length) —
|
|
622
|
+
// so caret positioning can still walk through trailing spaces, or null when
|
|
623
|
+
// nothing was stripped. Adjusts the surviving entries' logical `end`.
|
|
315
624
|
function stripTrailingWhitespace(entries) {
|
|
625
|
+
const stripped = [];
|
|
626
|
+
let strippedStart = null;
|
|
627
|
+
const result = () => (stripped.length ? { start: strippedStart, glyphs: stripped } : null);
|
|
316
628
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
317
629
|
const run = entries[i].run;
|
|
318
630
|
// rtl runs store glyphs in visual order: their logical end is index 0
|
|
@@ -325,21 +637,35 @@ function stripTrailingWhitespace(entries) {
|
|
|
325
637
|
count++;
|
|
326
638
|
wsWidth += g.ax;
|
|
327
639
|
}
|
|
328
|
-
if (count === 0) return;
|
|
640
|
+
if (count === 0) return result();
|
|
641
|
+
// stripped glyphs in logical order (rtl glyph storage is reversed)
|
|
642
|
+
const tail = fromFront
|
|
643
|
+
? run.glyphs.slice(0, count).reverse()
|
|
644
|
+
: run.glyphs.slice(run.glyphs.length - count);
|
|
645
|
+
let cuStripped = 0;
|
|
646
|
+
const info = tail.map((g) => {
|
|
647
|
+
const len = cuLength(g.codePoints);
|
|
648
|
+
cuStripped += len;
|
|
649
|
+
return { ax: g.ax, len };
|
|
650
|
+
});
|
|
651
|
+
stripped.unshift(...info);
|
|
652
|
+
strippedStart = entries[i].end - cuStripped;
|
|
329
653
|
if (count === run.glyphs.length) {
|
|
330
654
|
entries.splice(i, 1);
|
|
331
655
|
continue;
|
|
332
656
|
}
|
|
333
657
|
entries[i] = {
|
|
334
658
|
...entries[i],
|
|
659
|
+
end: entries[i].end - cuStripped,
|
|
335
660
|
run: {
|
|
336
661
|
...run,
|
|
337
662
|
glyphs: fromFront ? run.glyphs.slice(count) : run.glyphs.slice(0, -count),
|
|
338
663
|
width: run.width - wsWidth
|
|
339
664
|
}
|
|
340
665
|
};
|
|
341
|
-
return;
|
|
666
|
+
return result();
|
|
342
667
|
}
|
|
668
|
+
return result();
|
|
343
669
|
}
|
|
344
670
|
|
|
345
671
|
// compact levels key for the shaping cache: single char when uniform
|
package/lib/widgets/htmlview.js
CHANGED
|
@@ -65,6 +65,9 @@ const VOID_SKIP = new Set(['script', 'style', 'head', 'title', 'meta', 'link', '
|
|
|
65
65
|
* Standalone (windowless) use mirrors MarkdownView:
|
|
66
66
|
* const view = new HtmlView(null, { fonts });
|
|
67
67
|
* view.setHtml(html); view.layout(width); view.draw(ctx, x, y);
|
|
68
|
+
* Content that arrives asynchronously (images) invalidates the layout —
|
|
69
|
+
* pass `onInvalidate` to re-layout/redraw when that happens (window mode
|
|
70
|
+
* repaints automatically).
|
|
68
71
|
*/
|
|
69
72
|
export default class HtmlView {
|
|
70
73
|
constructor(window, opts = {}) {
|
|
@@ -74,6 +77,10 @@ export default class HtmlView {
|
|
|
74
77
|
|
|
75
78
|
/** called with (href, event, element) when a link is activated */
|
|
76
79
|
this.onLink = opts.onLink ?? null;
|
|
80
|
+
/** called when async content (an image) invalidates the layout;
|
|
81
|
+
* standalone hosts re-layout/redraw here — window mode also repaints
|
|
82
|
+
* on its own */
|
|
83
|
+
this.onInvalidate = opts.onInvalidate ?? null;
|
|
77
84
|
/** extra author stylesheet(s) applied after document <style>s */
|
|
78
85
|
this._userSheets = [].concat(opts.stylesheet ?? []);
|
|
79
86
|
/**
|
|
@@ -407,13 +414,11 @@ export default class HtmlView {
|
|
|
407
414
|
if (res instanceof SvgView) entry.svg = res;
|
|
408
415
|
else entry.image = res;
|
|
409
416
|
if (!res) entry.failed = true;
|
|
410
|
-
this.
|
|
411
|
-
if (this.window && this.window._mapped) this.render();
|
|
417
|
+
this._invalidate();
|
|
412
418
|
},
|
|
413
419
|
() => {
|
|
414
420
|
entry.failed = true;
|
|
415
|
-
this.
|
|
416
|
-
if (this.window && this.window._mapped) this.render();
|
|
421
|
+
this._invalidate();
|
|
417
422
|
}
|
|
418
423
|
);
|
|
419
424
|
}
|
|
@@ -422,6 +427,13 @@ export default class HtmlView {
|
|
|
422
427
|
for (const child of box.children) this._collectImages(child);
|
|
423
428
|
}
|
|
424
429
|
|
|
430
|
+
/** async content arrived: drop the cached layout, repaint and/or notify */
|
|
431
|
+
_invalidate() {
|
|
432
|
+
this._layoutWidth = -1;
|
|
433
|
+
if (this.window && this.window._mapped) this.render();
|
|
434
|
+
if (this.onInvalidate) this.onInvalidate();
|
|
435
|
+
}
|
|
436
|
+
|
|
425
437
|
// decoded bytes -> Image (PNG/JPEG) or SvgView (sniffed SVG markup)
|
|
426
438
|
_decodeResource(buf) {
|
|
427
439
|
const head = buf.subarray(0, 1024).toString('utf8');
|
|
@@ -51,7 +51,10 @@ const DEFAULT_THEME = {
|
|
|
51
51
|
* wnd.map();
|
|
52
52
|
*
|
|
53
53
|
* Standalone use (no window attach): `view.layout(width)` then
|
|
54
|
-
* `view.draw(ctx, x, y)`; `view.contentHeight` after layout.
|
|
54
|
+
* `view.draw(ctx, x, y)`; `view.contentHeight` after layout. Content that
|
|
55
|
+
* arrives asynchronously (mermaid models) invalidates the layout — pass
|
|
56
|
+
* `onInvalidate` to re-layout/redraw when that happens (window mode
|
|
57
|
+
* repaints automatically).
|
|
55
58
|
*/
|
|
56
59
|
export default class MarkdownView {
|
|
57
60
|
constructor(window, opts = {}) {
|
|
@@ -70,6 +73,10 @@ export default class MarkdownView {
|
|
|
70
73
|
/** called with (href, event) when a rendered link is clicked (window
|
|
71
74
|
* mode wires mousedown automatically; standalone callers use linkAt) */
|
|
72
75
|
this.onLink = opts.onLink ?? null;
|
|
76
|
+
/** called when async content (a mermaid model) invalidates the layout;
|
|
77
|
+
* standalone hosts re-layout/redraw here — window mode also repaints
|
|
78
|
+
* on its own */
|
|
79
|
+
this.onInvalidate = opts.onInvalidate ?? null;
|
|
73
80
|
|
|
74
81
|
if (this.window) {
|
|
75
82
|
this._ctx = this.window.getContext('2d');
|
|
@@ -116,8 +123,7 @@ export default class MarkdownView {
|
|
|
116
123
|
parseMermaid(text).then(
|
|
117
124
|
(model) => {
|
|
118
125
|
entry.model = model;
|
|
119
|
-
this.
|
|
120
|
-
if (this.window && this.window._mapped) this.render();
|
|
126
|
+
this._invalidate();
|
|
121
127
|
},
|
|
122
128
|
() => {
|
|
123
129
|
entry.failed = true; // unsupported/invalid: stays a code block
|
|
@@ -127,6 +133,13 @@ export default class MarkdownView {
|
|
|
127
133
|
return entry;
|
|
128
134
|
}
|
|
129
135
|
|
|
136
|
+
/** async content arrived: drop the cached layout, repaint and/or notify */
|
|
137
|
+
_invalidate() {
|
|
138
|
+
this._layoutWidth = -1;
|
|
139
|
+
if (this.window && this.window._mapped) this.render();
|
|
140
|
+
if (this.onInvalidate) this.onInvalidate();
|
|
141
|
+
}
|
|
142
|
+
|
|
130
143
|
/** lay content out for a container width; returns total content height */
|
|
131
144
|
layout(width) {
|
|
132
145
|
const fonts = this._fonts();
|