@react-x11/components 0.10.0 → 0.12.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 (155) hide show
  1. package/dist/code/index.d.ts.map +1 -1
  2. package/dist/code/index.js +25 -24
  3. package/dist/code/index.js.map +1 -1
  4. package/dist/code-language/index.d.ts +1 -1
  5. package/dist/code-language/index.d.ts.map +1 -1
  6. package/dist/code-language/index.js +1 -1
  7. package/dist/code-language/index.js.map +1 -1
  8. package/dist/code-language/runs.d.ts +31 -0
  9. package/dist/code-language/runs.d.ts.map +1 -1
  10. package/dist/code-language/runs.js +105 -0
  11. package/dist/code-language/runs.js.map +1 -1
  12. package/dist/codeblock/index.d.ts +13 -0
  13. package/dist/codeblock/index.d.ts.map +1 -1
  14. package/dist/codeblock/index.js +37 -1
  15. package/dist/codeblock/index.js.map +1 -1
  16. package/dist/flow/node.d.ts +14 -6
  17. package/dist/flow/node.d.ts.map +1 -1
  18. package/dist/flow/node.js +82 -64
  19. package/dist/flow/node.js.map +1 -1
  20. package/dist/html/controls.d.ts +26 -7
  21. package/dist/html/controls.d.ts.map +1 -1
  22. package/dist/html/controls.js +28 -3
  23. package/dist/html/controls.js.map +1 -1
  24. package/dist/html/css/cascade.d.ts +32 -10
  25. package/dist/html/css/cascade.d.ts.map +1 -1
  26. package/dist/html/css/cascade.js +89 -31
  27. package/dist/html/css/cascade.js.map +1 -1
  28. package/dist/html/css/color.d.ts +19 -0
  29. package/dist/html/css/color.d.ts.map +1 -1
  30. package/dist/html/css/color.js +86 -0
  31. package/dist/html/css/color.js.map +1 -1
  32. package/dist/html/css/parse.d.ts +11 -0
  33. package/dist/html/css/parse.d.ts.map +1 -1
  34. package/dist/html/css/parse.js +114 -4
  35. package/dist/html/css/parse.js.map +1 -1
  36. package/dist/html/css/style.d.ts +45 -5
  37. package/dist/html/css/style.d.ts.map +1 -1
  38. package/dist/html/css/style.js +363 -14
  39. package/dist/html/css/style.js.map +1 -1
  40. package/dist/html/css/ua.js +22 -5
  41. package/dist/html/css/ua.js.map +1 -1
  42. package/dist/html/css/values.d.ts +10 -1
  43. package/dist/html/css/values.d.ts.map +1 -1
  44. package/dist/html/css/values.js +27 -2
  45. package/dist/html/css/values.js.map +1 -1
  46. package/dist/html/css/vars.d.ts +5 -0
  47. package/dist/html/css/vars.d.ts.map +1 -1
  48. package/dist/html/css/vars.js +2 -2
  49. package/dist/html/css/vars.js.map +1 -1
  50. package/dist/html/dom.d.ts.map +1 -1
  51. package/dist/html/dom.js +35 -2
  52. package/dist/html/dom.js.map +1 -1
  53. package/dist/html/index.d.ts.map +1 -1
  54. package/dist/html/index.js +38 -1
  55. package/dist/html/index.js.map +1 -1
  56. package/dist/html/layout/block.d.ts +11 -2
  57. package/dist/html/layout/block.d.ts.map +1 -1
  58. package/dist/html/layout/block.js +306 -25
  59. package/dist/html/layout/block.js.map +1 -1
  60. package/dist/html/layout/boxes.d.ts +61 -6
  61. package/dist/html/layout/boxes.d.ts.map +1 -1
  62. package/dist/html/layout/boxes.js +65 -16
  63. package/dist/html/layout/boxes.js.map +1 -1
  64. package/dist/html/layout/cache.d.ts +2 -2
  65. package/dist/html/layout/cache.d.ts.map +1 -1
  66. package/dist/html/layout/cache.js +8 -3
  67. package/dist/html/layout/cache.js.map +1 -1
  68. package/dist/html/layout/floats.d.ts +4 -0
  69. package/dist/html/layout/floats.d.ts.map +1 -1
  70. package/dist/html/layout/floats.js +26 -0
  71. package/dist/html/layout/floats.js.map +1 -1
  72. package/dist/html/layout/inline.d.ts +29 -2
  73. package/dist/html/layout/inline.d.ts.map +1 -1
  74. package/dist/html/layout/inline.js +1454 -125
  75. package/dist/html/layout/inline.js.map +1 -1
  76. package/dist/html/node.d.ts +33 -0
  77. package/dist/html/node.d.ts.map +1 -1
  78. package/dist/html/node.js +608 -86
  79. package/dist/html/node.js.map +1 -1
  80. package/dist/html/paint.d.ts +53 -1
  81. package/dist/html/paint.d.ts.map +1 -1
  82. package/dist/html/paint.js +746 -214
  83. package/dist/html/paint.js.map +1 -1
  84. package/dist/html/surfaces.d.ts +7 -2
  85. package/dist/html/surfaces.d.ts.map +1 -1
  86. package/dist/html/surfaces.js +16 -0
  87. package/dist/html/surfaces.js.map +1 -1
  88. package/dist/html/svg.d.ts.map +1 -1
  89. package/dist/html/svg.js +27 -20
  90. package/dist/html/svg.js.map +1 -1
  91. package/dist/internal/codelines.d.ts +42 -0
  92. package/dist/internal/codelines.d.ts.map +1 -0
  93. package/dist/internal/codelines.js +170 -0
  94. package/dist/internal/codelines.js.map +1 -0
  95. package/dist/internal/window.d.ts +22 -0
  96. package/dist/internal/window.d.ts.map +1 -1
  97. package/dist/internal/window.js +35 -8
  98. package/dist/internal/window.js.map +1 -1
  99. package/dist/markdown/index.d.ts.map +1 -1
  100. package/dist/markdown/index.js +98 -26
  101. package/dist/markdown/index.js.map +1 -1
  102. package/dist/rich-text-editor/index.d.ts.map +1 -1
  103. package/dist/rich-text-editor/index.js +3 -8
  104. package/dist/rich-text-editor/index.js.map +1 -1
  105. package/dist/rich-text-editor/markdown.d.ts.map +1 -1
  106. package/dist/rich-text-editor/markdown.js +30 -1
  107. package/dist/rich-text-editor/markdown.js.map +1 -1
  108. package/dist/rich-text-editor/replace.d.ts +13 -0
  109. package/dist/rich-text-editor/replace.d.ts.map +1 -0
  110. package/dist/rich-text-editor/replace.js +64 -0
  111. package/dist/rich-text-editor/replace.js.map +1 -0
  112. package/dist/richtext/node.d.ts +7 -0
  113. package/dist/richtext/node.d.ts.map +1 -1
  114. package/dist/richtext/node.js.map +1 -1
  115. package/dist/terminal-output/index.d.ts.map +1 -1
  116. package/dist/terminal-output/index.js +100 -25
  117. package/dist/terminal-output/index.js.map +1 -1
  118. package/dist/terminal-output/runs.d.ts +4 -1
  119. package/dist/terminal-output/runs.d.ts.map +1 -1
  120. package/dist/terminal-output/runs.js +5 -1
  121. package/dist/terminal-output/runs.js.map +1 -1
  122. package/package.json +5 -3
  123. package/src/code/index.ts +34 -32
  124. package/src/code-language/index.ts +1 -1
  125. package/src/code-language/runs.ts +111 -1
  126. package/src/codeblock/index.ts +48 -1
  127. package/src/flow/node.ts +80 -64
  128. package/src/html/controls.ts +50 -9
  129. package/src/html/css/cascade.ts +134 -43
  130. package/src/html/css/color.ts +87 -0
  131. package/src/html/css/parse.ts +107 -4
  132. package/src/html/css/style.ts +386 -23
  133. package/src/html/css/ua.ts +22 -5
  134. package/src/html/css/values.ts +31 -3
  135. package/src/html/css/vars.ts +2 -2
  136. package/src/html/dom.ts +38 -2
  137. package/src/html/index.ts +39 -1
  138. package/src/html/layout/block.ts +360 -22
  139. package/src/html/layout/boxes.ts +98 -23
  140. package/src/html/layout/cache.ts +8 -3
  141. package/src/html/layout/floats.ts +24 -0
  142. package/src/html/layout/inline.ts +1690 -119
  143. package/src/html/node.ts +648 -101
  144. package/src/html/paint.ts +883 -283
  145. package/src/html/surfaces.ts +21 -1
  146. package/src/html/svg.ts +30 -19
  147. package/src/internal/codelines.ts +200 -0
  148. package/src/internal/window.ts +36 -8
  149. package/src/markdown/index.ts +145 -26
  150. package/src/rich-text-editor/index.ts +3 -11
  151. package/src/rich-text-editor/markdown.ts +36 -1
  152. package/src/rich-text-editor/replace.ts +85 -0
  153. package/src/richtext/node.ts +7 -0
  154. package/src/terminal-output/index.ts +127 -34
  155. package/src/terminal-output/runs.ts +16 -2
