@react-x11/components 0.11.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 (100) 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 +26 -7
  6. package/dist/html/controls.d.ts.map +1 -1
  7. package/dist/html/controls.js +28 -3
  8. package/dist/html/controls.js.map +1 -1
  9. package/dist/html/css/cascade.d.ts.map +1 -1
  10. package/dist/html/css/cascade.js +19 -6
  11. package/dist/html/css/cascade.js.map +1 -1
  12. package/dist/html/css/color.d.ts +19 -0
  13. package/dist/html/css/color.d.ts.map +1 -1
  14. package/dist/html/css/color.js +86 -0
  15. package/dist/html/css/color.js.map +1 -1
  16. package/dist/html/css/parse.d.ts +11 -0
  17. package/dist/html/css/parse.d.ts.map +1 -1
  18. package/dist/html/css/parse.js +114 -4
  19. package/dist/html/css/parse.js.map +1 -1
  20. package/dist/html/css/style.d.ts +38 -1
  21. package/dist/html/css/style.d.ts.map +1 -1
  22. package/dist/html/css/style.js +326 -8
  23. package/dist/html/css/style.js.map +1 -1
  24. package/dist/html/css/ua.js +14 -0
  25. package/dist/html/css/ua.js.map +1 -1
  26. package/dist/html/css/values.d.ts +10 -1
  27. package/dist/html/css/values.d.ts.map +1 -1
  28. package/dist/html/css/values.js +27 -2
  29. package/dist/html/css/values.js.map +1 -1
  30. package/dist/html/css/vars.d.ts +5 -0
  31. package/dist/html/css/vars.d.ts.map +1 -1
  32. package/dist/html/css/vars.js +2 -2
  33. package/dist/html/css/vars.js.map +1 -1
  34. package/dist/html/dom.d.ts.map +1 -1
  35. package/dist/html/dom.js +35 -2
  36. package/dist/html/dom.js.map +1 -1
  37. package/dist/html/index.d.ts.map +1 -1
  38. package/dist/html/index.js +38 -1
  39. package/dist/html/index.js.map +1 -1
  40. package/dist/html/layout/block.d.ts +11 -2
  41. package/dist/html/layout/block.d.ts.map +1 -1
  42. package/dist/html/layout/block.js +181 -18
  43. package/dist/html/layout/block.js.map +1 -1
  44. package/dist/html/layout/boxes.d.ts +34 -6
  45. package/dist/html/layout/boxes.d.ts.map +1 -1
  46. package/dist/html/layout/boxes.js +35 -14
  47. package/dist/html/layout/boxes.js.map +1 -1
  48. package/dist/html/layout/cache.d.ts +2 -2
  49. package/dist/html/layout/cache.d.ts.map +1 -1
  50. package/dist/html/layout/cache.js +8 -3
  51. package/dist/html/layout/cache.js.map +1 -1
  52. package/dist/html/layout/floats.d.ts +4 -0
  53. package/dist/html/layout/floats.d.ts.map +1 -1
  54. package/dist/html/layout/floats.js +26 -0
  55. package/dist/html/layout/floats.js.map +1 -1
  56. package/dist/html/layout/inline.d.ts +26 -2
  57. package/dist/html/layout/inline.d.ts.map +1 -1
  58. package/dist/html/layout/inline.js +1222 -115
  59. package/dist/html/layout/inline.js.map +1 -1
  60. package/dist/html/node.d.ts +33 -0
  61. package/dist/html/node.d.ts.map +1 -1
  62. package/dist/html/node.js +512 -51
  63. package/dist/html/node.js.map +1 -1
  64. package/dist/html/paint.d.ts +53 -1
  65. package/dist/html/paint.d.ts.map +1 -1
  66. package/dist/html/paint.js +546 -180
  67. package/dist/html/paint.js.map +1 -1
  68. package/dist/html/surfaces.d.ts +7 -2
  69. package/dist/html/surfaces.d.ts.map +1 -1
  70. package/dist/html/surfaces.js +16 -0
  71. package/dist/html/surfaces.js.map +1 -1
  72. package/dist/internal/window.d.ts +22 -0
  73. package/dist/internal/window.d.ts.map +1 -1
  74. package/dist/internal/window.js +35 -8
  75. package/dist/internal/window.js.map +1 -1
  76. package/dist/richtext/node.d.ts +7 -0
  77. package/dist/richtext/node.d.ts.map +1 -1
  78. package/dist/richtext/node.js.map +1 -1
  79. package/package.json +5 -3
  80. package/src/flow/node.ts +80 -64
  81. package/src/html/controls.ts +50 -9
  82. package/src/html/css/cascade.ts +19 -6
  83. package/src/html/css/color.ts +87 -0
  84. package/src/html/css/parse.ts +107 -4
  85. package/src/html/css/style.ts +343 -8
  86. package/src/html/css/ua.ts +14 -0
  87. package/src/html/css/values.ts +31 -3
  88. package/src/html/css/vars.ts +2 -2
  89. package/src/html/dom.ts +38 -2
  90. package/src/html/index.ts +39 -1
  91. package/src/html/layout/block.ts +228 -16
  92. package/src/html/layout/boxes.ts +55 -21
  93. package/src/html/layout/cache.ts +8 -3
  94. package/src/html/layout/floats.ts +24 -0
  95. package/src/html/layout/inline.ts +1435 -108
  96. package/src/html/node.ts +535 -54
  97. package/src/html/paint.ts +650 -241
  98. package/src/html/surfaces.ts +21 -1
  99. package/src/internal/window.ts +36 -8
  100. package/src/richtext/node.ts +7 -0
