@react-x11/components 0.12.0 → 0.13.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.
Files changed (80) hide show
  1. package/dist/html/controls.d.ts +12 -3
  2. package/dist/html/controls.d.ts.map +1 -1
  3. package/dist/html/controls.js +15 -55
  4. package/dist/html/controls.js.map +1 -1
  5. package/dist/html/css/cascade.d.ts +134 -20
  6. package/dist/html/css/cascade.d.ts.map +1 -1
  7. package/dist/html/css/cascade.js +519 -122
  8. package/dist/html/css/cascade.js.map +1 -1
  9. package/dist/html/css/parse.d.ts +29 -2
  10. package/dist/html/css/parse.d.ts.map +1 -1
  11. package/dist/html/css/parse.js +277 -9
  12. package/dist/html/css/parse.js.map +1 -1
  13. package/dist/html/css/style.d.ts +14 -0
  14. package/dist/html/css/style.d.ts.map +1 -1
  15. package/dist/html/css/style.js +2 -3
  16. package/dist/html/css/style.js.map +1 -1
  17. package/dist/html/css/ua.d.ts +11 -1
  18. package/dist/html/css/ua.d.ts.map +1 -1
  19. package/dist/html/css/ua.js +59 -7
  20. package/dist/html/css/ua.js.map +1 -1
  21. package/dist/html/dom.d.ts.map +1 -1
  22. package/dist/html/dom.js +3 -1
  23. package/dist/html/dom.js.map +1 -1
  24. package/dist/html/fonts.d.ts +25 -1
  25. package/dist/html/fonts.d.ts.map +1 -1
  26. package/dist/html/fonts.js +99 -6
  27. package/dist/html/fonts.js.map +1 -1
  28. package/dist/html/form.d.ts +200 -0
  29. package/dist/html/form.d.ts.map +1 -0
  30. package/dist/html/form.js +801 -0
  31. package/dist/html/form.js.map +1 -0
  32. package/dist/html/index.d.ts +12 -0
  33. package/dist/html/index.d.ts.map +1 -1
  34. package/dist/html/index.js +30 -301
  35. package/dist/html/index.js.map +1 -1
  36. package/dist/html/layout/block.d.ts.map +1 -1
  37. package/dist/html/layout/block.js +5 -1
  38. package/dist/html/layout/block.js.map +1 -1
  39. package/dist/html/layout/boxes.d.ts +31 -1
  40. package/dist/html/layout/boxes.d.ts.map +1 -1
  41. package/dist/html/layout/boxes.js +52 -8
  42. package/dist/html/layout/boxes.js.map +1 -1
  43. package/dist/html/layout/flex.js +4 -1
  44. package/dist/html/layout/flex.js.map +1 -1
  45. package/dist/html/layout/inline.d.ts.map +1 -1
  46. package/dist/html/layout/inline.js +10 -0
  47. package/dist/html/layout/inline.js.map +1 -1
  48. package/dist/html/node.d.ts +87 -15
  49. package/dist/html/node.d.ts.map +1 -1
  50. package/dist/html/node.js +510 -152
  51. package/dist/html/node.js.map +1 -1
  52. package/dist/html/paint.d.ts +22 -1
  53. package/dist/html/paint.d.ts.map +1 -1
  54. package/dist/html/paint.js +290 -20
  55. package/dist/html/paint.js.map +1 -1
  56. package/dist/html/widgets.d.ts +29 -0
  57. package/dist/html/widgets.d.ts.map +1 -0
  58. package/dist/html/widgets.js +620 -0
  59. package/dist/html/widgets.js.map +1 -0
  60. package/dist/index.d.ts +1 -1
  61. package/dist/index.d.ts.map +1 -1
  62. package/dist/index.js.map +1 -1
  63. package/package.json +2 -2
  64. package/src/html/controls.ts +29 -49
  65. package/src/html/css/cascade.ts +591 -140
  66. package/src/html/css/parse.ts +324 -9
  67. package/src/html/css/style.ts +24 -3
  68. package/src/html/css/ua.ts +62 -8
  69. package/src/html/dom.ts +3 -1
  70. package/src/html/fonts.ts +116 -6
  71. package/src/html/form.ts +962 -0
  72. package/src/html/index.ts +49 -338
  73. package/src/html/layout/block.ts +3 -0
  74. package/src/html/layout/boxes.ts +78 -11
  75. package/src/html/layout/flex.ts +4 -1
  76. package/src/html/layout/inline.ts +10 -0
  77. package/src/html/node.ts +498 -135
  78. package/src/html/paint.ts +330 -29
  79. package/src/html/widgets.ts +735 -0
  80. package/src/index.ts +1 -0
package/src/html/node.ts CHANGED
@@ -50,8 +50,14 @@ import type { Element } from 'domhandler';
50
50
  import { codePointAtOffset, codeUnitOffsets } from '../internal/text.js';
51
51
  import { attr, HtmlSource, imageUrlOf, isElement, tagOf } from './dom.js';
52
52
  import type { Document } from './dom.js';
53
- import { Cascade } from './css/cascade.js';
54
- import type { MetricFace } from './css/cascade.js';
53
+ import {
54
+ Cascade,
55
+ HOVER_FOLLOWED,
56
+ HOVER_PSEUDO_NONE,
57
+ HOVER_PSEUDO_OTHER,
58
+ HOVER_UNTOUCHED,
59
+ } from './css/cascade.js';
60
+ import type { HoverTouch, KeptStyles, MetricFace } from './css/cascade.js';
55
61
  import { mediaMatches, parseStylesheet } from './css/parse.js';
56
62
  import type { Stylesheet } from './css/parse.js';
57
63
  import { uaStylesheet } from './css/ua.js';
