@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/paint.ts CHANGED
@@ -43,12 +43,14 @@ import {
43
43
  CONTAIN_PAINT,
44
44
  CONTAIN_SIZE,
45
45
  copyStyle,
46
+ masked,
46
47
  scrolls,
47
48
  } from './css/style.js';
48
49
  import { contained } from './layout/block.js';
49
50
  import {
50
51
  BOX_RAISES,
51
52
  LINE_BOX_RAISES,
53
+ PADDED_FACES,
52
54
  Box,
53
55
  CLAMPED,
54
56
  CLIPPED_CELLS,
@@ -62,7 +64,13 @@ import {
62
64
  TEXT_RAISES,
63
65
  TEXT_SHIFTS,
64
66
  } from './layout/boxes.js';
65
- import type { BoxTree, LineBox, LineText, Marker } from './layout/boxes.js';
67
+ import type {
68
+ BoxTree,
69
+ EdgePlacement,
70
+ LineBox,
71
+ LineText,
72
+ Marker,
73
+ } from './layout/boxes.js';
66
74
  import {
67
75
  CLIPPED_TEXT,
68
76
  clipBoxOf,
@@ -89,7 +97,13 @@ export interface Rect {
89
97
  export interface PaintContext extends FillContext {
90
98
  beginPath?(): void;
91
99
  rect?(x: number, y: number, w: number, h: number): void;
92
- roundRect?(x: number, y: number, w: number, h: number, radii: number[]): void;
100
+ roundRect?(
101
+ x: number,
102
+ y: number,
103
+ w: number,
104
+ h: number,
105
+ radii: (number | { x: number; y: number })[],
106
+ ): void;
93
107
  moveTo?(x: number, y: number): void;
94
108
  lineTo?(x: number, y: number): void;
95
109
  bezierCurveTo?(
@@ -120,6 +134,15 @@ export interface PaintContext extends FillContext {
120
134
  translate?(x: number, y: number): void;
121
135
  /** What every drawing is multiplied by: an element's `opacity`. */
122
136
  globalAlpha?: number;
137
+ /** How a drawing meets what is under it; a value a backend does not
138
+ * have does not stick, and reads back as the one before. */
139
+ globalCompositeOperation?: string;
140
+ }
141
+
142
+ /** An offscreen surface, as painting uses one. */
143
+ export interface Offscreen {
144
+ getContext(kind: '2d'): unknown;
145
+ destroy?(): void;
123
146
  }
124
147
 
125
148
  export interface PaintOptions {
@@ -142,6 +165,18 @@ export interface PaintOptions {
142
165
  /** The whole element in window coordinates: the canvas the root's
143
166
  * background covers (CSS 2.1 14.2). Absent, the root box is it. */
144
167
  canvas?: Rect;
168
+ /** The viewport the document is seen through, in window coordinates:
169
+ * the scroll pane's, where one scrolls the element. What a fixed
170
+ * background is placed against (CSS 2.1 14.2.1) and where a fixed box
171
+ * is drawn (9.6.1), both laid out against the viewport at the
172
+ * document's top. Absent, the element is it. */
173
+ viewport?: Rect;
174
+ /** @internal Painting a fixed box already moved to the viewport. */
175
+ atViewport?: boolean;
176
+ /** A transparent surface `width` by `height` to draw on and composite
177
+ * with `drawImage`, the caller's to `destroy` once it has: a masked
178
+ * element is drawn on one. Null where there is no surface to be had. */
179
+ surface?(width: number, height: number): Offscreen | null;
145
180
  /** A drawing made once for its key on a surface `width` by `height` and
146
181
  * kept, to be drawn with `drawImage`: a blurred shadow, whose blur is
147
182
  * the cost. Null where there is no surface to be had. */
@@ -167,6 +202,14 @@ export interface PaintOptions {
167
202
  * document's height — handed up rather than kept on every box, where
168
203
  * writing it and reading it back cost this walk a fifth of its time.
169
204
  */
205
+ /**
206
+ * How far down an out-of-flow box's scrollable overflow reaches: its border
207
+ * box, and what it holds where it does not clip it (`computePaintBounds`),
208
+ * which the box it is in does not take in and the document does
209
+ * (`layoutDocument`).
210
+ */
211
+ export const OUT_OF_FLOW_REACH = new WeakMap<Box, number>();
212
+
170
213
  export function computePaintBounds(box: Box, moved = false): number {
171
214
  // A box with no rectangle of its own gives only what it holds: nothing,
172
215
  // until a child with bounds is met.
@@ -178,8 +221,12 @@ export function computePaintBounds(box: Box, moved = false): number {
178
221
  // How far down the content reaches, for the document's height: the
179
222
  // border box, and every box and line under it, but not past a box that
180
223
  // clips what it holds — where the scrollable overflow ends. Out-of-flow
181
- // boxes are the layout's to count.
182
- let bottom = y2;
224
+ // boxes are the layout's to count. A border box of no area reaches
225
+ // nowhere, as a browser has it (Blink's `ScrollableOverflowCalculator`
226
+ // adds no empty rect): the empty blocks a design leaves at its end,
227
+ // below the last one's bottom margin, made the page that margin taller
228
+ const empty = own && !(box.width > 0 && box.height > 0);
229
+ let bottom = empty ? -Infinity : y2;
183
230
  // A shadow is ink past the box, and no overflow: a repaint of the strip
184
231
  // under a card has to reach the card, and the document is no taller.
185
232
  const shadows = own ? box.style.boxShadow : null;
@@ -201,6 +248,27 @@ export function computePaintBounds(box: Box, moved = false): number {
201
248
  x2 = Math.max(x2, box.x + box.width + reach);
202
249
  y2 = Math.max(y2, box.y + box.height + reach);
203
250
  }
251
+ // An inline box's fragments are boxes on the lines they are on, and its
252
+ // padding and border reach above and below those lines: they count in
253
+ // the scrollable overflow of the block it is in (CSS Overflow 3, 2.2) —
254
+ // Blink adds each inline box fragment's border box
255
+ // (`ScrollableOverflowCalculator::AddItemsInternal`). Design 150's footer
256
+ // links, 50px of padding under their text, made Chrome's page 19px
257
+ // taller than the box they end. Only a box with padding or a border
258
+ // above or below its text can reach past its lines.
259
+ if (
260
+ box.kind === 'inline' &&
261
+ box.padTop + box.padBottom + box.borderTop + box.borderBottom > 0
262
+ ) {
263
+ const reach = inlineFragmentsReach(box);
264
+ if (reach) {
265
+ x1 = Math.min(x1, reach.x1);
266
+ y1 = Math.min(y1, reach.y1);
267
+ x2 = Math.max(x2, reach.x2);
268
+ y2 = Math.max(y2, reach.y2);
269
+ bottom = Math.max(bottom, reach.y2);
270
+ }
271
+ }
204
272
  const lines = box.lines;
205
273
  if (lines) {
206
274
  let tallest = 0;
@@ -254,6 +322,7 @@ export function computePaintBounds(box: Box, moved = false): number {
254
322
  }
255
323
  const reach = computePaintBounds(child, moved);
256
324
  if (!child.outOfFlow) bottom = Math.max(bottom, reach);
325
+ else OUT_OF_FLOW_REACH.set(child, reach);
257
326
  if (child.boundsY === Infinity) continue;
258
327
  x1 = Math.min(x1, child.boundsX);
259
328
  y1 = Math.min(y1, child.boundsY);
@@ -297,7 +366,7 @@ export function computePaintBounds(box: Box, moved = false): number {
297
366
  if (!own) return bottom;
298
367
  // whether the box clips only matters where its content reaches past it,
299
368
  // and its style is one more object a walk of every box would read
300
- const end = box.y + box.height;
369
+ const end = empty ? -Infinity : box.y + box.height;
301
370
  if (bottom <= end) return end;
302
371
  const style = box.style;
303
372
  return box.parent &&
@@ -322,6 +391,55 @@ export function hasRect(box: Box): boolean {
322
391
  return display !== 'table-column' && display !== 'table-column-group';
323
392
  }
324
393
 
394
+ /**
395
+ * Where an inline box's fragments are, over the lines of the block it is
396
+ * in that hold its text: from its face's ascent and its top padding and
397
+ * border above each line's baseline to its descent and its bottom ones
398
+ * below, as `paintInlineBoxes` draws them. Null for a box with no text or
399
+ * no face.
400
+ */
401
+ function inlineFragmentsReach(
402
+ box: Box,
403
+ ): { x1: number; y1: number; x2: number; y2: number } | null {
404
+ const face = box.decoration ?? PADDED_FACES.get(box);
405
+ if (!face || box.subtreeTextEnd <= box.subtreeTextStart) return null;
406
+ let block = box.parent;
407
+ while (block && block.kind === 'inline') block = block.parent;
408
+ const lines = block?.lines;
409
+ if (!lines?.length) return null;
410
+ const from = box.subtreeTextStart;
411
+ const to = box.subtreeTextEnd;
412
+ let lo = 0;
413
+ let hi = lines.length;
414
+ while (lo < hi) {
415
+ const mid = (lo + hi) >> 1;
416
+ if (lines[mid].textEnd > from) hi = mid;
417
+ else lo = mid + 1;
418
+ }
419
+ const moved = offsetOf(box);
420
+ let x1 = Infinity;
421
+ let y1 = Infinity;
422
+ let x2 = -Infinity;
423
+ let y2 = -Infinity;
424
+ for (let i = lo; i < lines.length; i += 1) {
425
+ const line = lines[i];
426
+ if (line.textStart >= to) break;
427
+ if (line.textEnd <= from) continue;
428
+ const raise = SHIFTED_LINES.has(line)
429
+ ? (LINE_BOX_RAISES.get(line)?.get(box) ?? BOX_RAISES.get(box) ?? 0)
430
+ : 0;
431
+ const baseline = line.y + line.baseline - raise + (moved?.y ?? 0);
432
+ x1 = Math.min(x1, line.x);
433
+ x2 = Math.max(x2, line.x + line.width);
434
+ y1 = Math.min(y1, baseline - face.ascent - box.padTop - box.borderTop);
435
+ y2 = Math.max(
436
+ y2,
437
+ baseline + face.descent + box.padBottom + box.borderBottom,
438
+ );
439
+ }
440
+ return x1 === Infinity ? null : { x1, y1, x2, y2 };
441
+ }
442
+
325
443
  /** Children lists past this size get the sorted viewport index; below it a
326
444
  * linear scan is cheaper than keeping one. */
327
445
  const PAINT_INDEX_MIN = 64;
@@ -507,7 +625,7 @@ function paintCanvas(
507
625
  // body shows — or by the viewport, where it is fixed
508
626
  const box =
509
627
  style.backgroundAttachment === 'fixed' && options.canvas
510
- ? options.canvas
628
+ ? (options.viewport ?? options.canvas)
511
629
  : originBox(anchor, options, style);
512
630
  paintGradient(
513
631
  ctx,
@@ -635,12 +753,14 @@ function paddingBox(box: Box, options: PaintOptions): Rect {
635
753
  }
636
754
 
637
755
  /** Where a layer of a box's background is placed, in window coordinates:
638
- * the box its `background-origin` names (CSS Backgrounds 3, 3.8). */
756
+ * the box its `background-origin` names (CSS Backgrounds 3, 3.8) — of the
757
+ * whole strip, for a fragment of an inline box that is sliced. */
639
758
  function originBox(
640
- box: Frame,
759
+ frame: Frame,
641
760
  options: PaintOptions,
642
761
  style: ComputedStyle,
643
762
  ): Rect {
763
+ const box = frame.strip ?? frame;
644
764
  const [t, r, b, l] = edgeInsets(box, style.backgroundOrigin);
645
765
  return {
646
766
  x: box.x + l + options.originX,
@@ -718,7 +838,12 @@ type Frame = Pick<
718
838
  | 'style'
719
839
  > &
720
840
  // the padding a `content-box` background is inset by, where it has any
721
- Partial<Pick<Box, 'padTop' | 'padRight' | 'padBottom' | 'padLeft'>>;
841
+ Partial<Pick<Box, 'padTop' | 'padRight' | 'padBottom' | 'padLeft'>> & {
842
+ /** Of an inline box's fragment, where the box goes on to another line:
843
+ * its fragments laid end to end as one box, which its images and
844
+ * gradients are placed in (`box-decoration-break: slice`). */
845
+ strip?: Frame;
846
+ };
722
847
 
723
848
  /** Where a box's background and border go: its border box, which for a
724
849
  * table leaves out the captions around it (CSS 2.1 17.4). */
@@ -752,8 +877,13 @@ function paintBox(ctx: PaintContext, box: Box, options: PaintOptions): void {
752
877
  ctx.save();
753
878
  ctx.globalAlpha = ctx.globalAlpha! * opacity;
754
879
  }
880
+ if (masked(box.style) && paintMasked(ctx, box, options)) {
881
+ if (fade) ctx.restore();
882
+ return;
883
+ }
755
884
  // `clip` shows the part of an absolutely positioned box it names, its own
756
- // background and borders among it (CSS 2.1 11.1.2)
885
+ // background and borders among it (CSS 2.1 11.1.2) — written out here,
886
+ // not called: a frame more a level and a deep document ran out of stack
757
887
  const clip = box.outOfFlow && box.style.clip ? clipOf(box, options) : null;
758
888
  if (!clip || (clip.w > 0 && clip.h > 0)) {
759
889
  const clipped = !!clip && pushClip(ctx, clip, null);
@@ -763,6 +893,145 @@ function paintBox(ctx: PaintContext, box: Box, options: PaintOptions): void {
763
893
  if (fade) ctx.restore();
764
894
  }
765
895
 
896
+ /** A box and what it holds, cut to its `clip` as `paintBox` cuts it. */
897
+ function paintClipped(
898
+ ctx: PaintContext,
899
+ box: Box,
900
+ options: PaintOptions,
901
+ ): void {
902
+ const clip = box.outOfFlow && box.style.clip ? clipOf(box, options) : null;
903
+ if (clip && !(clip.w > 0 && clip.h > 0)) return;
904
+ const clipped = !!clip && pushClip(ctx, clip, null);
905
+ paintContent(ctx, box, options);
906
+ if (clipped) ctx.restore();
907
+ }
908
+
909
+ const MASK_LAYERS = new WeakMap<ComputedStyle, ComputedStyle[]>();
910
+
911
+ /** No corners: a mask layer is placed in the box's rectangle, which its
912
+ * radii do not round (CSS Masking 1, 7). */
913
+ const SQUARE_RADII: ComputedStyle['borderRadius'] = [0, 0, 0, 0];
914
+
915
+ /**
916
+ * A style for each of a box's mask layers, top first, as `layersOf` makes
917
+ * a background's: the box's own style with the layer's image, repeat, size,
918
+ * position, origin and clip in its background's place, and no colour — so
919
+ * the background painter places and repeats a mask layer as it does a
920
+ * background layer, which is what CSS Masking 1 says a mask layer is (7).
921
+ * Made once a style.
922
+ */
923
+ function maskLayersOf(style: ComputedStyle): ComputedStyle[] {
924
+ let layers = MASK_LAYERS.get(style);
925
+ if (layers) return layers;
926
+ const mask = style.mask;
927
+ const nth = <T>(list: readonly T[], i: number): T => list[i % list.length];
928
+ layers = mask.images.map((image, i) => {
929
+ const layer = Object.create(style) as ComputedStyle;
930
+ layer.backgroundColor = null;
931
+ layer.backgroundImages = null;
932
+ layer.backgroundImage = typeof image === 'string' ? image : null;
933
+ layer.backgroundGradient =
934
+ image !== null && typeof image !== 'string' ? image : null;
935
+ layer.backgroundRepeat = nth(mask.repeats, i);
936
+ layer.backgroundSize = nth(mask.sizes, i);
937
+ layer.backgroundAttachment = 'scroll';
938
+ [layer.backgroundPositionX, layer.backgroundPositionY] = nth(
939
+ mask.positions,
940
+ i,
941
+ );
942
+ layer.backgroundOrigin = nth(mask.origins, i);
943
+ layer.backgroundClip = nth(mask.clips, i);
944
+ layer.backgroundClipText = false;
945
+ layer.borderRadius = SQUARE_RADII;
946
+ layer.borderRadiusY = null;
947
+ return layer;
948
+ });
949
+ MASK_LAYERS.set(style, layers);
950
+ return layers;
951
+ }
952
+
953
+ /**
954
+ * A masked box (CSS Masking 1, 7): the box and what it holds drawn on a
955
+ * surface of their own, which the alpha of its mask layers — painted as a
956
+ * background's are, on a second surface, each added over the ones below
957
+ * it — then cuts with `destination-in`, and the result drawn in its place,
958
+ * as the group a mask makes. Only the mask painting area, the border box,
959
+ * can show, and only its part in the damage is drawn.
960
+ *
961
+ * A layer whose image has not arrived is transparent black (7.2), so a box
962
+ * none of whose layers can be drawn yet draws nothing: an icon masked out
963
+ * of its `background-color` is not a solid square while its image loads.
964
+ * False where there is no surface to draw on, or no `destination-in` to
965
+ * draw with, and the caller paints the box unmasked.
966
+ */
967
+ function paintMasked(
968
+ ctx: PaintContext,
969
+ box: Box,
970
+ options: PaintOptions,
971
+ ): boolean {
972
+ const layers = maskLayersOf(box.style);
973
+ const drawable = layers.some(
974
+ (layer) =>
975
+ !!layer.backgroundGradient ||
976
+ (!!layer.backgroundImage &&
977
+ !!options.backgroundImageFor?.(layer.backgroundImage)),
978
+ );
979
+ if (!drawable) return true;
980
+ if (!options.surface || !ctx.drawImage) return false;
981
+ let x0 = Math.floor(box.x + options.originX);
982
+ let y0 = Math.floor(frameY(box) + options.originY);
983
+ let x1 = Math.ceil(box.x + box.width + options.originX);
984
+ let y1 = Math.ceil(frameY(box) + frameHeight(box) + options.originY);
985
+ const damage = options.damage;
986
+ if (damage) {
987
+ x0 = Math.max(x0, Math.floor(damage.x));
988
+ y0 = Math.max(y0, Math.floor(damage.y));
989
+ x1 = Math.min(x1, Math.ceil(damage.x + damage.width));
990
+ y1 = Math.min(y1, Math.ceil(damage.y + damage.height));
991
+ }
992
+ const w = x1 - x0;
993
+ const h = y1 - y0;
994
+ if (!(w > 0 && h > 0)) return true;
995
+ const content = options.surface(w, h);
996
+ const mask = content && options.surface(w, h);
997
+ try {
998
+ if (!content || !mask) return false;
999
+ const cctx = content.getContext('2d') as PaintContext;
1000
+ const mctx = mask.getContext('2d') as PaintContext;
1001
+ // the surfaces' own coordinates, the window's moved to their corner
1002
+ const on: PaintOptions = {
1003
+ ...options,
1004
+ originX: options.originX - x0,
1005
+ originY: options.originY - y0,
1006
+ damage: { x: 0, y: 0, width: w, height: h },
1007
+ canvas: options.canvas && {
1008
+ ...options.canvas,
1009
+ x: options.canvas.x - x0,
1010
+ y: options.canvas.y - y0,
1011
+ },
1012
+ viewport: options.viewport && {
1013
+ ...options.viewport,
1014
+ x: options.viewport.x - x0,
1015
+ y: options.viewport.y - y0,
1016
+ },
1017
+ clips: [],
1018
+ };
1019
+ for (let i = layers.length - 1; i >= 0; i -= 1) {
1020
+ paintLayer(mctx, box, on, layers[i], frameImages(mctx, box, on));
1021
+ }
1022
+ paintClipped(cctx, box, on);
1023
+ cctx.globalCompositeOperation = 'destination-in';
1024
+ if (cctx.globalCompositeOperation !== 'destination-in') return false;
1025
+ cctx.drawImage!(mask, 0, 0);
1026
+ cctx.globalCompositeOperation = 'source-over';
1027
+ ctx.drawImage(content, x0, y0);
1028
+ return true;
1029
+ } finally {
1030
+ content?.destroy?.();
1031
+ mask?.destroy?.();
1032
+ }
1033
+ }
1034
+
766
1035
  function paintContent(
767
1036
  ctx: PaintContext,
768
1037
  box: Box,
@@ -1047,19 +1316,6 @@ function paintShadows(
1047
1316
  );
1048
1317
  continue;
1049
1318
  }
1050
- if (
1051
- bakedOuter(
1052
- ctx,
1053
- options,
1054
- s,
1055
- color,
1056
- shape,
1057
- around,
1058
- covered ? null : { rect: own, corners },
1059
- )
1060
- ) {
1061
- continue;
1062
- }
1063
1319
  // what of the shadow falls under the box is not drawn
1064
1320
  const clipped = !covered && ctx.clip && ctx.rect && ctx.save;
1065
1321
  if (clipped) {
@@ -1088,7 +1344,7 @@ function paintShadows(
1088
1344
  s,
1089
1345
  color,
1090
1346
  (dx) => {
1091
- roundedRect(
1347
+ shadowShape(
1092
1348
  ctx,
1093
1349
  shape.x - dx,
1094
1350
  shape.y,
@@ -1151,7 +1407,6 @@ function paintShadows(
1151
1407
  fillRing(ctx, pad, inner, hole, within);
1152
1408
  continue;
1153
1409
  }
1154
- if (bakedInset(ctx, options, s, color, pad, inner, hole, within)) continue;
1155
1410
  if (!ctx.clip || !ctx.save || !ctx.rect) continue;
1156
1411
  ctx.save();
1157
1412
  ctx.beginPath();
@@ -1172,173 +1427,23 @@ function paintShadows(
1172
1427
  (dx) => {
1173
1428
  ctx.rect!(frame.x - dx, frame.y, frame.width, frame.height);
1174
1429
  if (hole.width > 0 && hole.height > 0) {
1175
- roundedRect(
1430
+ shadowShape(
1176
1431
  ctx,
1177
1432
  hole.x - dx,
1178
1433
  hole.y,
1179
1434
  hole.width,
1180
1435
  hole.height,
1181
1436
  within,
1182
- true,
1183
- true,
1184
1437
  );
1185
1438
  }
1186
1439
  },
1187
1440
  frame.x + frame.width + shadowReach(s.blur),
1441
+ 'evenodd',
1188
1442
  );
1189
1443
  ctx.restore();
1190
1444
  }
1191
1445
  }
1192
1446
 
1193
- /** Corners as part of a key. */
1194
- function cornerKey(c: Corners): string {
1195
- return `${c.x.join(',')}/${c.y.join(',')}`;
1196
- }
1197
-
1198
- /**
1199
- * An outer shadow drawn once on a surface of its own and composited: the
1200
- * shape's shadow, and where `box` is given the box cut out of it — so a box
1201
- * that shows what is behind it shows no shadow there, with the clip on the
1202
- * small surface rather than the window. False where there is no surface.
1203
- */
1204
- function bakedOuter(
1205
- ctx: PaintContext,
1206
- options: PaintOptions,
1207
- s: BoxShadow,
1208
- color: string,
1209
- shape: Rect,
1210
- around: Corners,
1211
- box: { rect: Rect; corners: Corners } | null,
1212
- ): boolean {
1213
- if (!options.cached || !ctx.drawImage) return false;
1214
- const reach = shadowReach(s.blur) + 1;
1215
- const x0 = Math.floor(shape.x) - reach;
1216
- const y0 = Math.floor(shape.y) - reach;
1217
- const w = Math.ceil(shape.x + shape.width) + reach - x0;
1218
- const h = Math.ceil(shape.y + shape.height) + reach - y0;
1219
- const sx = shape.x - x0;
1220
- const sy = shape.y - y0;
1221
- const cut = box && {
1222
- x: box.rect.x - x0,
1223
- y: box.rect.y - y0,
1224
- width: box.rect.width,
1225
- height: box.rect.height,
1226
- };
1227
- const key = [
1228
- 'shadow',
1229
- w,
1230
- h,
1231
- sx,
1232
- sy,
1233
- shape.width,
1234
- shape.height,
1235
- cornerKey(around),
1236
- s.blur,
1237
- color,
1238
- cut
1239
- ? `${cut.x},${cut.y},${cut.width},${cut.height},${cornerKey(box.corners)}`
1240
- : '',
1241
- ].join('|');
1242
- const image = options.cached(key, w, h, (sctx) => {
1243
- if (cut) {
1244
- sctx.save();
1245
- sctx.beginPath!();
1246
- sctx.rect!(0, 0, w, h);
1247
- roundedRect(
1248
- sctx,
1249
- cut.x,
1250
- cut.y,
1251
- cut.width,
1252
- cut.height,
1253
- box.corners,
1254
- true,
1255
- true,
1256
- );
1257
- sctx.clip!();
1258
- }
1259
- fillShadow(
1260
- sctx,
1261
- s,
1262
- color,
1263
- (dx) => {
1264
- roundedRect(sctx, sx - dx, sy, shape.width, shape.height, around);
1265
- },
1266
- sx + shape.width + reach,
1267
- );
1268
- if (cut) sctx.restore();
1269
- });
1270
- if (!image) return false;
1271
- ctx.drawImage(image, x0, y0);
1272
- return true;
1273
- }
1274
-
1275
- /**
1276
- * An inset shadow drawn once on a surface the size of the padding box and
1277
- * composited: a frame around the hole, whose shadow falls inside, clipped
1278
- * to the padding box's corners on the surface. False where there is none.
1279
- */
1280
- function bakedInset(
1281
- ctx: PaintContext,
1282
- options: PaintOptions,
1283
- s: BoxShadow,
1284
- color: string,
1285
- pad: Rect,
1286
- inner: Corners,
1287
- hole: Rect,
1288
- within: Corners,
1289
- ): boolean {
1290
- if (!options.cached || !ctx.drawImage) return false;
1291
- const w = Math.round(pad.width);
1292
- const h = Math.round(pad.height);
1293
- const hx = hole.x - pad.x;
1294
- const hy = hole.y - pad.y;
1295
- const reach = shadowReach(s.blur) + Math.abs(s.x) + Math.abs(s.y) + 1;
1296
- const key = [
1297
- 'inset',
1298
- w,
1299
- h,
1300
- cornerKey(inner),
1301
- hx,
1302
- hy,
1303
- hole.width,
1304
- hole.height,
1305
- cornerKey(within),
1306
- s.blur,
1307
- color,
1308
- ].join('|');
1309
- const image = options.cached(key, w, h, (sctx) => {
1310
- sctx.save();
1311
- sctx.beginPath!();
1312
- roundedRect(sctx, 0, 0, w, h, inner);
1313
- sctx.clip!();
1314
- fillShadow(
1315
- sctx,
1316
- s,
1317
- color,
1318
- (dx) => {
1319
- sctx.rect!(-reach - dx, -reach, w + 2 * reach, h + 2 * reach);
1320
- if (hole.width > 0 && hole.height > 0) {
1321
- roundedRect(
1322
- sctx,
1323
- hx - dx,
1324
- hy,
1325
- hole.width,
1326
- hole.height,
1327
- within,
1328
- true,
1329
- true,
1330
- );
1331
- }
1332
- },
1333
- w + reach + shadowReach(s.blur),
1334
- );
1335
- sctx.restore();
1336
- });
1337
- if (!image) return false;
1338
- ctx.drawImage(image, pad.x, pad.y);
1339
- return true;
1340
- }
1341
-
1342
1447
  /**
1343
1448
  * Fill a shadow's shape: in its colour where it has no blur, and otherwise
1344
1449
  * as the context's shadow of the shape drawn `dx` to the left — clear of
@@ -1351,12 +1456,13 @@ function fillShadow(
1351
1456
  color: string,
1352
1457
  shape: (dx: number) => void,
1353
1458
  right: number,
1459
+ rule: 'nonzero' | 'evenodd' = 'nonzero',
1354
1460
  ): void {
1355
1461
  if (!(s.blur > 0)) {
1356
1462
  ctx.fillStyle = color;
1357
1463
  ctx.beginPath!();
1358
1464
  shape(0);
1359
- ctx.fill!();
1465
+ ctx.fill!(rule);
1360
1466
  return;
1361
1467
  }
1362
1468
  const dx = Math.ceil(right) + 1;
@@ -1368,10 +1474,36 @@ function fillShadow(
1368
1474
  ctx.fillStyle = '#000000';
1369
1475
  ctx.beginPath!();
1370
1476
  shape(dx);
1371
- ctx.fill!();
1477
+ ctx.fill!(rule);
1372
1478
  ctx.restore();
1373
1479
  }
1374
1480
 
1481
+ /**
1482
+ * A shadow's shape, spelled the way a 2d context recognises one: a `rect`,
1483
+ * or a `roundRect` with each corner's own radii, elliptical where the
1484
+ * corner is. react-x11's contexts draw the blurred shadow of such a shape —
1485
+ * and of a `rect` less such a shape, filled `evenodd` — from a tile they
1486
+ * keep, where one built of curves they blur afresh on every fill.
1487
+ */
1488
+ function shadowShape(
1489
+ ctx: PaintContext,
1490
+ x: number,
1491
+ y: number,
1492
+ w: number,
1493
+ h: number,
1494
+ c: Corners,
1495
+ ): void {
1496
+ if (c.x.every((r) => r === 0) || c.y.every((r) => r === 0)) {
1497
+ ctx.rect!(x, y, w, h);
1498
+ return;
1499
+ }
1500
+ // a circular corner as the number every context has always taken; only
1501
+ // an elliptical one as the point
1502
+ const corner = (i: number) =>
1503
+ c.x[i] === c.y[i] ? c.x[i] : { x: c.x[i], y: c.y[i] };
1504
+ ctx.roundRect!(x, y, w, h, [corner(0), corner(1), corner(2), corner(3)]);
1505
+ }
1506
+
1375
1507
  /**
1376
1508
  * The band between two rounded rectangles, the inner inside the outer, in
1377
1509
  * the colour already set. Where a side has no band the two edges meet, and
@@ -1445,6 +1577,7 @@ function inFlow(parent: Box, child: Box, options: PaintOptions): boolean {
1445
1577
  style.overflowX === 'visible' &&
1446
1578
  style.overflowY === 'visible' &&
1447
1579
  opacityOf(child) >= 1 &&
1580
+ !masked(style) &&
1448
1581
  // containment makes it a stacking context, painted whole
1449
1582
  !contained(child, CONTAIN_LAYOUT | CONTAIN_PAINT)
1450
1583
  );
@@ -1472,6 +1605,7 @@ function flowFlex(parent: Box, child: Box, options: PaintOptions): boolean {
1472
1605
  style.overflowX === 'visible' &&
1473
1606
  style.overflowY === 'visible' &&
1474
1607
  !(child.outOfFlow && style.clip) &&
1608
+ !masked(style) &&
1475
1609
  !contained(child, CONTAIN_LAYOUT | CONTAIN_PAINT)
1476
1610
  );
1477
1611
  }
@@ -1744,7 +1878,55 @@ function paintPositioned(
1744
1878
  return;
1745
1879
  }
1746
1880
  }
1747
- paintBox(ctx, box, options);
1881
+ paintBox(ctx, box, atViewport(box, options));
1882
+ }
1883
+
1884
+ /**
1885
+ * The options a box is painted with where it is fixed to the viewport
1886
+ * (CSS 2.1 9.6.1): laid out against the viewport at the document's top,
1887
+ * and drawn where the viewport is now, however far the pane that scrolls
1888
+ * the element has moved it — a fixed header scrolled away with the text.
1889
+ * Once for a box and all it holds.
1890
+ */
1891
+ function atViewport(box: Box, options: PaintOptions): PaintOptions {
1892
+ const viewport = options.viewport;
1893
+ if (!viewport || options.atViewport || !fixedToViewport(box)) {
1894
+ return options;
1895
+ }
1896
+ // where the document's top left is from the viewport's, less where the
1897
+ // viewport is in the document: the scroll
1898
+ const root = rootOf(box);
1899
+ const dx = viewport.x - options.originX - root.x;
1900
+ const dy = viewport.y - options.originY - root.y;
1901
+ return {
1902
+ ...options,
1903
+ originX: options.originX + dx,
1904
+ originY: options.originY + dy,
1905
+ atViewport: true,
1906
+ };
1907
+ }
1908
+
1909
+ /** Per tree, its boxes fixed to the viewport (`fixedToViewport`), found
1910
+ * as the layout positions them: what the element answers a scroll pane's
1911
+ * blit with (`HtmlViewNode.viewportFixedRects`). */
1912
+ export const FIXED_BOXES = new WeakMap<object, Box[]>();
1913
+
1914
+ /** Whether a box is fixed to the viewport: `position: fixed` with no
1915
+ * transformed or contained box around it, which would be its containing
1916
+ * block instead (CSS Transforms 1, CSS Containment 2, 3.3). */
1917
+ export function fixedToViewport(box: Box): boolean {
1918
+ if (box.style.position !== 'fixed') return false;
1919
+ for (let at = box.parent; at?.parent; at = at.parent) {
1920
+ if (at.style.translate || at.style.transformTranslate) return false;
1921
+ if (contained(at, CONTAIN_LAYOUT | CONTAIN_PAINT)) return false;
1922
+ }
1923
+ return true;
1924
+ }
1925
+
1926
+ function rootOf(box: Box): Box {
1927
+ let root = box;
1928
+ while (root.parent) root = root.parent;
1929
+ return root;
1748
1930
  }
1749
1931
 
1750
1932
  /**
@@ -2212,6 +2394,8 @@ function stacksLayers(box: Box): boolean {
2212
2394
  const style = box.style;
2213
2395
  if (style.position === 'fixed' || style.position === 'sticky') return true;
2214
2396
  if (style.opacity < 1 || FADED_BLOCKS.has(box)) return true;
2397
+ // and so does a mask, which is applied to the group (CSS Masking 1, 7)
2398
+ if (masked(style)) return true;
2215
2399
  // layout and paint containment make one (CSS Containment 2, 3.3, 3.5)
2216
2400
  if (contained(box, CONTAIN_LAYOUT | CONTAIN_PAINT)) return true;
2217
2401
  if (typeof style.zIndex !== 'number') return false;
@@ -2231,7 +2415,7 @@ function flexItem(box: Box): boolean {
2231
2415
  * outside a box that clips where its containing block is, a fixed one
2232
2416
  * outside every one, and a relative one inside every one.
2233
2417
  */
2234
- function clipsFor(box: Box, context: Box): Box[] {
2418
+ export function clipsFor(box: Box, context: Box): Box[] {
2235
2419
  let from: Box | null = box.parent;
2236
2420
  if (box.outOfFlow) {
2237
2421
  const fixed = box.style.position === 'fixed';
@@ -2261,6 +2445,7 @@ function paintStacked(
2261
2445
  box: Box,
2262
2446
  options: PaintOptions,
2263
2447
  ): void {
2448
+ options = atViewport(box, options);
2264
2449
  if (!intersects(box, options)) return;
2265
2450
  const between = CLIPS_BETWEEN.get(box);
2266
2451
  if (!between) {
@@ -2439,7 +2624,9 @@ function paintBackground(
2439
2624
  // box, which is where it starts, and repeated under the borders — or
2440
2625
  // the viewport's, where it is fixed; its line runs across the whole of
2441
2626
  // it, not the part this paint reaches
2442
- const fixed = style.backgroundAttachment === 'fixed' && options.canvas;
2627
+ const fixed =
2628
+ style.backgroundAttachment === 'fixed' &&
2629
+ (options.viewport ?? options.canvas);
2443
2630
  const origin = fixed || originBox(box, options, style);
2444
2631
  const at = snapped(origin.x, origin.y, origin.width, origin.height);
2445
2632
  if (rounded && style.backgroundSize !== 'auto' && ctx.clip) {
@@ -2929,7 +3116,7 @@ function paintBackgroundImage(
2929
3116
  corners: Corners | null = null,
2930
3117
  ): void {
2931
3118
  if (style.backgroundAttachment === 'fixed' && options.canvas) {
2932
- at = options.canvas;
3119
+ at = options.viewport ?? options.canvas;
2933
3120
  }
2934
3121
  const url = style.backgroundImage;
2935
3122
  const loaded = url ? options.backgroundImageFor?.(url) : null;
@@ -4157,7 +4344,13 @@ function paintLines(ctx: PaintContext, box: Box, options: PaintOptions): void {
4157
4344
  const bleeds: Bleed[] = [];
4158
4345
  for (const line of visible) {
4159
4346
  if (line.background) paintLineBackground(ctx, line, options);
4160
- paintInlineBoxes(ctx, line, options, line === lines[0] ? null : bleeds);
4347
+ paintInlineBoxes(
4348
+ ctx,
4349
+ line,
4350
+ lines,
4351
+ options,
4352
+ line === lines[0] ? null : bleeds,
4353
+ );
4161
4354
  for (const text of line.texts) {
4162
4355
  const natural = text.layout.lines[text.layoutLine];
4163
4356
  if (natural)
@@ -4690,38 +4883,34 @@ function paintLineBackground(
4690
4883
  );
4691
4884
  }
4692
4885
 
4693
- function paintInlineBoxes(
4694
- ctx: PaintContext,
4695
- line: LineBox,
4696
- options: PaintOptions,
4697
- /** Where a fragment that reaches up over the lines before goes, to be
4698
- * drawn again over their text; null for a block's first line. */
4699
- bleeds: Bleed[] | null = null,
4700
- ): void {
4701
- let fragments: Map<Box, InlineFragment> | null = null;
4702
- const widen = (box: Box, left: number, right: number): void => {
4703
- fragments ??= new Map();
4704
- const f = fragments.get(box);
4705
- if (f) {
4706
- f.left = Math.min(f.left, left);
4707
- f.right = Math.max(f.right, right);
4708
- } else {
4709
- fragments.set(box, { left, right, start: false, end: false });
4710
- }
4711
- };
4712
- // Every text on a line is drawn on one baseline (`finishLine`), and an
4713
- // engine's is from the top of its layout rather than of the line. Taken
4714
- // where the line has each text: a box `position: relative` moves is
4715
- // moved by its own offset below, and the boxes around it are not.
4716
- let baseline = line.y + line.baseline;
4886
+ /**
4887
+ * The fragments of the decorated inline boxes on a line: how far across it
4888
+ * each reaches, its border box, and whether the box opens or closes on it.
4889
+ * Where the line holds text `position: relative` moved, taken where the
4890
+ * text was: a fragment is moved by its own box's offset.
4891
+ *
4892
+ * A box is more than one fragment on a line where bidi reordering puts
4893
+ * what is not the box's between parts of it (CSS 2.1 9.10): a letter of
4894
+ * another element, an atomic, another element's edge. Its parts are one
4895
+ * fragment wherever nothing else comes between them, whatever room is
4896
+ * between them — a space it hangs, a box of its own inside it — so a line
4897
+ * that reads one way has one fragment of each box, as it always had.
4898
+ */
4899
+ function fragmentsOn(line: LineBox): Map<Box, InlineFragment[]> | null {
4900
+ /** What is on the line, where, and which decorated boxes it is in. */
4901
+ const marks: {
4902
+ left: number;
4903
+ right: number;
4904
+ boxes: readonly Box[];
4905
+ /** An edge's own box, whose fragment it opens or closes. */
4906
+ edge?: EdgePlacement;
4907
+ }[] = [];
4717
4908
  const shifted = SHIFTED_LINES.has(line);
4718
4909
  for (const text of line.texts) {
4719
4910
  const natural = text.layout.lines[text.layoutLine];
4720
4911
  const boxAt = text.spans.boxAt;
4721
4912
  if (!natural || !boxAt) continue;
4722
4913
  const shift = shifted ? TEXT_SHIFTS.get(text) : undefined;
4723
- const raise = shifted ? (TEXT_RAISES.get(text) ?? 0) : 0;
4724
- baseline = text.drawY - (shift?.y ?? 0) + raise + natural.baseline;
4725
4914
  const x = text.drawX - (shift?.x ?? 0) + natural.x;
4726
4915
  for (const run of natural.runs) {
4727
4916
  const owner = boxAt.call(text.spans, run.start);
@@ -4731,44 +4920,159 @@ function paintInlineBoxes(
4731
4920
  const left = Math.max(x, x + run.x);
4732
4921
  const right = Math.min(x + natural.width, x + run.x + run.width);
4733
4922
  if (right <= left) continue;
4734
- for (const box of decoratedAncestors(owner)) widen(box, left, right);
4923
+ marks.push({ left, right, boxes: decoratedAncestors(owner) });
4924
+ }
4925
+ // and the spaces `pre-wrap` keeps that the line ends on, which hang
4926
+ // past it and are their box's all the same (`LineText.hung`)
4927
+ for (const space of text.hung ?? []) {
4928
+ const owner = boxAt.call(text.spans, space.at);
4929
+ if (!owner) continue;
4930
+ const left = text.drawX - (shift?.x ?? 0) + space.x;
4931
+ marks.push({
4932
+ left,
4933
+ right: left + space.width,
4934
+ boxes: decoratedAncestors(owner),
4935
+ });
4735
4936
  }
4736
4937
  }
4737
4938
  for (const placed of line.atomics) {
4738
4939
  const atomic = placed.box;
4739
- for (const box of decoratedAncestors(atomic)) {
4740
- widen(
4741
- box,
4742
- placed.x - atomic.marginLeft,
4743
- placed.x + atomic.width + atomic.marginRight,
4744
- );
4745
- }
4940
+ marks.push({
4941
+ left: placed.x - atomic.marginLeft,
4942
+ right: placed.x + atomic.width + atomic.marginRight,
4943
+ boxes: decoratedAncestors(atomic),
4944
+ });
4746
4945
  }
4747
4946
  for (const edge of line.edges ?? []) {
4748
- const box = edge.box;
4749
- if (!box.decoration) continue;
4750
- // The element's `direction` says which side its start is on, whatever
4751
- // its text reads as (CSS 2.1 8.6); the edge's margin is outside the box,
4752
- // its border and padding inside.
4753
- const onLeft = (edge.side === 'start') !== (box.style.direction === 'rtl');
4754
- if (onLeft) {
4755
- const left = edge.x + box.marginLeft;
4756
- widen(box, left, left);
4757
- } else {
4758
- const right = edge.x + edge.width - box.marginRight;
4759
- widen(box, right, right);
4947
+ marks.push({
4948
+ left: edge.x,
4949
+ right: edge.x + edge.width,
4950
+ boxes: decoratedAncestors(edge.box),
4951
+ edge,
4952
+ });
4953
+ }
4954
+ let fragments: Map<Box, InlineFragment[]> | null = null;
4955
+ // each box's parts, left to right
4956
+ const parts = new Map<Box, typeof marks>();
4957
+ for (const mark of marks) {
4958
+ for (const box of mark.boxes) {
4959
+ let list = parts.get(box);
4960
+ if (!list) parts.set(box, (list = []));
4961
+ list.push(mark);
4760
4962
  }
4761
- const f = fragments!.get(box)!;
4762
- if (edge.side === 'start') f.start = true;
4763
- else f.end = true;
4963
+ const own = mark.edge?.box;
4964
+ if (own?.decoration) {
4965
+ let list = parts.get(own);
4966
+ if (!list) parts.set(own, (list = []));
4967
+ list.push(mark);
4968
+ }
4969
+ }
4970
+ for (const [box, list] of parts) {
4971
+ list.sort((a, b) => a.left - b.left || a.right - b.right);
4972
+ const out: InlineFragment[] = [];
4973
+ let open: InlineFragment | null = null;
4974
+ let reach = -Infinity;
4975
+ for (const mark of list) {
4976
+ // The element's `direction` says which side its start is on, whatever
4977
+ // its text reads as (CSS 2.1 8.6); its own edge's margin is outside
4978
+ // the box, its border and padding inside.
4979
+ let left = mark.left;
4980
+ let right = mark.right;
4981
+ const own = mark.edge?.box === box ? mark.edge : null;
4982
+ if (own) {
4983
+ const onLeft =
4984
+ (own.side === 'start') !== (box.style.direction === 'rtl');
4985
+ if (onLeft) left = right = own.x + box.marginLeft;
4986
+ else left = right = own.x + own.width - box.marginRight;
4987
+ }
4988
+ if (!open || between(marks, box, reach, mark.left)) {
4989
+ open = { left, right, start: false, end: false };
4990
+ out.push(open);
4991
+ } else {
4992
+ open.left = Math.min(open.left, left);
4993
+ open.right = Math.max(open.right, right);
4994
+ }
4995
+ reach = Math.max(reach, mark.right);
4996
+ if (own) {
4997
+ if (own.side === 'start') open.start = true;
4998
+ else open.end = true;
4999
+ }
5000
+ }
5001
+ (fragments ??= new Map()).set(box, out);
5002
+ }
5003
+ return fragments;
5004
+ }
5005
+
5006
+ /** Whether anything on a line that is not in a box lies between two of its
5007
+ * parts, from `left` to `right`: what splits it into two fragments. */
5008
+ function between(
5009
+ marks: readonly {
5010
+ left: number;
5011
+ right: number;
5012
+ boxes: readonly Box[];
5013
+ edge?: EdgePlacement;
5014
+ }[],
5015
+ box: Box,
5016
+ left: number,
5017
+ right: number,
5018
+ ): boolean {
5019
+ if (right - left < 0.5) return false;
5020
+ for (const mark of marks) {
5021
+ if (mark.right - mark.left < 0.5) continue;
5022
+ if (mark.boxes.includes(box) || mark.edge?.box === box) continue;
5023
+ const middle = (mark.left + mark.right) / 2;
5024
+ if (middle > left && middle < right) return true;
4764
5025
  }
5026
+ return false;
5027
+ }
5028
+
5029
+ function paintInlineBoxes(
5030
+ ctx: PaintContext,
5031
+ line: LineBox,
5032
+ /** The block's lines, which `line` is one of. */
5033
+ lines: readonly LineBox[],
5034
+ options: PaintOptions,
5035
+ /** Where a fragment that reaches up over the lines before goes, to be
5036
+ * drawn again over their text; null for a block's first line. */
5037
+ bleeds: Bleed[] | null = null,
5038
+ ): void {
5039
+ const fragments = fragmentsOn(line);
4765
5040
  if (!fragments) return;
4766
- const boxes = [...(fragments as Map<Box, InlineFragment>).keys()].sort(
4767
- (a, b) => depthOf(a) - depthOf(b),
4768
- );
5041
+ // Every text on a line is drawn on one baseline (`finishLine`), and an
5042
+ // engine's is from the top of its layout rather than of the line. Taken
5043
+ // where the line has each text: a box `position: relative` moves is
5044
+ // moved by its own offset below, and the boxes around it are not.
5045
+ let baseline = line.y + line.baseline;
5046
+ const shifted = SHIFTED_LINES.has(line);
5047
+ for (const text of line.texts) {
5048
+ const natural = text.layout.lines[text.layoutLine];
5049
+ if (!natural || !text.spans.boxAt) continue;
5050
+ const shift = shifted ? TEXT_SHIFTS.get(text) : undefined;
5051
+ const raise = shifted ? (TEXT_RAISES.get(text) ?? 0) : 0;
5052
+ baseline = text.drawY - (shift?.y ?? 0) + raise + natural.baseline;
5053
+ }
5054
+ const boxes = [...fragments.keys()].sort((a, b) => depthOf(a) - depthOf(b));
4769
5055
  for (const box of boxes) {
4770
5056
  if (box.style.visibility !== 'visible') continue;
4771
- const f = (fragments as Map<Box, InlineFragment>).get(box)!;
5057
+ for (const f of fragments.get(box)!) {
5058
+ paintInlineFragment(ctx, line, lines, options, bleeds, box, f, baseline);
5059
+ }
5060
+ }
5061
+ }
5062
+
5063
+ /** One fragment of an inline box on a line (`paintInlineBoxes`). */
5064
+ function paintInlineFragment(
5065
+ ctx: PaintContext,
5066
+ line: LineBox,
5067
+ lines: readonly LineBox[],
5068
+ options: PaintOptions,
5069
+ bleeds: Bleed[] | null,
5070
+ box: Box,
5071
+ f: InlineFragment,
5072
+ baseline: number,
5073
+ ): void {
5074
+ const shifted = SHIFTED_LINES.has(line);
5075
+ {
4772
5076
  const face = box.decoration!;
4773
5077
  // on its own baseline, which `vertical-align` may raise off the line's
4774
5078
  const own = shifted
@@ -4802,10 +5106,14 @@ function paintInlineBoxes(
4802
5106
  padRight: rightEnds ? box.padRight : 0,
4803
5107
  style,
4804
5108
  };
4805
- if (fragment.width <= 0) continue;
4806
- // its images from its own padding box, as its gradients are, each
4807
- // fragment alike: `slice` would lay them out as though the fragments
4808
- // were one box end to end, which a box on one line is
5109
+ if (fragment.width <= 0) return;
5110
+ // its images and gradients from the strip its fragments make, where it
5111
+ // goes on to another line, and from its own padding box where it is
5112
+ // on this one alone or `clone` says each fragment is its own
5113
+ if (!(f.start && f.end) && sliced(box.style)) {
5114
+ const strip = stripFor(lines, line, box, fragment);
5115
+ if (strip) fragment.strip = strip;
5116
+ }
4809
5117
  paintLayers(ctx, fragment, options, frameImages(ctx, fragment, options));
4810
5118
  paintBorders(ctx, fragment, options);
4811
5119
  if (box.style.outlineStyle !== 'none') paintOutline(ctx, fragment, options);
@@ -4815,6 +5123,93 @@ function paintInlineBoxes(
4815
5123
  }
4816
5124
  }
4817
5125
 
5126
+ /** Whether an inline box places background images or gradients, which
5127
+ * `slice` places across its fragments, as one box end to end. */
5128
+ function sliced(style: ComputedStyle): boolean {
5129
+ return (
5130
+ style.boxDecorationBreak === 'slice' &&
5131
+ (style.backgroundImage !== null ||
5132
+ style.backgroundGradient !== null ||
5133
+ style.backgroundImages !== null)
5134
+ );
5135
+ }
5136
+
5137
+ /** Where an inline box's fragments sit along the strip they make laid end
5138
+ * to end: how wide it is, on how many lines, and how far along it each
5139
+ * line's fragment starts, in the box's direction. */
5140
+ interface Strip {
5141
+ width: number;
5142
+ lines: number;
5143
+ before: Map<LineBox, number>;
5144
+ }
5145
+
5146
+ /** Of a block's lines, by the array they are, the strip of each inline box
5147
+ * on them that `sliced` says places images across its fragments: made
5148
+ * once a layout, as `MOVED_OFF_LINES` is, so a repaint of one line reads
5149
+ * the lines before it without going over them again. What `sliced` reads
5150
+ * is never restyled in place (`HtmlViewNode`), which lays the lines out
5151
+ * again instead. */
5152
+ const STRIPS = new WeakMap<readonly LineBox[], Map<Box, Strip>>();
5153
+
5154
+ function stripsOf(lines: readonly LineBox[]): Map<Box, Strip> {
5155
+ const strips = new Map<Box, Strip>();
5156
+ for (const line of lines) {
5157
+ const fragments = fragmentsOn(line);
5158
+ if (!fragments) continue;
5159
+ for (const [box, list] of fragments) {
5160
+ if (!sliced(box.style)) continue;
5161
+ let strip = strips.get(box);
5162
+ if (!strip) {
5163
+ strip = { width: 0, lines: 0, before: new Map() };
5164
+ strips.set(box, strip);
5165
+ }
5166
+ strip.before.set(line, strip.width);
5167
+ for (const f of list) strip.width += Math.max(0, f.right - f.left);
5168
+ strip.lines += 1;
5169
+ }
5170
+ }
5171
+ return strips;
5172
+ }
5173
+
5174
+ /**
5175
+ * The box an inline box's fragment on `line` is a slice of (CSS
5176
+ * Fragmentation 3, 5.4): the box's fragments laid end to end in its
5177
+ * direction, as though it had not wrapped — as wide as
5178
+ * they are together, with the box's own borders and padding at its two
5179
+ * ends, placed so that this fragment is where it falls along it. So an
5180
+ * image `no-repeat` at the start is on the first fragment alone, and a
5181
+ * gradient runs once across them all. Undefined where the box is on one
5182
+ * line.
5183
+ */
5184
+ function stripFor(
5185
+ lines: readonly LineBox[],
5186
+ line: LineBox,
5187
+ box: Box,
5188
+ fragment: Frame,
5189
+ ): Frame | undefined {
5190
+ let strips = STRIPS.get(lines);
5191
+ if (!strips) {
5192
+ strips = stripsOf(lines);
5193
+ STRIPS.set(lines, strips);
5194
+ }
5195
+ const strip = strips.get(box);
5196
+ const before = strip?.before.get(line);
5197
+ if (!strip || strip.lines < 2 || before === undefined) return undefined;
5198
+ const x =
5199
+ box.style.direction === 'rtl'
5200
+ ? fragment.x + fragment.width + before - strip.width
5201
+ : fragment.x - before;
5202
+ return {
5203
+ ...fragment,
5204
+ x,
5205
+ width: strip.width,
5206
+ borderLeft: box.borderLeft,
5207
+ borderRight: box.borderRight,
5208
+ padLeft: box.padLeft,
5209
+ padRight: box.padRight,
5210
+ };
5211
+ }
5212
+
4818
5213
  /** An inline box's style on a line it does not both start and end on: no
4819
5214
  * rounded corner on a side it goes on from. Made once a style and a pair
4820
5215
  * of ends, so a repaint copies no style and the layers made of it are
@@ -4892,6 +5287,20 @@ function decoratedAncestors(box: Box): Box[] {
4892
5287
  return found;
4893
5288
  }
4894
5289
 
5290
+ /** A restyle in place (a `:hover`) gave an inline box a decoration where it
5291
+ * had none, or took it away, and the lists kept above do not know: every
5292
+ * box whose list could name it — inside it, through the inline boxes the
5293
+ * walk above climbs — looks again. A document built again has new boxes,
5294
+ * and so no lists. */
5295
+ export function forgetDecoratedAncestors(box: Box): void {
5296
+ const stack = [...box.children];
5297
+ while (stack.length) {
5298
+ const at = stack.pop()!;
5299
+ DECORATED.delete(at);
5300
+ if (at.kind === 'inline') stack.push(...at.children);
5301
+ }
5302
+ }
5303
+
4895
5304
  /**
4896
5305
  * The band the document selection covers on one line.
4897
5306
  *