package/src/html/node.ts CHANGED
@@ -60,8 +60,12 @@ import {
60
60
  BOX_RAISES,
61
61
  buildBoxes,
62
62
  CONTENT_IMAGES,
63
+ CUT_BLOCKS,
64
+ GENERATED_FROM,
65
+ INLINE_OFFSETS,
63
66
  LINE_BOX_RAISES,
64
67
  SHIFTED_LINES,
68
+ TEXT_SHIFTS,
65
69
  } from './layout/boxes.js';
66
70
  import type {
67
71
  Box,
@@ -71,10 +75,11 @@ import type {
71
75
  ReplacedKind,
72
76
  TextLayoutLike,
73
77
  } from './layout/boxes.js';
74
- import { layoutDocument } from './layout/block.js';
78
+ import { layoutDocument, retranslate, transformed } from './layout/block.js';
75
79
  import { TextLayoutCache } from './layout/cache.js';
76
80
  import { shapingSafe } from './layout/shaping.js';
77
- import { SurfaceCache } from './surfaces.js';
81
+ import { SurfaceCache, newSurface } from './surfaces.js';
82
+ import type { SurfaceLike } from './surfaces.js';
78
83
  import { faceExtentOf, inlineDecoration, runFor } from './layout/inline.js';
79
84
  import type { FontsLike } from './layout/inline.js';
80
85
  import type { TextRun } from '../richtext/index.js';
@@ -88,12 +93,18 @@ import {
88
93
  import { lineBands as bandsFor } from '../richtext/runs.js';
89
94
  import {
90
95
  clipsOverflow,
96
+ computePaintBounds,
91
97
  containingBlockOf,
98
+ FIXED_BOXES,
99
+ fixedToViewport,
100
+ forgetDecoratedAncestors,
92
101
  hasRect,
102
+ hoistNegative,
93
103
  holds,
94
104
  holdsAbsolute,
95
105
  paintDocument,
96
106
  queryChildIndex,
107
+ stackLayers,
97
108
  } from './paint.js';
98
109
  import type { PaintContext } from './paint.js';
99
110
  import { controlRectsOf, measureControl } from './controls.js';
@@ -221,6 +232,8 @@ export class HtmlViewNode extends Node {
221
232
  private _sheetsRead: SheetsRead | null = null;
222
233
  /** Blurred shadows, drawn once each. */
223
234
  private _shadowCache: SurfaceCache | null = null;
235
+ /** Whether the backend was found to have no offscreen surface. */
236
+ private _noSurface = false;
224
237
  private _cascade: Cascade | null = null;
225
238
  private _tree: BoxTree | null = null;
226
239
  /** The faces `_warmFaces` has asked the font matcher for, and of which
@@ -440,13 +453,15 @@ export class HtmlViewNode extends Node {
440
453
  */
441
454
  private _requestBackgrounds(tree: BoxTree): void {
442
455
  for (const box of tree.backgrounds) {
443
- 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;
444
459
  // each layer's, where there is more than one
445
460
  for (const url of box.style.backgroundImages ?? [
446
461
  box.style.backgroundImage,
447
462
  ]) {
448
463
  if (typeof url === 'string') {
449
- this._resources.request({ url, kind: 'image', element: box.el });
464
+ this._resources.request({ url, kind: 'image', element });
450
465
  }
451
466
  }
452
467
  const border = box.style.borderImage.source;
@@ -454,9 +469,15 @@ export class HtmlViewNode extends Node {
454
469
  this._resources.request({
455
470
  url: border,
456
471
  kind: 'image',
457
- element: box.el,
472
+ element,
458
473
  });
459
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
+ }
460
481
  }
461
482
  // and every image generated content names, which is only known there
462
483
  // too
@@ -705,6 +726,55 @@ export class HtmlViewNode extends Node {
705
726
  return true;
706
727
  }
707
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
+
708
778
  private _viewportHeight(): number {
709
779
  // The viewport a `vh` resolves against is the one the document is seen
710
780
  // through, not the document — a document taller than it does not make
@@ -1196,39 +1266,49 @@ export class HtmlViewNode extends Node {
1196
1266
  rect = { x: box.x, y: box.y, width: box.width, height: box.height };
1197
1267
  } else {
1198
1268
  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
- }
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);
1225
1279
  }
1226
- if (box.kind === 'inline') {
1227
- fragmentReach(box, bands);
1228
- const fonts = this._layouts?.fonts;
1229
- if (fonts) fragmentHeights(box, bands, fonts);
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
+ });
1230
1289
  }
1231
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
+ }
1232
1312
  if (!rect) {
1233
1313
  // an empty subtree's range is `[0, 0)`, wherever it stands: where
1234
1314
  // its text would be is where the text after it starts
@@ -1251,7 +1331,20 @@ export class HtmlViewNode extends Node {
1251
1331
  const tree = this._tree;
1252
1332
  if (!tree) return null;
1253
1333
  const local = this._toDocument(x, y);
1254
- 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
+ };
1255
1348
  }