@@ -60,6 +66,7 @@ import {
60
66
  BOX_RAISES,
61
67
  buildBoxes,
62
68
  CONTENT_IMAGES,
69
+ CLEARED_FROM,
63
70
  CUT_BLOCKS,
64
71
  GENERATED_FROM,
65
72
  INLINE_OFFSETS,
@@ -104,6 +111,7 @@ import {
104
111
  holdsAbsolute,
105
112
  paintDocument,
106
113
  queryChildIndex,
114
+ selectionRows,
107
115
  stackLayers,
108
116
  } from './paint.js';
109
117
  import type { PaintContext } from './paint.js';
@@ -249,6 +257,8 @@ export class HtmlViewNode extends Node {
249
257
  * that read it (`vw`, `vh`). */
250
258
  private _styledWidth = -1;
251
259
  private _styledHeight = -1;
260
+ /** The cascade the box tree's styles were computed by. */
261
+ private _styledWith: Cascade | null = null;
252
262
  private _documentHeight = 0;
253
263
  private _documentWidth = 0;
254
264
  /** The viewport height the document was last laid out under, and whether
@@ -266,6 +276,20 @@ export class HtmlViewNode extends Node {
266
276
  * the text carries surrogate pairs. null until checked. */
267
277
  private _pointsAreUnits: boolean | null = null;
268
278
  private _hovered: Element[] = [];
279
+ /** Where the pointer last moved to, in logical window pixels, while it
280
+ * is over the element (`defaultMouseMove`). */
281
+ private _pointerAt: { x: number; y: number } | null = null;
282
+ /** A hover held until the content under a still pointer stops moving:
283
+ * the timer, and when the content last moved (`_holdHover`). */
284
+ private _heldHover: unknown = null;
285
+ private _heldSince = 0;
286
+ /**
287
+ * The elements the build of the boxes that is due has to style again,
288
+ * where every change asking for it said which (`_invalidate`), and null
289
+ * where any element's style may have changed — a new cascade, a viewport
290
+ * a `vw` reads, a face that arrived.
291
+ */
292
+ private _restyleOnly: Set<Element> | null = null;
269
293
  private _scriptsSeen = new WeakSet<Element>();
270
294
  private _controls: ControlRect[] = [];
271
295
  private _reportedDomRevision = -1;
@@ -363,6 +387,7 @@ export class HtmlViewNode extends Node {
363
387
  controlPadY: look.controlPadY * s,
364
388
  controlBorder: look.controlBorder * s,
365
389
  controlRadius: look.controlRadius * s,
390
+ controlFontSize: (look.controlFontSize ?? look.fontSize) * s,
366
391
  };
367
392
  this._deviceLookFor = look;
368
393
  this._deviceLookAt = s;
@@ -372,7 +397,26 @@ export class HtmlViewNode extends Node {
372
397
 
373
398
  // --- the pipeline ---------------------------------------------------------
374
399
 
375
- private _invalidate(stale: Stale): void {
400
+ /**
401
+ * `restyle` with `Stale.Boxes`: the elements whose styles the change
402
+ * could have touched, where that is all of it — a pointer move's reach
403
+ * (`_hoverReach`). The build it asks for keeps every other element's
404
+ * (`_restyleOnly`).
405
+ */
406
+ private _invalidate(
407
+ stale: Stale,
408
+ restyle: ReadonlySet<Element> | null = null,
409
+ ): void {
410
+ if (stale >= Stale.Boxes) {
411
+ const only = stale === Stale.Boxes ? restyle : null;
412
+ if (this._stale < Stale.Boxes) {
413
+ this._restyleOnly = only ? new Set(only) : null;
414
+ } else if (this._restyleOnly && only) {
415
+ for (const el of only) this._restyleOnly.add(el);
416
+ } else {
417
+ this._restyleOnly = null;
418
+ }
419
+ }
376
420
  if (stale > this._stale) this._stale = stale;
377
421
  if (stale >= Stale.Layout) this.invalidateMeasure('content');
378
422
  this.invalidate(stale >= Stale.Layout, this, 'props');
@@ -578,7 +622,10 @@ export class HtmlViewNode extends Node {
578
622
  kept.cascade.viewportHeight = this._viewportHeight();
579
623
  faces = kept.faces;
580
624
  } else {
581
- const sheets: Stylesheet[] = [uaStylesheet(look)];
625
+ // the UA sheet's `px` are CSS pixels, which the cascade scales like an
626
+ // author's, so it is written from the look in CSS pixels: from the
627
+ // device one, a control's size and a button's chrome were doubled at 2x
628
+ const sheets: Stylesheet[] = [uaStylesheet(props.look)];
582
629
  const imports: ImportRead[][] = [];
583
630
  faces = [];
584
631
  let order = 0;
@@ -633,10 +680,17 @@ export class HtmlViewNode extends Node {
633
680
  // The faces this width and scheme declare: a `@font-face` may sit in a
634
681
  // `@media` block like any rule.
635
682
  const cssWidth = width / this._scale;
683
+ const cssHeight = this._viewportHeight() / this._scale;
636
684
  this._webFonts.setFallback(look.fontFamily);
637
685
  this._webFonts.setFaces(
638
686
  faces.filter((f) =>
639
- mediaMatches(f.rule.media, cssWidth, look.colorScheme),
687
+ mediaMatches(
688
+ f.rule.media,
689
+ cssWidth,
690
+ look.colorScheme,
691
+ cssHeight,
692
+ this._scale,
693
+ ),
640
694
  ),
641
695
  );
642
696
  this._cascade.setPointer({
@@ -893,15 +947,21 @@ export class HtmlViewNode extends Node {
893
947
  const wasWidth = this._documentWidth;
894
948
  const wasHeight = this._documentHeight;
895
949
 
950
+ // the styles a build may keep (`_restyleOnly`), unless something
951
+ // found below changes them all
952
+ let restyleOnly = this._stale === Stale.Boxes ? this._restyleOnly : null;
953
+ this._restyleOnly = null;
896
954
  if (this._stale >= Stale.Style || !this._cascade) {
897
955
  this._restyle(target);
898
956
  this._stale = Math.max(this._stale, Stale.Boxes) as Stale;
957
+ restyleOnly = null;
899
958
  } else if (this._cascade.mediaBand(target) !== this._mediaBand) {
900
959
  // A resize that crossed a `@media` breakpoint is the one resize that
901
960
  // does have to restyle. Knowing which resizes those are is why the
902
961
  // breakpoints are collected at parse time.
903
962
  this._restyle(target);
904
963
  this._stale = Math.max(this._stale, Stale.Boxes) as Stale;
964
+ restyleOnly = null;
905
965
  }
906
966
 
907
967
  const cascade = this._cascade;
@@ -913,18 +973,27 @@ export class HtmlViewNode extends Node {
913
973
  // again, where some style reads the side that moved. A document that
914
974
  // reads neither — most — goes on skipping the cascade on a resize.
915
975
  if (
916
- this._stale < Stale.Boxes &&
917
- ((cascade.readsViewportWidth && this._styledWidth !== target) ||
918
- (cascade.readsViewportHeight && this._styledHeight !== viewport))
976
+ (cascade.readsViewportWidth && this._styledWidth !== target) ||
977
+ (cascade.readsViewportHeight && this._styledHeight !== viewport)
919
978
  ) {
920
- this._stale = Stale.Boxes;
979
+ if (this._stale < Stale.Boxes) this._stale = Stale.Boxes;
980
+ restyleOnly = null;
921
981
  }
922
982
 
923
983
  if (this._stale >= Stale.Boxes || !this._tree) {
924
984
  const look = this._deviceLook();
985
+ // A build a pointer move asked for styles the elements the move
986
+ // reached, and takes every other element's from the tree it replaces
987
+ // (`Cascade.beginSharing`): the first build only, since a second is
988
+ // one the first found a reason for — a face, an image's size.
989
+ let kept: KeptStyles | null =
990
+ restyleOnly && this._tree && this._styledWith === cascade
991
+ ? { styles: this._tree.styles, restyle: restyleOnly }
992
+ : null;
925
993
  const build = () =>
926
994
  buildBoxes(this._source.document, {
927
995
  cascade,
996
+ kept,
928
997
  scale: this._scale,
929
998
  imageSize: (el) => this._resources.imageSize(imageUrlOf(el) ?? ''),
930
999
  urlSize: (url) => this._resources.imageSize(url),
@@ -932,6 +1001,8 @@ export class HtmlViewNode extends Node {
932
1001
  measureControl(el, kind, style, this._fonts(), look),
933
1002
  });
934
1003
  this._tree = build();
1004
+ kept = null;
1005
+ this._styledWith = cascade;
935
1006
  this._styledWidth = target;
936
1007
  this._styledHeight = viewport;
937
1008
  this._requestBackgrounds(this._tree);
@@ -1010,6 +1081,7 @@ export class HtmlViewNode extends Node {
1010
1081
  y: r.y / s,
1011
1082
  width: r.width / s,
1012
1083
  height: r.height / s,
1084
+ fontSize: r.fontSize / s,
1013
1085
  ...(r.bare && {
1014
1086
  bare: {
1015
1087
  ...r.bare,
@@ -1112,6 +1184,7 @@ export class HtmlViewNode extends Node {
1112
1184
  }
1113
1185
 
1114
1186
  override destroySubtree(): void {
1187
+ this._dropHeldHover();
1115
1188
  this._resources.destroy();
1116
1189
  this._webFonts.destroy();
1117
1190
  this._source.destroy();
@@ -1209,7 +1282,8 @@ export class HtmlViewNode extends Node {
1209
1282
  const to = this._toUnits(end);
1210
1283
  if (to <= from) return [];
1211
1284
  const out: Rect[] = [];
1212
- collectBands(tree.root, from, to, this.abs.x, this.abs.y, out);
1285
+ // the rows the highlight paints, over glyphs taller than their line
1286
+ collectBands(tree.root, from, to, this.abs.x, this.abs.y, out, true);
1213
1287
  return out;
1214
1288
  }
1215
1289
 
@@ -1280,11 +1354,16 @@ export class HtmlViewNode extends Node {
1280
1354
  for (const block of (box.cut ? CUT_BLOCKS.get(box) : null) ?? []) {
1281
1355
  const around = block.parent;
1282
1356
  if (!around) continue;
1357
+ // from where clearance moved it down from, as Blink's line around
1358
+ // a block in an inline box starts there: the Zen Garden's 214
1359
+ // makes its content an inline box of floats and a footer that
1360
+ // clears them, and Chrome measures it from the floats' top
1361
+ const top = Math.min(block.y, CLEARED_FROM.get(block) ?? block.y);
1283
1362
  bands.push({
1284
1363
  x: around.contentX,
1285
- y: block.y,
1364
+ y: top,
1286
1365
  width: around.contentWidth,
1287
- height: block.height,
1366
+ height: block.y + block.height - top,
1288
1367
  });
1289
1368
  }
1290
1369
  for (const band of bands) rect = rect ? unionRect(rect, band) : band;
@@ -1381,6 +1460,7 @@ export class HtmlViewNode extends Node {
1381
1460
  * contains a `:hover` rule.
1382
1461
  */
1383
1462
  setHover(x: number, y: number): boolean {
1463
+ this._dropHeldHover();
1384
1464
  const cascade = this._cascade;
1385
1465
  if (!cascade || !cascade.hoverSensitive) return false;
1386
1466
  const chain: Element[] = [];
@@ -1398,6 +1478,7 @@ export class HtmlViewNode extends Node {
1398
1478
  }
1399
1479
 
1400
1480
  clearHover(): boolean {
1481
+ this._dropHeldHover();
1401
1482
  if (!this._hovered.length) return false;
1402
1483
  const was = this._hovered;
1403
1484
  this._hovered = [];
@@ -1406,95 +1487,145 @@ export class HtmlViewNode extends Node {
1406
1487
  return true;
1407
1488
  }
1408
1489
 
1409
- /** The hovered chain moved: restyle where it did, or the document. */
1490
+ /**
1491
+ * The hovered chain moved: nothing, where no rule that tests the pointer
1492
+ * names an element it moved over; a restyle where they are, where all it
1493
+ * changed is ink (`_hoverInPlace`); and otherwise the boxes built again,
1494
+ * with the styles of every element the move did not reach kept.
1495
+ */
1410
1496
  private _restyleHover(was: readonly Element[], now: readonly Element[]) {
1411
- // a restyle in place names what it repainted (`_hoverInPlace`)
1412
- if (this._hoverInPlace(was, now) === false) this._invalidate(Stale.Boxes);
1497
+ const reach = this._hoverReach(was, now);
1498
+ if (reach !== null) {
1499
+ if (!reach.size) return;
1500
+ if (this._hoverInPlace(reach, was)) return;
1501
+ }
1502
+ this._invalidate(Stale.Boxes, reach);
1413
1503
  }
1414
1504
 
1415
1505
  /**
1416
- * A pointer move restyled where it happened, when all it changed is ink.
1506
+ * The elements whose styles a move of the hovered chain can change, or
1507
+ * null where that cannot be said (`Cascade.hoverLocal`).
1417
1508
  *
1418
- * A move to another element is a change to the cascade, and the pipeline
1419
- * answers one by building every box again and laying the document out:
1420
- * 270 ms on X11 and twice that on Cocoa for a Wikipedia article, on every
1421
- * link the pointer crossed. But the rules a page writes for `:hover`
1422
- * nearly all change a colour, an underline, a background or a border's
1423
- * colour — 77 of that article's 79 — and those move nothing. So:
1424
- *
1425
- * - Only an element whose hover state flipped and that a compound testing
1426
- * the pointer could match can change (`Cascade.hoverTouches`), with its
1427
- * subtree, and its later siblings where a sibling combinator follows.
1428
- * A move between two paragraphs under `a:hover` touches nothing.
1429
- * - Those are styled again from their parents. Where anything but ink
1430
- * differs (`PAINT_ONLY`), or a box is not the element's own — an
1431
- * anonymous or a pseudo-element box, a marker, a control — this is
1432
- * not the move to take.
1433
- * - The boxes take their new styles, and each layout of their text is
1434
- * made again from the runs it was made from (`TextLayoutCache.inputsOf`)
1435
- * with the new ink (`runFor`), where its geometry comes out the same.
1436
- *
1437
- * Everything is checked before anything is changed. 'none' where nothing
1438
- * could change, 'painted' where it was restyled here, false where the
1439
- * document has to be built again.
1509
+ * Only an element whose hover state flipped and that a compound testing
1510
+ * the pointer could match can change (`Cascade.hoverTouches`), with its
1511
+ * subtree, and its later siblings' where a sibling combinator is in play;
1512
+ * and the elements a `:has()` testing the pointer may flip, which are
1513
+ * around the ones that changed. A move between two paragraphs under
1514
+ * `a:hover` reaches nothing.
1440
1515
  */
1441
- private _hoverInPlace(
1516
+ private _hoverReach(
1442
1517
  was: readonly Element[],
1443
1518
  now: readonly Element[],
1444
- ): 'none' | 'painted' | false {
1519
+ ): Set<Element> | null {
1445
1520
  const cascade = this._cascade;
1446
- const tree = this._tree;
1447
- const layouts = this._layouts;
1448
- if (!cascade || !tree || !layouts || !cascade.hoverLocal) return false;
1449
- if (this._stale !== Stale.Nothing || this._laidOutWidth < 0) return false;
1450
-
1521
+ if (!cascade || !cascade.hoverLocal) return null;
1451
1522
  const before = new Set(was);
1452
1523
  const after = new Set(now);
1453
- const roots: Element[] = [];
1454
- // and the elements a `:has()` testing the pointer may flip, which
1455
- // are around the ones that changed; the chains are ancestor chains,
1456
- // so the deepest that changed on each side is where to look from
1524
+ const roots = new Map<Element, HoverTouch>();
1457
1525
  const flipped = (el: Element, other: Set<Element>): void => {
1458
1526
  if (other.has(el)) return;
1459
- if (cascade.hoverTouches(el)) roots.push(el);
1527
+ const touch = cascade.hoverTouches(el);
1528
+ if (touch > (roots.get(el) ?? HOVER_UNTOUCHED)) roots.set(el, touch);
1460
1529
  };
1461
1530
  for (const el of was) flipped(el, after);
1462
1531
  for (const el of now) flipped(el, before);
1532
+ // the chains are ancestor chains, so the deepest that changed on each
1533
+ // side is where a `:has()` is looked for from
1463
1534
  const deepest = (chain: readonly Element[], other: Set<Element>) => {
1464
1535
  const el = chain[0];
1465
1536
  if (el && !other.has(el)) cascade.hoverAnchors(el, roots);
1466
1537
  };
1467
1538
  deepest(was, after);
1468
1539
  deepest(now, before);
1469
- if (!roots.length) return 'none';
1470
1540
 
1471
- // what may restyle: the roots' subtrees, which inherit from them and a
1472
- // descendant combinator reaches, and their later siblings' too where a
1473
- // sibling combinator follows a compound that tests the pointer
1541
+ // the roots' subtrees, which inherit from them and a descendant
1542
+ // combinator reaches, and their later siblings' too where a sibling
1543
+ // combinator follows the compound that tests the pointer
1474
1544
  const reach = new Set<Element>();
1475
- const collect = (el: Element): boolean => {
1545
+ const collect = (el: Element): void => {
1476
1546
  const stack: Element[] = [el];
1477
1547
  while (stack.length) {
1478
1548
  const at = stack.pop()!;
1479
1549
  if (reach.has(at)) continue;
1480
1550
  reach.add(at);
1481
- if (reach.size > HOVER_RESTYLE_LIMIT) return false;
1482
1551
  for (const child of at.children)
1483
1552
  if (isElement(child)) stack.push(child);
1484
1553
  }
1485
- return true;
1486
1554
  };
1487
- for (const root of roots) {
1488
- if (!collect(root)) return false;
1489
- if (!cascade.hoverSiblings) continue;
1555
+ for (const [root, touch] of roots) {
1556
+ collect(root);
1557
+ if (touch !== HOVER_FOLLOWED) continue;
1490
1558
  for (let s = root.nextSibling; s; s = s.nextSibling) {
1491
- if (isElement(s) && !collect(s)) return false;
1559
+ if (isElement(s)) collect(s);
1492
1560
  }
1493
1561
  }
1562
+ return reach;
1563
+ }
1564
+
1565
+ /**
1566
+ * A pointer move restyled where it happened, when all it changed is ink.
1567
+ *
1568
+ * A move to another element is a change to the cascade, and the pipeline
1569
+ * answers one by building every box again and laying the document out:
1570
+ * 270 ms on X11 and twice that on Cocoa for a Wikipedia article, on every
1571
+ * link the pointer crossed. But the rules a page writes for `:hover`
1572
+ * nearly all change a colour, an underline, a background or a border's
1573
+ * colour — 77 of that article's 79 — and those move nothing. So:
1574
+ *
1575
+ * - The elements the move reached (`_hoverReach`) are styled again from
1576
+ * their parents — the ones a rule testing the pointer answers
1577
+ * differently for, or whose parent's style changed; the rest, most of
1578
+ * a hovered card or table row, have the styles they had
1579
+ * (`Cascade.pointerChanged`). Where anything but ink differs
1580
+ * (`PAINT_ONLY`), this is not the move to take.
1581
+ * - The boxes take their new styles: an element's own, the text in it,
1582
+ * the anonymous boxes the fix-up made around what is in it, and its
1583
+ * `::before` and `::after`, styled again by their own rules. A box
1584
+ * whose style is derived some other way — a marker, a first letter, a
1585
+ * control — is not one to restyle here.
1586
+ * - Each layout of their text is made again from the runs it was made
1587
+ * from (`TextLayoutCache.inputsOf`) with the new ink (`runFor`), where
1588
+ * its geometry comes out the same.
1589
+ *
1590
+ * Everything is checked before anything is changed, and nothing is looked
1591
+ * at but the blocks the restyled elements are in (`_blocksOf`): a move
1592
+ * costs what it changed, in a document of any length. True where it was
1593
+ * restyled here, or had nothing to restyle; false where the boxes have to
1594
+ * be built again.
1595
+ */
1596
+ private _hoverInPlace(
1597
+ reach: ReadonlySet<Element>,
1598
+ was: readonly Element[],
1599
+ ): boolean {
1600
+ const cascade = this._cascade;
1601
+ const tree = this._tree;
1602
+ const layouts = this._layouts;
1603
+ if (!cascade || !tree || !layouts) return false;
1604
+ if (this._stale !== Stale.Nothing || this._laidOutWidth < 0) return false;
1605
+ if (reach.size > HOVER_RESTYLE_LIMIT) return false;
1606
+ // a first line's, a first letter's and a marker's styles are layout's
1607
+ const pseudoRules = cascade.hoverPseudo;
1608
+ if (pseudoRules === HOVER_PSEUDO_OTHER) return false;
1609
+
1610
+ // whether the rules that test the pointer answer differently for an
1611
+ // element than they did before the move
1612
+ const before = { hovered: new Set(was), active: EMPTY_SET };
1613
+ const flips = new Map<Element, boolean>();
1614
+ const flipped = (el: Element): boolean => {
1615
+ let flip = flips.get(el);
1616
+ if (flip === undefined) {
1617
+ flip = cascade.pointerChanged(el, before);
1618
+ flips.set(el, flip);
1619
+ }
1620
+ return flip;
1621
+ };
1494
1622
 
1495
1623
  // styled again from their parents, a parent first
1496
1624
  const fresh = new Map<Element, ComputedStyle>();
1497
1625
  const changed = new Map<Element, ComputedStyle>();
1626
+ // the ones whose ink is what it was: only their custom properties
1627
+ // changed, which the elements under them read
1628
+ const quiet = new Set<Element>();
1498
1629
  let refused = false;
1499
1630
  // what changed beyond ink (`hoverChange`): how far some box's ink
1500
1631
  // reaches, the order its layer paints in, where one is
@@ -1515,51 +1646,123 @@ export class HtmlViewNode extends Node {
1515
1646
  refused = true;
1516
1647
  return null;
1517
1648
  }
1518
- const style = cascade.styleFor(el, parentStyle, kept.inFlex);
1519
- fresh.set(el, style);
1520
- const diff = hoverChange(kept.style, style);
1521
- if (diff === false) refused = true;
1522
- else if (diff) {
1523
- changed.set(el, style);
1524
- if (diff.reach) reaches = true;
1525
- if (diff.order) reorders = true;
1526
- if (diff.move) moving.add(el);
1649
+ const parentWas = parent ? tree.styles.get(parent)!.style : parentStyle;
1650
+ let style = kept.style;
1651
+ if (parentStyle !== parentWas || flipped(el)) {
1652
+ const made = cascade.styleFor(el, parentStyle, kept.inFlex);
1653
+ const diff = hoverChange(kept.style, made);
1654
+ if (diff === false) refused = true;
1655
+ else if (diff) {
1656
+ style = made;
1657
+ changed.set(el, made);
1658
+ if (!diff.ink) quiet.add(el);
1659
+ if (diff.reach) reaches = true;
1660
+ if (diff.order) reorders = true;
1661
+ if (diff.move) moving.add(el);
1662
+ }
1527
1663
  }
1664
+ fresh.set(el, style);
1528
1665
  return style;
1529
1666
  };
1530
1667
  for (const el of reach) {
1531
1668
  styleOf(el);
1532
1669
  if (refused) return false;
1533
1670
  }
1534
- if (!changed.size) return 'none';
1671
+ if (!changed.size && pseudoRules === HOVER_PSEUDO_NONE) return true;
1535
1672
  for (const el of changed.keys()) {
1536
1673
  const tag = tagOf(el);
1537
1674
  // their backgrounds are the canvas's
1538
1675
  if (tag === 'html' || tag === 'body') return false;
1539
1676
  }
1540
1677
 
1541
- // the boxes: each changed element's own, and nothing that takes its
1542
- // style from one without being it
1678
+ // The boxes, each under its parent: what a box takes depends on what
1679
+ // the box it is in took.
1543
1680
  const fonts = layouts.fonts;
1681
+ const blocks = this._blocksOf(
1682
+ tree,
1683
+ pseudoRules === HOVER_PSEUDO_NONE ? changed.keys() : reach,
1684
+ );
1544
1685
  const restyled: [Box, ComputedStyle][] = [];
1686
+ const next = new Map<Box, ComputedStyle>();
1687
+ /** The restyled boxes whose ink changed, to repaint. */
1688
+ const inked = new Set<Box>();
1689
+ /** The `::before` and `::after` boxes found, by element: 1 and 2. */
1690
+ const generated = new Map<Element, number>();
1545
1691
  const redecorated: [Box, Box['decoration']][] = [];
1546
- const walk: Box[] = [tree.root];
1692
+ const walk: Box[] = blocks.slice();
1547
1693
  while (walk.length) {
1548
1694
  const box = walk.pop()!;
1549
1695
  for (const child of box.children) walk.push(child);
1550
- const owner = box.el ?? nearestElement(box);
1551
- if (!owner) continue;
1552
- const style = changed.get(owner);
1696
+ const parent = box.parent;
1697
+ const above = parent ? next.get(parent) : undefined;
1698
+ let style: ComputedStyle | undefined;
1699
+ let ink = !!parent && inked.has(parent);
1700
+ if (box.kind === 'text' || box.kind === 'break') {
1701
+ // Text is set in the style of the box it is in — an element's, a
1702
+ // pseudo-element's — or of its element, where the fix-up put an
1703
+ // anonymous box around it.
1704
+ const el = box.el;
1705
+ if (parent && box.style === parent.style) style = above;
1706
+ else if (el && box.style === tree.styles.get(el)?.style) {
1707
+ style = changed.get(el);
1708
+ ink = !quiet.has(el);
1709
+ } else if (above || (el && changed.has(el))) return false;
1710
+ } else if (box.pseudo === 'before' || box.pseudo === 'after') {
1711
+ const from = GENERATED_FROM.get(box);
1712
+ if (!from) {
1713
+ // an inside marker, made of its item's style and its own rules
1714
+ if (above) return false;
1715
+ continue;
1716
+ }
1717
+ if (!reach.has(from)) continue;
1718
+ generated.set(
1719
+ from,
1720
+ (generated.get(from) ?? 0) | (box.pseudo === 'before' ? 1 : 2),
1721
+ );
1722
+ const inherits = fresh.get(from);
1723
+ const kept = tree.styles.get(from)?.style;
1724
+ if (!inherits || !kept) return false;
1725
+ if (inherits === kept && !flipped(from)) continue;
1726
+ // styled by its own rules over its element's style, as the builder
1727
+ // styled it, in its element's box
1728
+ if (!parent || parent.style !== kept || box.marker) return false;
1729
+ const made = cascade.pseudoStyleFor(from, box.pseudo, inherits);
1730
+ if (!made || made.display === 'none') return false;
1731
+ const diff = hoverChange(box.style, made);
1732
+ if (diff === false) return false;
1733
+ if (!diff) continue;
1734
+ if (diff.reach || diff.order || diff.move) return false;
1735
+ style = made;
1736
+ ink = diff.ink;
1737
+ } else if (box.pseudo) {
1738
+ // a first letter, styled from the box its letter is in
1739
+ if (above) return false;
1740
+ } else if (box.el) {
1741
+ const el = box.el;
1742
+ style = changed.get(el);
1743
+ if (!style) continue;
1744
+ const kept = tree.styles.get(el)!.style;
1745
+ // a style derived from the element's, or drawn from it somewhere
1746
+ // else — a widget mounted beside the document takes its look from
1747
+ // it. An image, a drawing or a rule is drawn here, from its box,
1748
+ // like any.
1749
+ if (box.style !== kept) return false;
1750
+ if (box.marker || WIDGETS.has(box.replaced)) return false;
1751
+ if (moving.has(el) && !movable(box, kept, style)) return false;
1752
+ ink = !quiet.has(el);
1753
+ } else if (above) {
1754
+ // An anonymous box takes what its parent's style passes on
1755
+ // (`anonymousStyles`), so it takes that of the new one: the text of
1756
+ // a link made a flex row is in one, and takes the link's colour.
1757
+ if (GENERATED_FROM.has(box)) return false;
1758
+ const display = box.style.display;
1759
+ if (tree.anonymous(parent!.style, display) !== box.style) return false;
1760
+ style = tree.anonymous(above, display);
1761
+ }
1553
1762
  if (!style) continue;
1554
- const kept = tree.styles.get(owner)!;
1555
- // an anonymous box, a pseudo-element, a marker, a control: a style
1556
- // derived from the element's, or drawn from it somewhere else — a
1557
- // widget mounted beside the document takes its look from it. An
1558
- // image, a drawing or a rule is drawn here, from its box, like any.
1559
- if (!box.el || box.style !== kept.style) return false;
1560
- if (box.marker || WIDGETS.has(box.replaced)) return false;
1561
- if (moving.has(owner) && !movable(box, kept.style, style)) return false;
1763
+ next.set(box, style);
1562
1764
  restyled.push([box, style]);
1765
+ if (ink) inked.add(box);
1563
1766
  if (box.kind === 'inline') {
1564
1767
  const decoration = inlineDecoration(fonts, box, style);
1565
1768
  if ((decoration === null) !== (box.decoration === null)) {
@@ -1569,12 +1772,28 @@ export class HtmlViewNode extends Node {
1569
1772
  if (decoration !== box.decoration) redecorated.push([box, decoration]);
1570
1773
  }
1571
1774
  }
1775
+ // a `::before` or an `::after` a rule that tests the pointer now gives
1776
+ // an element is a box to build
1777
+ if (pseudoRules !== HOVER_PSEUDO_NONE) {
1778
+ for (const el of reach) {
1779
+ if (!flipped(el)) continue;
1780
+ const style = fresh.get(el);
1781
+ if (!style) continue;
1782
+ const has = generated.get(el) ?? 0;
1783
+ for (const [which, bit] of GENERATED_BITS) {
1784
+ if (has & bit) continue;
1785
+ const made = cascade.pseudoStyleFor(el, which, style);
1786
+ if (made && made.display !== 'none') return false;
1787
+ }
1788
+ }
1789
+ }
1790
+ if (!restyled.length) return true;
1572
1791
 
1573
- // the text: each layout holding a run of a changed element's, made
1574
- // again with its new ink, where it comes out the same shape
1792
+ // the text: each layout holding a run of a restyled box's, made again
1793
+ // with its new ink, where it comes out the same shape
1575
1794
  const relaid = new Map<TextLayoutLike, TextLayoutLike | null>();
1576
1795
  const texts: LineText[] = [];
1577
- walk.push(tree.root);
1796
+ if (inked.size) for (const block of blocks) walk.push(block);
1578
1797
  while (walk.length) {
1579
1798
  const box = walk.pop()!;
1580
1799
  for (const child of box.children) walk.push(child);
@@ -1583,16 +1802,16 @@ export class HtmlViewNode extends Node {
1583
1802
  for (const text of line.texts) {
1584
1803
  texts.push(text);
1585
1804
  if (relaid.has(text.layout)) continue;
1586
- const next = reinked(text, layouts, changed, tree.styles);
1587
- if (next === false) return false;
1588
- relaid.set(text.layout, next);
1805
+ const again = reinked(text, layouts, next);
1806
+ if (again === false) return false;
1807
+ relaid.set(text.layout, again);
1589
1808
  }
1590
1809
  }
1591
1810
  }
1592
1811
 
1593
1812
  // what each box drew before, to be repainted with what it draws after
1594
1813
  const inks: Rect[] = [];
1595
- for (const [box] of restyled) {
1814
+ for (const box of inked) {
1596
1815
  const ink = inkOf(box);
1597
1816
  if (ink) inks.push(ink);
1598
1817
  }
@@ -1602,7 +1821,9 @@ export class HtmlViewNode extends Node {
1602
1821
  for (const [box, style] of restyled) {
1603
1822
  const was = box.style;
1604
1823
  box.style = style;
1605
- if (moving.has(box.el!)) moved.push([box, was]);
1824
+ if (box.el && moving.has(box.el) && box.kind !== 'text') {
1825
+ moved.push([box, was]);
1826
+ }
1606
1827
  }
1607
1828
  for (const [box, decoration] of redecorated) {
1608
1829
  // what is inside it keeps which boxes around it paint, and this is
@@ -1613,8 +1834,8 @@ export class HtmlViewNode extends Node {
1613
1834
  box.decoration = decoration;
1614
1835
  }
1615
1836
  for (const text of texts) {
1616
- const next = relaid.get(text.layout);
1617
- if (next) text.layout = next;
1837
+ const again = relaid.get(text.layout);
1838
+ if (again) text.layout = again;
1618
1839
  }
1619
1840
  for (const [el, style] of changed) {
1620
1841
  tree.styles.set(el, { style, inFlex: tree.styles.get(el)!.inFlex });
@@ -1645,14 +1866,61 @@ export class HtmlViewNode extends Node {
1645
1866
  if (moved.length) this._reportControls();
1646
1867
  if (relayout) {
1647
1868
  this._invalidate(Stale.Layout);
1648
- return 'painted';
1869
+ return true;
1649
1870
  }
1650
- for (const [box] of restyled) {
1871
+ for (const box of inked) {
1651
1872
  const ink = inkOf(box);
1652
1873
  if (ink) inks.push(ink);
1653
1874
  }
1654
1875
  this._repaintInk(inks);
1655
- return 'painted';
1876
+ return true;
1877
+ }
1878
+
1879
+ /** An element's first box in document order, for the tree it was asked
1880
+ * of: made on the first move over a tree, which is one walk of it. */
1881
+ private _firstBoxes: { tree: BoxTree; of: Map<Element, Box> } | null = null;
1882
+
1883
+ /**
1884
+ * The blocks a restyle of `changed` has to look in: for each element, the
1885
+ * box its text is set in the lines of — the nearest box around its first
1886
+ * that is not an inline one — which holds every box that takes its style
1887
+ * from it, the pieces a block in it broke it into among them
1888
+ * (`breakAround`). One that is inside another is left to it.
1889
+ */
1890
+ private _blocksOf(tree: BoxTree, changed: Iterable<Element>): Box[] {
1891
+ let index = this._firstBoxes;
1892
+ if (index?.tree !== tree) {
1893
+ const of = new Map<Element, Box>();
1894
+ const stack: Box[] = [tree.root];
1895
+ while (stack.length) {
1896
+ const box = stack.pop()!;
1897
+ if (box.el && !of.has(box.el)) of.set(box.el, box);
1898
+ for (let i = box.children.length - 1; i >= 0; i -= 1) {
1899
+ stack.push(box.children[i]);
1900
+ }
1901
+ }
1902
+ index = this._firstBoxes = { tree, of };
1903
+ }
1904
+ const blocks = new Set<Box>();
1905
+ for (const el of changed) {
1906
+ let box: Box | null = index.of.get(el) ?? null;
1907
+ while (
1908
+ box?.parent &&
1909
+ (box.kind === 'inline' || box.kind === 'text' || box.kind === 'break')
1910
+ ) {
1911
+ box = box.parent;
1912
+ }
1913
+ if (box) blocks.add(box);
1914
+ }
1915
+ const out: Box[] = [];
1916
+ for (const block of blocks) {
1917
+ let inside = false;
1918
+ for (let at = block.parent; at && !inside; at = at.parent) {
1919
+ inside = blocks.has(at);
1920
+ }
1921
+ if (!inside) out.push(block);
1922
+ }
1923
+ return out;
1656
1924
  }
1657
1925
 
1658
1926
  /**
@@ -1715,20 +1983,65 @@ export class HtmlViewNode extends Node {
1715
1983
  // `:hover` is wired at the element rather than through React: the cascade
1716
1984
  // already knows whether any rule in the document tests it, `setHover`
1717
1985
  // returns without work when none does, and a document that does use it
1718
- // re-styles only when the hovered chain actually changed. The restyle is
1719
- // still document-wide — narrowing it to the affected subtree is the
1720
- // phase-2 item the PRD records.
1986
+ // re-styles only when the hovered chain actually changed, and then only
1987
+ // the elements the change reached (`_restyleHover`).
1988
+ //
1989
+ // **A hover waits for a scroll to stop.** Core asks again what is under a
1990
+ // pointer that stayed where it was after every frame that laid out —
1991
+ // hover follows content, react-x11#793 — and says so with a move to the
1992
+ // point the pointer is already at. While a document scrolls that is every
1993
+ // frame, each with something else under the pointer: a restyle a frame
1994
+ // at best, and where a hover moves something, the boxes built again a
1995
+ // frame, which took a page scrolled under a parked pointer from 52 frames
1996
+ // a second to 4. A browser holds its hover until the scroll is over, for
1997
+ // the same reason; so does this, for `HOVER_REST_MS` after the content
1998
+ // last moved. What was hovered stays hovered as it scrolls away, as it
1999
+ // does there. A move of the pointer itself is answered at once.
1721
2000
 
1722
2001
  override defaultMouseMove(ev: X11MouseEvent): void {
1723
2002
  super.defaultMouseMove?.(ev);
2003
+ const at = this._pointerAt;
2004
+ if (at !== null && at.x === ev.x && at.y === ev.y) {
2005
+ this._holdHover();
2006
+ return;
2007
+ }
2008
+ this._pointerAt = { x: ev.x, y: ev.y };
1724
2009
  this.setHover(ev.x, ev.y);
1725
2010
  }
1726
2011
 
1727
2012
  override defaultMouseLeave(ev: X11MouseEvent): void {
1728
2013
  super.defaultMouseLeave?.(ev);
2014
+ this._pointerAt = null;
1729
2015
  this.clearHover();
1730
2016
  }
1731
2017
 
2018
+ /** The content moved under the pointer: the hover is asked for once it
2019
+ * has been still for `HOVER_REST_MS`. One timer a wait, put off by
2020
+ * being armed again when it finds the content moved since. */
2021
+ private _holdHover(): void {
2022
+ if (!this._cascade?.hoverSensitive) return;
2023
+ this._heldSince = hoverClock.now();
2024
+ if (this._heldHover !== null) return;
2025
+ const rest = (): void => {
2026
+ this._heldHover = null;
2027
+ const at = this._pointerAt;
2028
+ if (!at || this.destroyed) return;
2029
+ const left = this._heldSince + HOVER_REST_MS - hoverClock.now();
2030
+ if (left > 0) {
2031
+ this._heldHover = hoverClock.arm(rest, left);
2032
+ return;
2033
+ }
2034
+ this.setHover(at.x, at.y);
2035
+ };
2036
+ this._heldHover = hoverClock.arm(rest, HOVER_REST_MS);
2037
+ }
2038
+
2039
+ private _dropHeldHover(): void {
2040
+ if (this._heldHover === null) return;
2041
+ hoverClock.disarm(this._heldHover);
2042
+ this._heldHover = null;
2043
+ }
2044
+
1732
2045
  // --- paint ----------------------------------------------------------------
1733
2046
 
1734
2047
  override paint(ctx: Context2D): void {
@@ -2026,6 +2339,8 @@ function sameRects(a: ControlRect[], b: ControlRect[]): boolean {
2026
2339
  p.y !== q.y ||
2027
2340
  p.width !== q.width ||
2028
2341
  p.height !== q.height ||
2342
+ p.fontFamily !== q.fontFamily ||
2343
+ p.fontSize !== q.fontSize ||
2029
2344
  p.opacity !== q.opacity ||
2030
2345
  !sameBare(p.bare, q.bare)
2031
2346
  ) {
@@ -2320,6 +2635,9 @@ function lineBands(
2320
2635
  /** Where the lines put the text, not where a relative offset moved it
2321
2636
  * (`TEXT_SHIFTS`): what an inline box's own offset is added to. */
2322
2637
  laidOut = false,
2638
+ /** The rows a highlight covers (`selectionRows`) rather than the line's:
2639
+ * past it where the glyphs are taller than the line. */
2640
+ ink = false,
2323
2641
  ): void {
2324
2642
  // First line whose text can reach `from`, by binary search over the
2325
2643
  // sorted text starts; stop at the first line past `to`.
@@ -2348,6 +2666,9 @@ function lineBands(
2348
2666
  // a link that ends a line a space wider than Chrome's. So the text
2349
2667
  // a line ends with is measured no further than its content.
2350
2668
  const ends = endsLine(line, text, natural);
2669
+ const rows = ink
2670
+ ? selectionRows(line, text, natural)
2671
+ : { y: line.y, height: line.height };
2351
2672
  for (const band of bandsFor(
2352
2673
  text.layout,
2353
2674
  natural,
@@ -2363,9 +2684,9 @@ function lineBands(
2363
2684
  }
2364
2685
  out.push({
2365
2686
  x: dx + left + text.drawX - moved,
2366
- y: dy + line.y,
2687
+ y: dy + rows.y,
2367
2688
  width: right - left,
2368
- height: line.height,
2689
+ height: rows.height,
2369
2690
  });
2370
2691
  }
2371
2692
  // the spaces `pre-wrap` keeps that the line ends on, which have no
@@ -2375,9 +2696,9 @@ function lineBands(
2375
2696
  if (at < a || at >= b) continue;
2376
2697
  out.push({
2377
2698
  x: dx + space.x + text.drawX - moved,
2378
- y: dy + line.y,
2699
+ y: dy + rows.y,
2379
2700
  width: space.width,
2380
- height: line.height,
2701
+ height: rows.height,
2381
2702
  });
2382
2703
  }
2383
2704
  }
@@ -2407,14 +2728,15 @@ function collectBands(
2407
2728
  dx: number,
2408
2729
  dy: number,
2409
2730
  out: Rect[],
2731
+ ink = false,
2410
2732
  ): void {
2411
2733
  if (box.subtreeTextEnd <= box.subtreeTextStart) return;
2412
2734
  if (box.subtreeTextEnd <= from || box.subtreeTextStart >= to) return;
2413
- if (box.lines) lineBands(box.lines, from, to, dx, dy, out);
2735
+ if (box.lines) lineBands(box.lines, from, to, dx, dy, out, false, ink);
2414
2736
  // Atomics are ordinary children, reached below.
2415
2737
  for (const child of box.children) {
2416
2738
  if (child.kind === 'text' || child.kind === 'break') continue;
2417
- collectBands(child, from, to, dx, dy, out);
2739
+ collectBands(child, from, to, dx, dy, out, ink);
2418
2740
  }
2419
2741
  }
2420
2742
 
@@ -2621,6 +2943,38 @@ function compareKeys(a: readonly number[], b: readonly number[]): number {
2621
2943
  * that matches a container reaches everything in it. */
2622
2944
  const HOVER_RESTYLE_LIMIT = 300;
2623
2945
 
2946
+ /** How long the content under a still pointer has to have stopped moving
2947
+ * before the hover is asked for again (`HtmlViewNode._holdHover`): a
2948
+ * tenth of a second, which is what WebKit waits after a scroll before it
2949
+ * sends the mouse move that updates its own. */
2950
+ const HOVER_REST_MS = 100;
2951
+
2952
+ const timers = globalThis as {
2953
+ setTimeout?(fn: () => void, ms: number): unknown;
2954
+ clearTimeout?(id: unknown): void;
2955
+ };
2956
+
2957
+ /**
2958
+ * The clock a held hover waits on. Through `globalThis` because `src/`
2959
+ * compiles with `types: []`, and unref'd where the runtime allows it: a
2960
+ * timer of a document's must not keep a process alive that is otherwise
2961
+ * done. One object, exported from this module though not from the package,
2962
+ * so a test can hold it and say when the rest is over (`test/held-clock.ts`).
2963
+ */
2964
+ export const hoverClock = {
2965
+ now(): number {
2966
+ return Date.now();
2967
+ },
2968
+ arm(step: () => void, ms: number): unknown {
2969
+ const handle = timers.setTimeout?.(step, ms) ?? null;
2970
+ (handle as { unref?(): void } | null)?.unref?.();
2971
+ return handle;
2972
+ },
2973
+ disarm(handle: unknown): void {
2974
+ timers.clearTimeout?.(handle);
2975
+ },
2976
+ };
2977
+
2624
2978
  /**
2625
2979
  * The computed properties a pointer move may change in place: ink, which
2626
2980
  * moves nothing. A background colour is drawn inside the box it colours
@@ -2676,11 +3030,21 @@ const FACE_FIELDS = [
2676
3030
  /** What a restyle in place changed beyond ink: how far a box's ink
2677
3031
  * reaches, the order its layer paints in, or where it is. */
2678
3032
  interface HoverChange {
3033
+ /** Whether anything that is drawn changed: false where only the custom
3034
+ * properties did, which the elements under this one read. */
3035
+ ink: boolean;
2679
3036
  reach: boolean;
2680
3037
  order: boolean;
2681
3038
  move: boolean;
2682
3039
  }
2683
3040
 
3041
+ /** An element's `::before` and `::after`, each with its bit in the set
3042
+ * of the ones a restyle found boxes of (`_hoverInPlace`). */
3043
+ const GENERATED_BITS = [
3044
+ ['before', 1],
3045
+ ['after', 2],
3046
+ ] as const;
3047
+
2684
3048
  /** The replaced boxes that are controls (`BoxTree.controls`, `controls.ts`):
2685
3049
  * a widget mounted beside the document, or a button whose press it reports. */
2686
3050
  const WIDGETS = new Set<ReplacedKind>([
@@ -2717,8 +3081,17 @@ function hoverChange(
2717
3081
  const b = now as unknown as Record<string, unknown>;
2718
3082
  let change: HoverChange | null = null;
2719
3083
  for (const key in b) {
2720
- if (key === 'custom' || sameValue(a[key], b[key])) continue;
2721
- change ??= { reach: false, order: false, move: false };
3084
+ // the sets are shared by what made them (`Cascade._customFor`): one
3085
+ // object where they are the same
3086
+ if (key === 'custom') {
3087
+ if (a[key] !== b[key]) {
3088
+ change ??= { ink: false, reach: false, order: false, move: false };
3089
+ }
3090
+ continue;
3091
+ }
3092
+ if (sameValue(a[key], b[key])) continue;
3093
+ change ??= { ink: true, reach: false, order: false, move: false };
3094
+ change.ink = true;
2722
3095
  if (PAINT_ONLY.has(key)) continue;
2723
3096
  if (INK_REACH.has(key)) {
2724
3097
  change.reach = true;
@@ -2802,16 +3175,9 @@ function sameValue(x: unknown, y: unknown): boolean {
2802
3175
  return true;
2803
3176
  }
2804
3177
 
2805
- /** The element an anonymous box takes its style from: its nearest
2806
- * ancestor's that has one. */
2807
- function nearestElement(box: Box): Element | null {
2808
- for (let at = box.parent; at; at = at.parent) if (at.el) return at.el;
2809
- return null;
2810
- }
2811
-
2812
3178
  /**
2813
- * `text`'s layout made again with the ink of the elements a pointer move
2814
- * restyled: from the runs it was made from, each of a changed element's
3179
+ * `text`'s layout made again with the ink of the boxes a pointer move
3180
+ * restyled: from the runs it was made from, each of a restyled text box's
2815
3181
  * given the ink its new style makes (`runFor`) and nothing else. Null where
2816
3182
  * none of its runs is theirs; false where that cannot be told — a run this
2817
3183
  * cannot place in the document, one some other pass inked (a first line's),
@@ -2820,19 +3186,16 @@ function nearestElement(box: Box): Element | null {
2820
3186
  function reinked(
2821
3187
  text: LineText,
2822
3188
  layouts: TextLayoutCache,
2823
- changed: ReadonlyMap<Element, ComputedStyle>,
2824
- styles: BoxTree['styles'],
3189
+ next: ReadonlyMap<Box, ComputedStyle>,
2825
3190
  ): TextLayoutLike | null | false {
2826
3191
  const spans = text.spans;
2827
- const ownerAt = (offset: number): Element | null =>
2828
- spans.boxAt ? (spans.boxAt(offset)?.el ?? null) : null;
2829
3192
  const inputs = layouts.inputsOf(text.layout);
2830
3193
  if (!inputs || !spans.boxAt) {
2831
3194
  // not one this could make again: none of its text may be theirs
2832
3195
  for (const line of text.layout.lines) {
2833
3196
  for (const run of line.runs) {
2834
- const owner = spans.boxAt ? ownerAt(run.start) : null;
2835
- if (!spans.boxAt || (owner && changed.has(owner))) return false;
3197
+ const box = spans.boxAt?.(run.start);
3198
+ if (!spans.boxAt || (box && next.has(box))) return false;
2836
3199
  }
2837
3200
  }
2838
3201
  return null;
@@ -2843,25 +3206,25 @@ function reinked(
2843
3206
  for (let i = 0; i < content.length; i += 1) {
2844
3207
  const run = content[i];
2845
3208
  const length = run.text.length;
2846
- const owner = length ? ownerAt(offset) : null;
2847
- const style = owner ? changed.get(owner) : undefined;
2848
- if (owner && style) {
2849
- if (ownerAt(offset + length - 1) !== owner) return false;
2850
- const was = runFor(run.text, styles.get(owner)!.style);
3209
+ const box = length ? spans.boxAt(offset) : null;
3210
+ const style = box ? next.get(box) : undefined;
3211
+ if (box && style) {
3212
+ if (spans.boxAt(offset + length - 1) !== box) return false;
3213
+ const was = runFor(run.text, box.style);
2851
3214
  const now = runFor(run.text, style);
2852
3215
  for (const f of FACE_FIELDS) if (!sameValue(was[f], now[f])) return false;
2853
- let next: Record<string, unknown> | null = null;
3216
+ let inked: Record<string, unknown> | null = null;
2854
3217
  for (const f of INK_FIELDS) {
2855
3218
  // a run that is not what its style made — a first line's colour
2856
3219
  if (!sameValue(run[f], was[f])) return false;
2857
3220
  if (sameValue(was[f], now[f])) continue;
2858
- next ??= { ...run };
2859
- if (now[f] === undefined) delete next[f];
2860
- else next[f] = now[f];
3221
+ inked ??= { ...run };
3222
+ if (now[f] === undefined) delete inked[f];
3223
+ else inked[f] = now[f];
2861
3224
  }
2862
- if (next) {
3225
+ if (inked) {
2863
3226
  runs ??= content.slice();
2864
- runs[i] = next as unknown as TextRun;
3227
+ runs[i] = inked as unknown as TextRun;
2865
3228
  }
2866
3229
  }
2867
3230
  offset += length;