@react-x11/components 0.11.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 (126) hide show
  1. package/dist/flow/node.d.ts +14 -6
  2. package/dist/flow/node.d.ts.map +1 -1
  3. package/dist/flow/node.js +82 -64
  4. package/dist/flow/node.js.map +1 -1
  5. package/dist/html/controls.d.ts +38 -10
  6. package/dist/html/controls.d.ts.map +1 -1
  7. package/dist/html/controls.js +43 -58
  8. package/dist/html/controls.js.map +1 -1
  9. package/dist/html/css/cascade.d.ts +134 -20
  10. package/dist/html/css/cascade.d.ts.map +1 -1
  11. package/dist/html/css/cascade.js +538 -128
  12. package/dist/html/css/cascade.js.map +1 -1
  13. package/dist/html/css/color.d.ts +19 -0
  14. package/dist/html/css/color.d.ts.map +1 -1
  15. package/dist/html/css/color.js +86 -0
  16. package/dist/html/css/color.js.map +1 -1
  17. package/dist/html/css/parse.d.ts +40 -2
  18. package/dist/html/css/parse.d.ts.map +1 -1
  19. package/dist/html/css/parse.js +391 -13
  20. package/dist/html/css/parse.js.map +1 -1
  21. package/dist/html/css/style.d.ts +52 -1
  22. package/dist/html/css/style.d.ts.map +1 -1
  23. package/dist/html/css/style.js +328 -11
  24. package/dist/html/css/style.js.map +1 -1
  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 -7
  28. package/dist/html/css/ua.js.map +1 -1
  29. package/dist/html/css/values.d.ts +10 -1
  30. package/dist/html/css/values.d.ts.map +1 -1
  31. package/dist/html/css/values.js +27 -2
  32. package/dist/html/css/values.js.map +1 -1
  33. package/dist/html/css/vars.d.ts +5 -0
  34. package/dist/html/css/vars.d.ts.map +1 -1
  35. package/dist/html/css/vars.js +2 -2
  36. package/dist/html/css/vars.js.map +1 -1
  37. package/dist/html/dom.d.ts.map +1 -1
  38. package/dist/html/dom.js +38 -3
  39. package/dist/html/dom.js.map +1 -1
  40. package/dist/html/fonts.d.ts +25 -1
  41. package/dist/html/fonts.d.ts.map +1 -1
  42. package/dist/html/fonts.js +99 -6
  43. package/dist/html/fonts.js.map +1 -1
  44. package/dist/html/form.d.ts +200 -0
  45. package/dist/html/form.d.ts.map +1 -0
  46. package/dist/html/form.js +801 -0
  47. package/dist/html/form.js.map +1 -0
  48. package/dist/html/index.d.ts +12 -0
  49. package/dist/html/index.d.ts.map +1 -1
  50. package/dist/html/index.js +30 -264
  51. package/dist/html/index.js.map +1 -1
  52. package/dist/html/layout/block.d.ts +11 -2
  53. package/dist/html/layout/block.d.ts.map +1 -1
  54. package/dist/html/layout/block.js +186 -19
  55. package/dist/html/layout/block.js.map +1 -1
  56. package/dist/html/layout/boxes.d.ts +65 -7
  57. package/dist/html/layout/boxes.d.ts.map +1 -1
  58. package/dist/html/layout/boxes.js +87 -22
  59. package/dist/html/layout/boxes.js.map +1 -1
  60. package/dist/html/layout/cache.d.ts +2 -2
  61. package/dist/html/layout/cache.d.ts.map +1 -1
  62. package/dist/html/layout/cache.js +8 -3
  63. package/dist/html/layout/cache.js.map +1 -1
  64. package/dist/html/layout/flex.js +4 -1
  65. package/dist/html/layout/flex.js.map +1 -1
  66. package/dist/html/layout/floats.d.ts +4 -0
  67. package/dist/html/layout/floats.d.ts.map +1 -1
  68. package/dist/html/layout/floats.js +26 -0
  69. package/dist/html/layout/floats.js.map +1 -1
  70. package/dist/html/layout/inline.d.ts +26 -2
  71. package/dist/html/layout/inline.d.ts.map +1 -1
  72. package/dist/html/layout/inline.js +1232 -115
  73. package/dist/html/layout/inline.js.map +1 -1
  74. package/dist/html/node.d.ts +120 -15
  75. package/dist/html/node.d.ts.map +1 -1
  76. package/dist/html/node.js +993 -174
  77. package/dist/html/node.js.map +1 -1
  78. package/dist/html/paint.d.ts +75 -2
  79. package/dist/html/paint.d.ts.map +1 -1
  80. package/dist/html/paint.js +836 -200
  81. package/dist/html/paint.js.map +1 -1
  82. package/dist/html/surfaces.d.ts +7 -2
  83. package/dist/html/surfaces.d.ts.map +1 -1
  84. package/dist/html/surfaces.js +16 -0
  85. package/dist/html/surfaces.js.map +1 -1
  86. package/dist/html/widgets.d.ts +29 -0
  87. package/dist/html/widgets.d.ts.map +1 -0
  88. package/dist/html/widgets.js +620 -0
  89. package/dist/html/widgets.js.map +1 -0
  90. package/dist/index.d.ts +1 -1
  91. package/dist/index.d.ts.map +1 -1
  92. package/dist/index.js.map +1 -1
  93. package/dist/internal/window.d.ts +22 -0
  94. package/dist/internal/window.d.ts.map +1 -1
  95. package/dist/internal/window.js +35 -8
  96. package/dist/internal/window.js.map +1 -1
  97. package/dist/richtext/node.d.ts +7 -0
  98. package/dist/richtext/node.d.ts.map +1 -1
  99. package/dist/richtext/node.js.map +1 -1
  100. package/package.json +6 -4
  101. package/src/flow/node.ts +80 -64
  102. package/src/html/controls.ts +79 -58
  103. package/src/html/css/cascade.ts +610 -146
  104. package/src/html/css/color.ts +87 -0
  105. package/src/html/css/parse.ts +431 -13
  106. package/src/html/css/style.ts +367 -11
  107. package/src/html/css/ua.ts +76 -8
  108. package/src/html/css/values.ts +31 -3
  109. package/src/html/css/vars.ts +2 -2
  110. package/src/html/dom.ts +41 -3
  111. package/src/html/fonts.ts +116 -6
  112. package/src/html/form.ts +962 -0
  113. package/src/html/index.ts +49 -300
  114. package/src/html/layout/block.ts +231 -16
  115. package/src/html/layout/boxes.ts +133 -32
  116. package/src/html/layout/cache.ts +8 -3
  117. package/src/html/layout/flex.ts +4 -1
  118. package/src/html/layout/floats.ts +24 -0
  119. package/src/html/layout/inline.ts +1445 -108
  120. package/src/html/node.ts +1010 -166
  121. package/src/html/paint.ts +980 -270
  122. package/src/html/surfaces.ts +21 -1
  123. package/src/html/widgets.ts +735 -0
  124. package/src/index.ts +1 -0
  125. package/src/internal/window.ts +36 -8
  126. package/src/richtext/node.ts +7 -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,8 +66,13 @@ import {
60
66
  BOX_RAISES,
61
67
  buildBoxes,
62
68
  CONTENT_IMAGES,
69
+ CLEARED_FROM,
70
+ CUT_BLOCKS,
71
+ GENERATED_FROM,
72
+ INLINE_OFFSETS,
63
73
  LINE_BOX_RAISES,
64
74
  SHIFTED_LINES,
75
+ TEXT_SHIFTS,
65
76
  } from './layout/boxes.js';