1256
1349
 
1257
1350
  /**
@@ -1268,7 +1361,13 @@ export class HtmlViewNode extends Node {
1268
1361
  const tree = this._tree;
1269
1362
  if (!tree) return null;
1270
1363
  const hit = { text: false };
1271
- 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
+ );
1272
1371
  const cursor = el ? tree.styles.get(el)?.style.cursor : null;
1273
1372
  // a keyword; a `url()` this cannot load falls back, as its list would
1274
1373
  if (cursor && cursor !== 'auto' && /^[a-z-]+$/.test(cursor)) return cursor;
@@ -1309,9 +1408,8 @@ export class HtmlViewNode extends Node {
1309
1408
 
1310
1409
  /** The hovered chain moved: restyle where it did, or the document. */
1311
1410
  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);
1411
+ // a restyle in place names what it repainted (`_hoverInPlace`)
1412
+ if (this._hoverInPlace(was, now) === false) this._invalidate(Stale.Boxes);
1315
1413
  }
1316
1414
 
1317
1415
  /**
@@ -1398,6 +1496,11 @@ export class HtmlViewNode extends Node {
1398
1496
  const fresh = new Map<Element, ComputedStyle>();
1399
1497
  const changed = new Map<Element, ComputedStyle>();
1400
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>();
1401
1504
  const styleOf = (el: Element): ComputedStyle | null => {
1402
1505
  const kept = tree.styles.get(el);
1403
1506
  if (!kept) return null;
@@ -1414,9 +1517,14 @@ export class HtmlViewNode extends Node {
1414
1517
  }
1415
1518
  const style = cascade.styleFor(el, parentStyle, kept.inFlex);
1416
1519
  fresh.set(el, style);
1417
- const diff = inkOnly(kept.style, style);
1520
+ const diff = hoverChange(kept.style, style);
1418
1521
  if (diff === false) refused = true;
1419
- 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
+ }
1420
1528
  return style;
1421
1529
  };
1422
1530
  for (const el of reach) {
@@ -1445,9 +1553,12 @@ export class HtmlViewNode extends Node {
1445
1553
  if (!style) continue;
1446
1554
  const kept = tree.styles.get(owner)!;
1447
1555
  // an anonymous box, a pseudo-element, a marker, a control: a style
1448
- // 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.
1449
1559
  if (!box.el || box.style !== kept.style) return false;
1450
- 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;
1451
1562
  restyled.push([box, style]);
1452
1563
  if (box.kind === 'inline') {
1453
1564
  const decoration = inlineDecoration(fonts, box, style);
@@ -1479,9 +1590,28 @@ export class HtmlViewNode extends Node {
1479
1590
  }
1480
1591
  }
1481
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
+
1482
1600
  // 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;
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
+ }
1485
1615
  for (const text of texts) {
1486
1616
  const next = relaid.get(text.layout);
1487
1617
  if (next) text.layout = next;
@@ -1489,9 +1619,67 @@ export class HtmlViewNode extends Node {
1489
1619
  for (const [el, style] of changed) {
1490
1620
  tree.styles.set(el, { style, inFlex: tree.styles.get(el)!.inFlex });
1491
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);
1492
1655
  return 'painted';
1493
1656
  }
1494
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
+
1495
1683
  /** The document, for an application that wants to read or change it. */
