@react-x11/components 0.12.0 → 0.14.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 (150) hide show
  1. package/dist/html/controls.d.ts +41 -5
  2. package/dist/html/controls.d.ts.map +1 -1
  3. package/dist/html/controls.js +85 -57
  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 +545 -126
  8. package/dist/html/css/cascade.js.map +1 -1
  9. package/dist/html/css/color.d.ts +18 -0
  10. package/dist/html/css/color.d.ts.map +1 -1
  11. package/dist/html/css/color.js +27 -0
  12. package/dist/html/css/color.js.map +1 -1
  13. package/dist/html/css/parse.d.ts +47 -6
  14. package/dist/html/css/parse.d.ts.map +1 -1
  15. package/dist/html/css/parse.js +336 -30
  16. package/dist/html/css/parse.js.map +1 -1
  17. package/dist/html/css/style.d.ts +142 -15
  18. package/dist/html/css/style.d.ts.map +1 -1
  19. package/dist/html/css/style.js +563 -147
  20. package/dist/html/css/style.js.map +1 -1
  21. package/dist/html/css/transform.d.ts +85 -0
  22. package/dist/html/css/transform.d.ts.map +1 -0
  23. package/dist/html/css/transform.js +425 -0
  24. package/dist/html/css/transform.js.map +1 -0
  25. package/dist/html/css/ua.d.ts +11 -1
  26. package/dist/html/css/ua.d.ts.map +1 -1
  27. package/dist/html/css/ua.js +73 -9
  28. package/dist/html/css/ua.js.map +1 -1
  29. package/dist/html/css/values.d.ts +4 -0
  30. package/dist/html/css/values.d.ts.map +1 -1
  31. package/dist/html/css/values.js.map +1 -1
  32. package/dist/html/dom.d.ts.map +1 -1
  33. package/dist/html/dom.js +3 -1
  34. package/dist/html/dom.js.map +1 -1
  35. package/dist/html/fonts.d.ts +78 -4
  36. package/dist/html/fonts.d.ts.map +1 -1
  37. package/dist/html/fonts.js +373 -19
  38. package/dist/html/fonts.js.map +1 -1
  39. package/dist/html/form.d.ts +200 -0
  40. package/dist/html/form.d.ts.map +1 -0
  41. package/dist/html/form.js +801 -0
  42. package/dist/html/form.js.map +1 -0
  43. package/dist/html/index.d.ts +12 -0
  44. package/dist/html/index.d.ts.map +1 -1
  45. package/dist/html/index.js +30 -301
  46. package/dist/html/index.js.map +1 -1
  47. package/dist/html/layout/axes.d.ts +14 -0
  48. package/dist/html/layout/axes.d.ts.map +1 -0
  49. package/dist/html/layout/axes.js +28 -0
  50. package/dist/html/layout/axes.js.map +1 -0
  51. package/dist/html/layout/block.d.ts +93 -12
  52. package/dist/html/layout/block.d.ts.map +1 -1
  53. package/dist/html/layout/block.js +393 -39
  54. package/dist/html/layout/block.js.map +1 -1
  55. package/dist/html/layout/boxes.d.ts +90 -2
  56. package/dist/html/layout/boxes.d.ts.map +1 -1
  57. package/dist/html/layout/boxes.js +74 -10
  58. package/dist/html/layout/boxes.js.map +1 -1
  59. package/dist/html/layout/cache.d.ts +1 -1
  60. package/dist/html/layout/cache.d.ts.map +1 -1
  61. package/dist/html/layout/cache.js +6 -0
  62. package/dist/html/layout/cache.js.map +1 -1
  63. package/dist/html/layout/css-grid.d.ts.map +1 -1
  64. package/dist/html/layout/css-grid.js +90 -18
  65. package/dist/html/layout/css-grid.js.map +1 -1
  66. package/dist/html/layout/flex.d.ts.map +1 -1
  67. package/dist/html/layout/flex.js +574 -84
  68. package/dist/html/layout/flex.js.map +1 -1
  69. package/dist/html/layout/inline.d.ts +3 -0
  70. package/dist/html/layout/inline.d.ts.map +1 -1
  71. package/dist/html/layout/inline.js +154 -54
  72. package/dist/html/layout/inline.js.map +1 -1
  73. package/dist/html/layout/multicol.d.ts +42 -0
  74. package/dist/html/layout/multicol.d.ts.map +1 -0
  75. package/dist/html/layout/multicol.js +897 -0
  76. package/dist/html/layout/multicol.js.map +1 -0
  77. package/dist/html/layout/shaping.d.ts +7 -0
  78. package/dist/html/layout/shaping.d.ts.map +1 -1
  79. package/dist/html/layout/shaping.js +25 -20
  80. package/dist/html/layout/shaping.js.map +1 -1
  81. package/dist/html/layout/table.d.ts.map +1 -1
  82. package/dist/html/layout/table.js +62 -18
  83. package/dist/html/layout/table.js.map +1 -1
  84. package/dist/html/node.d.ts +90 -15
  85. package/dist/html/node.d.ts.map +1 -1
  86. package/dist/html/node.js +771 -186
  87. package/dist/html/node.js.map +1 -1
  88. package/dist/html/paint.d.ts +101 -5
  89. package/dist/html/paint.d.ts.map +1 -1
  90. package/dist/html/paint.js +1502 -160
  91. package/dist/html/paint.js.map +1 -1
  92. package/dist/html/svg.d.ts +15 -2
  93. package/dist/html/svg.d.ts.map +1 -1
  94. package/dist/html/svg.js +249 -17
  95. package/dist/html/svg.js.map +1 -1
  96. package/dist/html/widgets.d.ts +29 -0
  97. package/dist/html/widgets.d.ts.map +1 -0
  98. package/dist/html/widgets.js +700 -0
  99. package/dist/html/widgets.js.map +1 -0
  100. package/dist/html/woff2.d.ts +14 -0
  101. package/dist/html/woff2.d.ts.map +1 -0
  102. package/dist/html/woff2.js +608 -0
  103. package/dist/html/woff2.js.map +1 -0
  104. package/dist/index.d.ts +1 -1
  105. package/dist/index.d.ts.map +1 -1
  106. package/dist/index.js.map +1 -1
  107. package/dist/maps/controller.d.ts +17 -1
  108. package/dist/maps/controller.d.ts.map +1 -1
  109. package/dist/maps/controller.js +27 -7
  110. package/dist/maps/controller.js.map +1 -1
  111. package/dist/richtext/node.d.ts +8 -1
  112. package/dist/richtext/node.d.ts.map +1 -1
  113. package/dist/richtext/node.js +5 -1
  114. package/dist/richtext/node.js.map +1 -1
  115. package/dist/richtext/runs.d.ts +25 -6
  116. package/dist/richtext/runs.d.ts.map +1 -1
  117. package/dist/richtext/runs.js +240 -24
  118. package/dist/richtext/runs.js.map +1 -1
  119. package/package.json +2 -2
  120. package/src/html/controls.ts +114 -51
  121. package/src/html/css/cascade.ts +614 -140
  122. package/src/html/css/color.ts +29 -0
  123. package/src/html/css/parse.ts +388 -33
  124. package/src/html/css/style.ts +688 -150
  125. package/src/html/css/transform.ts +459 -0
  126. package/src/html/css/ua.ts +77 -10
  127. package/src/html/css/values.ts +4 -0
  128. package/src/html/dom.ts +3 -1
  129. package/src/html/fonts.ts +417 -24
  130. package/src/html/form.ts +962 -0
  131. package/src/html/index.ts +49 -338
  132. package/src/html/layout/axes.ts +62 -0
  133. package/src/html/layout/block.ts +447 -37
  134. package/src/html/layout/boxes.ts +143 -14
  135. package/src/html/layout/cache.ts +6 -0
  136. package/src/html/layout/css-grid.ts +95 -22
  137. package/src/html/layout/flex.ts +675 -78
  138. package/src/html/layout/inline.ts +159 -55
  139. package/src/html/layout/multicol.ts +977 -0
  140. package/src/html/layout/shaping.ts +27 -18
  141. package/src/html/layout/table.ts +62 -19
  142. package/src/html/node.ts +798 -172
  143. package/src/html/paint.ts +1742 -241
  144. package/src/html/svg.ts +277 -19
  145. package/src/html/widgets.ts +821 -0
  146. package/src/html/woff2.ts +612 -0
  147. package/src/index.ts +1 -0
  148. package/src/maps/controller.ts +28 -7
  149. package/src/richtext/node.ts +13 -2
  150. package/src/richtext/runs.ts +313 -35
package/src/html/node.ts CHANGED
@@ -50,22 +50,33 @@ 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
- import type { Stylesheet } from './css/parse.js';
62
+ import type { MediaCondition, Stylesheet } from './css/parse.js';
57
63
  import { uaStylesheet } from './css/ua.js';
58
64
  import type { ComputedStyle, RootLook } from './css/style.js';
59
65
  import {
60
66
  BOX_RAISES,
61
67
  buildBoxes,
62
68
  CONTENT_IMAGES,
69
+ CLEARED_FROM,
70
+ COLUMN_LINES,
71
+ COLUMN_PIECES,
72
+ COLUMN_ROWS,
63
73
  CUT_BLOCKS,
64
74
  GENERATED_FROM,
65
75
  INLINE_OFFSETS,
66
76
  LINE_BOX_RAISES,
67
77
  SHIFTED_LINES,
68
78
  TEXT_SHIFTS,
79
+ columned,
69
80
  } from './layout/boxes.js';