66
77
  import type {
67
78
  Box,
@@ -71,10 +82,11 @@ import type {
71
82
  ReplacedKind,
72
83
  TextLayoutLike,
73
84
  } from './layout/boxes.js';
74
- import { layoutDocument } from './layout/block.js';
85
+ import { layoutDocument, retranslate, transformed } from './layout/block.js';
75
86
  import { TextLayoutCache } from './layout/cache.js';
76
87
  import { shapingSafe } from './layout/shaping.js';
77
- import { SurfaceCache } from './surfaces.js';
88
+ import { SurfaceCache, newSurface } from './surfaces.js';
89
+ import type { SurfaceLike } from './surfaces.js';
78
90
  import { faceExtentOf, inlineDecoration, runFor } from './layout/inline.js';
79
91
  import type { FontsLike } from './layout/inline.js';
80
92
  import type { TextRun } from '../richtext/index.js';
@@ -88,12 +100,19 @@ import {
88
100
  import { lineBands as bandsFor } from '../richtext/runs.js';
89
101
  import {
90
102
  clipsOverflow,
103
+ computePaintBounds,
91
104
  containingBlockOf,
105
+ FIXED_BOXES,
106
+ fixedToViewport,
107
+ forgetDecoratedAncestors,
92
108
  hasRect,
109
+ hoistNegative,
93
110
  holds,
94
111
  holdsAbsolute,
95
112
  paintDocument,
96
113
  queryChildIndex,
114
+ selectionRows,
115
+ stackLayers,
97
116
  } from './paint.js';
98
117
  import type { PaintContext } from './paint.js';
99
118
  import { controlRectsOf, measureControl } from './controls.js';
@@ -221,6 +240,8 @@ export class HtmlViewNode extends Node {
221
240
  private _sheetsRead: SheetsRead | null = null;
222
241
  /** Blurred shadows, drawn once each. */
223
242
  private _shadowCache: SurfaceCache | null = null;
243
+ /** Whether the backend was found to have no offscreen surface. */
244
+ private _noSurface = false;
224
245
  private _cascade: Cascade | null = null;
225
246
  private _tree: BoxTree | null = null;
226
247
  /** The faces `_warmFaces` has asked the font matcher for, and of which
@@ -236,6 +257,8 @@ export class HtmlViewNode extends Node {
236
257
  * that read it (`vw`, `vh`). */
237
258
  private _styledWidth = -1;
238
259
  private _styledHeight = -1;
260
+ /** The cascade the box tree's styles were computed by. */
261
+ private _styledWith: Cascade | null = null;
239
262
  private _documentHeight = 0;
240
263
  private _documentWidth = 0;
241
264
  /** The viewport height the document was last laid out under, and whether
@@ -253,6 +276,20 @@ export class HtmlViewNode extends Node {
253
276
  * the text carries surrogate pairs. null until checked. */
254
277
  private _pointsAreUnits: boolean | null = null;
255
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;
256
293
  private _scriptsSeen = new WeakSet<Element>();
257
294
  private _controls: ControlRect[] = [];
258
295
  private _reportedDomRevision = -1;
@@ -350,6 +387,7 @@ export class HtmlViewNode extends Node {
350
387
  controlPadY: look.controlPadY * s,
351
388
  controlBorder: look.controlBorder * s,
352
389
  controlRadius: look.controlRadius * s,
390
+ controlFontSize: (look.controlFontSize ?? look.fontSize) * s,
353
391
  };
354
392
  this._deviceLookFor = look;
355
393
  this._deviceLookAt = s;
@@ -359,7 +397,26 @@ export class HtmlViewNode extends Node {
359
397
 
360
398
  // --- the pipeline ---------------------------------------------------------
361
399
 
362
- 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
+ }
363
420
  if (stale > this._stale) this._stale = stale;
364
421
  if (stale >= Stale.Layout) this.invalidateMeasure('content');
365
422
  this.invalidate(stale >= Stale.Layout, this, 'props');
@@ -440,13 +497,15 @@ export class HtmlViewNode extends Node {
440
497
  */
441
498
  private _requestBackgrounds(tree: BoxTree): void {
442
499
  for (const box of tree.backgrounds) {
443
- if (!box.el) continue;
500
+ // a pseudo-element's are its element's
501
+ const element = box.el ?? GENERATED_FROM.get(box);
502
+ if (!element) continue;
444
503
  // each layer's, where there is more than one
445
504
  for (const url of box.style.backgroundImages ?? [
446
505
  box.style.backgroundImage,
447
506
  ]) {
448
507
  if (typeof url === 'string') {
449
- this._resources.request({ url, kind: 'image', element: box.el });
508
+ this._resources.request({ url, kind: 'image', element });
450
509
  }
451
510
  }
452
511
  const border = box.style.borderImage.source;
@@ -454,9 +513,15 @@ export class HtmlViewNode extends Node {
454
513
  this._resources.request({
455
514
  url: border,
456
515
  kind: 'image',
457
- element: box.el,
516
+ element,
458
517
  });
459
518
  }
519
+ // and each mask layer's, which is an image as a background's is
520
+ for (const url of box.style.mask.images) {
521
+ if (typeof url === 'string') {
522
+ this._resources.request({ url, kind: 'image', element });
523
+ }
524
+ }
460
525
  }
461
526
  // and every image generated content names, which is only known there
462
527
  // too
@@ -557,7 +622,10 @@ export class HtmlViewNode extends Node {
557
622
  kept.cascade.viewportHeight = this._viewportHeight();
558
623
  faces = kept.faces;
559
624
  } else {
560
- 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)];
561
629
  const imports: ImportRead[][] = [];
562
630
  faces = [];
563
631
  let order = 0;
@@ -612,10 +680,17 @@ export class HtmlViewNode extends Node {
612
680
  // The faces this width and scheme declare: a `@font-face` may sit in a
613
681
  // `@media` block like any rule.
614
682
  const cssWidth = width / this._scale;
683
+ const cssHeight = this._viewportHeight() / this._scale;
615
684
  this._webFonts.setFallback(look.fontFamily);
616
685
  this._webFonts.setFaces(
617
686
  faces.filter((f) =>
618
- mediaMatches(f.rule.media, cssWidth, look.colorScheme),
687
+ mediaMatches(
688
+ f.rule.media,
689
+ cssWidth,
690
+ look.colorScheme,
691
+ cssHeight,
692
+ this._scale,
693
+ ),
619
694
  ),
620
695
  );
621
696
  this._cascade.setPointer({
@@ -705,6 +780,55 @@ export class HtmlViewNode extends Node {
705
780
  return true;
706
781
  }
707
782
 
783
+ /**
784
+ * The viewport the document is seen through, in window coordinates: the
785
+ * content box of the pane that scrolls the element, where one does —
786
+ * what `position: fixed` and a fixed background are placed against, as
787
+ * a browser places them against its page area. Null where nothing
788
+ * scrolls it, and the element is its own.
789
+ */
790
+ private _viewport(): Rect | null {
791
+ for (let node = this.parent; node; node = node.parent) {
792
+ // `Scrollable`'s, and not every node's: asked the way core asks it
793
+ const scroller = node as { isScroller?: () => boolean };
794
+ if (!scroller.isScroller?.()) continue;
795
+ const box = node.contentBox();
796
+ return box.width > 0 && box.height > 0 ? box : null;
797
+ }
798
+ return null;
799
+ }
800
+
801
+ /**
802
+ * What the document draws fixed to the viewport of the pane that scrolls
803
+ * it, for that pane's scroll blit to repaint rather than copy with the
804
+ * text (react-x11's `viewportFixedRects`): the whole viewport where a
805
+ * fixed background shows through it, which makes a scroll a repaint, and
806
+ * where each fixed box is drawn. Null for a document with neither, and
807
+ * where nothing scrolls the element.
808
+ */
809
+ override viewportFixedRects(): Rect[] | null {
810
+ const tree = this._tree;
811
+ const viewport = tree && this._viewport();
812
+ if (!tree || !viewport) return null;
813
+ if (hasFixedBackground(tree)) return [viewport];
814
+ const boxes = FIXED_BOXES.get(tree);
815
+ if (!boxes) return null;
816
+ // laid out against the viewport at the document's top, and drawn at the
817
+ // viewport (`atViewport`)
818
+ const root = tree.root;
819
+ const out: Rect[] = [];
820
+ for (const box of boxes) {
821
+ if (!(box.boundsWidth > 0 && box.boundsHeight > 0)) continue;
822
+ out.push({
823
+ x: viewport.x + box.boundsX - root.x,
824
+ y: viewport.y + box.boundsY - root.y,
825
+ width: box.boundsWidth,
826
+ height: box.boundsHeight,
827
+ });
828
+ }
829
+ return out.length ? out : null;
830
+ }
831
+
708
832
  private _viewportHeight(): number {
709
833
  // The viewport a `vh` resolves against is the one the document is seen
710
834
  // through, not the document — a document taller than it does not make
@@ -823,15 +947,21 @@ export class HtmlViewNode extends Node {
823
947
  const wasWidth = this._documentWidth;
824
948
  const wasHeight = this._documentHeight;
825
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;
826
954
  if (this._stale >= Stale.Style || !this._cascade) {
827
955
  this._restyle(target);
828
956
  this._stale = Math.max(this._stale, Stale.Boxes) as Stale;
957
+ restyleOnly = null;
829
958
  } else if (this._cascade.mediaBand(target) !== this._mediaBand) {
830
959
  // A resize that crossed a `@media` breakpoint is the one resize that
831
960
  // does have to restyle. Knowing which resizes those are is why the
832
961
  // breakpoints are collected at parse time.
833
962
  this._restyle(target);
834
963
  this._stale = Math.max(this._stale, Stale.Boxes) as Stale;
964
+ restyleOnly = null;
835
965
  }
836
966
 
837
967
  const cascade = this._cascade;
@@ -843,18 +973,27 @@ export class HtmlViewNode extends Node {
843
973
  // again, where some style reads the side that moved. A document that
844
974
  // reads neither — most — goes on skipping the cascade on a resize.
845
975
  if (
846
- this._stale < Stale.Boxes &&
847
- ((cascade.readsViewportWidth && this._styledWidth !== target) ||
848
- (cascade.readsViewportHeight && this._styledHeight !== viewport))
976
+ (cascade.readsViewportWidth && this._styledWidth !== target) ||
977
+ (cascade.readsViewportHeight && this._styledHeight !== viewport)
849
978
  ) {
850
- this._stale = Stale.Boxes;
979
+ if (this._stale < Stale.Boxes) this._stale = Stale.Boxes;
980
+ restyleOnly = null;
851
981
  }
852
982
 
853
983
  if (this._stale >= Stale.Boxes || !this._tree) {
854
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;
855
993
  const build = () =>
856
994
  buildBoxes(this._source.document, {
857
995
  cascade,
996
+ kept,
858
997
  scale: this._scale,
859
998
  imageSize: (el) => this._resources.imageSize(imageUrlOf(el) ?? ''),
860
999
  urlSize: (url) => this._resources.imageSize(url),
@@ -862,6 +1001,8 @@ export class HtmlViewNode extends Node {
862
1001
  measureControl(el, kind, style, this._fonts(), look),
863
1002
  });
864
1003
  this._tree = build();
1004
+ kept = null;
1005
+ this._styledWith = cascade;
865
1006
  this._styledWidth = target;
866
1007
  this._styledHeight = viewport;
867
1008
  this._requestBackgrounds(this._tree);
@@ -940,6 +1081,7 @@ export class HtmlViewNode extends Node {
940
1081
  y: r.y / s,
941
1082
  width: r.width / s,
942
1083
  height: r.height / s,
1084
+ fontSize: r.fontSize / s,
943
1085
  ...(r.bare && {
944
1086
  bare: {
945
1087
  ...r.bare,
@@ -1042,6 +1184,7 @@ export class HtmlViewNode extends Node {
1042
1184
  }
1043
1185
 
1044
1186
  override destroySubtree(): void {
1187
+ this._dropHeldHover();
1045
1188
  this._resources.destroy();
1046
1189
  this._webFonts.destroy();
1047
1190
  this._source.destroy();
@@ -1139,7 +1282,8 @@ export class HtmlViewNode extends Node {
1139
1282
  const to = this._toUnits(end);
1140
1283
  if (to <= from) return [];
1141
1284
  const out: Rect[] = [];
1142
- 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);
1143
1287
  return out;
1144
1288
  }
1145
1289
 
@@ -1196,39 +1340,54 @@ export class HtmlViewNode extends Node {
1196
1340
  rect = { x: box.x, y: box.y, width: box.width, height: box.height };
1197
1341
  } else {
1198
1342
  const bands: Rect[] = [];
1199
- // an inline box's fragments are on the lines of the block it is laid
1200
- // out in, and no text of a float or a positioned box inside it — on
1201
- // lines of their own — is one of them (CSSOM View 6.1): a list item
1202
- // made inline around an absolute link measured as the link
1203
- let block: Box | null = box.parent;
1204
- while (block && !block.lines) block = block.parent;
1205
- if (box.subtreeTextEnd > box.subtreeTextStart) {
1206
- if (box.kind === 'inline' && block?.lines) {
1207
- lineBands(
1208
- block.lines,
1209
- box.subtreeTextStart,
1210
- box.subtreeTextEnd,
1211
- 0,
1212
- 0,
1213
- bands,
1214
- );
1215
- } else {
1216
- collectBands(
1217
- tree.root,
1218
- box.subtreeTextStart,
1219
- box.subtreeTextEnd,
1220
- 0,
1221
- 0,
1222
- bands,
1223
- );
1224
- }
1343
+ const fonts = this._layouts?.fonts ?? null;
1344
+ // an inline box broken around the blocks in it is its pieces, a box
1345
+ // of the element's each (CSS 2.1 9.2.1.1), and its fragments are
1346
+ // theirs; and on the lines between them it has a fragment around each
1347
+ // block, across the box the block is in and as tall as its border
1348
+ // box, as a browser's has — Blink's block-in-inline, Gecko's split
1349
+ // inline's anonymous block. A list item made inline around a block
1350
+ // link measured as the empty edge after it
1351
+ for (const piece of piecesOf(tree.root, box)) {
1352
+ inlineBands(tree.root, piece, fonts, bands);
1225
1353
  }
1226
- if (box.kind === 'inline') {
1227
- fragmentReach(box, bands);
1228
- const fonts = this._layouts?.fonts;
1229
- if (fonts) fragmentHeights(box, bands, fonts);
1354
+ for (const block of (box.cut ? CUT_BLOCKS.get(box) : null) ?? []) {
1355
+ const around = block.parent;
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);
1362
+ bands.push({
1363
+ x: around.contentX,
1364
+ y: top,
1365
+ width: around.contentWidth,
1366
+ height: block.y + block.height - top,
1367
+ });
1230
1368
  }
1231
1369
  for (const band of bands) rect = rect ? unionRect(rect, band) : band;
1370
+ if (!rect) {
1371
+ // on no line, where its block has none: text set at no size takes
1372
+ // no room and gets no box, and CSS puts it on lines of no height
1373
+ // at the block's top (9.4.2), as a browser measures it — nothing,
1374
+ // where the block's content starts. Where the text after it
1375
+ // starts was the next text that took room, elsewhere in the
1376
+ // document: the Zen Garden's 170 hides a heading at `font-size:
1377
+ // 0`, and an `<abbr>` in it measured as the end of the paragraph
1378
+ // above it
1379
+ let block = box.parent;
1380
+ while (block && block.kind === 'inline') block = block.parent;
1381
+ if (block && !block.lines?.length) {
1382
+ const rtl = block.style.direction === 'rtl';
1383
+ rect = {
1384
+ x: rtl ? block.contentX + block.contentWidth : block.contentX,
1385
+ y: block.contentY,
1386
+ width: 0,
1387
+ height: 0,
1388
+ };
1389
+ }
1390
+ }
1232
1391
  if (!rect) {
1233
1392
  // an empty subtree's range is `[0, 0)`, wherever it stands: where
1234
1393
  // its text would be is where the text after it starts
@@ -1251,7 +1410,20 @@ export class HtmlViewNode extends Node {
1251
1410
  const tree = this._tree;
1252
1411
  if (!tree) return null;
1253
1412
  const local = this._toDocument(x, y);
1254
- return deepestAt(tree, local.x, local.y);
1413
+ return deepestAt(tree, local.x, local.y, undefined, this._fixedShift());
1414
+ }
1415
+
1416
+ /** How far a box fixed to the viewport is drawn from where it was laid
1417
+ * out, in the document's pixels: the scroll of the pane the element is
1418
+ * in (`atViewport`). Null where nothing scrolls it. */
1419
+ private _fixedShift(): { x: number; y: number } | null {
1420
+ const tree = this._tree;
1421
+ const viewport = tree && FIXED_BOXES.has(tree) && this._viewport();
1422
+ if (!tree || !viewport) return null;
1423
+ return {
1424
+ x: viewport.x - this.abs.x - tree.root.x,
1425
+ y: viewport.y - this.abs.y - tree.root.y,
1426
+ };
1255
1427
  }
1256
1428
 
1257
1429
  /**
@@ -1268,7 +1440,13 @@ export class HtmlViewNode extends Node {
1268
1440
  const tree = this._tree;
1269
1441
  if (!tree) return null;
1270
1442
  const hit = { text: false };
1271
- const el = deepestAt(tree, x - this.abs.x, y - this.abs.y, hit);
1443
+ const el = deepestAt(
1444
+ tree,
1445
+ x - this.abs.x,
1446
+ y - this.abs.y,
1447
+ hit,
1448
+ this._fixedShift(),
1449
+ );
1272
1450
  const cursor = el ? tree.styles.get(el)?.style.cursor : null;
1273
1451
  // a keyword; a `url()` this cannot load falls back, as its list would
1274
1452
  if (cursor && cursor !== 'auto' && /^[a-z-]+$/.test(cursor)) return cursor;
@@ -1282,6 +1460,7 @@ export class HtmlViewNode extends Node {
1282
1460
  * contains a `:hover` rule.
1283
1461
  */
1284
1462
  setHover(x: number, y: number): boolean {
1463
+ this._dropHeldHover();
1285
1464
  const cascade = this._cascade;
1286
1465
  if (!cascade || !cascade.hoverSensitive) return false;
1287
1466
  const chain: Element[] = [];
@@ -1299,6 +1478,7 @@ export class HtmlViewNode extends Node {
1299
1478
  }
1300
1479
 
1301
1480
  clearHover(): boolean {
1481
+ this._dropHeldHover();
1302
1482
  if (!this._hovered.length) return false;
1303
1483
  const was = this._hovered;
1304
1484
  this._hovered = [];
@@ -1307,97 +1487,151 @@ export class HtmlViewNode extends Node {
1307
1487
  return true;
1308
1488
  }
1309
1489
 
1310
- /** 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
+ */
1311
1496
  private _restyleHover(was: readonly Element[], now: readonly Element[]) {
1312
- const done = this._hoverInPlace(was, now);
1313
- if (done === 'painted') this.invalidate(false, this, 'props');
1314
- else if (done === 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);
1315
1503
  }
1316
1504
 
1317
1505
  /**
1318
- * 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`).
1319
1508
  *
1320
- * A move to another element is a change to the cascade, and the pipeline
1321
- * answers one by building every box again and laying the document out:
1322
- * 270 ms on X11 and twice that on Cocoa for a Wikipedia article, on every
1323
- * link the pointer crossed. But the rules a page writes for `:hover`
1324
- * nearly all change a colour, an underline, a background or a border's
1325
- * colour — 77 of that article's 79 — and those move nothing. So:
1326
- *
1327
- * - Only an element whose hover state flipped and that a compound testing
1328
- * the pointer could match can change (`Cascade.hoverTouches`), with its
1329
- * subtree, and its later siblings where a sibling combinator follows.
1330
- * A move between two paragraphs under `a:hover` touches nothing.
1331
- * - Those are styled again from their parents. Where anything but ink
1332
- * differs (`PAINT_ONLY`), or a box is not the element's own — an
1333
- * anonymous or a pseudo-element box, a marker, a control — this is
1334
- * not the move to take.
1335
- * - The boxes take their new styles, and each layout of their text is
1336
- * made again from the runs it was made from (`TextLayoutCache.inputsOf`)
1337
- * with the new ink (`runFor`), where its geometry comes out the same.
1338
- *
1339
- * Everything is checked before anything is changed. 'none' where nothing
1340
- * could change, 'painted' where it was restyled here, false where the
1341
- * 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.
1342
1515
  */
1343
- private _hoverInPlace(
1516
+ private _hoverReach(
1344
1517
  was: readonly Element[],
1345
1518
  now: readonly Element[],
1346
- ): 'none' | 'painted' | false {
1519
+ ): Set<Element> | null {
1347
1520
  const cascade = this._cascade;
1348
- const tree = this._tree;
1349
- const layouts = this._layouts;
1350
- if (!cascade || !tree || !layouts || !cascade.hoverLocal) return false;
1351
- if (this._stale !== Stale.Nothing || this._laidOutWidth < 0) return false;
1352
-
1521
+ if (!cascade || !cascade.hoverLocal) return null;
1353
1522
  const before = new Set(was);
1354
1523
  const after = new Set(now);
1355
- const roots: Element[] = [];
1356
- // and the elements a `:has()` testing the pointer may flip, which
1357
- // are around the ones that changed; the chains are ancestor chains,
1358
- // so the deepest that changed on each side is where to look from
1524
+ const roots = new Map<Element, HoverTouch>();
1359
1525
  const flipped = (el: Element, other: Set<Element>): void => {
1360
1526
  if (other.has(el)) return;
1361
- 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);
1362
1529
  };
1363
1530
  for (const el of was) flipped(el, after);
1364
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
1365
1534
  const deepest = (chain: readonly Element[], other: Set<Element>) => {
1366
1535
  const el = chain[0];
1367
1536
  if (el && !other.has(el)) cascade.hoverAnchors(el, roots);
1368
1537
  };
1369
1538
  deepest(was, after);
1370
1539
  deepest(now, before);
1371
- if (!roots.length) return 'none';
1372
1540
 
1373
- // what may restyle: the roots' subtrees, which inherit from them and a
1374
- // descendant combinator reaches, and their later siblings' too where a
1375
- // 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
1376
1544
  const reach = new Set<Element>();
1377
- const collect = (el: Element): boolean => {
1545
+ const collect = (el: Element): void => {
1378
1546
  const stack: Element[] = [el];
1379
1547
  while (stack.length) {
1380
1548
  const at = stack.pop()!;
1381
1549
  if (reach.has(at)) continue;
1382
1550
  reach.add(at);
1383
- if (reach.size > HOVER_RESTYLE_LIMIT) return false;
1384
1551
  for (const child of at.children)
1385
1552
  if (isElement(child)) stack.push(child);
1386
1553
  }
1387
- return true;
1388
1554
  };
1389
- for (const root of roots) {
1390
- if (!collect(root)) return false;
1391
- if (!cascade.hoverSiblings) continue;
1555
+ for (const [root, touch] of roots) {
1556
+ collect(root);
1557
+ if (touch !== HOVER_FOLLOWED) continue;
1392
1558
  for (let s = root.nextSibling; s; s = s.nextSibling) {
1393
- if (isElement(s) && !collect(s)) return false;
1559
+ if (isElement(s)) collect(s);
1394
1560
  }
1395
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
+ };
1396
1622
 
1397
1623
  // styled again from their parents, a parent first
1398
1624
  const fresh = new Map<Element, ComputedStyle>();
1399
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>();
1400
1629
  let refused = false;
1630
+ // what changed beyond ink (`hoverChange`): how far some box's ink
1631
+ // reaches, the order its layer paints in, where one is
1632
+ let reaches = false;
1633
+ let reorders = false;
1634
+ const moving = new Set<Element>();
1401
1635
  const styleOf = (el: Element): ComputedStyle | null => {
1402
1636
  const kept = tree.styles.get(el);
1403
1637
  if (!kept) return null;
@@ -1412,43 +1646,123 @@ export class HtmlViewNode extends Node {
1412
1646
  refused = true;
1413
1647
  return null;
1414
1648
  }
1415
- const style = cascade.styleFor(el, parentStyle, kept.inFlex);
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
+ }
1663
+ }
1416
1664
  fresh.set(el, style);
1417
- const diff = inkOnly(kept.style, style);
1418
- if (diff === false) refused = true;
1419
- else if (diff) changed.set(el, style);
1420
1665
  return style;
1421
1666
  };
1422
1667
  for (const el of reach) {
1423
1668
  styleOf(el);
1424
1669
  if (refused) return false;
1425
1670
  }
1426
- if (!changed.size) return 'none';
1671
+ if (!changed.size && pseudoRules === HOVER_PSEUDO_NONE) return true;
1427
1672
  for (const el of changed.keys()) {
1428
1673
  const tag = tagOf(el);
1429
1674
  // their backgrounds are the canvas's
1430
1675
  if (tag === 'html' || tag === 'body') return false;
1431
1676
  }
1432
1677
 
1433
- // the boxes: each changed element's own, and nothing that takes its
1434
- // 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.
1435
1680
  const fonts = layouts.fonts;
1681
+ const blocks = this._blocksOf(
1682
+ tree,
1683
+ pseudoRules === HOVER_PSEUDO_NONE ? changed.keys() : reach,
1684
+ );
1436
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>();
1437
1691
  const redecorated: [Box, Box['decoration']][] = [];
1438
- const walk: Box[] = [tree.root];
1692
+ const walk: Box[] = blocks.slice();
1439
1693
  while (walk.length) {
1440
1694
  const box = walk.pop()!;
1441
1695
  for (const child of box.children) walk.push(child);
1442
- const owner = box.el ?? nearestElement(box);
1443
- if (!owner) continue;
1444
- 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
+ }
1445
1762
  if (!style) continue;
1446
- const kept = tree.styles.get(owner)!;
1447
- // an anonymous box, a pseudo-element, a marker, a control: a style
1448
- // derived from the element's, or drawn from it somewhere else
1449
- if (!box.el || box.style !== kept.style) return false;
1450
- if (box.marker || box.replaced !== 'none') return false;
1763
+ next.set(box, style);
1451
1764
  restyled.push([box, style]);
1765
+ if (ink) inked.add(box);
1452
1766
  if (box.kind === 'inline') {
1453
1767
  const decoration = inlineDecoration(fonts, box, style);
1454
1768
  if ((decoration === null) !== (box.decoration === null)) {
@@ -1458,12 +1772,28 @@ export class HtmlViewNode extends Node {
1458
1772
  if (decoration !== box.decoration) redecorated.push([box, decoration]);
1459
1773
  }
1460
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;
1461
1791
 
1462
- // the text: each layout holding a run of a changed element's, made
1463
- // 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
1464
1794
  const relaid = new Map<TextLayoutLike, TextLayoutLike | null>();
1465
1795
  const texts: LineText[] = [];
1466
- walk.push(tree.root);
1796
+ if (inked.size) for (const block of blocks) walk.push(block);
1467
1797
  while (walk.length) {
1468
1798
  const box = walk.pop()!;
1469
1799
  for (const child of box.children) walk.push(child);
@@ -1472,24 +1802,150 @@ export class HtmlViewNode extends Node {
1472
1802
  for (const text of line.texts) {
1473
1803
  texts.push(text);
1474
1804
  if (relaid.has(text.layout)) continue;
1475
- const next = reinked(text, layouts, changed, tree.styles);
1476
- if (next === false) return false;
1477
- relaid.set(text.layout, next);
1805
+ const again = reinked(text, layouts, next);
1806
+ if (again === false) return false;
1807
+ relaid.set(text.layout, again);
1478
1808
  }
1479
1809
  }
1480
1810
  }
1481
1811
 
1812
+ // what each box drew before, to be repainted with what it draws after
1813
+ const inks: Rect[] = [];
1814
+ for (const box of inked) {
1815
+ const ink = inkOf(box);
1816
+ if (ink) inks.push(ink);
1817
+ }
1818
+
1482
1819
  // and only now, all of it
1483
- for (const [box, style] of restyled) box.style = style;
1484
- for (const [box, decoration] of redecorated) box.decoration = decoration;
1820
+ const moved: [Box, ComputedStyle][] = [];
1821
+ for (const [box, style] of restyled) {
1822
+ const was = box.style;
1823
+ box.style = style;
1824
+ if (box.el && moving.has(box.el) && box.kind !== 'text') {
1825
+ moved.push([box, was]);
1826
+ }
1827
+ }
1828
+ for (const [box, decoration] of redecorated) {
1829
+ // what is inside it keeps which boxes around it paint, and this is
1830
+ // one more or one fewer of them
1831
+ if ((decoration === null) !== (box.decoration === null)) {
1832
+ forgetDecoratedAncestors(box);
1833
+ }
1834
+ box.decoration = decoration;
1835
+ }
1485
1836
  for (const text of texts) {
1486
- const next = relaid.get(text.layout);
1487
- if (next) text.layout = next;
1837
+ const again = relaid.get(text.layout);
1838
+ if (again) text.layout = again;
1488
1839
  }
1489
1840
  for (const [el, style] of changed) {
1490
1841
  tree.styles.set(el, { style, inFlex: tree.styles.get(el)!.inFlex });
1491
1842
  }
1492
- return 'painted';
1843
+ // A box that moved moves what is in it, as layout moved it the first
1844
+ // time (`applyRelativeOffsets`), and a later layout moves it the same
1845
+ // way; where it reached the document's end, before or after, the
1846
+ // document's height may have changed with it, which only a layout says.
1847
+ let relayout = false;
1848
+ for (const [box, was] of moved) {
1849
+ const before = box.boundsY + box.boundsHeight;
1850
+ retranslate(box, was);
1851
+ tree.relative = true;
1852
+ if (before >= this._documentHeight) relayout = true;
1853
+ }
1854
+ // how far the boxes' ink reaches, and the order their layers paint in,
1855
+ // as layout leaves them (`layoutDocument`)
1856
+ if (reaches || reorders || moved.length) {
1857
+ computePaintBounds(tree.root, tree.movedInline);
1858
+ if (tree.negative) hoistNegative(tree.root);
1859
+ stackLayers(tree.root);
1860
+ }
1861
+ for (const [box] of moved) {
1862
+ if (box.boundsY + box.boundsHeight >= this._documentHeight) {
1863
+ relayout = true;
1864
+ }
1865
+ }
1866
+ if (moved.length) this._reportControls();
1867
+ if (relayout) {
1868
+ this._invalidate(Stale.Layout);
1869
+ return true;
1870
+ }
1871
+ for (const box of inked) {
1872
+ const ink = inkOf(box);
1873
+ if (ink) inks.push(ink);
1874
+ }
1875
+ this._repaintInk(inks);
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;
1924
+ }
1925
+
1926
+ /**
1927
+ * Repaint the document where `inks` are, in its own coordinates: the ink
1928
+ * of the boxes a hover restyled, before and after. Past a handful of rects
1929
+ * the damage is one around them all, which is what core would make of
1930
+ * them anyway.
1931
+ */
1932
+ private _repaintInk(inks: Rect[]): void {
1933
+ if (!inks.length) return;
1934
+ const x = this.abs.x;
1935
+ const y = this.abs.y;
1936
+ const place = (r: Rect): Rect => ({
1937
+ x: Math.floor(x + r.x),
1938
+ y: Math.floor(y + r.y),
1939
+ width: Math.ceil(r.width) + 1,
1940
+ height: Math.ceil(r.height) + 1,
1941
+ });
1942
+ if (inks.length > 6) {
1943
+ let all = inks[0];
1944
+ for (const r of inks) all = unionRect(all, r);
1945
+ this.invalidate(false, place(all), 'props');
1946
+ return;
1947
+ }
1948
+ for (const r of inks) this.invalidate(false, place(r), 'props');
1493
1949
  }
1494
1950
 
1495
1951
  /** The document, for an application that wants to read or change it. */
@@ -1527,20 +1983,65 @@ export class HtmlViewNode extends Node {
1527
1983
  // `:hover` is wired at the element rather than through React: the cascade
1528
1984
  // already knows whether any rule in the document tests it, `setHover`
1529
1985
  // returns without work when none does, and a document that does use it
1530
- // re-styles only when the hovered chain actually changed. The restyle is
1531
- // still document-wide — narrowing it to the affected subtree is the
1532
- // 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.
1533
2000
 
1534
2001
  override defaultMouseMove(ev: X11MouseEvent): void {
1535
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 };
1536
2009
  this.setHover(ev.x, ev.y);
1537
2010
  }
1538
2011
 
1539
2012
  override defaultMouseLeave(ev: X11MouseEvent): void {
1540
2013
  super.defaultMouseLeave?.(ev);
2014
+ this._pointerAt = null;
1541
2015
  this.clearHover();
1542
2016
  }
1543
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
+
1544
2045
  // --- paint ----------------------------------------------------------------
1545
2046
 
1546
2047
  override paint(ctx: Context2D): void {
@@ -1570,6 +2071,15 @@ export class HtmlViewNode extends Node {
1570
2071
  }
1571
2072
  }
1572
2073
 
2074
+ /** A surface for the painter to draw a masked element on, asked of the
2075
+ * backend until it is found to have none. */
2076
+ private _surface(width: number, height: number): SurfaceLike | null {
2077
+ if (this._noSurface) return null;
2078
+ const surface = newSurface(this.app, width, height);
2079
+ if (!surface) this._noSurface = true;
2080
+ return surface;
2081
+ }
2082
+
1573
2083
  private _paint(
1574
2084
  ctx: Context2D,
1575
2085
  tree: BoxTree,
@@ -1582,6 +2092,7 @@ export class HtmlViewNode extends Node {
1582
2092
  // the root's background covers the whole element, not only the
1583
2093
  // document: an element grown past its content is canvas too
1584
2094
  canvas: this.abs,
2095
+ viewport: this._viewport() ?? undefined,
1585
2096
  scale: this._scale,
1586
2097
  damage,
1587
2098
  selection: range
@@ -1597,6 +2108,7 @@ export class HtmlViewNode extends Node {
1597
2108
  const size = image ? this._resources.imageSize(url) : null;
1598
2109
  return image && size ? { image, ...size } : null;
1599
2110
  },
2111
+ surface: (width, height) => this._surface(width, height),
1600
2112
  cached: (key, width, height, draw) =>
1601
2113
  (this._shadowCache ??= new SurfaceCache(this.app)).get(
1602
2114
  key,
@@ -1610,6 +2122,30 @@ export class HtmlViewNode extends Node {
1610
2122
 
1611
2123
  const EMPTY_SET: ReadonlySet<Element> = new Set();
1612
2124
 
2125
+ /** Whether a tree has a background fixed to the viewport: an image a box
2126
+ * names (`tree.backgrounds`), or the canvas's image or gradient — the
2127
+ * root's, or the body's it paints, which the root box stands in for where
2128
+ * the markup has neither element. */
2129
+ function hasFixedBackground(tree: BoxTree): boolean {
2130
+ for (const box of tree.backgrounds) {
2131
+ if (box.style.backgroundAttachment === 'fixed') return true;
2132
+ }
2133
+ const root = tree.root;
2134
+ const body = root.children.find((child) => child.el?.name === 'body');
2135
+ for (const box of body ? [root, body] : [root]) {
2136
+ const style = box.style;
2137
+ if (
2138
+ style.backgroundAttachment === 'fixed' &&
2139
+ (style.backgroundImage ||
2140
+ style.backgroundImages ||
2141
+ style.backgroundGradient)
2142
+ ) {
2143
+ return true;
2144
+ }
2145
+ }
2146
+ return false;
2147
+ }
2148
+
1613
2149
  /** A sheet as the restyle reads it: its text, the encoding it was decoded
1614
2150
  * from, the element that brought it, and what its URLs resolve against. */
1615
2151
  interface SheetText {
@@ -1656,6 +2192,94 @@ function boxFor(root: Box, element: Element): Box | null {
1656
2192
  return null;
1657
2193
  }
1658
2194
 
2195
+ /**
2196
+ * An inline element's boxes: the one it has, or where a block in it broke
2197
+ * it (`breakAround`), each piece, in document order.
2198
+ */
2199
+ function piecesOf(root: Box, first: Box): Box[] {
2200
+ // the first piece is cut at its end where there are more
2201
+ if (first.kind !== 'inline' || !(first.cut & 2)) return [first];
2202
+ const pieces: Box[] = [];
2203
+ const stack: Box[] = [root];
2204
+ while (stack.length) {
2205
+ const box = stack.pop()!;
2206
+ if (box.el === first.el && box.kind === 'inline') pieces.push(box);
2207
+ for (let i = box.children.length - 1; i >= 0; i -= 1) {
2208
+ stack.push(box.children[i]);
2209
+ }
2210
+ }
2211
+ return pieces;
2212
+ }
2213
+
2214
+ /**
2215
+ * The bands of one inline box's fragments, or a text's: on the lines of
2216
+ * the block it is laid out in, and no text of a float or a positioned box
2217
+ * inside it — on lines of their own — is one of them (CSSOM View 6.1): a
2218
+ * list item made inline around an absolute link measured as the link.
2219
+ * Each across its text, the inline-blocks and inline boxes' edges it holds
2220
+ * and its own padding and border where it starts or ends there, and as
2221
+ * tall as its border box.
2222
+ */
2223
+ function inlineBands(
2224
+ root: Box,
2225
+ box: Box,
2226
+ fonts: FontsLike | null,
2227
+ out: Rect[],
2228
+ ): void {
2229
+ // its own, apart from any other piece's: `fragmentHeights` makes each of
2230
+ // the bands it is handed as tall as this box on that band's line
2231
+ const bands: Rect[] = [];
2232
+ let block: Box | null = box.parent;
2233
+ while (block && !block.lines) block = block.parent;
2234
+ if (box.subtreeTextEnd > box.subtreeTextStart) {
2235
+ if (box.kind === 'inline' && block?.lines) {
2236
+ lineBands(
2237
+ block.lines,
2238
+ box.subtreeTextStart,
2239
+ box.subtreeTextEnd,
2240
+ 0,
2241
+ 0,
2242
+ bands,
2243
+ true,
2244
+ );
2245
+ } else {
2246
+ collectBands(root, box.subtreeTextStart, box.subtreeTextEnd, 0, 0, bands);
2247
+ }
2248
+ }
2249
+ if (box.kind === 'inline') {
2250
+ fragmentReach(box, bands);
2251
+ if (fonts) fragmentHeights(box, bands, fonts);
2252
+ // where `position: relative` on it or an inline box around it moved
2253
+ // it, all of it (CSS 2.1 9.4.3), as `getBoundingClientRect` has it
2254
+ // (CSSOM View 6.1): the bands are where the lines put it, and a link
2255
+ // moved 120px down measured on its line, as wide as from where it was
2256
+ // laid out to where it was drawn
2257
+ const { x, y } = relativeOffsetOf(box);
2258
+ if (x || y) {
2259
+ for (const band of bands) {
2260
+ band.x += x;
2261
+ band.y += y;
2262
+ }
2263
+ }
2264
+ }
2265
+ out.push(...bands);
2266
+ }
2267
+
2268
+ /** How far an inline box and the inline boxes around it moved it by their
2269
+ * relative offsets (`INLINE_OFFSETS`). */
2270
+ function relativeOffsetOf(box: Box): { x: number; y: number } {
2271
+ let x = 0;
2272
+ let y = 0;
2273
+ for (let at: Box | null = box; at?.kind === 'inline'; at = at.parent) {
2274
+ const offset = INLINE_OFFSETS.get(at);
2275
+ if (offset) {
2276
+ x += offset.x;
2277
+ y += offset.y;
2278
+ }
2279
+ }
2280
+ return { x, y };
2281
+ }
2282
+
1659
2283
  /** Where the document's text goes on after a box with none: the start of
1660
2284
  * the next text in document order, or the end of the last before it. */
1661
2285
  function textAfter(root: Box, target: Box): number {
@@ -1715,6 +2339,9 @@ function sameRects(a: ControlRect[], b: ControlRect[]): boolean {
1715
2339
  p.y !== q.y ||
1716
2340
  p.width !== q.width ||
1717
2341
  p.height !== q.height ||
2342
+ p.fontFamily !== q.fontFamily ||
2343
+ p.fontSize !== q.fontSize ||
2344
+ p.opacity !== q.opacity ||
1718
2345
  !sameBare(p.bare, q.bare)
1719
2346
  ) {
1720
2347
  return false;
@@ -1732,7 +2359,8 @@ function sameBare(a?: BareField, b?: BareField): boolean {
1732
2359
  a.height === b.height &&
1733
2360
  a.color === b.color &&
1734
2361
  a.fontFamily === b.fontFamily &&
1735
- a.fontSize === b.fontSize
2362
+ a.fontSize === b.fontSize &&
2363
+ a.chevron === b.chevron
1736
2364
  );
1737
2365
  }
1738
2366
 
@@ -2004,6 +2632,12 @@ function lineBands(
2004
2632
  dx: number,
2005
2633
  dy: number,
2006
2634
  out: Rect[],
2635
+ /** Where the lines put the text, not where a relative offset moved it
2636
+ * (`TEXT_SHIFTS`): what an inline box's own offset is added to. */
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,
2007
2641
  ): void {
2008
2642
  // First line whose text can reach `from`, by binary search over the
2009
2643
  // sorted text starts; stop at the first line past `to`.
@@ -2025,6 +2659,16 @@ function lineBands(
2025
2659
  const b = Math.min(to, text.textEnd);
2026
2660
  if (b <= a) continue;
2027
2661
  const offsets = layoutOffsetsOf(text.layout);
2662
+ const moved = laidOut ? (TEXT_SHIFTS.get(text)?.x ?? 0) : 0;
2663
+ // The spaces a line ends on are removed (CSS Text 3, 4.1.2), and
2664
+ // take no room on it: the engine sets them past its line's content,
2665
+ // where a caret after them goes, and a band that took them in made
2666
+ // a link that ends a line a space wider than Chrome's. So the text
2667
+ // a line ends with is measured no further than its content.
2668
+ const ends = endsLine(line, text, natural);
2669
+ const rows = ink
2670
+ ? selectionRows(line, text, natural)
2671
+ : { y: line.y, height: line.height };
2028
2672
  for (const band of bandsFor(
2029
2673
  text.layout,
2030
2674
  natural,
@@ -2032,11 +2676,29 @@ function lineBands(
2032
2676
  layoutOffsetOf(text, a),
2033
2677
  layoutOffsetOf(text, b, true),
2034
2678
  )) {
2679
+ let left = band.x;
2680
+ let right = band.x + band.width;
2681
+ if (ends) {
2682
+ left = Math.max(left, natural.x);
2683
+ right = Math.max(left, Math.min(right, natural.x + natural.width));
2684
+ }
2685
+ out.push({
2686
+ x: dx + left + text.drawX - moved,
2687
+ y: dy + rows.y,
2688
+ width: right - left,
2689
+ height: rows.height,
2690
+ });
2691
+ }
2692
+ // the spaces `pre-wrap` keeps that the line ends on, which have no
2693
+ // run: the engine hung them past the line (`LineText.hung`)
2694
+ for (const space of text.hung ?? []) {
2695
+ const at = documentOffsetOf(text, space.at);
2696
+ if (at < a || at >= b) continue;
2035
2697
  out.push({
2036
- x: dx + band.x + text.drawX,
2037
- y: dy + line.y,
2038
- width: band.width,
2039
- height: line.height,
2698
+ x: dx + space.x + text.drawX - moved,
2699
+ y: dy + rows.y,
2700
+ width: space.width,
2701
+ height: rows.height,
2040
2702
  });
2041
2703
  }
2042
2704
  }
@@ -2044,6 +2706,21 @@ function lineBands(
2044
2706
  }
2045
2707
  }
2046
2708
 
2709
+ /** Whether a line's text is where the line ends: its last, with nothing
2710
+ * placed after it — an inline-block or an image past its content. */
2711
+ function endsLine(
2712
+ line: LineBox,
2713
+ text: LineText,
2714
+ natural: { x: number; width: number },
2715
+ ): boolean {
2716
+ if (line.texts[line.texts.length - 1] !== text) return false;
2717
+ const right = text.drawX + natural.x + natural.width;
2718
+ for (const placed of line.atomics) {
2719
+ if (placed.x >= right - 0.01) return false;
2720
+ }
2721
+ return true;
2722
+ }
2723
+
2047
2724
  function collectBands(
2048
2725
  box: Box,
2049
2726
  from: number,
@@ -2051,14 +2728,15 @@ function collectBands(
2051
2728
  dx: number,
2052
2729
  dy: number,
2053
2730
  out: Rect[],
2731
+ ink = false,
2054
2732
  ): void {
2055
2733
  if (box.subtreeTextEnd <= box.subtreeTextStart) return;
2056
2734
  if (box.subtreeTextEnd <= from || box.subtreeTextStart >= to) return;
2057
- 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);
2058
2736
  // Atomics are ordinary children, reached below.
2059
2737
  for (const child of box.children) {
2060
2738
  if (child.kind === 'text' || child.kind === 'break') continue;
2061
- collectBands(child, from, to, dx, dy, out);
2739
+ collectBands(child, from, to, dx, dy, out, ink);
2062
2740
  }
2063
2741
  }
2064
2742
 
@@ -2078,6 +2756,10 @@ function deepestAt(
2078
2756
  /** Told whether what was found was found under text: a run's, rather
2079
2757
  * than a box's. */
2080
2758
  hit?: { text: boolean },
2759
+ /** How far a box fixed to the viewport is drawn from where it was laid
2760
+ * out (`HtmlViewNode._fixedShift`): the point is taken back by it in
2761
+ * there, as paint moved the box by it (`atViewport`). */
2762
+ fixedShift: { x: number; y: number } | null = null,
2081
2763
  ): Element | null {
2082
2764
  const box = tree.root;
2083
2765
  let found: Element | null = box.el;
@@ -2118,10 +2800,31 @@ function deepestAt(
2118
2800
  // Zen Garden's archive links are absolute `<li>`s in an `overflow:
2119
2801
  // hidden` list with no height of its own, and not one of them could be
2120
2802
  // hovered or pressed.
2803
+ let atViewport = false;
2121
2804
  const enter = (
2122
2805
  child: Box,
2123
2806
  context: readonly number[],
2124
2807
  clipped: readonly Box[],
2808
+ ): void => {
2809
+ if (!fixedShift || atViewport || !fixedToViewport(child)) {
2810
+ enterAt(child, context, clipped);
2811
+ return;
2812
+ }
2813
+ x -= fixedShift.x;
2814
+ y -= fixedShift.y;
2815
+ atViewport = true;
2816
+ try {
2817
+ enterAt(child, context, clipped);
2818
+ } finally {
2819
+ x += fixedShift.x;
2820
+ y += fixedShift.y;
2821
+ atViewport = false;
2822
+ }
2823
+ };
2824
+ const enterAt = (
2825
+ child: Box,
2826
+ context: readonly number[],
2827
+ clipped: readonly Box[],
2125
2828
  ): void => {
2126
2829
  const style = child.style;
2127
2830
  if (
@@ -2240,6 +2943,38 @@ function compareKeys(a: readonly number[], b: readonly number[]): number {
2240
2943
  * that matches a container reaches everything in it. */
2241
2944
  const HOVER_RESTYLE_LIMIT = 300;
2242
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
+
2243
2978
  /**
2244
2979
  * The computed properties a pointer move may change in place: ink, which
2245
2980
  * moves nothing. A background colour is drawn inside the box it colours
@@ -2292,16 +3027,135 @@ const FACE_FIELDS = [
2292
3027
  /** Whether two styles differ in ink alone: true where they do, null where
2293
3028
  * they do not differ, false where something else does. Custom properties
2294
3029
  * are read through the properties that use them. */
2295
- function inkOnly(was: ComputedStyle, now: ComputedStyle): boolean | null {
3030
+ /** What a restyle in place changed beyond ink: how far a box's ink
3031
+ * reaches, the order its layer paints in, or where it is. */
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;
3036
+ reach: boolean;
3037
+ order: boolean;
3038
+ move: boolean;
3039
+ }
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
+
3048
+ /** The replaced boxes that are controls (`BoxTree.controls`, `controls.ts`):
3049
+ * a widget mounted beside the document, or a button whose press it reports. */
3050
+ const WIDGETS = new Set<ReplacedKind>([
3051
+ 'input',
3052
+ 'textarea',
3053
+ 'select',
3054
+ 'button',
3055
+ 'checkbox',
3056
+ 'radio',
3057
+ ]);
3058
+
3059
+ /** What only moves how far a box's ink reaches: a shadow, and an outline's
3060
+ * size, which take no room (CSS Backgrounds 3, 7.1; CSS UI 4, 3). */
3061
+ const INK_REACH = new Set([
3062
+ 'boxShadow',
3063
+ 'outlineStyle',
3064
+ 'outlineWidth',
3065
+ 'outlineOffset',
3066
+ ]);
3067
+
3068
+ /**
3069
+ * How two styles of one element differ, for a restyle in place: null where
3070
+ * they do not, a `HoverChange` where every difference is ink, how far ink
3071
+ * reaches, a `z-index` that stays a stacking context's, or a translation —
3072
+ * none of which moves anything but the box and what it holds (CSS
3073
+ * Transforms 1: a transform does not affect layout) — and false where
3074
+ * anything else differs.
3075
+ */
3076
+ function hoverChange(
3077
+ was: ComputedStyle,
3078
+ now: ComputedStyle,
3079
+ ): HoverChange | null | false {
2296
3080
  const a = was as unknown as Record<string, unknown>;
2297
3081
  const b = now as unknown as Record<string, unknown>;
2298
- let ink = false;
3082
+ let change: HoverChange | null = null;
2299
3083
  for (const key in b) {
2300
- if (key === 'custom' || sameValue(a[key], b[key])) continue;
2301
- if (!PAINT_ONLY.has(key)) return false;
2302
- ink = true;
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;
3095
+ if (PAINT_ONLY.has(key)) continue;
3096
+ if (INK_REACH.has(key)) {
3097
+ change.reach = true;
3098
+ continue;
3099
+ }
3100
+ if (key === 'zIndex') {
3101
+ // one stacking context either way, with nothing hoisted below the
3102
+ // flow (`hoistNegative`): only its place among its layers changes
3103
+ const z0 = a[key];
3104
+ const z1 = b[key];
3105
+ if (typeof z0 !== 'number' || typeof z1 !== 'number') return false;
3106
+ if (z0 < 0 || z1 < 0) return false;
3107
+ change.order = true;
3108
+ continue;
3109
+ }
3110
+ if (key === 'transformTranslate' || key === 'translate') {
3111
+ change.move = true;
3112
+ continue;
3113
+ }
3114
+ return false;
2303
3115
  }
2304
- return ink ? true : null;
3116
+ return change;
3117
+ }
3118
+
3119
+ /**
3120
+ * Whether a box can take a new translation where it is: a box of its own
3121
+ * rather than an inline one, whose text is what moves; and the same
3122
+ * containing block and stacking context for what is in it, which a
3123
+ * transform makes a box (CSS Transforms 1, 2) — so either transformed
3124
+ * before and after, or already positioned with a `z-index`, and holding
3125
+ * nothing fixed, which only a transform takes in.
3126
+ */
3127
+ function movable(box: Box, was: ComputedStyle, now: ComputedStyle): boolean {
3128
+ if (box.kind === 'inline') return false;
3129
+ if (transformed(was) === transformed(now)) return true;
3130
+ if (was.position === 'static') return false;
3131
+ if (typeof was.zIndex !== 'number' || typeof now.zIndex !== 'number') {
3132
+ return false;
3133
+ }
3134
+ const stack: Box[] = [...box.children];
3135
+ while (stack.length) {
3136
+ const at = stack.pop()!;
3137
+ if (at.style.position === 'fixed') return false;
3138
+ for (const child of at.children) stack.push(child);
3139
+ }
3140
+ return true;
3141
+ }
3142
+
3143
+ /** Where a box draws, in document coordinates: its ink bounds, or for an
3144
+ * inline box or a run of text, which are drawn on their block's lines and
3145
+ * have none of their own, the block's. */
3146
+ function inkOf(box: Box): Rect | null {
3147
+ let at: Box | null = box;
3148
+ while (at && (at.kind === 'text' || at.kind === 'break' || !hasRect(at))) {
3149
+ at = at.parent;
3150
+ }
3151
+ if (!at || at.boundsY === Infinity) return null;
3152
+ if (!(at.boundsWidth > 0 && at.boundsHeight > 0)) return null;
3153
+ return {
3154
+ x: at.boundsX,
3155
+ y: at.boundsY,
3156
+ width: at.boundsWidth,
3157
+ height: at.boundsHeight,
3158
+ };
2305
3159
  }
2306
3160
 
2307
3161
  /** Structural equality for a computed value: a number, a string, or the
@@ -2321,16 +3175,9 @@ function sameValue(x: unknown, y: unknown): boolean {
2321
3175
  return true;
2322
3176
  }
2323
3177
 
2324
- /** The element an anonymous box takes its style from: its nearest
2325
- * ancestor's that has one. */
2326
- function nearestElement(box: Box): Element | null {
2327
- for (let at = box.parent; at; at = at.parent) if (at.el) return at.el;
2328
- return null;
2329
- }
2330
-
2331
3178
  /**
2332
- * `text`'s layout made again with the ink of the elements a pointer move
2333
- * 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
2334
3181
  * given the ink its new style makes (`runFor`) and nothing else. Null where
2335
3182
  * none of its runs is theirs; false where that cannot be told — a run this
2336
3183
  * cannot place in the document, one some other pass inked (a first line's),
@@ -2339,19 +3186,16 @@ function nearestElement(box: Box): Element | null {
2339
3186
  function reinked(
2340
3187
  text: LineText,
2341
3188
  layouts: TextLayoutCache,
2342
- changed: ReadonlyMap<Element, ComputedStyle>,
2343
- styles: BoxTree['styles'],
3189
+ next: ReadonlyMap<Box, ComputedStyle>,
2344
3190
  ): TextLayoutLike | null | false {
2345
3191
  const spans = text.spans;
2346
- const ownerAt = (offset: number): Element | null =>
2347
- spans.boxAt ? (spans.boxAt(offset)?.el ?? null) : null;
2348
3192
  const inputs = layouts.inputsOf(text.layout);
2349
3193
  if (!inputs || !spans.boxAt) {
2350
3194
  // not one this could make again: none of its text may be theirs
2351
3195
  for (const line of text.layout.lines) {
2352
3196
  for (const run of line.runs) {
2353
- const owner = spans.boxAt ? ownerAt(run.start) : null;
2354
- 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;
2355
3199
  }
2356
3200
  }
2357
3201
  return null;
@@ -2362,25 +3206,25 @@ function reinked(
2362
3206
  for (let i = 0; i < content.length; i += 1) {
2363
3207
  const run = content[i];
2364
3208
  const length = run.text.length;
2365
- const owner = length ? ownerAt(offset) : null;
2366
- const style = owner ? changed.get(owner) : undefined;
2367
- if (owner && style) {
2368
- if (ownerAt(offset + length - 1) !== owner) return false;
2369
- 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);
2370
3214
  const now = runFor(run.text, style);
2371
3215
  for (const f of FACE_FIELDS) if (!sameValue(was[f], now[f])) return false;
2372
- let next: Record<string, unknown> | null = null;
3216
+ let inked: Record<string, unknown> | null = null;
2373
3217
  for (const f of INK_FIELDS) {
2374
3218
  // a run that is not what its style made — a first line's colour
2375
3219
  if (!sameValue(run[f], was[f])) return false;
2376
3220
  if (sameValue(was[f], now[f])) continue;
2377
- next ??= { ...run };
2378
- if (now[f] === undefined) delete next[f];
2379
- else next[f] = now[f];
3221
+ inked ??= { ...run };
3222
+ if (now[f] === undefined) delete inked[f];
3223
+ else inked[f] = now[f];
2380
3224
  }
2381
- if (next) {
3225
+ if (inked) {
2382
3226
  runs ??= content.slice();
2383
- runs[i] = next as unknown as TextRun;
3227
+ runs[i] = inked as unknown as TextRun;
2384
3228
  }
2385
3229
  }
2386
3230
  offset += length;