1496
1684
  get document(): Document {
1497
1685
  return this._source.document;
@@ -1570,6 +1758,15 @@ export class HtmlViewNode extends Node {
1570
1758
  }
1571
1759
  }
1572
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
+
1573
1770
  private _paint(
1574
1771
  ctx: Context2D,
1575
1772
  tree: BoxTree,
@@ -1582,6 +1779,7 @@ export class HtmlViewNode extends Node {
1582
1779
  // the root's background covers the whole element, not only the
1583
1780
  // document: an element grown past its content is canvas too
1584
1781
  canvas: this.abs,
1782
+ viewport: this._viewport() ?? undefined,
1585
1783
  scale: this._scale,
1586
1784
  damage,
1587
1785
  selection: range
@@ -1597,6 +1795,7 @@ export class HtmlViewNode extends Node {
1597
1795
  const size = image ? this._resources.imageSize(url) : null;
1598
1796
  return image && size ? { image, ...size } : null;
1599
1797
  },
1798
+ surface: (width, height) => this._surface(width, height),
1600
1799
  cached: (key, width, height, draw) =>
1601
1800
  (this._shadowCache ??= new SurfaceCache(this.app)).get(
1602
1801
  key,
@@ -1610,6 +1809,30 @@ export class HtmlViewNode extends Node {
1610
1809
 
1611
1810
  const EMPTY_SET: ReadonlySet<Element> = new Set();
1612
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
+
1613
1836
  /** A sheet as the restyle reads it: its text, the encoding it was decoded
1614
1837
  * from, the element that brought it, and what its URLs resolve against. */
1615
1838
  interface SheetText {
@@ -1656,6 +1879,94 @@ function boxFor(root: Box, element: Element): Box | null {
1656
1879
  return null;
1657
1880
  }
1658
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
+
1659
1970
  /** Where the document's text goes on after a box with none: the start of
1660
1971
  * the next text in document order, or the end of the last before it. */
1661
1972
  function textAfter(root: Box, target: Box): number {
@@ -1715,6 +2026,7 @@ function sameRects(a: ControlRect[], b: ControlRect[]): boolean {
1715
2026
  p.y !== q.y ||
1716
2027
  p.width !== q.width ||
1717
2028
  p.height !== q.height ||
2029
+ p.opacity !== q.opacity ||
1718
2030
  !sameBare(p.bare, q.bare)
1719
2031
  ) {
1720
2032
  return false;
@@ -1732,7 +2044,8 @@ function sameBare(a?: BareField, b?: BareField): boolean {
1732
2044
  a.height === b.height &&
1733
2045
  a.color === b.color &&
1734
2046
  a.fontFamily === b.fontFamily &&
1735
- a.fontSize === b.fontSize
2047
+ a.fontSize === b.fontSize &&
2048
+ a.chevron === b.chevron
1736
2049
  );
1737
2050
  }
1738
2051
 
@@ -2004,6 +2317,9 @@ function lineBands(
2004
2317
  dx: number,
2005
2318
  dy: number,
2006
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,
2007
2323
  ): void {
2008
2324
  // First line whose text can reach `from`, by binary search over the
2009
2325
  // sorted text starts; stop at the first line past `to`.
@@ -2025,6 +2341,13 @@ function lineBands(
2025
2341
  const b = Math.min(to, text.textEnd);
2026
2342
  if (b <= a) continue;
2027
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);
2028
2351
  for (const band of bandsFor(
2029
2352
  text.layout,
2030
2353
  natural,
@@ -2032,10 +2355,28 @@ function lineBands(
2032
2355
  layoutOffsetOf(text, a),
2033
2356
  layoutOffsetOf(text, b, true),
2034
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));
2363
+ }
2035
2364
  out.push({
2036
- x: dx + band.x + text.drawX,
2365
+ x: dx + left + text.drawX - moved,
2037
2366
  y: dy + line.y,
2038
- width: band.width,
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,
2039
2380
  height: line.height,
2040
2381
  });
2041
2382
  }
@@ -2044,6 +2385,21 @@ function lineBands(
2044
2385
  }
2045
2386
  }
2046
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
+
2047
2403
  function collectBands(
2048
2404
  box: Box,
2049
2405
  from: number,
@@ -2078,6 +2434,10 @@ function deepestAt(
2078
2434
  /** Told whether what was found was found under text: a run's, rather
2079
2435
  * than a box's. */
2080
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,
2081
2441
  ): Element | null {
2082
2442
  const box = tree.root;
2083
2443
  let found: Element | null = box.el;
@@ -2118,10 +2478,31 @@ function deepestAt(
2118
2478
  // Zen Garden's archive links are absolute `<li>`s in an `overflow:
2119
2479
  // hidden` list with no height of its own, and not one of them could be
2120
2480
  // hovered or pressed.
2481
+ let atViewport = false;
2121
2482
  const enter = (
2122
2483
  child: Box,
2123
2484
  context: readonly number[],
2124
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[],
2125
2506
  ): void => {
2126
2507
  const style = child.style;
2127
2508
  if (
@@ -2292,16 +2673,116 @@ const FACE_FIELDS = [
2292
2673
  /** Whether two styles differ in ink alone: true where they do, null where
2293
2674
  * they do not differ, false where something else does. Custom properties
2294
2675
  * are read through the properties that use them. */
2295
- 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 {
2296
2716
  const a = was as unknown as Record<string, unknown>;
2297
2717
  const b = now as unknown as Record<string, unknown>;
2298
- let ink = false;
2718
+ let change: HoverChange | null = null;
2299
2719
  for (const key in b) {
2300
2720
  if (key === 'custom' || sameValue(a[key], b[key])) continue;
2301
- if (!PAINT_ONLY.has(key)) return false;
2302
- 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;
2303
2760
  }
2304
- return ink ? true : null;
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;
2777
+ }
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
+ };
2305
2786
  }
2306
2787
 
2307
2788
  /** Structural equality for a computed value: a number, a string, or the