package/src/html/node.ts CHANGED
@@ -51,23 +51,36 @@ 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
53
  import { Cascade } from './css/cascade.js';
54
+ import type { MetricFace } from './css/cascade.js';
54
55
  import { mediaMatches, parseStylesheet } from './css/parse.js';
55
56
  import type { Stylesheet } from './css/parse.js';
56
57
  import { uaStylesheet } from './css/ua.js';
57
58
  import type { ComputedStyle, RootLook } from './css/style.js';
58
- import { buildBoxes, CONTENT_IMAGES } from './layout/boxes.js';
59
+ import {
60
+ BOX_RAISES,
61
+ buildBoxes,
62
+ CONTENT_IMAGES,
63
+ CUT_BLOCKS,
64
+ GENERATED_FROM,
65
+ INLINE_OFFSETS,
66
+ LINE_BOX_RAISES,
67
+ SHIFTED_LINES,
68
+ TEXT_SHIFTS,
69
+ } from './layout/boxes.js';
59
70
  import type {
60
71
  Box,
61
72
  BoxTree,
73
+ LineBox,
62
74
  LineText,
63
75
  ReplacedKind,
64
76
  TextLayoutLike,
65
77
  } from './layout/boxes.js';
66
- import { layoutDocument } from './layout/block.js';
78
+ import { layoutDocument, retranslate, transformed } from './layout/block.js';
67
79
  import { TextLayoutCache } from './layout/cache.js';
68
80
  import { shapingSafe } from './layout/shaping.js';
69
- import { SurfaceCache } from './surfaces.js';
70
- import { inlineDecoration, runFor } from './layout/inline.js';
81
+ import { SurfaceCache, newSurface } from './surfaces.js';
82
+ import type { SurfaceLike } from './surfaces.js';
83
+ import { faceExtentOf, inlineDecoration, runFor } from './layout/inline.js';
71
84
  import type { FontsLike } from './layout/inline.js';
72
85
  import type { TextRun } from '../richtext/index.js';
73
86
  // Through the inline module rather than a second cache: the offsets table for