70
81
  import type {
71
82
  Box,
@@ -75,8 +86,10 @@ import type {
75
86
  ReplacedKind,
76
87
  TextLayoutLike,
77
88
  } from './layout/boxes.js';
78
- import { layoutDocument, retranslate, transformed } from './layout/block.js';
89
+ import { layoutDocument, placedMatrix, retranslate } from './layout/block.js';
90
+ import { invert, mapPoint, mapRect, transformed } from './css/transform.js';
79
91
  import { TextLayoutCache } from './layout/cache.js';
92
+ import { weightAxes } from './layout/axes.js';
80
93
  import { shapingSafe } from './layout/shaping.js';
81
94
  import { SurfaceCache, newSurface } from './surfaces.js';
82
95
  import type { SurfaceLike } from './surfaces.js';
@@ -102,9 +115,16 @@ import {
102
115
  hoistNegative,
103
116
  holds,
104
117
  holdsAbsolute,
118
+ inClip,
119
+ inClipPath,
120
+ onLine,
121
+ ownBounds,
105
122
  paintDocument,
123
+ pathClips,
106
124
  queryChildIndex,
125
+ selectionRows,
107
126
  stackLayers,
127
+ stacksLayers,
108
128
  } from './paint.js';
109
129
  import type { PaintContext } from './paint.js';
110
130
  import { controlRectsOf, measureControl } from './controls.js';
@@ -249,6 +269,8 @@ export class HtmlViewNode extends Node {
249
269
  * that read it (`vw`, `vh`). */
250
270
  private _styledWidth = -1;
251
271
  private _styledHeight = -1;
272
+ /** The cascade the box tree's styles were computed by. */
273
+ private _styledWith: Cascade | null = null;
252
274
  private _documentHeight = 0;
253
275
  private _documentWidth = 0;
254
276
  /** The viewport height the document was last laid out under, and whether
@@ -266,6 +288,20 @@ export class HtmlViewNode extends Node {
266
288
  * the text carries surrogate pairs. null until checked. */
267
289
  private _pointsAreUnits: boolean | null = null;
268
290
  private _hovered: Element[] = [];
291
+ /** Where the pointer last moved to, in logical window pixels, while it
292
+ * is over the element (`defaultMouseMove`). */
293
+ private _pointerAt: { x: number; y: number } | null = null;
294
+ /** A hover held until the content under a still pointer stops moving:
295
+ * the timer, and when the content last moved (`_holdHover`). */
296
+ private _heldHover: unknown = null;
297
+ private _heldSince = 0;
298
+ /**
299
+ * The elements the build of the boxes that is due has to style again,
300
+ * where every change asking for it said which (`_invalidate`), and null
301
+ * where any element's style may have changed — a new cascade, a viewport
302
+ * a `vw` reads, a face that arrived.
303
+ */
304
+ private _restyleOnly: Set<Element> | null = null;
269
305
  private _scriptsSeen = new WeakSet<Element>();
270
306
  private _controls: ControlRect[] = [];
271
307
  private _reportedDomRevision = -1;
@@ -363,6 +399,7 @@ export class HtmlViewNode extends Node {
363
399
  controlPadY: look.controlPadY * s,
364
400
  controlBorder: look.controlBorder * s,
365
401
  controlRadius: look.controlRadius * s,
402
+ controlFontSize: (look.controlFontSize ?? look.fontSize) * s,
366
403
  };
367
404
  this._deviceLookFor = look;
368
405
  this._deviceLookAt = s;
@@ -372,7 +409,26 @@ export class HtmlViewNode extends Node {
372
409
 
373
410
  // --- the pipeline ---------------------------------------------------------
374
411
 
375
- private _invalidate(stale: Stale): void {
412
+ /**
413
+ * `restyle` with `Stale.Boxes`: the elements whose styles the change
414
+ * could have touched, where that is all of it — a pointer move's reach
415
+ * (`_hoverReach`). The build it asks for keeps every other element's
416
+ * (`_restyleOnly`).
417
+ */
418
+ private _invalidate(
419
+ stale: Stale,
420
+ restyle: ReadonlySet<Element> | null = null,
421
+ ): void {
422
+ if (stale >= Stale.Boxes) {
423
+ const only = stale === Stale.Boxes ? restyle : null;
424
+ if (this._stale < Stale.Boxes) {
425
+ this._restyleOnly = only ? new Set(only) : null;
426
+ } else if (this._restyleOnly && only) {
427
+ for (const el of only) this._restyleOnly.add(el);
428
+ } else {
429
+ this._restyleOnly = null;
430
+ }
431
+ }
376
432
  if (stale > this._stale) this._stale = stale;
377
433
  if (stale >= Stale.Layout) this.invalidateMeasure('content');
378
434
  this.invalidate(stale >= Stale.Layout, this, 'props');
@@ -578,7 +634,10 @@ export class HtmlViewNode extends Node {
578
634
  kept.cascade.viewportHeight = this._viewportHeight();
579
635
  faces = kept.faces;
580
636
  } else {
581
- const sheets: Stylesheet[] = [uaStylesheet(look)];
637
+ // the UA sheet's `px` are CSS pixels, which the cascade scales like an
638
+ // author's, so it is written from the look in CSS pixels: from the
639
+ // device one, a control's size and a button's chrome were doubled at 2x
640
+ const sheets: Stylesheet[] = [uaStylesheet(props.look)];
582
641
  const imports: ImportRead[][] = [];
583
642
  faces = [];
584
643
  let order = 0;
@@ -633,10 +692,17 @@ export class HtmlViewNode extends Node {
633
692
  // The faces this width and scheme declare: a `@font-face` may sit in a
634
693
  // `@media` block like any rule.
635
694
  const cssWidth = width / this._scale;
695
+ const cssHeight = this._viewportHeight() / this._scale;
636
696
  this._webFonts.setFallback(look.fontFamily);
637
697
  this._webFonts.setFaces(
638
698
  faces.filter((f) =>
639
- mediaMatches(f.rule.media, cssWidth, look.colorScheme),
699
+ mediaMatches(
700
+ f.rule.media,
701
+ cssWidth,
702
+ look.colorScheme,
703
+ cssHeight,
704
+ this._scale,
705
+ ),
640
706
  ),
641
707
  );
642
708
  this._cascade.setPointer({
@@ -662,15 +728,23 @@ export class HtmlViewNode extends Node {
662
728
  place: (sheet: Stylesheet, element: Element) => void,
663
729
  depth = 0,
664
730
  chain: Set<string> = new Set(),
731
+ /** The media queries `sheet` is itself under, where it was imported
732
+ * under some. */
733
+ under: MediaCondition[][] | null = null,
665
734
  ): void {
666
- for (const url of sheet.imports) {
735
+ for (let i = 0; i < sheet.imports.length; i += 1) {
736
+ const url = sheet.imports[i];
737
+ // what it imports is under its import's media queries, and under
738
+ // the ones the sheet importing it is under
739
+ const conditions = sheet.importConditions[i];
740
+ const media = conditions ? [...(under ?? []), conditions] : under;
667
741
  this._resources.request({ url, kind: 'stylesheet', element });
668
742
  const fetched = this._resources.stylesheet(url, [encoding]);
669
743
  const base = this._resources.sheetBase(url);
670
744
  seen.push({ url, text: fetched?.text ?? null, base });
671
745
  const key = base ?? url;
672
746
  if (!fetched || depth >= MAX_IMPORT_DEPTH || chain.has(key)) continue;
673
- const imported = parseStylesheet(fetched.text, 0, layers, base);
747
+ const imported = parseStylesheet(fetched.text, 0, layers, base, media);
674
748
  chain.add(key);
675
749
  this._placeImports(
676
750
  imported,
@@ -681,6 +755,7 @@ export class HtmlViewNode extends Node {
681
755
  place,
682
756
  depth + 1,
683
757
  chain,
758
+ media,
684
759
  );
685
760
  chain.delete(key);
686
761
  place(imported, element);
@@ -795,11 +870,22 @@ export class HtmlViewNode extends Node {
795
870
  return height && height > 0 ? height : 600;
796
871
  }
797
872
 
873
+ /** The engine this document lays text out with, and the one it is over:
874
+ * one object while the engine is, since caches are kept by it. */
875
+ private _engine: { over: FontsLike; fonts: FontsLike } | null = null;
876
+
798
877
  private _fonts(): FontsLike | null {
799
878
  const fonts = (this.app as { fonts?: FontsLike } | null)?.fonts;
879
+ if (!fonts) return null;
800
880
  // a face the engine cannot shape from costs its characters, not the
801
881
  // document (`shaping.ts`)
802
- return fonts ? shapingSafe(fonts) : null;
882
+ const safe = shapingSafe(fonts);
883
+ if (this._engine?.over !== safe) {
884
+ // and a variable face of the document's is set at the weight its
885
+ // rule has for a style's (`axes.ts`)
886
+ this._engine = { over: safe, fonts: weightAxes(safe, this._webFonts) };
887
+ }
888
+ return this._engine.fonts;
803
889
  }
804
890
 
805
891
  /** The text layouts the last pass made, for this one to reuse. */
@@ -893,15 +979,21 @@ export class HtmlViewNode extends Node {
893
979
  const wasWidth = this._documentWidth;
894
980
  const wasHeight = this._documentHeight;
895
981
 
982
+ // the styles a build may keep (`_restyleOnly`), unless something
983
+ // found below changes them all
984
+ let restyleOnly = this._stale === Stale.Boxes ? this._restyleOnly : null;
985
+ this._restyleOnly = null;
896
986
  if (this._stale >= Stale.Style || !this._cascade) {
897
987
  this._restyle(target);
898
988
  this._stale = Math.max(this._stale, Stale.Boxes) as Stale;
989
+ restyleOnly = null;
899
990
  } else if (this._cascade.mediaBand(target) !== this._mediaBand) {
900
991
  // A resize that crossed a `@media` breakpoint is the one resize that
901
992
  // does have to restyle. Knowing which resizes those are is why the
902
993
  // breakpoints are collected at parse time.
903
994
  this._restyle(target);
904
995
  this._stale = Math.max(this._stale, Stale.Boxes) as Stale;
996
+ restyleOnly = null;
905
997
  }
906
998
 
907
999
  const cascade = this._cascade;
@@ -913,18 +1005,27 @@ export class HtmlViewNode extends Node {
913
1005
  // again, where some style reads the side that moved. A document that
914
1006
  // reads neither — most — goes on skipping the cascade on a resize.
915
1007
  if (
916
- this._stale < Stale.Boxes &&
917
- ((cascade.readsViewportWidth && this._styledWidth !== target) ||
918
- (cascade.readsViewportHeight && this._styledHeight !== viewport))
1008
+ (cascade.readsViewportWidth && this._styledWidth !== target) ||
1009
+ (cascade.readsViewportHeight && this._styledHeight !== viewport)
919
1010
  ) {
920
- this._stale = Stale.Boxes;
1011
+ if (this._stale < Stale.Boxes) this._stale = Stale.Boxes;
1012
+ restyleOnly = null;
921
1013
  }
922
1014
 
923
1015
  if (this._stale >= Stale.Boxes || !this._tree) {
924
1016
  const look = this._deviceLook();
1017
+ // A build a pointer move asked for styles the elements the move
1018
+ // reached, and takes every other element's from the tree it replaces
1019
+ // (`Cascade.beginSharing`): the first build only, since a second is
1020
+ // one the first found a reason for — a face, an image's size.
1021
+ let kept: KeptStyles | null =
1022
+ restyleOnly && this._tree && this._styledWith === cascade
1023
+ ? { styles: this._tree.styles, restyle: restyleOnly }
1024
+ : null;
925
1025
  const build = () =>
926
1026
  buildBoxes(this._source.document, {
927
1027
  cascade,
1028
+ kept,
928
1029
  scale: this._scale,
929
1030
  imageSize: (el) => this._resources.imageSize(imageUrlOf(el) ?? ''),
930
1031
  urlSize: (url) => this._resources.imageSize(url),
@@ -932,6 +1033,8 @@ export class HtmlViewNode extends Node {
932
1033
  measureControl(el, kind, style, this._fonts(), look),
933
1034
  });
934
1035
  this._tree = build();
1036
+ kept = null;
1037
+ this._styledWith = cascade;
935
1038
  this._styledWidth = target;
936
1039
  this._styledHeight = viewport;
937
1040
  this._requestBackgrounds(this._tree);
@@ -1001,7 +1104,7 @@ export class HtmlViewNode extends Node {
1001
1104
  // The boxes are device pixels; each rect becomes the style of a widget
1002
1105
  // mounted beside this element, and a style is logical.
1003
1106
  const s = this._scale;
1004
- const rects = controlRectsOf(tree).map((r) =>
1107
+ const rects = controlRectsOf(tree, s).map((r) =>
1005
1108
  s === 1
1006
1109
  ? r
1007
1110
  : {
@@ -1010,6 +1113,15 @@ export class HtmlViewNode extends Node {
1010
1113
  y: r.y / s,
1011
1114
  width: r.width / s,
1012
1115
  height: r.height / s,
1116
+ fontSize: r.fontSize / s,
1117
+ ...(r.clip && {
1118
+ clip: {
1119
+ x: r.clip.x / s,
1120
+ y: r.clip.y / s,
1121
+ width: r.clip.width / s,
1122
+ height: r.clip.height / s,
1123
+ },
1124
+ }),
1013
1125
  ...(r.bare && {
1014
1126
  bare: {
1015
1127
  ...r.bare,
@@ -1112,6 +1224,7 @@ export class HtmlViewNode extends Node {
1112
1224
  }
1113
1225
 
1114
1226
  override destroySubtree(): void {
1227
+ this._dropHeldHover();
1115
1228
  this._resources.destroy();
1116
1229
  this._webFonts.destroy();
1117
1230
  this._source.destroy();
@@ -1209,7 +1322,8 @@ export class HtmlViewNode extends Node {
1209
1322
  const to = this._toUnits(end);
1210
1323
  if (to <= from) return [];
1211
1324
  const out: Rect[] = [];
1212
- collectBands(tree.root, from, to, this.abs.x, this.abs.y, out);
1325
+ // the rows the highlight paints, over glyphs taller than their line
1326
+ collectBands(tree.root, from, to, this.abs.x, this.abs.y, out, true);
1213
1327
  return out;
1214
1328
  }
1215
1329
 
@@ -1280,11 +1394,16 @@ export class HtmlViewNode extends Node {
1280
1394
  for (const block of (box.cut ? CUT_BLOCKS.get(box) : null) ?? []) {
1281
1395
  const around = block.parent;
1282
1396
  if (!around) continue;
1397
+ // from where clearance moved it down from, as Blink's line around
1398
+ // a block in an inline box starts there: the Zen Garden's 214
1399
+ // makes its content an inline box of floats and a footer that
1400
+ // clears them, and Chrome measures it from the floats' top
1401
+ const top = Math.min(block.y, CLEARED_FROM.get(block) ?? block.y);
1283
1402
  bands.push({
1284
1403
  x: around.contentX,
1285
- y: block.y,
1404
+ y: top,
1286
1405
  width: around.contentWidth,
1287
- height: block.height,
1406
+ height: block.y + block.height - top,
1288
1407
  });
1289
1408
  }
1290
1409
  for (const band of bands) rect = rect ? unionRect(rect, band) : band;
@@ -1317,6 +1436,9 @@ export class HtmlViewNode extends Node {
1317
1436
  rect = { x: caret.x, y: caret.y, width: 0, height: caret.height };
1318
1437
  }
1319
1438
  }
1439
+ // where it is drawn: the rectangle around it, turned and scaled as the
1440
+ // boxes it is in are, and itself (`getBoundingClientRect`, CSSOM View)
1441
+ rect = throughTransforms(rect, box);
1320
1442
  const s = this._scale;
1321
1443
  return {
1322
1444
  x: rect.x / s,
@@ -1381,6 +1503,7 @@ export class HtmlViewNode extends Node {
1381
1503
  * contains a `:hover` rule.
1382
1504
  */
1383
1505
  setHover(x: number, y: number): boolean {
1506
+ this._dropHeldHover();
1384
1507
  const cascade = this._cascade;
1385
1508
  if (!cascade || !cascade.hoverSensitive) return false;
1386
1509
  const chain: Element[] = [];
@@ -1398,6 +1521,7 @@ export class HtmlViewNode extends Node {
1398
1521
  }
1399
1522
 
1400
1523
  clearHover(): boolean {
1524
+ this._dropHeldHover();
1401
1525
  if (!this._hovered.length) return false;
1402
1526
  const was = this._hovered;
1403
1527
  this._hovered = [];
@@ -1406,95 +1530,145 @@ export class HtmlViewNode extends Node {
1406
1530
  return true;
1407
1531
  }
1408
1532
 
1409
- /** The hovered chain moved: restyle where it did, or the document. */
1533
+ /**
1534
+ * The hovered chain moved: nothing, where no rule that tests the pointer
1535
+ * names an element it moved over; a restyle where they are, where all it
1536
+ * changed is ink (`_hoverInPlace`); and otherwise the boxes built again,
1537
+ * with the styles of every element the move did not reach kept.
1538
+ */
1410
1539
  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);
1540
+ const reach = this._hoverReach(was, now);
1541
+ if (reach !== null) {
1542
+ if (!reach.size) return;
1543
+ if (this._hoverInPlace(reach, was)) return;
1544
+ }
1545
+ this._invalidate(Stale.Boxes, reach);
1413
1546
  }
1414
1547
 
1415
1548
  /**
1416
- * A pointer move restyled where it happened, when all it changed is ink.
1417
- *
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:
1549
+ * The elements whose styles a move of the hovered chain can change, or
1550
+ * null where that cannot be said (`Cascade.hoverLocal`).
1424
1551
  *
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.
1552
+ * Only an element whose hover state flipped and that a compound testing
1553
+ * the pointer could match can change (`Cascade.hoverTouches`), with its
1554
+ * subtree, and its later siblings' where a sibling combinator is in play;
1555
+ * and the elements a `:has()` testing the pointer may flip, which are
1556
+ * around the ones that changed. A move between two paragraphs under
1557
+ * `a:hover` reaches nothing.
1440
1558
  */
1441
- private _hoverInPlace(
1559
+ private _hoverReach(
1442
1560
  was: readonly Element[],
1443
1561
  now: readonly Element[],
1444
- ): 'none' | 'painted' | false {
1562
+ ): Set<Element> | null {
1445
1563
  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
-
1564
+ if (!cascade || !cascade.hoverLocal) return null;
1451
1565
  const before = new Set(was);
1452
1566
  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
1567
+ const roots = new Map<Element, HoverTouch>();
1457
1568
  const flipped = (el: Element, other: Set<Element>): void => {
1458
1569
  if (other.has(el)) return;
1459
- if (cascade.hoverTouches(el)) roots.push(el);
1570
+ const touch = cascade.hoverTouches(el);
1571
+ if (touch > (roots.get(el) ?? HOVER_UNTOUCHED)) roots.set(el, touch);
1460
1572
  };
1461
1573
  for (const el of was) flipped(el, after);
1462
1574
  for (const el of now) flipped(el, before);
1575
+ // the chains are ancestor chains, so the deepest that changed on each
1576
+ // side is where a `:has()` is looked for from
1463
1577
  const deepest = (chain: readonly Element[], other: Set<Element>) => {
1464
1578
  const el = chain[0];
1465
1579
  if (el && !other.has(el)) cascade.hoverAnchors(el, roots);
1466
1580
  };
1467
1581
  deepest(was, after);
1468
1582
  deepest(now, before);
1469
- if (!roots.length) return 'none';
1470
1583
 
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
1584
+ // the roots' subtrees, which inherit from them and a descendant
1585
+ // combinator reaches, and their later siblings' too where a sibling
1586
+ // combinator follows the compound that tests the pointer
1474
1587
  const reach = new Set<Element>();
1475
- const collect = (el: Element): boolean => {
1588
+ const collect = (el: Element): void => {
1476
1589
  const stack: Element[] = [el];
1477
1590
  while (stack.length) {
1478
1591
  const at = stack.pop()!;
1479
1592
  if (reach.has(at)) continue;
1480
1593
  reach.add(at);
1481
- if (reach.size > HOVER_RESTYLE_LIMIT) return false;
1482
1594
  for (const child of at.children)
1483
1595
  if (isElement(child)) stack.push(child);
1484
1596
  }
1485
- return true;
1486
1597
  };
1487
- for (const root of roots) {
1488
- if (!collect(root)) return false;
1489
- if (!cascade.hoverSiblings) continue;
1598
+ for (const [root, touch] of roots) {
1599
+ collect(root);
1600
+ if (touch !== HOVER_FOLLOWED) continue;
1490
1601
  for (let s = root.nextSibling; s; s = s.nextSibling) {
1491
- if (isElement(s) && !collect(s)) return false;
1602
+ if (isElement(s)) collect(s);
1492
1603
  }
1493
1604
  }
1605
+ return reach;
1606
+ }
1607
+
1608
+ /**
1609
+ * A pointer move restyled where it happened, when all it changed is ink.
1610
+ *
1611
+ * A move to another element is a change to the cascade, and the pipeline
1612
+ * answers one by building every box again and laying the document out:
1613
+ * 270 ms on X11 and twice that on Cocoa for a Wikipedia article, on every
1614
+ * link the pointer crossed. But the rules a page writes for `:hover`
1615
+ * nearly all change a colour, an underline, a background or a border's
1616
+ * colour — 77 of that article's 79 — and those move nothing. So:
1617
+ *
1618
+ * - The elements the move reached (`_hoverReach`) are styled again from
1619
+ * their parents — the ones a rule testing the pointer answers
1620
+ * differently for, or whose parent's style changed; the rest, most of
1621
+ * a hovered card or table row, have the styles they had
1622
+ * (`Cascade.pointerChanged`). Where anything but ink differs
1623
+ * (`PAINT_ONLY`), this is not the move to take.
1624
+ * - The boxes take their new styles: an element's own, the text in it,
1625
+ * the anonymous boxes the fix-up made around what is in it, and its
1626
+ * `::before` and `::after`, styled again by their own rules. A box
1627
+ * whose style is derived some other way — a marker, a first letter, a
1628
+ * control — is not one to restyle here.
1629
+ * - Each layout of their text is made again from the runs it was made
1630
+ * from (`TextLayoutCache.inputsOf`) with the new ink (`runFor`), where
1631
+ * its geometry comes out the same.
1632
+ *
1633
+ * Everything is checked before anything is changed, and nothing is looked
1634
+ * at but the blocks the restyled elements are in (`_blocksOf`): a move
1635
+ * costs what it changed, in a document of any length. True where it was
1636
+ * restyled here, or had nothing to restyle; false where the boxes have to
1637
+ * be built again.
1638
+ */
1639
+ private _hoverInPlace(
1640
+ reach: ReadonlySet<Element>,
1641
+ was: readonly Element[],
1642
+ ): boolean {
1643
+ const cascade = this._cascade;
1644
+ const tree = this._tree;
1645
+ const layouts = this._layouts;
1646
+ if (!cascade || !tree || !layouts) return false;
1647
+ if (this._stale !== Stale.Nothing || this._laidOutWidth < 0) return false;
1648
+ if (reach.size > HOVER_RESTYLE_LIMIT) return false;
1649
+ // a first line's, a first letter's and a marker's styles are layout's
1650
+ const pseudoRules = cascade.hoverPseudo;
1651
+ if (pseudoRules === HOVER_PSEUDO_OTHER) return false;
1652
+
1653
+ // whether the rules that test the pointer answer differently for an
1654
+ // element than they did before the move
1655
+ const before = { hovered: new Set(was), active: EMPTY_SET };
1656
+ const flips = new Map<Element, boolean>();
1657
+ const flipped = (el: Element): boolean => {
1658
+ let flip = flips.get(el);
1659
+ if (flip === undefined) {
1660
+ flip = cascade.pointerChanged(el, before);
1661
+ flips.set(el, flip);
1662
+ }
1663
+ return flip;
1664
+ };
1494
1665
 
1495
1666
  // styled again from their parents, a parent first
1496
1667
  const fresh = new Map<Element, ComputedStyle>();
1497
1668
  const changed = new Map<Element, ComputedStyle>();
1669
+ // the ones whose ink is what it was: only their custom properties
1670
+ // changed, which the elements under them read
1671
+ const quiet = new Set<Element>();
1498
1672
  let refused = false;
1499
1673
  // what changed beyond ink (`hoverChange`): how far some box's ink
1500
1674
  // reaches, the order its layer paints in, where one is
@@ -1515,51 +1689,123 @@ export class HtmlViewNode extends Node {
1515
1689
  refused = true;
1516
1690
  return null;
1517
1691
  }
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);
1692
+ const parentWas = parent ? tree.styles.get(parent)!.style : parentStyle;
1693
+ let style = kept.style;
1694
+ if (parentStyle !== parentWas || flipped(el)) {
1695
+ const made = cascade.styleFor(el, parentStyle, kept.inFlex);
1696
+ const diff = hoverChange(kept.style, made);
1697
+ if (diff === false) refused = true;
1698
+ else if (diff) {
1699
+ style = made;
1700
+ changed.set(el, made);
1701
+ if (!diff.ink) quiet.add(el);
1702
+ if (diff.reach) reaches = true;
1703
+ if (diff.order) reorders = true;
1704
+ if (diff.move) moving.add(el);
1705
+ }
1527
1706
  }
1707
+ fresh.set(el, style);
1528
1708
  return style;
1529
1709
  };
1530
1710
  for (const el of reach) {
1531
1711
  styleOf(el);
1532
1712
  if (refused) return false;
1533
1713
  }
1534
- if (!changed.size) return 'none';
1714
+ if (!changed.size && pseudoRules === HOVER_PSEUDO_NONE) return true;
1535
1715
  for (const el of changed.keys()) {
1536
1716
  const tag = tagOf(el);
1537
1717
  // their backgrounds are the canvas's
1538
1718
  if (tag === 'html' || tag === 'body') return false;
1539
1719
  }
1540
1720
 
1541
- // the boxes: each changed element's own, and nothing that takes its
1542
- // style from one without being it
1721
+ // The boxes, each under its parent: what a box takes depends on what
1722
+ // the box it is in took.
1543
1723
  const fonts = layouts.fonts;
1724
+ const blocks = this._blocksOf(
1725
+ tree,
1726
+ pseudoRules === HOVER_PSEUDO_NONE ? changed.keys() : reach,
1727
+ );
1544
1728
  const restyled: [Box, ComputedStyle][] = [];
1729
+ const next = new Map<Box, ComputedStyle>();
1730
+ /** The restyled boxes whose ink changed, to repaint. */
1731
+ const inked = new Set<Box>();
1732
+ /** The `::before` and `::after` boxes found, by element: 1 and 2. */
1733
+ const generated = new Map<Element, number>();
1545
1734
  const redecorated: [Box, Box['decoration']][] = [];
1546
- const walk: Box[] = [tree.root];
1735
+ const walk: Box[] = blocks.slice();
1547
1736
  while (walk.length) {
1548
1737
  const box = walk.pop()!;
1549
1738
  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);
1739
+ const parent = box.parent;
1740
+ const above = parent ? next.get(parent) : undefined;
1741
+ let style: ComputedStyle | undefined;
1742
+ let ink = !!parent && inked.has(parent);
1743
+ if (box.kind === 'text' || box.kind === 'break') {
1744
+ // Text is set in the style of the box it is in — an element's, a
1745
+ // pseudo-element's — or of its element, where the fix-up put an
1746
+ // anonymous box around it.
1747
+ const el = box.el;
1748
+ if (parent && box.style === parent.style) style = above;
1749
+ else if (el && box.style === tree.styles.get(el)?.style) {
1750
+ style = changed.get(el);
1751
+ ink = !quiet.has(el);
1752
+ } else if (above || (el && changed.has(el))) return false;
1753
+ } else if (box.pseudo === 'before' || box.pseudo === 'after') {
1754
+ const from = GENERATED_FROM.get(box);
1755
+ if (!from) {
1756
+ // an inside marker, made of its item's style and its own rules
1757
+ if (above) return false;
1758
+ continue;
1759
+ }
1760
+ if (!reach.has(from)) continue;
1761
+ generated.set(
1762
+ from,
1763
+ (generated.get(from) ?? 0) | (box.pseudo === 'before' ? 1 : 2),
1764
+ );
1765
+ const inherits = fresh.get(from);
1766
+ const kept = tree.styles.get(from)?.style;
1767
+ if (!inherits || !kept) return false;
1768
+ if (inherits === kept && !flipped(from)) continue;
1769
+ // styled by its own rules over its element's style, as the builder
1770
+ // styled it, in its element's box
1771
+ if (!parent || parent.style !== kept || box.marker) return false;
1772
+ const made = cascade.pseudoStyleFor(from, box.pseudo, inherits);
1773
+ if (!made || made.display === 'none') return false;
1774
+ const diff = hoverChange(box.style, made);
1775
+ if (diff === false) return false;
1776
+ if (!diff) continue;
1777
+ if (diff.reach || diff.order || diff.move) return false;
1778
+ style = made;
1779
+ ink = diff.ink;
1780
+ } else if (box.pseudo) {
1781
+ // a first letter, styled from the box its letter is in
1782
+ if (above) return false;
1783
+ } else if (box.el) {
1784
+ const el = box.el;
1785
+ style = changed.get(el);
1786
+ if (!style) continue;
1787
+ const kept = tree.styles.get(el)!.style;
1788
+ // a style derived from the element's, or drawn from it somewhere
1789
+ // else — a widget mounted beside the document takes its look from
1790
+ // it. An image, a drawing or a rule is drawn here, from its box,
1791
+ // like any.
1792
+ if (box.style !== kept) return false;
1793
+ if (box.marker || WIDGETS.has(box.replaced)) return false;
1794
+ if (moving.has(el) && !movable(box, kept, style)) return false;
1795
+ ink = !quiet.has(el);
1796
+ } else if (above) {
1797
+ // An anonymous box takes what its parent's style passes on
1798
+ // (`anonymousStyles`), so it takes that of the new one: the text of
1799
+ // a link made a flex row is in one, and takes the link's colour.
1800
+ if (GENERATED_FROM.has(box)) return false;
1801
+ const display = box.style.display;
1802
+ if (tree.anonymous(parent!.style, display) !== box.style) return false;
1803
+ style = tree.anonymous(above, display);
1804
+ }
1553
1805
  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;
1806
+ next.set(box, style);
1562
1807
  restyled.push([box, style]);
1808
+ if (ink) inked.add(box);
1563
1809
  if (box.kind === 'inline') {
1564
1810
  const decoration = inlineDecoration(fonts, box, style);
1565
1811
  if ((decoration === null) !== (box.decoration === null)) {
@@ -1569,12 +1815,28 @@ export class HtmlViewNode extends Node {
1569
1815
  if (decoration !== box.decoration) redecorated.push([box, decoration]);
1570
1816
  }
1571
1817
  }
1818
+ // a `::before` or an `::after` a rule that tests the pointer now gives
1819
+ // an element is a box to build
1820
+ if (pseudoRules !== HOVER_PSEUDO_NONE) {
1821
+ for (const el of reach) {
1822
+ if (!flipped(el)) continue;
1823
+ const style = fresh.get(el);
1824
+ if (!style) continue;
1825
+ const has = generated.get(el) ?? 0;
1826
+ for (const [which, bit] of GENERATED_BITS) {
1827
+ if (has & bit) continue;
1828
+ const made = cascade.pseudoStyleFor(el, which, style);
1829
+ if (made && made.display !== 'none') return false;
1830
+ }
1831
+ }
1832
+ }
1833
+ if (!restyled.length) return true;
1572
1834
 
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
1835
+ // the text: each layout holding a run of a restyled box's, made again
1836
+ // with its new ink, where it comes out the same shape
1575
1837
  const relaid = new Map<TextLayoutLike, TextLayoutLike | null>();
1576
1838
  const texts: LineText[] = [];
1577
- walk.push(tree.root);
1839
+ if (inked.size) for (const block of blocks) walk.push(block);
1578
1840
  while (walk.length) {
1579
1841
  const box = walk.pop()!;
1580
1842
  for (const child of box.children) walk.push(child);
@@ -1583,16 +1845,16 @@ export class HtmlViewNode extends Node {
1583
1845
  for (const text of line.texts) {
1584
1846
  texts.push(text);
1585
1847
  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);
1848
+ const again = reinked(text, layouts, next);
1849
+ if (again === false) return false;
1850
+ relaid.set(text.layout, again);
1589
1851
  }
1590
1852
  }
1591
1853
  }
1592
1854
 
1593
1855
  // what each box drew before, to be repainted with what it draws after
1594
1856
  const inks: Rect[] = [];
1595
- for (const [box] of restyled) {
1857
+ for (const box of inked) {
1596
1858
  const ink = inkOf(box);
1597
1859
  if (ink) inks.push(ink);
1598
1860
  }
@@ -1602,7 +1864,9 @@ export class HtmlViewNode extends Node {
1602
1864
  for (const [box, style] of restyled) {
1603
1865
  const was = box.style;
1604
1866
  box.style = style;
1605
- if (moving.has(box.el!)) moved.push([box, was]);
1867
+ if (box.el && moving.has(box.el) && box.kind !== 'text') {
1868
+ moved.push([box, was]);
1869
+ }
1606
1870
  }
1607
1871
  for (const [box, decoration] of redecorated) {
1608
1872
  // what is inside it keeps which boxes around it paint, and this is
@@ -1613,8 +1877,8 @@ export class HtmlViewNode extends Node {
1613
1877
  box.decoration = decoration;
1614
1878
  }
1615
1879
  for (const text of texts) {
1616
- const next = relaid.get(text.layout);
1617
- if (next) text.layout = next;
1880
+ const again = relaid.get(text.layout);
1881
+ if (again) text.layout = again;
1618
1882
  }
1619
1883
  for (const [el, style] of changed) {
1620
1884
  tree.styles.set(el, { style, inFlex: tree.styles.get(el)!.inFlex });
@@ -1645,14 +1909,61 @@ export class HtmlViewNode extends Node {
1645
1909
  if (moved.length) this._reportControls();
1646
1910
  if (relayout) {
1647
1911
  this._invalidate(Stale.Layout);
1648
- return 'painted';
1912
+ return true;
1649
1913
  }
1650
- for (const [box] of restyled) {
1914
+ for (const box of inked) {
1651
1915
  const ink = inkOf(box);
1652
1916
  if (ink) inks.push(ink);
1653
1917
  }
1654
1918
  this._repaintInk(inks);
1655
- return 'painted';
1919
+ return true;
1920
+ }
1921
+
1922
+ /** An element's first box in document order, for the tree it was asked
1923
+ * of: made on the first move over a tree, which is one walk of it. */
1924
+ private _firstBoxes: { tree: BoxTree; of: Map<Element, Box> } | null = null;
1925
+
1926
+ /**
1927
+ * The blocks a restyle of `changed` has to look in: for each element, the
1928
+ * box its text is set in the lines of — the nearest box around its first
1929
+ * that is not an inline one — which holds every box that takes its style
1930
+ * from it, the pieces a block in it broke it into among them
1931
+ * (`breakAround`). One that is inside another is left to it.
1932
+ */
1933
+ private _blocksOf(tree: BoxTree, changed: Iterable<Element>): Box[] {
1934
+ let index = this._firstBoxes;
1935
+ if (index?.tree !== tree) {
1936
+ const of = new Map<Element, Box>();
1937
+ const stack: Box[] = [tree.root];
1938
+ while (stack.length) {
1939
+ const box = stack.pop()!;
1940
+ if (box.el && !of.has(box.el)) of.set(box.el, box);
1941
+ for (let i = box.children.length - 1; i >= 0; i -= 1) {
1942
+ stack.push(box.children[i]);
1943
+ }
1944
+ }
1945
+ index = this._firstBoxes = { tree, of };
1946
+ }
1947
+ const blocks = new Set<Box>();
1948
+ for (const el of changed) {
1949
+ let box: Box | null = index.of.get(el) ?? null;
1950
+ while (
1951
+ box?.parent &&
1952
+ (box.kind === 'inline' || box.kind === 'text' || box.kind === 'break')
1953
+ ) {
1954
+ box = box.parent;
1955
+ }
1956
+ if (box) blocks.add(box);
1957
+ }
1958
+ const out: Box[] = [];
1959
+ for (const block of blocks) {
1960
+ let inside = false;
1961
+ for (let at = block.parent; at && !inside; at = at.parent) {
1962
+ inside = blocks.has(at);
1963
+ }
1964
+ if (!inside) out.push(block);
1965
+ }
1966
+ return out;
1656
1967
  }
1657
1968
 
1658
1969
  /**
@@ -1715,20 +2026,65 @@ export class HtmlViewNode extends Node {
1715
2026
  // `:hover` is wired at the element rather than through React: the cascade
1716
2027
  // already knows whether any rule in the document tests it, `setHover`
1717
2028
  // 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.
2029
+ // re-styles only when the hovered chain actually changed, and then only
2030
+ // the elements the change reached (`_restyleHover`).
2031
+ //
2032
+ // **A hover waits for a scroll to stop.** Core asks again what is under a
2033
+ // pointer that stayed where it was after every frame that laid out —
2034
+ // hover follows content, react-x11#793 — and says so with a move to the
2035
+ // point the pointer is already at. While a document scrolls that is every
2036
+ // frame, each with something else under the pointer: a restyle a frame
2037
+ // at best, and where a hover moves something, the boxes built again a
2038
+ // frame, which took a page scrolled under a parked pointer from 52 frames
2039
+ // a second to 4. A browser holds its hover until the scroll is over, for
2040
+ // the same reason; so does this, for `HOVER_REST_MS` after the content
2041
+ // last moved. What was hovered stays hovered as it scrolls away, as it
2042
+ // does there. A move of the pointer itself is answered at once.
1721
2043
 
1722
2044
  override defaultMouseMove(ev: X11MouseEvent): void {
1723
2045
  super.defaultMouseMove?.(ev);
2046
+ const at = this._pointerAt;
2047
+ if (at !== null && at.x === ev.x && at.y === ev.y) {
2048
+ this._holdHover();
2049
+ return;
2050
+ }
2051
+ this._pointerAt = { x: ev.x, y: ev.y };
1724
2052
  this.setHover(ev.x, ev.y);
1725
2053
  }
1726
2054
 
1727
2055
  override defaultMouseLeave(ev: X11MouseEvent): void {
1728
2056
  super.defaultMouseLeave?.(ev);
2057
+ this._pointerAt = null;
1729
2058
  this.clearHover();
1730
2059
  }
1731
2060
 
2061
+ /** The content moved under the pointer: the hover is asked for once it
2062
+ * has been still for `HOVER_REST_MS`. One timer a wait, put off by
2063
+ * being armed again when it finds the content moved since. */
2064
+ private _holdHover(): void {
2065
+ if (!this._cascade?.hoverSensitive) return;
2066
+ this._heldSince = hoverClock.now();
2067
+ if (this._heldHover !== null) return;
2068
+ const rest = (): void => {
2069
+ this._heldHover = null;
2070
+ const at = this._pointerAt;
2071
+ if (!at || this.destroyed) return;
2072
+ const left = this._heldSince + HOVER_REST_MS - hoverClock.now();
2073
+ if (left > 0) {
2074
+ this._heldHover = hoverClock.arm(rest, left);
2075
+ return;
2076
+ }
2077
+ this.setHover(at.x, at.y);
2078
+ };
2079
+ this._heldHover = hoverClock.arm(rest, HOVER_REST_MS);
2080
+ }
2081
+
2082
+ private _dropHeldHover(): void {
2083
+ if (this._heldHover === null) return;
2084
+ hoverClock.disarm(this._heldHover);
2085
+ this._heldHover = null;
2086
+ }
2087
+
1732
2088
  // --- paint ----------------------------------------------------------------
1733
2089
 
1734
2090
  override paint(ctx: Context2D): void {
@@ -2026,7 +2382,10 @@ function sameRects(a: ControlRect[], b: ControlRect[]): boolean {
2026
2382
  p.y !== q.y ||
2027
2383
  p.width !== q.width ||
2028
2384
  p.height !== q.height ||
2385
+ p.fontFamily !== q.fontFamily ||
2386
+ p.fontSize !== q.fontSize ||
2029
2387
  p.opacity !== q.opacity ||
2388
+ !sameClip(p.clip, q.clip) ||
2030
2389
  !sameBare(p.bare, q.bare)
2031
2390
  ) {
2032
2391
  return false;
@@ -2035,6 +2394,13 @@ function sameRects(a: ControlRect[], b: ControlRect[]): boolean {
2035
2394
  return true;
2036
2395
  }
2037
2396
 
2397
+ function sameClip(a?: ControlRect['clip'], b?: ControlRect['clip']): boolean {
2398
+ if (!a || !b) return a === b;
2399
+ return (
2400
+ a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height
2401
+ );
2402
+ }
2403
+
2038
2404
  function sameBare(a?: BareField, b?: BareField): boolean {
2039
2405
  if (!a || !b) return a === b;
2040
2406
  return (
@@ -2084,7 +2450,16 @@ function nearestText(box: Box, x: number, y: number): number | null {
2084
2450
  const distance = dy * 4 + dx;
2085
2451
  if (distance >= bestDistance) continue;
2086
2452
  bestDistance = distance;
2087
- const local = text.layout.indexAt(x - text.drawX, y - text.drawY);
2453
+ // in the row that is this line's, where the layout's other lines are
2454
+ // in another column and a point past this one is not over them
2455
+ let row = y - text.drawY;
2456
+ if (columned.any && COLUMN_ROWS.has(text)) {
2457
+ row = Math.max(
2458
+ natural.y,
2459
+ Math.min(row, natural.y + natural.height - 0.01),
2460
+ );
2461
+ }
2462
+ const local = text.layout.indexAt(x - text.drawX, row);
2088
2463
  const offsets = layoutOffsetsOf(text.layout);
2089
2464
  const units = offsets.length
2090
2465
  ? offsets[Math.max(0, Math.min(local, offsets.length - 1))]
@@ -2098,14 +2473,37 @@ function nearestText(box: Box, x: number, y: number): number | null {
2098
2473
  axisDistance(y, node.boundsY, node.boundsHeight) * 4 +
2099
2474
  axisDistance(x, node.boundsX, node.boundsWidth);
2100
2475
  if (boundsDistance >= bestDistance) return;
2476
+ // in a box painted through a matrix, the point taken back through it
2477
+ // (`deepestAt`): the text is where it was laid out
2478
+ const matrix = placedMatrix(node);
2479
+ if (!matrix) {
2480
+ visitIn(node);
2481
+ return;
2482
+ }
2483
+ const back = invert(matrix);
2484
+ if (!back) return;
2485
+ const px = x;
2486
+ const py = y;
2487
+ [x, y] = mapPoint(back, px, py);
2488
+ try {
2489
+ visitIn(node);
2490
+ } finally {
2491
+ x = px;
2492
+ y = py;
2493
+ }
2494
+ };
2101
2495
 
2496
+ const visitIn = (node: Box): void => {
2102
2497
  const lines = node.lines;
2103
2498
  if (lines?.length) {
2104
2499
  // The line nearest in y, by binary search; then outward both ways
2105
2500
  // while vertical distance alone could still beat the best.
2106
2501
  let lo = 0;
2107
2502
  let hi = lines.length;
2108
- while (lo < hi) {
2503
+ // the lines columns took apart are in no order down the page: each
2504
+ // is tried
2505
+ const apart = columned.any && COLUMN_LINES.has(lines);
2506
+ while (!apart && lo < hi) {
2109
2507
  const mid = (lo + hi) >> 1;
2110
2508
  if (lines[mid].y > y) hi = mid;
2111
2509
  else lo = mid + 1;
@@ -2116,8 +2514,10 @@ function nearestText(box: Box, x: number, y: number): number | null {
2116
2514
  tryLine(lines[i]);
2117
2515
  }
2118
2516
  for (let i = lo; i < lines.length; i += 1) {
2119
- if (axisDistance(y, lines[i].y, lines[i].height) * 4 >= bestDistance)
2517
+ if (axisDistance(y, lines[i].y, lines[i].height) * 4 >= bestDistance) {
2518
+ if (apart) continue;
2120
2519
  break;
2520
+ }
2121
2521
  tryLine(lines[i]);
2122
2522
  }
2123
2523
  }
@@ -2204,7 +2604,16 @@ function caretAt(
2204
2604
  const caret = text.layout.caretPosition(
2205
2605
  codePointAtOffset(offsets, layoutUnits),
2206
2606
  );
2207
- found = { x: text.drawX + caret.x, y: line.y, height: line.height };
2607
+ const at = throughTransforms(
2608
+ {
2609
+ x: text.drawX + caret.x,
2610
+ y: line.y,
2611
+ width: 0,
2612
+ height: line.height,
2613
+ },
2614
+ node,
2615
+ );
2616
+ found = { x: at.x, y: at.y, height: at.height };
2208
2617
  return;
2209
2618
  }
2210
2619
  }
@@ -2219,6 +2628,21 @@ function caretAt(
2219
2628
  return found;
2220
2629
  }
2221
2630
 
2631
+ /**
2632
+ * A rectangle in a box's own coordinates — the ones it and what it holds
2633
+ * were laid out in — as the rectangle around where it is drawn: through
2634
+ * the matrix of each box from `from` up that is painted through one
2635
+ * (`placedMatrix`). The rectangle itself, in a document that transforms
2636
+ * nothing but by moving it.
2637
+ */
2638
+ function throughTransforms(rect: Rect, from: Box | null): Rect {
2639
+ for (let at = from; at; at = at.parent) {
2640
+ const matrix = placedMatrix(at);
2641
+ if (matrix) rect = mapRect(matrix, rect.x, rect.y, rect.width, rect.height);
2642
+ }
2643
+ return rect;
2644
+ }
2645
+
2222
2646
  /** Every band a document range covers, in window coordinates. The subtree
2223
2647
  * text ranges prune the walk to the boxes the range actually crosses, so a
2224
2648
  * drag's small range costs its own paragraphs; only Ctrl+A pays for all. */
@@ -2320,6 +2744,9 @@ function lineBands(
2320
2744
  /** Where the lines put the text, not where a relative offset moved it
2321
2745
  * (`TEXT_SHIFTS`): what an inline box's own offset is added to. */
2322
2746
  laidOut = false,
2747
+ /** The rows a highlight covers (`selectionRows`) rather than the line's:
2748
+ * past it where the glyphs are taller than the line. */
2749
+ ink = false,
2323
2750
  ): void {
2324
2751
  // First line whose text can reach `from`, by binary search over the
2325
2752
  // sorted text starts; stop at the first line past `to`.
@@ -2348,6 +2775,9 @@ function lineBands(
2348
2775
  // a link that ends a line a space wider than Chrome's. So the text
2349
2776
  // a line ends with is measured no further than its content.
2350
2777
  const ends = endsLine(line, text, natural);
2778
+ const rows = ink
2779
+ ? selectionRows(line, text, natural)
2780
+ : { y: line.y, height: line.height };
2351
2781
  for (const band of bandsFor(
2352
2782
  text.layout,
2353
2783
  natural,
@@ -2363,9 +2793,22 @@ function lineBands(
2363
2793
  }
2364
2794
  out.push({
2365
2795
  x: dx + left + text.drawX - moved,
2366
- y: dy + line.y,
2796
+ y: dy + rows.y,
2367
2797
  width: right - left,
2368
- height: line.height,
2798
+ height: rows.height,
2799
+ });
2800
+ }
2801
+ // The spaces a piece of a line ends on, where the line goes on
2802
+ // after them: the engine stripped them from the piece it laid out
2803
+ // as a line, and they are the text's all the same, in the elements
2804
+ // that hold its end — a link whose text ends in a space, before
2805
+ // the next link, is that space wider than its letters
2806
+ if (text.trail && b >= text.textEnd && !rtlLine(natural)) {
2807
+ out.push({
2808
+ x: dx + natural.x + natural.width + text.drawX - moved,
2809
+ y: dy + rows.y,
2810
+ width: text.trail,
2811
+ height: rows.height,
2369
2812
  });
2370
2813
  }
2371
2814
  // the spaces `pre-wrap` keeps that the line ends on, which have no
@@ -2375,9 +2818,9 @@ function lineBands(
2375
2818
  if (at < a || at >= b) continue;
2376
2819
  out.push({
2377
2820
  x: dx + space.x + text.drawX - moved,
2378
- y: dy + line.y,
2821
+ y: dy + rows.y,
2379
2822
  width: space.width,
2380
- height: line.height,
2823
+ height: rows.height,
2381
2824
  });
2382
2825
  }
2383
2826
  }
@@ -2385,6 +2828,14 @@ function lineBands(
2385
2828
  }
2386
2829
  }
2387
2830
 
2831
+ /** Whether a laid-out line's last run reads right to left, its end at its
2832
+ * left. */
2833
+ function rtlLine(natural: {
2834
+ runs: { run?: { direction?: string } }[];
2835
+ }): boolean {
2836
+ return natural.runs[natural.runs.length - 1]?.run?.direction === 'rtl';
2837
+ }
2838
+
2388
2839
  /** Whether a line's text is where the line ends: its last, with nothing
2389
2840
  * placed after it — an inline-block or an image past its content. */
2390
2841
  function endsLine(
@@ -2407,14 +2858,30 @@ function collectBands(
2407
2858
  dx: number,
2408
2859
  dy: number,
2409
2860
  out: Rect[],
2861
+ ink = false,
2410
2862
  ): void {
2411
2863
  if (box.subtreeTextEnd <= box.subtreeTextStart) return;
2412
2864
  if (box.subtreeTextEnd <= from || box.subtreeTextStart >= to) return;
2413
- if (box.lines) lineBands(box.lines, from, to, dx, dy, out);
2865
+ const first = out.length;
2866
+ if (box.lines) lineBands(box.lines, from, to, dx, dy, out, false, ink);
2414
2867
  // Atomics are ordinary children, reached below.
2415
2868
  for (const child of box.children) {
2416
2869
  if (child.kind === 'text' || child.kind === 'break') continue;
2417
- collectBands(child, from, to, dx, dy, out);
2870
+ collectBands(child, from, to, dx, dy, out, ink);
2871
+ }
2872
+ // where a box painted through a matrix draws them
2873
+ const matrix = out.length > first ? placedMatrix(box) : null;
2874
+ if (!matrix) return;
2875
+ for (let i = first; i < out.length; i += 1) {
2876
+ const band = out[i];
2877
+ const to = mapRect(
2878
+ matrix,
2879
+ band.x - dx,
2880
+ band.y - dy,
2881
+ band.width,
2882
+ band.height,
2883
+ );
2884
+ out[i] = { ...band, ...to, x: to.x + dx, y: to.y + dy };
2418
2885
  }
2419
2886
  }
2420
2887
 
@@ -2455,7 +2922,22 @@ function deepestAt(
2455
2922
  // are painted in `z-index` order and then the document's (`byZIndex`),
2456
2923
  // the negative ones under the flow: the Zen Garden's `›` has
2457
2924
  // `z-index: 3` over the bar the "View All Designs" link fills after it.
2458
- const take = (el: Element, key: readonly number[], text: boolean) => {
2925
+ // Nor is a box the pointer passes through under it: one that is not
2926
+ // visible, which is no target for the pointer in any browser (CSS 2.1
2927
+ // 11.2 has it invisible, and a hit test finds what is seen), or one with
2928
+ // `pointer-events: none` (CSS UI 4, 5.2). Both are inherited, and what is
2929
+ // in such a box may say otherwise, so the walk goes on through it. A
2930
+ // site's closed menu is laid out over its page, hidden: GitHub's covered
2931
+ // its repository's tabs, and the pointer over a tab was over the menu.
2932
+ const take = (
2933
+ el: Element,
2934
+ style: ComputedStyle,
2935
+ key: readonly number[],
2936
+ text: boolean,
2937
+ ) => {
2938
+ if (style.visibility !== 'visible' || style.pointerEvents === 'none') {
2939
+ return;
2940
+ }
2459
2941
  if (compareKeys(key, foundKey) < 0) return;
2460
2942
  found = el;
2461
2943
  foundKey = key;
@@ -2499,12 +2981,72 @@ function deepestAt(
2499
2981
  atViewport = false;
2500
2982
  }
2501
2983
  };
2984
+ // A box painted through a matrix (`placedMatrix`) is under the point
2985
+ // where the matrix puts it: inside the rectangle around what it draws
2986
+ // there, the point is taken back through the matrix, into the
2987
+ // coordinates the box and all it holds were laid out in, and the walk
2988
+ // goes on with that. An icon turned a quarter is pressed where it is
2989
+ // drawn; a box flattened to nothing is nowhere.
2502
2990
  const enterAt = (
2503
2991
  child: Box,
2504
2992
  context: readonly number[],
2505
2993
  clipped: readonly Box[],
2994
+ ): void => {
2995
+ const matrix = placedMatrix(child);
2996
+ if (!matrix) {
2997
+ enterIn(child, context, clipped, null);
2998
+ return;
2999
+ }
3000
+ if (
3001
+ x < child.boundsX ||
3002
+ x >= child.boundsX + child.boundsWidth ||
3003
+ y < child.boundsY ||
3004
+ y >= child.boundsY + child.boundsHeight
3005
+ ) {
3006
+ return;
3007
+ }
3008
+ const back = invert(matrix);
3009
+ if (!back) return;
3010
+ const px = x;
3011
+ const py = y;
3012
+ [x, y] = mapPoint(back, px, py);
3013
+ try {
3014
+ enterIn(child, context, clipped, ownBounds(child));
3015
+ } finally {
3016
+ x = px;
3017
+ y = py;
3018
+ }
3019
+ };
3020
+ const enterIn = (
3021
+ child: Box,
3022
+ context: readonly number[],
3023
+ clipped: readonly Box[],
3024
+ /** What the box draws in its own coordinates, where those are not the
3025
+ * ones its bounds are in: a box painted through a matrix. */
3026
+ bounds: Rect | null,
2506
3027
  ): void => {
2507
3028
  const style = child.style;
3029
+ // What a `clip-path` cuts away of a box it cuts away of all the box
3030
+ // holds, whatever that is positioned from (CSS Masking 1, 5.1), and
3031
+ // nothing not drawn is under a pointer: a label a page hides for a
3032
+ // screen reader alone under `clip-path: inset(50%)` took the hover and
3033
+ // the press of what was drawn where it lay.
3034
+ if (pathClips(child) && !inClipPath(child, x, y)) return;
3035
+ // `clip` is not that rule as paint has it. It shows the part of an
3036
+ // absolute box it names (CSS 2.1 11.1.2), and past that part nothing is
3037
+ // drawn of the box, nor of what is painted with it. A stacking context
3038
+ // paints all it holds, inside its clip. A box that is none leaves its
3039
+ // positioned boxes to the context around it, which cuts each to the
3040
+ // clips its containing block is under (`clipsFor`): this one for all
3041
+ // but a fixed box, which is under none and is drawn past it. So past
3042
+ // its clip the first is left, and the second is walked as a box that
3043
+ // clips its overflow is past its edge — whether or not the point is in
3044
+ // its own rectangle, which a clip may show only a part of. Both were
3045
+ // walked as if they showed whole: a label hidden for a screen reader
3046
+ // alone under `clip: rect(0, 0, 0, 0)` took the hover and the press of
3047
+ // the link drawn where it lay.
3048
+ let cut = !inClip(child, x, y);
3049
+ if (cut && stacksLayers(child)) return;
2508
3050
  if (
2509
3051
  style.position !== 'static' ||
2510
3052
  (child.parent?.kind === 'flex' && typeof style.zIndex === 'number')
@@ -2512,6 +3054,14 @@ function deepestAt(
2512
3054
  // a flex item with a `z-index` is layered unpositioned (`layered`)
2513
3055
  const z = style.zIndex === 'auto' ? 0 : style.zIndex;
2514
3056
  context = [...context, z < 0 ? HIT_NEGATIVE : HIT_POSITIONED, z];
3057
+ } else if (
3058
+ transformed(style) &&
3059
+ child.kind !== 'inline' &&
3060
+ child.parent &&
3061
+ !onLine(child.parent, child)
3062
+ ) {
3063
+ // a transformed box is painted with the positioned ones (`layered`)
3064
+ context = [...context, HIT_POSITIONED, 0];
2515
3065
  } else if (style.float !== 'none') context = [...context, HIT_FLOAT];
2516
3066
  if (clipped.length !== 0 && child.outOfFlow) {
2517
3067
  const containing = containingBlockOf(child);
@@ -2519,34 +3069,46 @@ function deepestAt(
2519
3069
  ? clipped.filter((clip) => holds(clip, containing))
2520
3070
  : [];
2521
3071
  }
2522
- const inside =
2523
- x >= child.x &&
2524
- x < child.x + child.width &&
2525
- y >= child.y &&
2526
- y < child.y + child.height;
3072
+ // in one of its pieces, where columns broke it: its rect takes in the
3073
+ // columns between them, which are other boxes'
3074
+ const pieces = columned.any ? COLUMN_PIECES.get(child) : undefined;
3075
+ const inside = pieces
3076
+ ? pieces.some(
3077
+ (piece) =>
3078
+ x >= piece.x &&
3079
+ x < piece.x + piece.width &&
3080
+ y >= piece.y &&
3081
+ y < piece.y + piece.height,
3082
+ )
3083
+ : x >= child.x &&
3084
+ x < child.x + child.width &&
3085
+ y >= child.y &&
3086
+ y < child.y + child.height;
2527
3087
  const own = inside && hasRect(child);
2528
3088
  if (!own) {
2529
- const reach = child.boundsY;
2530
- const known =
2531
- Number.isFinite(reach) &&
2532
- (child.boundsWidth > 0 || child.boundsHeight > 0);
3089
+ const left = bounds ? bounds.x : child.boundsX;
3090
+ const reach = bounds ? bounds.y : child.boundsY;
3091
+ const across = bounds ? bounds.width : child.boundsWidth;
3092
+ const down = bounds ? bounds.height : child.boundsHeight;
3093
+ const known = Number.isFinite(reach) && (across > 0 || down > 0);
2533
3094
  if (!known) {
2534
3095
  if (!inside) return;
2535
3096
  } else if (
2536
- x < child.boundsX ||
2537
- x >= child.boundsX + child.boundsWidth ||
3097
+ x < left ||
3098
+ x >= left + across ||
2538
3099
  y < reach ||
2539
- y >= reach + child.boundsHeight
3100
+ y >= reach + down
2540
3101
  ) {
2541
3102
  return;
2542
3103
  }
2543
- if (clipsOverflow(child)) {
2544
- if (!holdsAbsolute(child)) return;
2545
- clipped = [...clipped, child];
2546
- }
3104
+ if (clipsOverflow(child)) cut = true;
3105
+ }
3106
+ if (cut) {
3107
+ if (!holdsAbsolute(child)) return;
3108
+ clipped = [...clipped, child];
2547
3109
  }
2548
3110
  if (own && child.el && clipped.length === 0) {
2549
- take(child.el, [...context, HIT_BLOCK], false);
3111
+ take(child.el, style, [...context, HIT_BLOCK], false);
2550
3112
  }
2551
3113
  visit(child, context, clipped);
2552
3114
  };
@@ -2593,7 +3155,8 @@ function deepestAt(
2593
3155
  tree.textBoxes,
2594
3156
  text.spans.documentAt(run.start),
2595
3157
  );
2596
- if (owner) take(owner, [...context, HIT_INLINE], true);
3158
+ const style = owner && tree.styles.get(owner)?.style;
3159
+ if (style) take(owner, style, [...context, HIT_INLINE], true);
2597
3160
  }
2598
3161
  }
2599
3162
  }
@@ -2621,6 +3184,38 @@ function compareKeys(a: readonly number[], b: readonly number[]): number {
2621
3184
  * that matches a container reaches everything in it. */
2622
3185
  const HOVER_RESTYLE_LIMIT = 300;
2623
3186
 
3187
+ /** How long the content under a still pointer has to have stopped moving
3188
+ * before the hover is asked for again (`HtmlViewNode._holdHover`): a
3189
+ * tenth of a second, which is what WebKit waits after a scroll before it
3190
+ * sends the mouse move that updates its own. */
3191
+ const HOVER_REST_MS = 100;
3192
+
3193
+ const timers = globalThis as {
3194
+ setTimeout?(fn: () => void, ms: number): unknown;
3195
+ clearTimeout?(id: unknown): void;
3196
+ };
3197
+
3198
+ /**
3199
+ * The clock a held hover waits on. Through `globalThis` because `src/`
3200
+ * compiles with `types: []`, and unref'd where the runtime allows it: a
3201
+ * timer of a document's must not keep a process alive that is otherwise
3202
+ * done. One object, exported from this module though not from the package,
3203
+ * so a test can hold it and say when the rest is over (`test/held-clock.ts`).
3204
+ */
3205
+ export const hoverClock = {
3206
+ now(): number {
3207
+ return Date.now();
3208
+ },
3209
+ arm(step: () => void, ms: number): unknown {
3210
+ const handle = timers.setTimeout?.(step, ms) ?? null;
3211
+ (handle as { unref?(): void } | null)?.unref?.();
3212
+ return handle;
3213
+ },
3214
+ disarm(handle: unknown): void {
3215
+ timers.clearTimeout?.(handle);
3216
+ },
3217
+ };
3218
+
2624
3219
  /**
2625
3220
  * The computed properties a pointer move may change in place: ink, which
2626
3221
  * moves nothing. A background colour is drawn inside the box it colours
@@ -2632,6 +3227,9 @@ const PAINT_ONLY = new Set([
2632
3227
  'color',
2633
3228
  'textFillColor',
2634
3229
  'cursor',
3230
+ 'pointerEvents',
3231
+ 'fill',
3232
+ 'stroke',
2635
3233
  'backgroundColor',
2636
3234
  'borderTopColor',
2637
3235
  'borderRightColor',
@@ -2648,6 +3246,8 @@ const PAINT_ONLY = new Set([
2648
3246
  'underlineThickness',
2649
3247
  'underlineOffset',
2650
3248
  'lineThrough',
3249
+ 'lineThroughStyle',
3250
+ 'lineThroughThickness',
2651
3251
  ]);
2652
3252
 
2653
3253
  /** The fields of a run its ink is: what `runFor` takes from `PAINT_ONLY`. */
@@ -2658,6 +3258,8 @@ const INK_FIELDS = [
2658
3258
  'underlineOffset',
2659
3259
  'underlineThickness',
2660
3260
  'strike',
3261
+ 'strikeStyle',
3262
+ 'strikeThickness',
2661
3263
  ] as const;
2662
3264
 
2663
3265
  /** The fields of a run its shape is, which ink never changes. */
@@ -2676,11 +3278,21 @@ const FACE_FIELDS = [
2676
3278
  /** What a restyle in place changed beyond ink: how far a box's ink
2677
3279
  * reaches, the order its layer paints in, or where it is. */
2678
3280
  interface HoverChange {
3281
+ /** Whether anything that is drawn changed: false where only the custom
3282
+ * properties did, which the elements under this one read. */
3283
+ ink: boolean;
2679
3284
  reach: boolean;
2680
3285
  order: boolean;
2681
3286
  move: boolean;
2682
3287
  }
2683
3288
 
3289
+ /** An element's `::before` and `::after`, each with its bit in the set
3290
+ * of the ones a restyle found boxes of (`_hoverInPlace`). */
3291
+ const GENERATED_BITS = [
3292
+ ['before', 1],
3293
+ ['after', 2],
3294
+ ] as const;
3295
+
2684
3296
  /** The replaced boxes that are controls (`BoxTree.controls`, `controls.ts`):
2685
3297
  * a widget mounted beside the document, or a button whose press it reports. */
2686
3298
  const WIDGETS = new Set<ReplacedKind>([
@@ -2701,10 +3313,20 @@ const INK_REACH = new Set([
2701
3313
  'outlineOffset',
2702
3314
  ]);
2703
3315
 
3316
+ /** What transforms a box: it moves, turns or scales where it is, and
3317
+ * nothing around it does (CSS Transforms 1, 3). */
3318
+ const TRANSFORM_FIELDS = new Set([
3319
+ 'translate',
3320
+ 'rotate',
3321
+ 'scale',
3322
+ 'transform',
3323
+ 'transformOrigin',
3324
+ ]);
3325
+
2704
3326
  /**
2705
3327
  * How two styles of one element differ, for a restyle in place: null where
2706
3328
  * they do not, a `HoverChange` where every difference is ink, how far ink
2707
- * reaches, a `z-index` that stays a stacking context's, or a translation —
3329
+ * reaches, a `z-index` that stays a stacking context's, or a transform —
2708
3330
  * none of which moves anything but the box and what it holds (CSS
2709
3331
  * Transforms 1: a transform does not affect layout) — and false where
2710
3332
  * anything else differs.
@@ -2717,8 +3339,17 @@ function hoverChange(
2717
3339
  const b = now as unknown as Record<string, unknown>;
2718
3340
  let change: HoverChange | null = null;
2719
3341
  for (const key in b) {
2720
- if (key === 'custom' || sameValue(a[key], b[key])) continue;
2721
- change ??= { reach: false, order: false, move: false };
3342
+ // the sets are shared by what made them (`Cascade._customFor`): one
3343
+ // object where they are the same
3344
+ if (key === 'custom') {
3345
+ if (a[key] !== b[key]) {
3346
+ change ??= { ink: false, reach: false, order: false, move: false };
3347
+ }
3348
+ continue;
3349
+ }
3350
+ if (sameValue(a[key], b[key])) continue;
3351
+ change ??= { ink: true, reach: false, order: false, move: false };
3352
+ change.ink = true;
2722
3353
  if (PAINT_ONLY.has(key)) continue;
2723
3354
  if (INK_REACH.has(key)) {
2724
3355
  change.reach = true;
@@ -2734,7 +3365,7 @@ function hoverChange(
2734
3365
  change.order = true;
2735
3366
  continue;
2736
3367
  }
2737
- if (key === 'transformTranslate' || key === 'translate') {
3368
+ if (TRANSFORM_FIELDS.has(key)) {
2738
3369
  change.move = true;
2739
3370
  continue;
2740
3371
  }
@@ -2777,12 +3408,17 @@ function inkOf(box: Box): Rect | null {
2777
3408
  }
2778
3409
  if (!at || at.boundsY === Infinity) return null;
2779
3410
  if (!(at.boundsWidth > 0 && at.boundsHeight > 0)) return null;
2780
- return {
2781
- x: at.boundsX,
2782
- y: at.boundsY,
2783
- width: at.boundsWidth,
2784
- height: at.boundsHeight,
2785
- };
3411
+ // its bounds are in the coordinates of the box it is in, which a box
3412
+ // around it painted through a matrix draws somewhere else
3413
+ return throughTransforms(
3414
+ {
3415
+ x: at.boundsX,
3416
+ y: at.boundsY,
3417
+ width: at.boundsWidth,
3418
+ height: at.boundsHeight,
3419
+ },
3420
+ at.parent,
3421
+ );
2786
3422
  }
2787
3423
 
2788
3424
  /** Structural equality for a computed value: a number, a string, or the
@@ -2802,16 +3438,9 @@ function sameValue(x: unknown, y: unknown): boolean {
2802
3438
  return true;
2803
3439
  }
2804
3440
 
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
3441
  /**
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
3442
+ * `text`'s layout made again with the ink of the boxes a pointer move
3443
+ * restyled: from the runs it was made from, each of a restyled text box's
2815
3444
  * given the ink its new style makes (`runFor`) and nothing else. Null where
2816
3445
  * none of its runs is theirs; false where that cannot be told — a run this
2817
3446
  * cannot place in the document, one some other pass inked (a first line's),
@@ -2820,19 +3449,16 @@ function nearestElement(box: Box): Element | null {
2820
3449
  function reinked(
2821
3450
  text: LineText,
2822
3451
  layouts: TextLayoutCache,
2823
- changed: ReadonlyMap<Element, ComputedStyle>,
2824
- styles: BoxTree['styles'],
3452
+ next: ReadonlyMap<Box, ComputedStyle>,
2825
3453
  ): TextLayoutLike | null | false {
2826
3454
  const spans = text.spans;
2827
- const ownerAt = (offset: number): Element | null =>
2828
- spans.boxAt ? (spans.boxAt(offset)?.el ?? null) : null;
2829
3455
  const inputs = layouts.inputsOf(text.layout);
2830
3456
  if (!inputs || !spans.boxAt) {
2831
3457
  // not one this could make again: none of its text may be theirs
2832
3458
  for (const line of text.layout.lines) {
2833
3459
  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;
3460
+ const box = spans.boxAt?.(run.start);
3461
+ if (!spans.boxAt || (box && next.has(box))) return false;
2836
3462
  }
2837
3463
  }
2838
3464
  return null;
@@ -2843,25 +3469,25 @@ function reinked(
2843
3469
  for (let i = 0; i < content.length; i += 1) {
2844
3470
  const run = content[i];
2845
3471
  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);
3472
+ const box = length ? spans.boxAt(offset) : null;
3473
+ const style = box ? next.get(box) : undefined;
3474
+ if (box && style) {
3475
+ if (spans.boxAt(offset + length - 1) !== box) return false;
3476
+ const was = runFor(run.text, box.style);
2851
3477
  const now = runFor(run.text, style);
2852
3478
  for (const f of FACE_FIELDS) if (!sameValue(was[f], now[f])) return false;
2853
- let next: Record<string, unknown> | null = null;
3479
+ let inked: Record<string, unknown> | null = null;
2854
3480
  for (const f of INK_FIELDS) {
2855
3481
  // a run that is not what its style made — a first line's colour
2856
3482
  if (!sameValue(run[f], was[f])) return false;
2857
3483
  if (sameValue(was[f], now[f])) continue;
2858
- next ??= { ...run };
2859
- if (now[f] === undefined) delete next[f];
2860
- else next[f] = now[f];
3484
+ inked ??= { ...run };
3485
+ if (now[f] === undefined) delete inked[f];
3486
+ else inked[f] = now[f];
2861
3487
  }
2862
- if (next) {
3488
+ if (inked) {
2863
3489
  runs ??= content.slice();
2864
- runs[i] = next as unknown as TextRun;
3490
+ runs[i] = inked as unknown as TextRun;
2865
3491
  }
2866
3492
  }
2867
3493
  offset += length;