@@ -80,12 +93,18 @@ import {
80
93
  import { lineBands as bandsFor } from '../richtext/runs.js';
81
94
  import {
82
95
  clipsOverflow,
96
+ computePaintBounds,
83
97
  containingBlockOf,
98
+ FIXED_BOXES,
99
+ fixedToViewport,
100
+ forgetDecoratedAncestors,
84
101
  hasRect,
102
+ hoistNegative,
85
103
  holds,
86
104
  holdsAbsolute,
87
105
  paintDocument,
88
106
  queryChildIndex,
107
+ stackLayers,
89
108
  } from './paint.js';
90
109
  import type { PaintContext } from './paint.js';
91
110
  import { controlRectsOf, measureControl } from './controls.js';
@@ -213,6 +232,8 @@ export class HtmlViewNode extends Node {
213
232
  private _sheetsRead: SheetsRead | null = null;
214
233
  /** Blurred shadows, drawn once each. */
215
234
  private _shadowCache: SurfaceCache | null = null;
235
+ /** Whether the backend was found to have no offscreen surface. */
236
+ private _noSurface = false;
216
237
  private _cascade: Cascade | null = null;
217
238
  private _tree: BoxTree | null = null;
218
239
  /** The faces `_warmFaces` has asked the font matcher for, and of which
@@ -432,13 +453,15 @@ export class HtmlViewNode extends Node {
432
453
  */
433
454
  private _requestBackgrounds(tree: BoxTree): void {
434
455
  for (const box of tree.backgrounds) {
435
- if (!box.el) continue;
456
+ // a pseudo-element's are its element's
457
+ const element = box.el ?? GENERATED_FROM.get(box);
458
+ if (!element) continue;
436
459
  // each layer's, where there is more than one
437
460
  for (const url of box.style.backgroundImages ?? [
438
461
  box.style.backgroundImage,
439
462
  ]) {
440
463
  if (typeof url === 'string') {
441
- this._resources.request({ url, kind: 'image', element: box.el });
464
+ this._resources.request({ url, kind: 'image', element });
442
465
  }
443
466
  }
444
467
  const border = box.style.borderImage.source;
@@ -446,9 +469,15 @@ export class HtmlViewNode extends Node {
446
469
  this._resources.request({
447
470
  url: border,
448
471
  kind: 'image',
449
- element: box.el,
472
+ element,
450
473
  });
451
474
  }
475
+ // and each mask layer's, which is an image as a background's is
476
+ for (const url of box.style.mask.images) {
477
+ if (typeof url === 'string') {
478
+ this._resources.request({ url, kind: 'image', element });
479
+ }
480
+ }
452
481
  }
453
482
  // and every image generated content names, which is only known there
454
483
  // too
@@ -583,9 +612,9 @@ export class HtmlViewNode extends Node {
583
612
  width,
584
613
  this._viewportHeight(),
585
614
  this._scale,
586
- fonts ? (family, size) => xHeightOf(fonts, family, size) : null,
587
- fonts ? (family, size) => zeroWidthOf(fonts, family, size) : null,
588
- fonts ? (family, size) => normalLineOf(fonts, family, size) : null,
615
+ fonts ? (face) => xHeightOf(fonts, face) : null,
616
+ fonts ? (face) => zeroWidthOf(fonts, face) : null,
617
+ fonts ? (face) => normalLineOf(fonts, face) : null,
589
618
  faces.length ? this._webFonts : null,
590
619
  );
591
620
  this._sheetsRead = {
@@ -697,6 +726,55 @@ export class HtmlViewNode extends Node {
697
726
  return true;
698
727
  }
699
728
 
729
+ /**
730
+ * The viewport the document is seen through, in window coordinates: the
731
+ * content box of the pane that scrolls the element, where one does —
732
+ * what `position: fixed` and a fixed background are placed against, as
733
+ * a browser places them against its page area. Null where nothing
734
+ * scrolls it, and the element is its own.
735
+ */
736
+ private _viewport(): Rect | null {
737
+ for (let node = this.parent; node; node = node.parent) {
738
+ // `Scrollable`'s, and not every node's: asked the way core asks it
739
+ const scroller = node as { isScroller?: () => boolean };
740
+ if (!scroller.isScroller?.()) continue;
741
+ const box = node.contentBox();
742
+ return box.width > 0 && box.height > 0 ? box : null;
743
+ }
744
+ return null;
745
+ }
746
+
747
+ /**
748
+ * What the document draws fixed to the viewport of the pane that scrolls
749
+ * it, for that pane's scroll blit to repaint rather than copy with the
750
+ * text (react-x11's `viewportFixedRects`): the whole viewport where a
751
+ * fixed background shows through it, which makes a scroll a repaint, and
752
+ * where each fixed box is drawn. Null for a document with neither, and
753
+ * where nothing scrolls the element.
754
+ */
755
+ override viewportFixedRects(): Rect[] | null {
756
+ const tree = this._tree;
757
+ const viewport = tree && this._viewport();
758
+ if (!tree || !viewport) return null;
759
+ if (hasFixedBackground(tree)) return [viewport];
760
+ const boxes = FIXED_BOXES.get(tree);
761
+ if (!boxes) return null;
762
+ // laid out against the viewport at the document's top, and drawn at the
763
+ // viewport (`atViewport`)
764
+ const root = tree.root;
765
+ const out: Rect[] = [];
766
+ for (const box of boxes) {
767
+ if (!(box.boundsWidth > 0 && box.boundsHeight > 0)) continue;
768
+ out.push({
769
+ x: viewport.x + box.boundsX - root.x,
770
+ y: viewport.y + box.boundsY - root.y,
771
+ width: box.boundsWidth,
772
+ height: box.boundsHeight,
773
+ });
774
+ }
775
+ return out.length ? out : null;
776
+ }
777
+
700
778
  private _viewportHeight(): number {
701
779
  // The viewport a `vh` resolves against is the one the document is seen
702
780
  // through, not the document — a document taller than it does not make
@@ -1188,18 +1266,49 @@ export class HtmlViewNode extends Node {
1188
1266
  rect = { x: box.x, y: box.y, width: box.width, height: box.height };
1189
1267
  } else {
1190
1268
  const bands: Rect[] = [];
1191
- if (box.subtreeTextEnd > box.subtreeTextStart) {
1192
- collectBands(
1193
- tree.root,
1194
- box.subtreeTextStart,
1195
- box.subtreeTextEnd,
1196
- 0,
1197
- 0,
1198
- bands,
1199
- );
1269
+ const fonts = this._layouts?.fonts ?? null;
1270
+ // an inline box broken around the blocks in it is its pieces, a box
1271
+ // of the element's each (CSS 2.1 9.2.1.1), and its fragments are
1272
+ // theirs; and on the lines between them it has a fragment around each
1273
+ // block, across the box the block is in and as tall as its border
1274
+ // box, as a browser's has — Blink's block-in-inline, Gecko's split
1275
+ // inline's anonymous block. A list item made inline around a block
1276
+ // link measured as the empty edge after it
1277
+ for (const piece of piecesOf(tree.root, box)) {
1278
+ inlineBands(tree.root, piece, fonts, bands);
1279
+ }
1280
+ for (const block of (box.cut ? CUT_BLOCKS.get(box) : null) ?? []) {
1281
+ const around = block.parent;
1282
+ if (!around) continue;
1283
+ bands.push({
1284
+ x: around.contentX,
1285
+ y: block.y,
1286
+ width: around.contentWidth,
1287
+ height: block.height,
1288
+ });
1200
1289
  }
1201
- if (box.kind === 'inline') fragmentReach(box, bands);
1202
1290
  for (const band of bands) rect = rect ? unionRect(rect, band) : band;
1291
+ if (!rect) {
1292
+ // on no line, where its block has none: text set at no size takes
1293
+ // no room and gets no box, and CSS puts it on lines of no height
1294
+ // at the block's top (9.4.2), as a browser measures it — nothing,
1295
+ // where the block's content starts. Where the text after it
1296
+ // starts was the next text that took room, elsewhere in the
1297
+ // document: the Zen Garden's 170 hides a heading at `font-size:
1298
+ // 0`, and an `<abbr>` in it measured as the end of the paragraph
1299
+ // above it
1300
+ let block = box.parent;
1301
+ while (block && block.kind === 'inline') block = block.parent;
1302
+ if (block && !block.lines?.length) {
1303
+ const rtl = block.style.direction === 'rtl';
1304
+ rect = {
1305
+ x: rtl ? block.contentX + block.contentWidth : block.contentX,
1306
+ y: block.contentY,
1307
+ width: 0,
1308
+ height: 0,
1309
+ };
1310
+ }
1311
+ }
1203
1312
  if (!rect) {
1204
1313
  // an empty subtree's range is `[0, 0)`, wherever it stands: where
1205
1314
  // its text would be is where the text after it starts
@@ -1222,7 +1331,20 @@ export class HtmlViewNode extends Node {
1222
1331
  const tree = this._tree;
1223
1332
  if (!tree) return null;
1224
1333
  const local = this._toDocument(x, y);
1225
- return deepestAt(tree, local.x, local.y);
1334
+ return deepestAt(tree, local.x, local.y, undefined, this._fixedShift());
1335
+ }
1336
+
1337
+ /** How far a box fixed to the viewport is drawn from where it was laid
1338
+ * out, in the document's pixels: the scroll of the pane the element is
1339
+ * in (`atViewport`). Null where nothing scrolls it. */
1340
+ private _fixedShift(): { x: number; y: number } | null {
1341
+ const tree = this._tree;
1342
+ const viewport = tree && FIXED_BOXES.has(tree) && this._viewport();
1343
+ if (!tree || !viewport) return null;
1344
+ return {
1345
+ x: viewport.x - this.abs.x - tree.root.x,
1346
+ y: viewport.y - this.abs.y - tree.root.y,
1347
+ };
1226
1348
  }
1227
1349
 
1228
1350
  /**
@@ -1239,7 +1361,13 @@ export class HtmlViewNode extends Node {
1239
1361
  const tree = this._tree;
1240
1362
  if (!tree) return null;
1241
1363
  const hit = { text: false };
1242
- const el = deepestAt(tree, x - this.abs.x, y - this.abs.y, hit);
1364
+ const el = deepestAt(
1365
+ tree,
1366
+ x - this.abs.x,
1367
+ y - this.abs.y,
1368
+ hit,
1369
+ this._fixedShift(),
1370
+ );
1243
1371
  const cursor = el ? tree.styles.get(el)?.style.cursor : null;
1244
1372
  // a keyword; a `url()` this cannot load falls back, as its list would
1245
1373
  if (cursor && cursor !== 'auto' && /^[a-z-]+$/.test(cursor)) return cursor;
@@ -1280,9 +1408,8 @@ export class HtmlViewNode extends Node {
1280
1408
 
1281
1409
  /** The hovered chain moved: restyle where it did, or the document. */
1282
1410
  private _restyleHover(was: readonly Element[], now: readonly Element[]) {
1283
- const done = this._hoverInPlace(was, now);
1284
- if (done === 'painted') this.invalidate(false, this, 'props');
1285
- else if (done === false) this._invalidate(Stale.Boxes);
1411
+ // a restyle in place names what it repainted (`_hoverInPlace`)
1412
+ if (this._hoverInPlace(was, now) === false) this._invalidate(Stale.Boxes);
1286
1413
  }
1287
1414
 
1288
1415
  /**
@@ -1369,6 +1496,11 @@ export class HtmlViewNode extends Node {
1369
1496
  const fresh = new Map<Element, ComputedStyle>();
1370
1497
  const changed = new Map<Element, ComputedStyle>();
1371
1498
  let refused = false;
1499
+ // what changed beyond ink (`hoverChange`): how far some box's ink
1500
+ // reaches, the order its layer paints in, where one is
1501
+ let reaches = false;
1502
+ let reorders = false;
1503
+ const moving = new Set<Element>();
1372
1504
  const styleOf = (el: Element): ComputedStyle | null => {
1373
1505
  const kept = tree.styles.get(el);
1374
1506
  if (!kept) return null;
@@ -1385,9 +1517,14 @@ export class HtmlViewNode extends Node {
1385
1517
  }
1386
1518
  const style = cascade.styleFor(el, parentStyle, kept.inFlex);
1387
1519
  fresh.set(el, style);
1388
- const diff = inkOnly(kept.style, style);
1520
+ const diff = hoverChange(kept.style, style);
1389
1521
  if (diff === false) refused = true;
1390
- else if (diff) changed.set(el, style);
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);
1527
+ }
1391
1528
  return style;
1392
1529
  };
1393
1530
  for (const el of reach) {
@@ -1416,9 +1553,12 @@ export class HtmlViewNode extends Node {
1416
1553
  if (!style) continue;
1417
1554
  const kept = tree.styles.get(owner)!;
1418
1555
  // an anonymous box, a pseudo-element, a marker, a control: a style
1419
- // derived from the element's, or drawn from it somewhere else
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.
1420
1559
  if (!box.el || box.style !== kept.style) return false;
1421
- if (box.marker || box.replaced !== 'none') return false;
1560
+ if (box.marker || WIDGETS.has(box.replaced)) return false;
1561
+ if (moving.has(owner) && !movable(box, kept.style, style)) return false;
1422
1562
  restyled.push([box, style]);
1423
1563
  if (box.kind === 'inline') {
1424
1564
  const decoration = inlineDecoration(fonts, box, style);
@@ -1450,9 +1590,28 @@ export class HtmlViewNode extends Node {
1450
1590
  }
1451
1591
  }
1452
1592
 
1593
+ // what each box drew before, to be repainted with what it draws after
1594
+ const inks: Rect[] = [];
1595
+ for (const [box] of restyled) {
1596
+ const ink = inkOf(box);
1597
+ if (ink) inks.push(ink);
1598
+ }
1599
+
1453
1600
  // and only now, all of it
1454
- for (const [box, style] of restyled) box.style = style;
1455
- for (const [box, decoration] of redecorated) box.decoration = decoration;
1601
+ const moved: [Box, ComputedStyle][] = [];
1602
+ for (const [box, style] of restyled) {
1603
+ const was = box.style;
1604
+ box.style = style;
1605
+ if (moving.has(box.el!)) moved.push([box, was]);
1606
+ }
1607
+ for (const [box, decoration] of redecorated) {
1608
+ // what is inside it keeps which boxes around it paint, and this is
1609
+ // one more or one fewer of them
1610
+ if ((decoration === null) !== (box.decoration === null)) {
1611
+ forgetDecoratedAncestors(box);
1612
+ }
1613
+ box.decoration = decoration;
1614
+ }
1456
1615
  for (const text of texts) {
1457
1616
  const next = relaid.get(text.layout);
1458
1617
  if (next) text.layout = next;
@@ -1460,9 +1619,67 @@ export class HtmlViewNode extends Node {
1460
1619
  for (const [el, style] of changed) {
1461
1620
  tree.styles.set(el, { style, inFlex: tree.styles.get(el)!.inFlex });
1462
1621
  }
1622
+ // A box that moved moves what is in it, as layout moved it the first
1623
+ // time (`applyRelativeOffsets`), and a later layout moves it the same
1624
+ // way; where it reached the document's end, before or after, the
1625
+ // document's height may have changed with it, which only a layout says.
1626
+ let relayout = false;
1627
+ for (const [box, was] of moved) {
1628
+ const before = box.boundsY + box.boundsHeight;
1629
+ retranslate(box, was);
1630
+ tree.relative = true;
1631
+ if (before >= this._documentHeight) relayout = true;
1632
+ }
1633
+ // how far the boxes' ink reaches, and the order their layers paint in,
1634
+ // as layout leaves them (`layoutDocument`)
1635
+ if (reaches || reorders || moved.length) {
1636
+ computePaintBounds(tree.root, tree.movedInline);
1637
+ if (tree.negative) hoistNegative(tree.root);
1638
+ stackLayers(tree.root);
1639
+ }
1640
+ for (const [box] of moved) {
1641
+ if (box.boundsY + box.boundsHeight >= this._documentHeight) {
1642
+ relayout = true;
1643
+ }
1644
+ }
1645
+ if (moved.length) this._reportControls();
1646
+ if (relayout) {
1647
+ this._invalidate(Stale.Layout);
1648
+ return 'painted';
1649
+ }
1650
+ for (const [box] of restyled) {
1651
+ const ink = inkOf(box);
1652
+ if (ink) inks.push(ink);
1653
+ }
1654
+ this._repaintInk(inks);
1463
1655
  return 'painted';
1464
1656
  }
1465
1657
 
1658
+ /**
1659
+ * Repaint the document where `inks` are, in its own coordinates: the ink
1660
+ * of the boxes a hover restyled, before and after. Past a handful of rects
1661
+ * the damage is one around them all, which is what core would make of
1662
+ * them anyway.
1663
+ */
1664
+ private _repaintInk(inks: Rect[]): void {
1665
+ if (!inks.length) return;
1666
+ const x = this.abs.x;
1667
+ const y = this.abs.y;
1668
+ const place = (r: Rect): Rect => ({
1669
+ x: Math.floor(x + r.x),
1670
+ y: Math.floor(y + r.y),
1671
+ width: Math.ceil(r.width) + 1,
1672
+ height: Math.ceil(r.height) + 1,
1673
+ });
1674
+ if (inks.length > 6) {
1675
+ let all = inks[0];
1676
+ for (const r of inks) all = unionRect(all, r);
1677
+ this.invalidate(false, place(all), 'props');
1678
+ return;
1679
+ }
1680
+ for (const r of inks) this.invalidate(false, place(r), 'props');
1681
+ }
1682
+
1466
1683
  /** The document, for an application that wants to read or change it. */
1467
1684
  get document(): Document {
1468
1685
  return this._source.document;
@@ -1541,6 +1758,15 @@ export class HtmlViewNode extends Node {
1541
1758
  }
1542
1759
  }
1543
1760
 
1761
+ /** A surface for the painter to draw a masked element on, asked of the
1762
+ * backend until it is found to have none. */
1763
+ private _surface(width: number, height: number): SurfaceLike | null {
1764
+ if (this._noSurface) return null;
1765
+ const surface = newSurface(this.app, width, height);
1766
+ if (!surface) this._noSurface = true;
1767
+ return surface;
1768
+ }
1769
+
1544
1770
  private _paint(
1545
1771
  ctx: Context2D,
1546
1772
  tree: BoxTree,
@@ -1553,6 +1779,7 @@ export class HtmlViewNode extends Node {
1553
1779
  // the root's background covers the whole element, not only the
1554
1780
  // document: an element grown past its content is canvas too
1555
1781
  canvas: this.abs,
1782
+ viewport: this._viewport() ?? undefined,
1556
1783
  scale: this._scale,
1557
1784
  damage,
1558
1785
  selection: range
@@ -1568,6 +1795,7 @@ export class HtmlViewNode extends Node {
1568
1795
  const size = image ? this._resources.imageSize(url) : null;
1569
1796
  return image && size ? { image, ...size } : null;
1570
1797
  },
1798
+ surface: (width, height) => this._surface(width, height),
1571
1799
  cached: (key, width, height, draw) =>
1572
1800
  (this._shadowCache ??= new SurfaceCache(this.app)).get(
1573
1801
  key,
@@ -1581,6 +1809,30 @@ export class HtmlViewNode extends Node {
1581
1809
 
1582
1810
  const EMPTY_SET: ReadonlySet<Element> = new Set();
1583
1811
 
1812
+ /** Whether a tree has a background fixed to the viewport: an image a box
1813
+ * names (`tree.backgrounds`), or the canvas's image or gradient — the
1814
+ * root's, or the body's it paints, which the root box stands in for where
1815
+ * the markup has neither element. */
1816
+ function hasFixedBackground(tree: BoxTree): boolean {
1817
+ for (const box of tree.backgrounds) {
1818
+ if (box.style.backgroundAttachment === 'fixed') return true;
1819
+ }
1820
+ const root = tree.root;
1821
+ const body = root.children.find((child) => child.el?.name === 'body');
1822
+ for (const box of body ? [root, body] : [root]) {
1823
+ const style = box.style;
1824
+ if (
1825
+ style.backgroundAttachment === 'fixed' &&
1826
+ (style.backgroundImage ||
1827
+ style.backgroundImages ||
1828
+ style.backgroundGradient)
1829
+ ) {
1830
+ return true;
1831
+ }
1832
+ }
1833
+ return false;
1834
+ }
1835
+
1584
1836
  /** A sheet as the restyle reads it: its text, the encoding it was decoded
1585
1837
  * from, the element that brought it, and what its URLs resolve against. */
1586
1838
  interface SheetText {
@@ -1627,6 +1879,94 @@ function boxFor(root: Box, element: Element): Box | null {
1627
1879
  return null;
1628
1880
  }
1629
1881
 
1882
+ /**
1883
+ * An inline element's boxes: the one it has, or where a block in it broke
1884
+ * it (`breakAround`), each piece, in document order.
1885
+ */
1886
+ function piecesOf(root: Box, first: Box): Box[] {
1887
+ // the first piece is cut at its end where there are more
1888
+ if (first.kind !== 'inline' || !(first.cut & 2)) return [first];
1889
+ const pieces: Box[] = [];
1890
+ const stack: Box[] = [root];
1891
+ while (stack.length) {
1892
+ const box = stack.pop()!;
1893
+ if (box.el === first.el && box.kind === 'inline') pieces.push(box);
1894
+ for (let i = box.children.length - 1; i >= 0; i -= 1) {
1895
+ stack.push(box.children[i]);
1896
+ }
1897
+ }
1898
+ return pieces;
1899
+ }
1900
+
1901
+ /**
1902
+ * The bands of one inline box's fragments, or a text's: on the lines of
1903
+ * the block it is laid out in, and no text of a float or a positioned box
1904
+ * inside it — on lines of their own — is one of them (CSSOM View 6.1): a
1905
+ * list item made inline around an absolute link measured as the link.
1906
+ * Each across its text, the inline-blocks and inline boxes' edges it holds
1907
+ * and its own padding and border where it starts or ends there, and as
1908
+ * tall as its border box.
1909
+ */
1910
+ function inlineBands(
1911
+ root: Box,
1912
+ box: Box,
1913
+ fonts: FontsLike | null,
1914
+ out: Rect[],
1915
+ ): void {
1916
+ // its own, apart from any other piece's: `fragmentHeights` makes each of
1917
+ // the bands it is handed as tall as this box on that band's line
1918
+ const bands: Rect[] = [];
1919
+ let block: Box | null = box.parent;
1920
+ while (block && !block.lines) block = block.parent;
1921
+ if (box.subtreeTextEnd > box.subtreeTextStart) {
1922
+ if (box.kind === 'inline' && block?.lines) {
1923
+ lineBands(
1924
+ block.lines,
1925
+ box.subtreeTextStart,
1926
+ box.subtreeTextEnd,
1927
+ 0,
1928
+ 0,
1929
+ bands,
1930
+ true,
1931
+ );
1932
+ } else {
1933
+ collectBands(root, box.subtreeTextStart, box.subtreeTextEnd, 0, 0, bands);
1934
+ }
1935
+ }
1936
+ if (box.kind === 'inline') {
1937
+ fragmentReach(box, bands);
1938
+ if (fonts) fragmentHeights(box, bands, fonts);
1939
+ // where `position: relative` on it or an inline box around it moved
1940
+ // it, all of it (CSS 2.1 9.4.3), as `getBoundingClientRect` has it
1941
+ // (CSSOM View 6.1): the bands are where the lines put it, and a link
1942
+ // moved 120px down measured on its line, as wide as from where it was
1943
+ // laid out to where it was drawn
1944
+ const { x, y } = relativeOffsetOf(box);
1945
+ if (x || y) {
1946
+ for (const band of bands) {
1947
+ band.x += x;
1948
+ band.y += y;
1949
+ }
1950
+ }
1951
+ }
1952
+ out.push(...bands);
1953
+ }
1954
+
1955
+ /** How far an inline box and the inline boxes around it moved it by their
1956
+ * relative offsets (`INLINE_OFFSETS`). */
1957
+ function relativeOffsetOf(box: Box): { x: number; y: number } {
1958
+ let x = 0;
1959
+ let y = 0;
1960
+ for (let at: Box | null = box; at?.kind === 'inline'; at = at.parent) {
1961
+ const offset = INLINE_OFFSETS.get(at);
1962
+ if (offset) {
1963
+ x += offset.x;
1964
+ y += offset.y;
1965
+ }
1966
+ }
1967
+ return { x, y };
1968
+ }
1969
+
1630
1970
  /** Where the document's text goes on after a box with none: the start of
1631
1971
  * the next text in document order, or the end of the last before it. */
1632
1972
  function textAfter(root: Box, target: Box): number {
@@ -1686,6 +2026,7 @@ function sameRects(a: ControlRect[], b: ControlRect[]): boolean {
1686
2026
  p.y !== q.y ||
1687
2027
  p.width !== q.width ||
1688
2028
  p.height !== q.height ||
2029
+ p.opacity !== q.opacity ||
1689
2030
  !sameBare(p.bare, q.bare)
1690
2031
  ) {
1691
2032
  return false;
@@ -1703,7 +2044,8 @@ function sameBare(a?: BareField, b?: BareField): boolean {
1703
2044
  a.height === b.height &&
1704
2045
  a.color === b.color &&
1705
2046
  a.fontFamily === b.fontFamily &&
1706
- a.fontSize === b.fontSize
2047
+ a.fontSize === b.fontSize &&
2048
+ a.chevron === b.chevron
1707
2049
  );
1708
2050
  }
1709
2051
 
@@ -1936,56 +2278,139 @@ function fragmentReach(box: Box, out: Rect[]): void {
1936
2278
  }
1937
2279
  }
1938
2280
 
1939
- function collectBands(
1940
- box: Box,
2281
+ /**
2282
+ * An inline element's bands made as tall as its fragments' border boxes,
2283
+ * as CSSOM View takes an inline box's client rects (6.1): its font's
2284
+ * content area about its own baseline, with the padding and the border
2285
+ * above and below it, which take no room on the line (CSS 2.1 10.6.1) and
2286
+ * which paint draws the background over (`paintInlineBoxes`). A band is a
2287
+ * line's height across until then: a link padded 10px below measured as
2288
+ * tall as its line and no taller.
2289
+ */
2290
+ function fragmentHeights(box: Box, bands: Rect[], fonts: FontsLike): void {
2291
+ let block = box.parent;
2292
+ while (block && !block.lines) block = block.parent;
2293
+ if (!block?.lines?.length) return;
2294
+ const face = faceExtentOf(fonts, box.style);
2295
+ const byTop = new Map<number, LineBox>();
2296
+ for (const line of block.lines) byTop.set(line.y, line);
2297
+ for (let i = 0; i < bands.length; i += 1) {
2298
+ const band = bands[i];
2299
+ const line = byTop.get(band.y);
2300
+ if (!line) continue;
2301
+ // on its own baseline, which `vertical-align` may raise off the line's
2302
+ const raise = SHIFTED_LINES.has(line)
2303
+ ? (LINE_BOX_RAISES.get(line)?.get(box) ?? BOX_RAISES.get(box) ?? 0)
2304
+ : 0;
2305
+ const baseline = line.y + line.baseline - raise;
2306
+ const top = baseline - face.ascent - box.padTop - box.borderTop;
2307
+ const bottom = baseline + face.descent + box.padBottom + box.borderBottom;
2308
+ bands[i] = { x: band.x, y: top, width: band.width, height: bottom - top };
2309
+ }
2310
+ }
2311
+
2312
+ /** The bands of a text range on one block's own lines. */
2313
+ function lineBands(
2314
+ lines: readonly LineBox[],
1941
2315
  from: number,
1942
2316
  to: number,
1943
2317
  dx: number,
1944
2318
  dy: number,
1945
2319
  out: Rect[],
2320
+ /** Where the lines put the text, not where a relative offset moved it
2321
+ * (`TEXT_SHIFTS`): what an inline box's own offset is added to. */
2322
+ laidOut = false,
1946
2323
  ): void {
1947
- if (box.subtreeTextEnd <= box.subtreeTextStart) return;
1948
- if (box.subtreeTextEnd <= from || box.subtreeTextStart >= to) return;
1949
- if (box.lines) {
1950
- const lines = box.lines;
1951
- // First line whose text can reach `from`, by binary search over the
1952
- // sorted text starts; stop at the first line past `to`.
1953
- let lo = 0;
1954
- let hi = lines.length;
1955
- while (lo < hi) {
1956
- const mid = (lo + hi) >> 1;
1957
- if (lines[mid].textEnd > from) hi = mid;
1958
- else lo = mid + 1;
1959
- }
1960
- for (let i = lo; i < lines.length; i += 1) {
1961
- const line = lines[i];
1962
- if (line.textStart >= to) break;
1963
- if (line.textEnd > from && line.textStart < to) {
1964
- for (const text of line.texts) {
1965
- const natural = text.layout.lines[text.layoutLine];
1966
- if (!natural) continue;
1967
- const a = Math.max(from, text.textStart);
1968
- const b = Math.min(to, text.textEnd);
1969
- if (b <= a) continue;
1970
- const offsets = layoutOffsetsOf(text.layout);
1971
- for (const band of bandsFor(
1972
- text.layout,
1973
- natural,
1974
- offsets,
1975
- layoutOffsetOf(text, a),
1976
- layoutOffsetOf(text, b, true),
1977
- )) {
1978
- out.push({
1979
- x: dx + band.x + text.drawX,
1980
- y: dy + line.y,
1981
- width: band.width,
1982
- height: line.height,
1983
- });
2324
+ // First line whose text can reach `from`, by binary search over the
2325
+ // sorted text starts; stop at the first line past `to`.
2326
+ let lo = 0;
2327
+ let hi = lines.length;
2328
+ while (lo < hi) {
2329
+ const mid = (lo + hi) >> 1;
2330
+ if (lines[mid].textEnd > from) hi = mid;
2331
+ else lo = mid + 1;
2332
+ }
2333
+ for (let i = lo; i < lines.length; i += 1) {
2334
+ const line = lines[i];
2335
+ if (line.textStart >= to) break;
2336
+ if (line.textEnd > from && line.textStart < to) {
2337
+ for (const text of line.texts) {
2338
+ const natural = text.layout.lines[text.layoutLine];
2339
+ if (!natural) continue;
2340
+ const a = Math.max(from, text.textStart);
2341
+ const b = Math.min(to, text.textEnd);
2342
+ if (b <= a) continue;
2343
+ const offsets = layoutOffsetsOf(text.layout);
2344
+ const moved = laidOut ? (TEXT_SHIFTS.get(text)?.x ?? 0) : 0;
2345
+ // The spaces a line ends on are removed (CSS Text 3, 4.1.2), and
2346
+ // take no room on it: the engine sets them past its line's content,
2347
+ // where a caret after them goes, and a band that took them in made
2348
+ // a link that ends a line a space wider than Chrome's. So the text
2349
+ // a line ends with is measured no further than its content.
2350
+ const ends = endsLine(line, text, natural);
2351
+ for (const band of bandsFor(
2352
+ text.layout,
2353
+ natural,
2354
+ offsets,
2355
+ layoutOffsetOf(text, a),
2356
+ layoutOffsetOf(text, b, true),
2357
+ )) {
2358
+ let left = band.x;
2359
+ let right = band.x + band.width;
2360
+ if (ends) {
2361
+ left = Math.max(left, natural.x);
2362
+ right = Math.max(left, Math.min(right, natural.x + natural.width));
1984
2363
  }
2364
+ out.push({
2365
+ x: dx + left + text.drawX - moved,
2366
+ y: dy + line.y,
2367
+ width: right - left,
2368
+ height: line.height,
2369
+ });
2370
+ }
2371
+ // the spaces `pre-wrap` keeps that the line ends on, which have no
2372
+ // run: the engine hung them past the line (`LineText.hung`)
2373
+ for (const space of text.hung ?? []) {
2374
+ const at = documentOffsetOf(text, space.at);
2375
+ if (at < a || at >= b) continue;
2376
+ out.push({
2377
+ x: dx + space.x + text.drawX - moved,
2378
+ y: dy + line.y,
2379
+ width: space.width,
2380
+ height: line.height,
2381
+ });
1985
2382
  }
1986
2383
  }
1987
2384
  }
1988
2385
  }
2386
+ }
2387
+
2388
+ /** Whether a line's text is where the line ends: its last, with nothing
2389
+ * placed after it — an inline-block or an image past its content. */
2390
+ function endsLine(
2391
+ line: LineBox,
2392
+ text: LineText,
2393
+ natural: { x: number; width: number },
2394
+ ): boolean {
2395
+ if (line.texts[line.texts.length - 1] !== text) return false;
2396
+ const right = text.drawX + natural.x + natural.width;
2397
+ for (const placed of line.atomics) {
2398
+ if (placed.x >= right - 0.01) return false;
2399
+ }
2400
+ return true;
2401
+ }
2402
+
2403
+ function collectBands(
2404
+ box: Box,
2405
+ from: number,
2406
+ to: number,
2407
+ dx: number,
2408
+ dy: number,
2409
+ out: Rect[],
2410
+ ): void {
2411
+ if (box.subtreeTextEnd <= box.subtreeTextStart) return;
2412
+ if (box.subtreeTextEnd <= from || box.subtreeTextStart >= to) return;
2413
+ if (box.lines) lineBands(box.lines, from, to, dx, dy, out);
1989
2414
  // Atomics are ordinary children, reached below.
1990
2415
  for (const child of box.children) {
1991
2416
  if (child.kind === 'text' || child.kind === 'break') continue;
@@ -2009,6 +2434,10 @@ function deepestAt(
2009
2434
  /** Told whether what was found was found under text: a run's, rather
2010
2435
  * than a box's. */
2011
2436
  hit?: { text: boolean },
2437
+ /** How far a box fixed to the viewport is drawn from where it was laid
2438
+ * out (`HtmlViewNode._fixedShift`): the point is taken back by it in
2439
+ * there, as paint moved the box by it (`atViewport`). */
2440
+ fixedShift: { x: number; y: number } | null = null,
2012
2441
  ): Element | null {
2013
2442
  const box = tree.root;
2014
2443
  let found: Element | null = box.el;
@@ -2049,10 +2478,31 @@ function deepestAt(
2049
2478
  // Zen Garden's archive links are absolute `<li>`s in an `overflow:
2050
2479
  // hidden` list with no height of its own, and not one of them could be
2051
2480
  // hovered or pressed.
2481
+ let atViewport = false;
2052
2482
  const enter = (
2053
2483
  child: Box,
2054
2484
  context: readonly number[],
2055
2485
  clipped: readonly Box[],
2486
+ ): void => {
2487
+ if (!fixedShift || atViewport || !fixedToViewport(child)) {
2488
+ enterAt(child, context, clipped);
2489
+ return;
2490
+ }
2491
+ x -= fixedShift.x;
2492
+ y -= fixedShift.y;
2493
+ atViewport = true;
2494
+ try {
2495
+ enterAt(child, context, clipped);
2496
+ } finally {
2497
+ x += fixedShift.x;
2498
+ y += fixedShift.y;
2499
+ atViewport = false;
2500
+ }
2501
+ };
2502
+ const enterAt = (
2503
+ child: Box,
2504
+ context: readonly number[],
2505
+ clipped: readonly Box[],
2056
2506
  ): void => {
2057
2507
  const style = child.style;
2058
2508
  if (
@@ -2223,16 +2673,116 @@ const FACE_FIELDS = [
2223
2673
  /** Whether two styles differ in ink alone: true where they do, null where
2224
2674
  * they do not differ, false where something else does. Custom properties
2225
2675
  * are read through the properties that use them. */
2226
- function inkOnly(was: ComputedStyle, now: ComputedStyle): boolean | null {
2676
+ /** What a restyle in place changed beyond ink: how far a box's ink
2677
+ * reaches, the order its layer paints in, or where it is. */
2678
+ interface HoverChange {
2679
+ reach: boolean;
2680
+ order: boolean;
2681
+ move: boolean;
2682
+ }
2683
+
2684
+ /** The replaced boxes that are controls (`BoxTree.controls`, `controls.ts`):
2685
+ * a widget mounted beside the document, or a button whose press it reports. */
2686
+ const WIDGETS = new Set<ReplacedKind>([
2687
+ 'input',
2688
+ 'textarea',
2689
+ 'select',
2690
+ 'button',
2691
+ 'checkbox',
2692
+ 'radio',
2693
+ ]);
2694
+
2695
+ /** What only moves how far a box's ink reaches: a shadow, and an outline's
2696
+ * size, which take no room (CSS Backgrounds 3, 7.1; CSS UI 4, 3). */
2697
+ const INK_REACH = new Set([
2698
+ 'boxShadow',
2699
+ 'outlineStyle',
2700
+ 'outlineWidth',
2701
+ 'outlineOffset',
2702
+ ]);
2703
+
2704
+ /**
2705
+ * How two styles of one element differ, for a restyle in place: null where
2706
+ * 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 —
2708
+ * none of which moves anything but the box and what it holds (CSS
2709
+ * Transforms 1: a transform does not affect layout) — and false where
2710
+ * anything else differs.
2711
+ */
2712
+ function hoverChange(
2713
+ was: ComputedStyle,
2714
+ now: ComputedStyle,
2715
+ ): HoverChange | null | false {
2227
2716
  const a = was as unknown as Record<string, unknown>;
2228
2717
  const b = now as unknown as Record<string, unknown>;
2229
- let ink = false;
2718
+ let change: HoverChange | null = null;
2230
2719
  for (const key in b) {
2231
2720
  if (key === 'custom' || sameValue(a[key], b[key])) continue;
2232
- if (!PAINT_ONLY.has(key)) return false;
2233
- ink = true;
2721
+ change ??= { reach: false, order: false, move: false };
2722
+ if (PAINT_ONLY.has(key)) continue;
2723
+ if (INK_REACH.has(key)) {
2724
+ change.reach = true;
2725
+ continue;
2726
+ }
2727
+ if (key === 'zIndex') {
2728
+ // one stacking context either way, with nothing hoisted below the
2729
+ // flow (`hoistNegative`): only its place among its layers changes
2730
+ const z0 = a[key];
2731
+ const z1 = b[key];
2732
+ if (typeof z0 !== 'number' || typeof z1 !== 'number') return false;
2733
+ if (z0 < 0 || z1 < 0) return false;
2734
+ change.order = true;
2735
+ continue;
2736
+ }
2737
+ if (key === 'transformTranslate' || key === 'translate') {
2738
+ change.move = true;
2739
+ continue;
2740
+ }
2741
+ return false;
2742
+ }
2743
+ return change;
2744
+ }
2745
+
2746
+ /**
2747
+ * Whether a box can take a new translation where it is: a box of its own
2748
+ * rather than an inline one, whose text is what moves; and the same
2749
+ * containing block and stacking context for what is in it, which a
2750
+ * transform makes a box (CSS Transforms 1, 2) — so either transformed
2751
+ * before and after, or already positioned with a `z-index`, and holding
2752
+ * nothing fixed, which only a transform takes in.
2753
+ */
2754
+ function movable(box: Box, was: ComputedStyle, now: ComputedStyle): boolean {
2755
+ if (box.kind === 'inline') return false;
2756
+ if (transformed(was) === transformed(now)) return true;
2757
+ if (was.position === 'static') return false;
2758
+ if (typeof was.zIndex !== 'number' || typeof now.zIndex !== 'number') {
2759
+ return false;
2760
+ }
2761
+ const stack: Box[] = [...box.children];
2762
+ while (stack.length) {
2763
+ const at = stack.pop()!;
2764
+ if (at.style.position === 'fixed') return false;
2765
+ for (const child of at.children) stack.push(child);
2766
+ }
2767
+ return true;
2768
+ }
2769
+
2770
+ /** Where a box draws, in document coordinates: its ink bounds, or for an
2771
+ * inline box or a run of text, which are drawn on their block's lines and
2772
+ * have none of their own, the block's. */
2773
+ function inkOf(box: Box): Rect | null {
2774
+ let at: Box | null = box;
2775
+ while (at && (at.kind === 'text' || at.kind === 'break' || !hasRect(at))) {
2776
+ at = at.parent;
2234
2777
  }
2235
- return ink ? true : null;
2778
+ if (!at || at.boundsY === Infinity) return null;
2779
+ 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
+ };
2236
2786
  }
2237
2787
 
2238
2788
  /** Structural equality for a computed value: a number, a string, or the
@@ -2375,45 +2925,42 @@ function ownerOf(boxes: readonly Box[], index: number): Element | null {
2375
2925
 
2376
2926
  export type { ControlRect, ReplacedKind, ResourceRequest, ResourceResult };
2377
2927
 
2378
- /** The advance of a font's "0" at a size, laid out: null where the engine
2379
- * cannot lay it out. */
2380
- function zeroWidthOf(
2381
- fonts: FontsLike,
2382
- family: string,
2383
- size: number,
2384
- ): number | null {
2928
+ /** The advance of a face's "0", laid out: null where the engine cannot
2929
+ * lay it out. */
2930
+ function zeroWidthOf(fonts: FontsLike, face: MetricFace): number | null {
2931
+ const { family, size, weight } = face;
2932
+ // a run is upright or slanted, as `inline.ts` sets one
2933
+ const style = face.style === 'normal' ? 'normal' : 'italic';
2385
2934
  try {
2386
- return fonts.layout([{ text: '0', family, size }], { family, size }, {})
2387
- .width;
2935
+ const run = { family, size, weight, style } as const;
2936
+ return fonts.layout([{ text: '0', ...run }], run, {}).width;
2388
2937
  } catch {
2389
2938
  return null;
2390
2939
  }
2391
2940
  }
2392
2941
 
2393
- /** A font's own line height at a size, as the engine reports it: null
2394
- * where it cannot say. */
2395
- function normalLineOf(
2396
- fonts: FontsLike,
2397
- family: string,
2398
- size: number,
2399
- ): number | null {
2942
+ /** A face's own line height, as the engine reports it: null where it
2943
+ * cannot say. */
2944
+ function normalLineOf(fonts: FontsLike, face: MetricFace): number | null {
2945
+ const { family, size, weight, style } = face;
2400
2946
  try {
2401
- const line = fonts.match(family, { size }).metrics(size).lineHeight;
2947
+ const line = fonts
2948
+ .match(family, { size, weight, style })
2949
+ .metrics(size).lineHeight;
2402
2950
  return line > 0 ? line : null;
2403
2951
  } catch {
2404
2952
  return null;
2405
2953
  }
2406
2954
  }
2407
2955
 
2408
- /** A font's x-height at a size, as the engine reports it: null where the
2409
- * face states none, or the engine does not say. */
2410
- function xHeightOf(
2411
- fonts: FontsLike,
2412
- family: string,
2413
- size: number,
2414
- ): number | null {
2956
+ /** A face's x-height, as the engine reports it: null where the face
2957
+ * states none, or the engine does not say. */
2958
+ function xHeightOf(fonts: FontsLike, face: MetricFace): number | null {
2959
+ const { family, size, weight, style } = face;
2415
2960
  try {
2416
- const metrics = fonts.match(family, { size }).metrics(size) as {
2961
+ const metrics = fonts
2962
+ .match(family, { size, weight, style })
2963
+ .metrics(size) as {
2417
2964
  xHeight?: number | null;
2418
2965
  };
2419
2966
  const x = metrics.xHeight;