@react-x11/components 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/dist/flow/node.d.ts +14 -6
  2. package/dist/flow/node.d.ts.map +1 -1
  3. package/dist/flow/node.js +82 -64
  4. package/dist/flow/node.js.map +1 -1
  5. package/dist/html/controls.d.ts +38 -10
  6. package/dist/html/controls.d.ts.map +1 -1
  7. package/dist/html/controls.js +43 -58
  8. package/dist/html/controls.js.map +1 -1
  9. package/dist/html/css/cascade.d.ts +134 -20
  10. package/dist/html/css/cascade.d.ts.map +1 -1
  11. package/dist/html/css/cascade.js +538 -128
  12. package/dist/html/css/cascade.js.map +1 -1
  13. package/dist/html/css/color.d.ts +19 -0
  14. package/dist/html/css/color.d.ts.map +1 -1
  15. package/dist/html/css/color.js +86 -0
  16. package/dist/html/css/color.js.map +1 -1
  17. package/dist/html/css/parse.d.ts +40 -2
  18. package/dist/html/css/parse.d.ts.map +1 -1
  19. package/dist/html/css/parse.js +391 -13
  20. package/dist/html/css/parse.js.map +1 -1
  21. package/dist/html/css/style.d.ts +52 -1
  22. package/dist/html/css/style.d.ts.map +1 -1
  23. package/dist/html/css/style.js +328 -11
  24. package/dist/html/css/style.js.map +1 -1
  25. package/dist/html/css/ua.d.ts +11 -1
  26. package/dist/html/css/ua.d.ts.map +1 -1
  27. package/dist/html/css/ua.js +73 -7
  28. package/dist/html/css/ua.js.map +1 -1
  29. package/dist/html/css/values.d.ts +10 -1
  30. package/dist/html/css/values.d.ts.map +1 -1
  31. package/dist/html/css/values.js +27 -2
  32. package/dist/html/css/values.js.map +1 -1
  33. package/dist/html/css/vars.d.ts +5 -0
  34. package/dist/html/css/vars.d.ts.map +1 -1
  35. package/dist/html/css/vars.js +2 -2
  36. package/dist/html/css/vars.js.map +1 -1
  37. package/dist/html/dom.d.ts.map +1 -1
  38. package/dist/html/dom.js +38 -3
  39. package/dist/html/dom.js.map +1 -1
  40. package/dist/html/fonts.d.ts +25 -1
  41. package/dist/html/fonts.d.ts.map +1 -1
  42. package/dist/html/fonts.js +99 -6
  43. package/dist/html/fonts.js.map +1 -1
  44. package/dist/html/form.d.ts +200 -0
  45. package/dist/html/form.d.ts.map +1 -0
  46. package/dist/html/form.js +801 -0
  47. package/dist/html/form.js.map +1 -0
  48. package/dist/html/index.d.ts +12 -0
  49. package/dist/html/index.d.ts.map +1 -1
  50. package/dist/html/index.js +30 -264
  51. package/dist/html/index.js.map +1 -1
  52. package/dist/html/layout/block.d.ts +11 -2
  53. package/dist/html/layout/block.d.ts.map +1 -1
  54. package/dist/html/layout/block.js +186 -19
  55. package/dist/html/layout/block.js.map +1 -1
  56. package/dist/html/layout/boxes.d.ts +65 -7
  57. package/dist/html/layout/boxes.d.ts.map +1 -1
  58. package/dist/html/layout/boxes.js +87 -22
  59. package/dist/html/layout/boxes.js.map +1 -1
  60. package/dist/html/layout/cache.d.ts +2 -2
  61. package/dist/html/layout/cache.d.ts.map +1 -1
  62. package/dist/html/layout/cache.js +8 -3
  63. package/dist/html/layout/cache.js.map +1 -1
  64. package/dist/html/layout/flex.js +4 -1
  65. package/dist/html/layout/flex.js.map +1 -1
  66. package/dist/html/layout/floats.d.ts +4 -0
  67. package/dist/html/layout/floats.d.ts.map +1 -1
  68. package/dist/html/layout/floats.js +26 -0
  69. package/dist/html/layout/floats.js.map +1 -1
  70. package/dist/html/layout/inline.d.ts +26 -2
  71. package/dist/html/layout/inline.d.ts.map +1 -1
  72. package/dist/html/layout/inline.js +1232 -115
  73. package/dist/html/layout/inline.js.map +1 -1
  74. package/dist/html/node.d.ts +120 -15
  75. package/dist/html/node.d.ts.map +1 -1
  76. package/dist/html/node.js +993 -174
  77. package/dist/html/node.js.map +1 -1
  78. package/dist/html/paint.d.ts +75 -2
  79. package/dist/html/paint.d.ts.map +1 -1
  80. package/dist/html/paint.js +836 -200
  81. package/dist/html/paint.js.map +1 -1
  82. package/dist/html/surfaces.d.ts +7 -2
  83. package/dist/html/surfaces.d.ts.map +1 -1
  84. package/dist/html/surfaces.js +16 -0
  85. package/dist/html/surfaces.js.map +1 -1
  86. package/dist/html/widgets.d.ts +29 -0
  87. package/dist/html/widgets.d.ts.map +1 -0
  88. package/dist/html/widgets.js +620 -0
  89. package/dist/html/widgets.js.map +1 -0
  90. package/dist/index.d.ts +1 -1
  91. package/dist/index.d.ts.map +1 -1
  92. package/dist/index.js.map +1 -1
  93. package/dist/internal/window.d.ts +22 -0
  94. package/dist/internal/window.d.ts.map +1 -1
  95. package/dist/internal/window.js +35 -8
  96. package/dist/internal/window.js.map +1 -1
  97. package/dist/richtext/node.d.ts +7 -0
  98. package/dist/richtext/node.d.ts.map +1 -1
  99. package/dist/richtext/node.js.map +1 -1
  100. package/package.json +6 -4
  101. package/src/flow/node.ts +80 -64
  102. package/src/html/controls.ts +79 -58
  103. package/src/html/css/cascade.ts +610 -146
  104. package/src/html/css/color.ts +87 -0
  105. package/src/html/css/parse.ts +431 -13
  106. package/src/html/css/style.ts +367 -11
  107. package/src/html/css/ua.ts +76 -8
  108. package/src/html/css/values.ts +31 -3
  109. package/src/html/css/vars.ts +2 -2
  110. package/src/html/dom.ts +41 -3
  111. package/src/html/fonts.ts +116 -6
  112. package/src/html/form.ts +962 -0
  113. package/src/html/index.ts +49 -300
  114. package/src/html/layout/block.ts +231 -16
  115. package/src/html/layout/boxes.ts +133 -32
  116. package/src/html/layout/cache.ts +8 -3
  117. package/src/html/layout/flex.ts +4 -1
  118. package/src/html/layout/floats.ts +24 -0
  119. package/src/html/layout/inline.ts +1445 -108
  120. package/src/html/node.ts +1010 -166
  121. package/src/html/paint.ts +980 -270
  122. package/src/html/surfaces.ts +21 -1
  123. package/src/html/widgets.ts +735 -0
  124. package/src/index.ts +1 -0
  125. package/src/internal/window.ts +36 -8
  126. package/src/richtext/node.ts +7 -0
package/src/html/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,15 @@ 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
+ SelectionStyler,
74
+ } from './layout/boxes.js';
75
+ import type { SelectionStyle } from './css/cascade.js';
66
76
  import {
67
77
  CLIPPED_TEXT,
68
78
  clipBoxOf,
@@ -89,7 +99,13 @@ export interface Rect {
89
99
  export interface PaintContext extends FillContext {
90
100
  beginPath?(): void;
91
101
  rect?(x: number, y: number, w: number, h: number): void;
92
- roundRect?(x: number, y: number, w: number, h: number, radii: number[]): void;
102
+ roundRect?(
103
+ x: number,
104
+ y: number,
105
+ w: number,
106
+ h: number,
107
+ radii: (number | { x: number; y: number })[],
108
+ ): void;
93
109
  moveTo?(x: number, y: number): void;
94
110
  lineTo?(x: number, y: number): void;
95
111
  bezierCurveTo?(
@@ -120,6 +136,15 @@ export interface PaintContext extends FillContext {
120
136
  translate?(x: number, y: number): void;
121
137
  /** What every drawing is multiplied by: an element's `opacity`. */
122
138
  globalAlpha?: number;
139
+ /** How a drawing meets what is under it; a value a backend does not
140
+ * have does not stick, and reads back as the one before. */
141
+ globalCompositeOperation?: string;
142
+ }
143
+
144
+ /** An offscreen surface, as painting uses one. */
145
+ export interface Offscreen {
146
+ getContext(kind: '2d'): unknown;
147
+ destroy?(): void;
123
148
  }
124
149
 
125
150
  export interface PaintOptions {
@@ -142,6 +167,18 @@ export interface PaintOptions {
142
167
  /** The whole element in window coordinates: the canvas the root's
143
168
  * background covers (CSS 2.1 14.2). Absent, the root box is it. */
144
169
  canvas?: Rect;
170
+ /** The viewport the document is seen through, in window coordinates:
171
+ * the scroll pane's, where one scrolls the element. What a fixed
172
+ * background is placed against (CSS 2.1 14.2.1) and where a fixed box
173
+ * is drawn (9.6.1), both laid out against the viewport at the
174
+ * document's top. Absent, the element is it. */
175
+ viewport?: Rect;
176
+ /** @internal Painting a fixed box already moved to the viewport. */
177
+ atViewport?: boolean;
178
+ /** A transparent surface `width` by `height` to draw on and composite
179
+ * with `drawImage`, the caller's to `destroy` once it has: a masked
180
+ * element is drawn on one. Null where there is no surface to be had. */
181
+ surface?(width: number, height: number): Offscreen | null;
145
182
  /** A drawing made once for its key on a surface `width` by `height` and
146
183
  * kept, to be drawn with `drawImage`: a blurred shadow, whose blur is
147
184
  * the cost. Null where there is no surface to be had. */
@@ -157,6 +194,9 @@ export interface PaintOptions {
157
194
  clips?: ClipLevel[];
158
195
  /** @internal Whether the tree has a layer below the flow (`hoistNegative`). */
159
196
  negative?: boolean;
197
+ /** @internal Each box's `::selection`, where a rule styles one
198
+ * (`BoxTree.selectionStyler`). */
199
+ selectionStyler?: SelectionStyler | null;
160
200
  }
161
201
 
162
202
  /**
@@ -167,6 +207,27 @@ export interface PaintOptions {
167
207
  * document's height — handed up rather than kept on every box, where
168
208
  * writing it and reading it back cost this walk a fifth of its time.
169
209
  */
210
+ /**
211
+ * How far down an out-of-flow box's scrollable overflow reaches: its border
212
+ * box, and what it holds where it does not clip it (`computePaintBounds`),
213
+ * which the box it is in does not take in and the document does
214
+ * (`layoutDocument`).
215
+ */
216
+ export const OUT_OF_FLOW_REACH = new WeakMap<Box, number>();
217
+
218
+ /**
219
+ * How far above and below a block's lines its inline content draws, at
220
+ * most, by the array its lines are: glyphs taller than a `line-height` under
221
+ * their face's height, and an atomic's ink past the line it is on. The
222
+ * slack a paint's cull of the lines takes on top of their own rows
223
+ * (`paintLines`); none for the lines of almost every block, whose content
224
+ * is inside them.
225
+ */
226
+ const LINE_INK = new WeakMap<
227
+ readonly LineBox[],
228
+ { above: number; below: number }
229
+ >();
230
+
170
231
  export function computePaintBounds(box: Box, moved = false): number {
171
232
  // A box with no rectangle of its own gives only what it holds: nothing,
172
233
  // until a child with bounds is met.
@@ -178,8 +239,12 @@ export function computePaintBounds(box: Box, moved = false): number {
178
239
  // How far down the content reaches, for the document's height: the
179
240
  // border box, and every box and line under it, but not past a box that
180
241
  // clips what it holds — where the scrollable overflow ends. Out-of-flow
181
- // boxes are the layout's to count.
182
- let bottom = y2;
242
+ // boxes are the layout's to count. A border box of no area reaches
243
+ // nowhere, as a browser has it (Blink's `ScrollableOverflowCalculator`
244
+ // adds no empty rect): the empty blocks a design leaves at its end,
245
+ // below the last one's bottom margin, made the page that margin taller
246
+ const empty = own && !(box.width > 0 && box.height > 0);
247
+ let bottom = empty ? -Infinity : y2;
183
248
  // A shadow is ink past the box, and no overflow: a repaint of the strip
184
249
  // under a card has to reach the card, and the document is no taller.
185
250
  const shadows = own ? box.style.boxShadow : null;
@@ -201,6 +266,27 @@ export function computePaintBounds(box: Box, moved = false): number {
201
266
  x2 = Math.max(x2, box.x + box.width + reach);
202
267
  y2 = Math.max(y2, box.y + box.height + reach);
203
268
  }
269
+ // An inline box's fragments are boxes on the lines they are on, and its
270
+ // padding and border reach above and below those lines: they count in
271
+ // the scrollable overflow of the block it is in (CSS Overflow 3, 2.2) —
272
+ // Blink adds each inline box fragment's border box
273
+ // (`ScrollableOverflowCalculator::AddItemsInternal`). Design 150's footer
274
+ // links, 50px of padding under their text, made Chrome's page 19px
275
+ // taller than the box they end. Only a box with padding or a border
276
+ // above or below its text can reach past its lines.
277
+ if (
278
+ box.kind === 'inline' &&
279
+ box.padTop + box.padBottom + box.borderTop + box.borderBottom > 0
280
+ ) {
281
+ const reach = inlineFragmentsReach(box);
282
+ if (reach) {
283
+ x1 = Math.min(x1, reach.x1);
284
+ y1 = Math.min(y1, reach.y1);
285
+ x2 = Math.max(x2, reach.x2);
286
+ y2 = Math.max(y2, reach.y2);
287
+ bottom = Math.max(bottom, reach.y2);
288
+ }
289
+ }
204
290
  const lines = box.lines;
205
291
  if (lines) {
206
292
  let tallest = 0;
@@ -254,6 +340,7 @@ export function computePaintBounds(box: Box, moved = false): number {
254
340
  }
255
341
  const reach = computePaintBounds(child, moved);
256
342
  if (!child.outOfFlow) bottom = Math.max(bottom, reach);
343
+ else OUT_OF_FLOW_REACH.set(child, reach);
257
344
  if (child.boundsY === Infinity) continue;
258
345
  x1 = Math.min(x1, child.boundsX);
259
346
  y1 = Math.min(y1, child.boundsY);
@@ -264,8 +351,21 @@ export function computePaintBounds(box: Box, moved = false): number {
264
351
  // above has its bounds already: walked from its line as well, an
265
352
  // inline-block in an inline-block was walked twice a level, and twenty of
266
353
  // them took seventy milliseconds a layout.
354
+ //
355
+ // And a line's text is drawn from its face's ascent above the baseline to
356
+ // its descent below, its content area (CSS 2.1 10.6.1), which is past the
357
+ // line box wherever `line-height` is under the face's height: a line box
358
+ // is its line-height, whatever its glyphs are. Zen Garden 215's 91px title
359
+ // sits on the 20px lines its body's `line-height: 1.25em` gave it, as a
360
+ // length, and hangs 40px above them and 40px below — ink that a repaint
361
+ // of those rows has to reach, where the lines alone do not.
362
+ let above = 0;
363
+ let below = 0;
267
364
  if (lines) {
365
+ const movedOff = MOVED_OFF_LINES.get(lines);
268
366
  for (const line of lines) {
367
+ let top = line.y;
368
+ let bottom = line.y + line.height;
269
369
  for (const placed of line.atomics) {
270
370
  const atomic = placed.box;
271
371
  if (atomic.boundsY === Infinity) continue;
@@ -273,9 +373,31 @@ export function computePaintBounds(box: Box, moved = false): number {
273
373
  y1 = Math.min(y1, atomic.boundsY);
274
374
  x2 = Math.max(x2, atomic.boundsX + atomic.boundsWidth);
275
375
  y2 = Math.max(y2, atomic.boundsY + atomic.boundsHeight);
376
+ top = Math.min(top, atomic.boundsY);
377
+ bottom = Math.max(bottom, atomic.boundsY + atomic.boundsHeight);
378
+ }
379
+ // what `position: relative` moved off a line the cull finds on its
380
+ // own (`reachedOff`), wherever it went
381
+ if (movedOff?.has(line)) continue;
382
+ for (const text of line.texts) {
383
+ const natural = text.layout.lines[text.layoutLine];
384
+ if (!natural) continue;
385
+ const baseline = text.drawY + natural.baseline;
386
+ top = Math.min(top, baseline - (natural.ascent ?? 0));
387
+ bottom = Math.max(bottom, baseline + (natural.descent ?? 0));
388
+ }
389
+ if (top < line.y) {
390
+ y1 = Math.min(y1, top);
391
+ above = Math.max(above, line.y - top);
392
+ }
393
+ if (bottom > line.y + line.height) {
394
+ y2 = Math.max(y2, bottom);
395
+ below = Math.max(below, bottom - line.y - line.height);
276
396
  }
277
397
  }
278
398
  }
399
+ if (lines && (above > 0 || below > 0)) LINE_INK.set(lines, { above, below });
400
+ else if (lines) LINE_INK.delete(lines);
279
401
  if (x1 === Infinity) {
280
402
  // nothing to draw: no damage meets it, and no parent takes it in
281
403
  box.boundsX = Infinity;
@@ -297,7 +419,7 @@ export function computePaintBounds(box: Box, moved = false): number {
297
419
  if (!own) return bottom;
298
420
  // whether the box clips only matters where its content reaches past it,
299
421
  // and its style is one more object a walk of every box would read
300
- const end = box.y + box.height;
422
+ const end = empty ? -Infinity : box.y + box.height;
301
423
  if (bottom <= end) return end;
302
424
  const style = box.style;
303
425
  return box.parent &&
@@ -322,6 +444,55 @@ export function hasRect(box: Box): boolean {
322
444
  return display !== 'table-column' && display !== 'table-column-group';
323
445
  }
324
446
 
447
+ /**
448
+ * Where an inline box's fragments are, over the lines of the block it is
449
+ * in that hold its text: from its face's ascent and its top padding and
450
+ * border above each line's baseline to its descent and its bottom ones
451
+ * below, as `paintInlineBoxes` draws them. Null for a box with no text or
452
+ * no face.
453
+ */
454
+ function inlineFragmentsReach(
455
+ box: Box,
456
+ ): { x1: number; y1: number; x2: number; y2: number } | null {
457
+ const face = box.decoration ?? PADDED_FACES.get(box);
458
+ if (!face || box.subtreeTextEnd <= box.subtreeTextStart) return null;
459
+ let block = box.parent;
460
+ while (block && block.kind === 'inline') block = block.parent;
461
+ const lines = block?.lines;
462
+ if (!lines?.length) return null;
463
+ const from = box.subtreeTextStart;
464
+ const to = box.subtreeTextEnd;
465
+ let lo = 0;
466
+ let hi = lines.length;
467
+ while (lo < hi) {
468
+ const mid = (lo + hi) >> 1;
469
+ if (lines[mid].textEnd > from) hi = mid;
470
+ else lo = mid + 1;
471
+ }
472
+ const moved = offsetOf(box);
473
+ let x1 = Infinity;
474
+ let y1 = Infinity;
475
+ let x2 = -Infinity;
476
+ let y2 = -Infinity;
477
+ for (let i = lo; i < lines.length; i += 1) {
478
+ const line = lines[i];
479
+ if (line.textStart >= to) break;
480
+ if (line.textEnd <= from) continue;
481
+ const raise = SHIFTED_LINES.has(line)
482
+ ? (LINE_BOX_RAISES.get(line)?.get(box) ?? BOX_RAISES.get(box) ?? 0)
483
+ : 0;
484
+ const baseline = line.y + line.baseline - raise + (moved?.y ?? 0);
485
+ x1 = Math.min(x1, line.x);
486
+ x2 = Math.max(x2, line.x + line.width);
487
+ y1 = Math.min(y1, baseline - face.ascent - box.padTop - box.borderTop);
488
+ y2 = Math.max(
489
+ y2,
490
+ baseline + face.descent + box.padBottom + box.borderBottom,
491
+ );
492
+ }
493
+ return x1 === Infinity ? null : { x1, y1, x2, y2 };
494
+ }
495
+
325
496
  /** Children lists past this size get the sorted viewport index; below it a
326
497
  * linear scan is cheaper than keeping one. */
327
498
  const PAINT_INDEX_MIN = 64;
@@ -408,6 +579,7 @@ export function paintDocument(
408
579
  ...options,
409
580
  canvasSource: canvas?.source,
410
581
  negative: tree.negative,
582
+ selectionStyler: options.selection ? tree.selectionStyler : null,
411
583
  });
412
584
  ctx.restore();
413
585
  }
@@ -507,7 +679,7 @@ function paintCanvas(
507
679
  // body shows — or by the viewport, where it is fixed
508
680
  const box =
509
681
  style.backgroundAttachment === 'fixed' && options.canvas
510
- ? options.canvas
682
+ ? (options.viewport ?? options.canvas)
511
683
  : originBox(anchor, options, style);
512
684
  paintGradient(
513
685
  ctx,
@@ -635,12 +807,14 @@ function paddingBox(box: Box, options: PaintOptions): Rect {
635
807
  }
636
808
 
637
809
  /** 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). */
810
+ * the box its `background-origin` names (CSS Backgrounds 3, 3.8) — of the
811
+ * whole strip, for a fragment of an inline box that is sliced. */
639
812
  function originBox(
640
- box: Frame,
813
+ frame: Frame,
641
814
  options: PaintOptions,
642
815
  style: ComputedStyle,
643
816
  ): Rect {
817
+ const box = frame.strip ?? frame;
644
818
  const [t, r, b, l] = edgeInsets(box, style.backgroundOrigin);
645
819
  return {
646
820
  x: box.x + l + options.originX,
@@ -718,7 +892,12 @@ type Frame = Pick<
718
892
  | 'style'
719
893
  > &
720
894
  // the padding a `content-box` background is inset by, where it has any
721
- Partial<Pick<Box, 'padTop' | 'padRight' | 'padBottom' | 'padLeft'>>;
895
+ Partial<Pick<Box, 'padTop' | 'padRight' | 'padBottom' | 'padLeft'>> & {
896
+ /** Of an inline box's fragment, where the box goes on to another line:
897
+ * its fragments laid end to end as one box, which its images and
898
+ * gradients are placed in (`box-decoration-break: slice`). */
899
+ strip?: Frame;
900
+ };
722
901
 
723
902
  /** Where a box's background and border go: its border box, which for a
724
903
  * table leaves out the captions around it (CSS 2.1 17.4). */
@@ -752,8 +931,13 @@ function paintBox(ctx: PaintContext, box: Box, options: PaintOptions): void {
752
931
  ctx.save();
753
932
  ctx.globalAlpha = ctx.globalAlpha! * opacity;
754
933
  }
934
+ if (masked(box.style) && paintMasked(ctx, box, options)) {
935
+ if (fade) ctx.restore();
936
+ return;
937
+ }
755
938
  // `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)
939
+ // background and borders among it (CSS 2.1 11.1.2) — written out here,
940
+ // not called: a frame more a level and a deep document ran out of stack
757
941
  const clip = box.outOfFlow && box.style.clip ? clipOf(box, options) : null;
758
942
  if (!clip || (clip.w > 0 && clip.h > 0)) {
759
943
  const clipped = !!clip && pushClip(ctx, clip, null);
@@ -763,6 +947,145 @@ function paintBox(ctx: PaintContext, box: Box, options: PaintOptions): void {
763
947
  if (fade) ctx.restore();
764
948
  }
765
949
 
950
+ /** A box and what it holds, cut to its `clip` as `paintBox` cuts it. */
951
+ function paintClipped(
952
+ ctx: PaintContext,
953
+ box: Box,
954
+ options: PaintOptions,
955
+ ): void {
956
+ const clip = box.outOfFlow && box.style.clip ? clipOf(box, options) : null;
957
+ if (clip && !(clip.w > 0 && clip.h > 0)) return;
958
+ const clipped = !!clip && pushClip(ctx, clip, null);
959
+ paintContent(ctx, box, options);
960
+ if (clipped) ctx.restore();
961
+ }
962
+
963
+ const MASK_LAYERS = new WeakMap<ComputedStyle, ComputedStyle[]>();
964
+
965
+ /** No corners: a mask layer is placed in the box's rectangle, which its
966
+ * radii do not round (CSS Masking 1, 7). */
967
+ const SQUARE_RADII: ComputedStyle['borderRadius'] = [0, 0, 0, 0];
968
+
969
+ /**
970
+ * A style for each of a box's mask layers, top first, as `layersOf` makes
971
+ * a background's: the box's own style with the layer's image, repeat, size,
972
+ * position, origin and clip in its background's place, and no colour — so
973
+ * the background painter places and repeats a mask layer as it does a
974
+ * background layer, which is what CSS Masking 1 says a mask layer is (7).
975
+ * Made once a style.
976
+ */
977
+ function maskLayersOf(style: ComputedStyle): ComputedStyle[] {
978
+ let layers = MASK_LAYERS.get(style);
979
+ if (layers) return layers;
980
+ const mask = style.mask;
981
+ const nth = <T>(list: readonly T[], i: number): T => list[i % list.length];
982
+ layers = mask.images.map((image, i) => {
983
+ const layer = Object.create(style) as ComputedStyle;
984
+ layer.backgroundColor = null;
985
+ layer.backgroundImages = null;
986
+ layer.backgroundImage = typeof image === 'string' ? image : null;
987
+ layer.backgroundGradient =
988
+ image !== null && typeof image !== 'string' ? image : null;
989
+ layer.backgroundRepeat = nth(mask.repeats, i);
990
+ layer.backgroundSize = nth(mask.sizes, i);
991
+ layer.backgroundAttachment = 'scroll';
992
+ [layer.backgroundPositionX, layer.backgroundPositionY] = nth(
993
+ mask.positions,
994
+ i,
995
+ );
996
+ layer.backgroundOrigin = nth(mask.origins, i);
997
+ layer.backgroundClip = nth(mask.clips, i);
998
+ layer.backgroundClipText = false;
999
+ layer.borderRadius = SQUARE_RADII;
1000
+ layer.borderRadiusY = null;
1001
+ return layer;
1002
+ });
1003
+ MASK_LAYERS.set(style, layers);
1004
+ return layers;
1005
+ }
1006
+
1007
+ /**
1008
+ * A masked box (CSS Masking 1, 7): the box and what it holds drawn on a
1009
+ * surface of their own, which the alpha of its mask layers — painted as a
1010
+ * background's are, on a second surface, each added over the ones below
1011
+ * it — then cuts with `destination-in`, and the result drawn in its place,
1012
+ * as the group a mask makes. Only the mask painting area, the border box,
1013
+ * can show, and only its part in the damage is drawn.
1014
+ *
1015
+ * A layer whose image has not arrived is transparent black (7.2), so a box
1016
+ * none of whose layers can be drawn yet draws nothing: an icon masked out
1017
+ * of its `background-color` is not a solid square while its image loads.
1018
+ * False where there is no surface to draw on, or no `destination-in` to
1019
+ * draw with, and the caller paints the box unmasked.
1020
+ */
1021
+ function paintMasked(
1022
+ ctx: PaintContext,
1023
+ box: Box,
1024
+ options: PaintOptions,
1025
+ ): boolean {
1026
+ const layers = maskLayersOf(box.style);
1027
+ const drawable = layers.some(
1028
+ (layer) =>
1029
+ !!layer.backgroundGradient ||
1030
+ (!!layer.backgroundImage &&
1031
+ !!options.backgroundImageFor?.(layer.backgroundImage)),
1032
+ );
1033
+ if (!drawable) return true;
1034
+ if (!options.surface || !ctx.drawImage) return false;
1035
+ let x0 = Math.floor(box.x + options.originX);
1036
+ let y0 = Math.floor(frameY(box) + options.originY);
1037
+ let x1 = Math.ceil(box.x + box.width + options.originX);
1038
+ let y1 = Math.ceil(frameY(box) + frameHeight(box) + options.originY);
1039
+ const damage = options.damage;
1040
+ if (damage) {
1041
+ x0 = Math.max(x0, Math.floor(damage.x));
1042
+ y0 = Math.max(y0, Math.floor(damage.y));
1043
+ x1 = Math.min(x1, Math.ceil(damage.x + damage.width));
1044
+ y1 = Math.min(y1, Math.ceil(damage.y + damage.height));
1045
+ }
1046
+ const w = x1 - x0;
1047
+ const h = y1 - y0;
1048
+ if (!(w > 0 && h > 0)) return true;
1049
+ const content = options.surface(w, h);
1050
+ const mask = content && options.surface(w, h);
1051
+ try {
1052
+ if (!content || !mask) return false;
1053
+ const cctx = content.getContext('2d') as PaintContext;
1054
+ const mctx = mask.getContext('2d') as PaintContext;
1055
+ // the surfaces' own coordinates, the window's moved to their corner
1056
+ const on: PaintOptions = {
1057
+ ...options,
1058
+ originX: options.originX - x0,
1059
+ originY: options.originY - y0,
1060
+ damage: { x: 0, y: 0, width: w, height: h },
1061
+ canvas: options.canvas && {
1062
+ ...options.canvas,
1063
+ x: options.canvas.x - x0,
1064
+ y: options.canvas.y - y0,
1065
+ },
1066
+ viewport: options.viewport && {
1067
+ ...options.viewport,
1068
+ x: options.viewport.x - x0,
1069
+ y: options.viewport.y - y0,
1070
+ },
1071
+ clips: [],
1072
+ };
1073
+ for (let i = layers.length - 1; i >= 0; i -= 1) {
1074
+ paintLayer(mctx, box, on, layers[i], frameImages(mctx, box, on));
1075
+ }
1076
+ paintClipped(cctx, box, on);
1077
+ cctx.globalCompositeOperation = 'destination-in';
1078
+ if (cctx.globalCompositeOperation !== 'destination-in') return false;
1079
+ cctx.drawImage!(mask, 0, 0);
1080
+ cctx.globalCompositeOperation = 'source-over';
1081
+ ctx.drawImage(content, x0, y0);
1082
+ return true;
1083
+ } finally {
1084
+ content?.destroy?.();
1085
+ mask?.destroy?.();
1086
+ }
1087
+ }
1088
+
766
1089
  function paintContent(
767
1090
  ctx: PaintContext,
768
1091
  box: Box,
@@ -1047,19 +1370,6 @@ function paintShadows(
1047
1370
  );
1048
1371
  continue;
1049
1372
  }
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
1373
  // what of the shadow falls under the box is not drawn
1064
1374
  const clipped = !covered && ctx.clip && ctx.rect && ctx.save;
1065
1375
  if (clipped) {
@@ -1088,7 +1398,7 @@ function paintShadows(
1088
1398
  s,
1089
1399
  color,
1090
1400
  (dx) => {
1091
- roundedRect(
1401
+ shadowShape(
1092
1402
  ctx,
1093
1403
  shape.x - dx,
1094
1404
  shape.y,
@@ -1151,7 +1461,6 @@ function paintShadows(
1151
1461
  fillRing(ctx, pad, inner, hole, within);
1152
1462
  continue;
1153
1463
  }
1154
- if (bakedInset(ctx, options, s, color, pad, inner, hole, within)) continue;
1155
1464
  if (!ctx.clip || !ctx.save || !ctx.rect) continue;
1156
1465
  ctx.save();
1157
1466
  ctx.beginPath();
@@ -1172,173 +1481,23 @@ function paintShadows(
1172
1481
  (dx) => {
1173
1482
  ctx.rect!(frame.x - dx, frame.y, frame.width, frame.height);
1174
1483
  if (hole.width > 0 && hole.height > 0) {
1175
- roundedRect(
1484
+ shadowShape(
1176
1485
  ctx,
1177
1486
  hole.x - dx,
1178
1487
  hole.y,
1179
1488
  hole.width,
1180
1489
  hole.height,
1181
1490
  within,
1182
- true,
1183
- true,
1184
1491
  );
1185
1492
  }
1186
1493
  },
1187
1494
  frame.x + frame.width + shadowReach(s.blur),
1495
+ 'evenodd',
1188
1496
  );
1189
1497
  ctx.restore();
1190
1498
  }
1191
1499
  }
1192
1500
 
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
1501
  /**
1343
1502
  * Fill a shadow's shape: in its colour where it has no blur, and otherwise
1344
1503
  * as the context's shadow of the shape drawn `dx` to the left — clear of
@@ -1351,12 +1510,13 @@ function fillShadow(
1351
1510
  color: string,
1352
1511
  shape: (dx: number) => void,
1353
1512
  right: number,
1513
+ rule: 'nonzero' | 'evenodd' = 'nonzero',
1354
1514
  ): void {
1355
1515
  if (!(s.blur > 0)) {
1356
1516
  ctx.fillStyle = color;
1357
1517
  ctx.beginPath!();
1358
1518
  shape(0);
1359
- ctx.fill!();
1519
+ ctx.fill!(rule);
1360
1520
  return;
1361
1521
  }
1362
1522
  const dx = Math.ceil(right) + 1;
@@ -1368,10 +1528,36 @@ function fillShadow(
1368
1528
  ctx.fillStyle = '#000000';
1369
1529
  ctx.beginPath!();
1370
1530
  shape(dx);
1371
- ctx.fill!();
1531
+ ctx.fill!(rule);
1372
1532
  ctx.restore();
1373
1533
  }
1374
1534
 
1535
+ /**
1536
+ * A shadow's shape, spelled the way a 2d context recognises one: a `rect`,
1537
+ * or a `roundRect` with each corner's own radii, elliptical where the
1538
+ * corner is. react-x11's contexts draw the blurred shadow of such a shape —
1539
+ * and of a `rect` less such a shape, filled `evenodd` — from a tile they
1540
+ * keep, where one built of curves they blur afresh on every fill.
1541
+ */
1542
+ function shadowShape(
1543
+ ctx: PaintContext,
1544
+ x: number,
1545
+ y: number,
1546
+ w: number,
1547
+ h: number,
1548
+ c: Corners,
1549
+ ): void {
1550
+ if (c.x.every((r) => r === 0) || c.y.every((r) => r === 0)) {
1551
+ ctx.rect!(x, y, w, h);
1552
+ return;
1553
+ }
1554
+ // a circular corner as the number every context has always taken; only
1555
+ // an elliptical one as the point
1556
+ const corner = (i: number) =>
1557
+ c.x[i] === c.y[i] ? c.x[i] : { x: c.x[i], y: c.y[i] };
1558
+ ctx.roundRect!(x, y, w, h, [corner(0), corner(1), corner(2), corner(3)]);
1559
+ }
1560
+
1375
1561
  /**
1376
1562
  * The band between two rounded rectangles, the inner inside the outer, in
1377
1563
  * the colour already set. Where a side has no band the two edges meet, and
@@ -1445,6 +1631,7 @@ function inFlow(parent: Box, child: Box, options: PaintOptions): boolean {
1445
1631
  style.overflowX === 'visible' &&
1446
1632
  style.overflowY === 'visible' &&
1447
1633
  opacityOf(child) >= 1 &&
1634
+ !masked(style) &&
1448
1635
  // containment makes it a stacking context, painted whole
1449
1636
  !contained(child, CONTAIN_LAYOUT | CONTAIN_PAINT)
1450
1637
  );
@@ -1472,6 +1659,7 @@ function flowFlex(parent: Box, child: Box, options: PaintOptions): boolean {
1472
1659
  style.overflowX === 'visible' &&
1473
1660
  style.overflowY === 'visible' &&
1474
1661
  !(child.outOfFlow && style.clip) &&
1662
+ !masked(style) &&
1475
1663
  !contained(child, CONTAIN_LAYOUT | CONTAIN_PAINT)
1476
1664
  );
1477
1665
  }
@@ -1744,7 +1932,55 @@ function paintPositioned(
1744
1932
  return;
1745
1933
  }
1746
1934
  }
1747
- paintBox(ctx, box, options);
1935
+ paintBox(ctx, box, atViewport(box, options));
1936
+ }
1937
+
1938
+ /**
1939
+ * The options a box is painted with where it is fixed to the viewport
1940
+ * (CSS 2.1 9.6.1): laid out against the viewport at the document's top,
1941
+ * and drawn where the viewport is now, however far the pane that scrolls
1942
+ * the element has moved it — a fixed header scrolled away with the text.
1943
+ * Once for a box and all it holds.
1944
+ */
1945
+ function atViewport(box: Box, options: PaintOptions): PaintOptions {
1946
+ const viewport = options.viewport;
1947
+ if (!viewport || options.atViewport || !fixedToViewport(box)) {
1948
+ return options;
1949
+ }
1950
+ // where the document's top left is from the viewport's, less where the
1951
+ // viewport is in the document: the scroll
1952
+ const root = rootOf(box);
1953
+ const dx = viewport.x - options.originX - root.x;
1954
+ const dy = viewport.y - options.originY - root.y;
1955
+ return {
1956
+ ...options,
1957
+ originX: options.originX + dx,
1958
+ originY: options.originY + dy,
1959
+ atViewport: true,
1960
+ };
1961
+ }
1962
+
1963
+ /** Per tree, its boxes fixed to the viewport (`fixedToViewport`), found
1964
+ * as the layout positions them: what the element answers a scroll pane's
1965
+ * blit with (`HtmlViewNode.viewportFixedRects`). */
1966
+ export const FIXED_BOXES = new WeakMap<object, Box[]>();
1967
+
1968
+ /** Whether a box is fixed to the viewport: `position: fixed` with no
1969
+ * transformed or contained box around it, which would be its containing
1970
+ * block instead (CSS Transforms 1, CSS Containment 2, 3.3). */
1971
+ export function fixedToViewport(box: Box): boolean {
1972
+ if (box.style.position !== 'fixed') return false;
1973
+ for (let at = box.parent; at?.parent; at = at.parent) {
1974
+ if (at.style.translate || at.style.transformTranslate) return false;
1975
+ if (contained(at, CONTAIN_LAYOUT | CONTAIN_PAINT)) return false;
1976
+ }
1977
+ return true;
1978
+ }
1979
+
1980
+ function rootOf(box: Box): Box {
1981
+ let root = box;
1982
+ while (root.parent) root = root.parent;
1983
+ return root;
1748
1984
  }
1749
1985
 
1750
1986
  /**
@@ -2212,6 +2448,8 @@ function stacksLayers(box: Box): boolean {
2212
2448
  const style = box.style;
2213
2449
  if (style.position === 'fixed' || style.position === 'sticky') return true;
2214
2450
  if (style.opacity < 1 || FADED_BLOCKS.has(box)) return true;
2451
+ // and so does a mask, which is applied to the group (CSS Masking 1, 7)
2452
+ if (masked(style)) return true;
2215
2453
  // layout and paint containment make one (CSS Containment 2, 3.3, 3.5)
2216
2454
  if (contained(box, CONTAIN_LAYOUT | CONTAIN_PAINT)) return true;
2217
2455
  if (typeof style.zIndex !== 'number') return false;
@@ -2231,7 +2469,7 @@ function flexItem(box: Box): boolean {
2231
2469
  * outside a box that clips where its containing block is, a fixed one
2232
2470
  * outside every one, and a relative one inside every one.
2233
2471
  */
2234
- function clipsFor(box: Box, context: Box): Box[] {
2472
+ export function clipsFor(box: Box, context: Box): Box[] {
2235
2473
  let from: Box | null = box.parent;
2236
2474
  if (box.outOfFlow) {
2237
2475
  const fixed = box.style.position === 'fixed';
@@ -2261,6 +2499,7 @@ function paintStacked(
2261
2499
  box: Box,
2262
2500
  options: PaintOptions,
2263
2501
  ): void {
2502
+ options = atViewport(box, options);
2264
2503
  if (!intersects(box, options)) return;
2265
2504
  const between = CLIPS_BETWEEN.get(box);
2266
2505
  if (!between) {
@@ -2439,7 +2678,9 @@ function paintBackground(
2439
2678
  // box, which is where it starts, and repeated under the borders — or
2440
2679
  // the viewport's, where it is fixed; its line runs across the whole of
2441
2680
  // it, not the part this paint reaches
2442
- const fixed = style.backgroundAttachment === 'fixed' && options.canvas;
2681
+ const fixed =
2682
+ style.backgroundAttachment === 'fixed' &&
2683
+ (options.viewport ?? options.canvas);
2443
2684
  const origin = fixed || originBox(box, options, style);
2444
2685
  const at = snapped(origin.x, origin.y, origin.width, origin.height);
2445
2686
  if (rounded && style.backgroundSize !== 'auto' && ctx.clip) {
@@ -2929,7 +3170,7 @@ function paintBackgroundImage(
2929
3170
  corners: Corners | null = null,
2930
3171
  ): void {
2931
3172
  if (style.backgroundAttachment === 'fixed' && options.canvas) {
2932
- at = options.canvas;
3173
+ at = options.viewport ?? options.canvas;
2933
3174
  }
2934
3175
  const url = style.backgroundImage;
2935
3176
  const loaded = url ? options.backgroundImageFor?.(url) : null;
@@ -4127,7 +4368,12 @@ function paintLines(ctx: PaintContext, box: Box, options: PaintOptions): void {
4127
4368
  const bottom = top + damage.height;
4128
4369
  let lo = 0;
4129
4370
  let hi = lines.length;
4130
- const slack = top - box.maxLineHeight;
4371
+ // and a line whose glyphs, or an atomic on it, reach past it
4372
+ // (`LINE_INK`) is drawn where they reach
4373
+ const ink = LINE_INK.get(lines);
4374
+ const above = ink?.above ?? 0;
4375
+ const below = ink?.below ?? 0;
4376
+ const slack = top - box.maxLineHeight - below;
4131
4377
  while (lo < hi) {
4132
4378
  const mid = (lo + hi) >> 1;
4133
4379
  if (lines[mid].y > slack) hi = mid;
@@ -4135,8 +4381,11 @@ function paintLines(ctx: PaintContext, box: Box, options: PaintOptions): void {
4135
4381
  }
4136
4382
  for (let i = lo; i < lines.length; i += 1) {
4137
4383
  const line = lines[i];
4138
- if (line.y >= bottom) break;
4139
- if (line.y + line.height > top) visible.push(line);
4384
+ if (line.y - above >= bottom) break;
4385
+ if (line.y < bottom && line.y + line.height > top) visible.push(line);
4386
+ else if (ink && inkInto(line, top, bottom)) {
4387
+ visible.push(line);
4388
+ }
4140
4389
  }
4141
4390
  // and a line the damage misses whose text or inline-block `position:
4142
4391
  // relative` moved into it: drawn by its line, it went undrawn with it
@@ -4144,7 +4393,7 @@ function paintLines(ctx: PaintContext, box: Box, options: PaintOptions): void {
4144
4393
  if (moved && reachedOff(visible, moved, top, bottom)) {
4145
4394
  const drawn = new Set(visible);
4146
4395
  for (const line of moved) {
4147
- if (movedInto(line, top, bottom)) drawn.add(line);
4396
+ if (inkInto(line, top, bottom)) drawn.add(line);
4148
4397
  }
4149
4398
  visible.length = 0;
4150
4399
  for (const line of lines) if (drawn.has(line)) visible.push(line);
@@ -4157,7 +4406,13 @@ function paintLines(ctx: PaintContext, box: Box, options: PaintOptions): void {
4157
4406
  const bleeds: Bleed[] = [];
4158
4407
  for (const line of visible) {
4159
4408
  if (line.background) paintLineBackground(ctx, line, options);
4160
- paintInlineBoxes(ctx, line, options, line === lines[0] ? null : bleeds);
4409
+ paintInlineBoxes(
4410
+ ctx,
4411
+ line,
4412
+ lines,
4413
+ options,
4414
+ line === lines[0] ? null : bleeds,
4415
+ );
4161
4416
  for (const text of line.texts) {
4162
4417
  const natural = text.layout.lines[text.layoutLine];
4163
4418
  if (natural)
@@ -4184,6 +4439,10 @@ function paintLines(ctx: PaintContext, box: Box, options: PaintOptions): void {
4184
4439
  // drawing it once per line would be one X request per line for the same
4185
4440
  // batch.
4186
4441
  const drawn = new Set<unknown>();
4442
+ // text a `::selection` colours is drawn in that colour where selected
4443
+ const recolored = options.selectionStyler
4444
+ ? recoloredBands(visible, options)
4445
+ : null;
4187
4446
  for (const line of visible) {
4188
4447
  for (const text of line.texts) {
4189
4448
  if (drawn.has(text.layout)) continue;
@@ -4197,7 +4456,9 @@ function paintLines(ctx: PaintContext, box: Box, options: PaintOptions): void {
4197
4456
  // thrown from the protocol encoder.
4198
4457
  continue;
4199
4458
  }
4200
- text.layout.draw(ctx, text.drawX + dx, top);
4459
+ const bands = recolored?.get(text.layout);
4460
+ if (bands) drawRecolored(ctx, text.layout, text.drawX + dx, top, bands);
4461
+ else text.layout.draw(ctx, text.drawX + dx, top);
4201
4462
  }
4202
4463
  }
4203
4464
 
@@ -4224,15 +4485,16 @@ function reachedOff(
4224
4485
  bottom: number,
4225
4486
  ): boolean {
4226
4487
  for (const line of moved) {
4227
- if (!visible.includes(line) && movedInto(line, top, bottom)) return true;
4488
+ if (!visible.includes(line) && inkInto(line, top, bottom)) return true;
4228
4489
  }
4229
4490
  return false;
4230
4491
  }
4231
4492
 
4232
- /** Whether what was moved off a line — its inline-blocks and images, where
4233
- * their ink is, and its texts, where they are drawn — reaches the rows
4234
- * between `top` and `bottom`. */
4235
- function movedInto(line: LineBox, top: number, bottom: number): boolean {
4493
+ /** Whether what a line draws — its inline-blocks and images, where their
4494
+ * ink is, and its texts, where they are drawn, their glyphs from ascent to
4495
+ * descent — reaches the rows between `top` and `bottom`: what was moved off
4496
+ * it, and what hangs past it. */
4497
+ function inkInto(line: LineBox, top: number, bottom: number): boolean {
4236
4498
  for (const placed of line.atomics) {
4237
4499
  const box = placed.box;
4238
4500
  if (box.boundsY < bottom && box.boundsY + box.boundsHeight > top) {
@@ -4242,8 +4504,16 @@ function movedInto(line: LineBox, top: number, bottom: number): boolean {
4242
4504
  for (const text of line.texts) {
4243
4505
  const natural = text.layout.lines[text.layoutLine];
4244
4506
  if (!natural) continue;
4245
- const y = text.drawY + natural.y;
4246
- if (y < bottom && y + natural.height > top) return true;
4507
+ const baseline = text.drawY + natural.baseline;
4508
+ const y1 = Math.min(
4509
+ text.drawY + natural.y,
4510
+ baseline - (natural.ascent ?? 0),
4511
+ );
4512
+ const y2 = Math.max(
4513
+ text.drawY + natural.y + natural.height,
4514
+ baseline + (natural.descent ?? 0),
4515
+ );
4516
+ if (y1 < bottom && y2 > top) return true;
4247
4517
  }
4248
4518
  return false;
4249
4519
  }
@@ -4690,38 +4960,34 @@ function paintLineBackground(
4690
4960
  );
4691
4961
  }
4692
4962
 
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;
4963
+ /**
4964
+ * The fragments of the decorated inline boxes on a line: how far across it
4965
+ * each reaches, its border box, and whether the box opens or closes on it.
4966
+ * Where the line holds text `position: relative` moved, taken where the
4967
+ * text was: a fragment is moved by its own box's offset.
4968
+ *
4969
+ * A box is more than one fragment on a line where bidi reordering puts
4970
+ * what is not the box's between parts of it (CSS 2.1 9.10): a letter of
4971
+ * another element, an atomic, another element's edge. Its parts are one
4972
+ * fragment wherever nothing else comes between them, whatever room is
4973
+ * between them — a space it hangs, a box of its own inside it — so a line
4974
+ * that reads one way has one fragment of each box, as it always had.
4975
+ */
4976
+ function fragmentsOn(line: LineBox): Map<Box, InlineFragment[]> | null {
4977
+ /** What is on the line, where, and which decorated boxes it is in. */
4978
+ const marks: {
4979
+ left: number;
4980
+ right: number;
4981
+ boxes: readonly Box[];
4982
+ /** An edge's own box, whose fragment it opens or closes. */
4983
+ edge?: EdgePlacement;
4984
+ }[] = [];
4717
4985
  const shifted = SHIFTED_LINES.has(line);
4718
4986
  for (const text of line.texts) {
4719
4987
  const natural = text.layout.lines[text.layoutLine];
4720
4988
  const boxAt = text.spans.boxAt;
4721
4989
  if (!natural || !boxAt) continue;
4722
4990
  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
4991
  const x = text.drawX - (shift?.x ?? 0) + natural.x;
4726
4992
  for (const run of natural.runs) {
4727
4993
  const owner = boxAt.call(text.spans, run.start);
@@ -4731,44 +4997,159 @@ function paintInlineBoxes(
4731
4997
  const left = Math.max(x, x + run.x);
4732
4998
  const right = Math.min(x + natural.width, x + run.x + run.width);
4733
4999
  if (right <= left) continue;
4734
- for (const box of decoratedAncestors(owner)) widen(box, left, right);
5000
+ marks.push({ left, right, boxes: decoratedAncestors(owner) });
5001
+ }
5002
+ // and the spaces `pre-wrap` keeps that the line ends on, which hang
5003
+ // past it and are their box's all the same (`LineText.hung`)
5004
+ for (const space of text.hung ?? []) {
5005
+ const owner = boxAt.call(text.spans, space.at);
5006
+ if (!owner) continue;
5007
+ const left = text.drawX - (shift?.x ?? 0) + space.x;
5008
+ marks.push({
5009
+ left,
5010
+ right: left + space.width,
5011
+ boxes: decoratedAncestors(owner),
5012
+ });
4735
5013
  }
4736
5014
  }
4737
5015
  for (const placed of line.atomics) {
4738
5016
  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
- }
5017
+ marks.push({
5018
+ left: placed.x - atomic.marginLeft,
5019
+ right: placed.x + atomic.width + atomic.marginRight,
5020
+ boxes: decoratedAncestors(atomic),
5021
+ });
4746
5022
  }
4747
5023
  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);
5024
+ marks.push({
5025
+ left: edge.x,
5026
+ right: edge.x + edge.width,
5027
+ boxes: decoratedAncestors(edge.box),
5028
+ edge,
5029
+ });
5030
+ }
5031
+ let fragments: Map<Box, InlineFragment[]> | null = null;
5032
+ // each box's parts, left to right
5033
+ const parts = new Map<Box, typeof marks>();
5034
+ for (const mark of marks) {
5035
+ for (const box of mark.boxes) {
5036
+ let list = parts.get(box);
5037
+ if (!list) parts.set(box, (list = []));
5038
+ list.push(mark);
5039
+ }
5040
+ const own = mark.edge?.box;
5041
+ if (own?.decoration) {
5042
+ let list = parts.get(own);
5043
+ if (!list) parts.set(own, (list = []));
5044
+ list.push(mark);
4760
5045
  }
4761
- const f = fragments!.get(box)!;
4762
- if (edge.side === 'start') f.start = true;
4763
- else f.end = true;
4764
5046
  }
5047
+ for (const [box, list] of parts) {
5048
+ list.sort((a, b) => a.left - b.left || a.right - b.right);
5049
+ const out: InlineFragment[] = [];
5050
+ let open: InlineFragment | null = null;
5051
+ let reach = -Infinity;
5052
+ for (const mark of list) {
5053
+ // The element's `direction` says which side its start is on, whatever
5054
+ // its text reads as (CSS 2.1 8.6); its own edge's margin is outside
5055
+ // the box, its border and padding inside.
5056
+ let left = mark.left;
5057
+ let right = mark.right;
5058
+ const own = mark.edge?.box === box ? mark.edge : null;
5059
+ if (own) {
5060
+ const onLeft =
5061
+ (own.side === 'start') !== (box.style.direction === 'rtl');
5062
+ if (onLeft) left = right = own.x + box.marginLeft;
5063
+ else left = right = own.x + own.width - box.marginRight;
5064
+ }
5065
+ if (!open || between(marks, box, reach, mark.left)) {
5066
+ open = { left, right, start: false, end: false };
5067
+ out.push(open);
5068
+ } else {
5069
+ open.left = Math.min(open.left, left);
5070
+ open.right = Math.max(open.right, right);
5071
+ }
5072
+ reach = Math.max(reach, mark.right);
5073
+ if (own) {
5074
+ if (own.side === 'start') open.start = true;
5075
+ else open.end = true;
5076
+ }
5077
+ }
5078
+ (fragments ??= new Map()).set(box, out);
5079
+ }
5080
+ return fragments;
5081
+ }
5082
+
5083
+ /** Whether anything on a line that is not in a box lies between two of its
5084
+ * parts, from `left` to `right`: what splits it into two fragments. */
5085
+ function between(
5086
+ marks: readonly {
5087
+ left: number;
5088
+ right: number;
5089
+ boxes: readonly Box[];
5090
+ edge?: EdgePlacement;
5091
+ }[],
5092
+ box: Box,
5093
+ left: number,
5094
+ right: number,
5095
+ ): boolean {
5096
+ if (right - left < 0.5) return false;
5097
+ for (const mark of marks) {
5098
+ if (mark.right - mark.left < 0.5) continue;
5099
+ if (mark.boxes.includes(box) || mark.edge?.box === box) continue;
5100
+ const middle = (mark.left + mark.right) / 2;
5101
+ if (middle > left && middle < right) return true;
5102
+ }
5103
+ return false;
5104
+ }
5105
+
5106
+ function paintInlineBoxes(
5107
+ ctx: PaintContext,
5108
+ line: LineBox,
5109
+ /** The block's lines, which `line` is one of. */
5110
+ lines: readonly LineBox[],
5111
+ options: PaintOptions,
5112
+ /** Where a fragment that reaches up over the lines before goes, to be
5113
+ * drawn again over their text; null for a block's first line. */
5114
+ bleeds: Bleed[] | null = null,
5115
+ ): void {
5116
+ const fragments = fragmentsOn(line);
4765
5117
  if (!fragments) return;
4766
- const boxes = [...(fragments as Map<Box, InlineFragment>).keys()].sort(
4767
- (a, b) => depthOf(a) - depthOf(b),
4768
- );
5118
+ // Every text on a line is drawn on one baseline (`finishLine`), and an
5119
+ // engine's is from the top of its layout rather than of the line. Taken
5120
+ // where the line has each text: a box `position: relative` moves is
5121
+ // moved by its own offset below, and the boxes around it are not.
5122
+ let baseline = line.y + line.baseline;
5123
+ const shifted = SHIFTED_LINES.has(line);
5124
+ for (const text of line.texts) {
5125
+ const natural = text.layout.lines[text.layoutLine];
5126
+ if (!natural || !text.spans.boxAt) continue;
5127
+ const shift = shifted ? TEXT_SHIFTS.get(text) : undefined;
5128
+ const raise = shifted ? (TEXT_RAISES.get(text) ?? 0) : 0;
5129
+ baseline = text.drawY - (shift?.y ?? 0) + raise + natural.baseline;
5130
+ }
5131
+ const boxes = [...fragments.keys()].sort((a, b) => depthOf(a) - depthOf(b));
4769
5132
  for (const box of boxes) {
4770
5133
  if (box.style.visibility !== 'visible') continue;
4771
- const f = (fragments as Map<Box, InlineFragment>).get(box)!;
5134
+ for (const f of fragments.get(box)!) {
5135
+ paintInlineFragment(ctx, line, lines, options, bleeds, box, f, baseline);
5136
+ }
5137
+ }
5138
+ }
5139
+
5140
+ /** One fragment of an inline box on a line (`paintInlineBoxes`). */
5141
+ function paintInlineFragment(
5142
+ ctx: PaintContext,
5143
+ line: LineBox,
5144
+ lines: readonly LineBox[],
5145
+ options: PaintOptions,
5146
+ bleeds: Bleed[] | null,
5147
+ box: Box,
5148
+ f: InlineFragment,
5149
+ baseline: number,
5150
+ ): void {
5151
+ const shifted = SHIFTED_LINES.has(line);
5152
+ {
4772
5153
  const face = box.decoration!;
4773
5154
  // on its own baseline, which `vertical-align` may raise off the line's
4774
5155
  const own = shifted
@@ -4802,10 +5183,14 @@ function paintInlineBoxes(
4802
5183
  padRight: rightEnds ? box.padRight : 0,
4803
5184
  style,
4804
5185
  };
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
5186
+ if (fragment.width <= 0) return;
5187
+ // its images and gradients from the strip its fragments make, where it
5188
+ // goes on to another line, and from its own padding box where it is
5189
+ // on this one alone or `clone` says each fragment is its own
5190
+ if (!(f.start && f.end) && sliced(box.style)) {
5191
+ const strip = stripFor(lines, line, box, fragment);
5192
+ if (strip) fragment.strip = strip;
5193
+ }
4809
5194
  paintLayers(ctx, fragment, options, frameImages(ctx, fragment, options));
4810
5195
  paintBorders(ctx, fragment, options);
4811
5196
  if (box.style.outlineStyle !== 'none') paintOutline(ctx, fragment, options);
@@ -4815,6 +5200,93 @@ function paintInlineBoxes(
4815
5200
  }
4816
5201
  }
4817
5202
 
5203
+ /** Whether an inline box places background images or gradients, which
5204
+ * `slice` places across its fragments, as one box end to end. */
5205
+ function sliced(style: ComputedStyle): boolean {
5206
+ return (
5207
+ style.boxDecorationBreak === 'slice' &&
5208
+ (style.backgroundImage !== null ||
5209
+ style.backgroundGradient !== null ||
5210
+ style.backgroundImages !== null)
5211
+ );
5212
+ }
5213
+
5214
+ /** Where an inline box's fragments sit along the strip they make laid end
5215
+ * to end: how wide it is, on how many lines, and how far along it each
5216
+ * line's fragment starts, in the box's direction. */
5217
+ interface Strip {
5218
+ width: number;
5219
+ lines: number;
5220
+ before: Map<LineBox, number>;
5221
+ }
5222
+
5223
+ /** Of a block's lines, by the array they are, the strip of each inline box
5224
+ * on them that `sliced` says places images across its fragments: made
5225
+ * once a layout, as `MOVED_OFF_LINES` is, so a repaint of one line reads
5226
+ * the lines before it without going over them again. What `sliced` reads
5227
+ * is never restyled in place (`HtmlViewNode`), which lays the lines out
5228
+ * again instead. */
5229
+ const STRIPS = new WeakMap<readonly LineBox[], Map<Box, Strip>>();
5230
+
5231
+ function stripsOf(lines: readonly LineBox[]): Map<Box, Strip> {
5232
+ const strips = new Map<Box, Strip>();
5233
+ for (const line of lines) {
5234
+ const fragments = fragmentsOn(line);
5235
+ if (!fragments) continue;
5236
+ for (const [box, list] of fragments) {
5237
+ if (!sliced(box.style)) continue;
5238
+ let strip = strips.get(box);
5239
+ if (!strip) {
5240
+ strip = { width: 0, lines: 0, before: new Map() };
5241
+ strips.set(box, strip);
5242
+ }
5243
+ strip.before.set(line, strip.width);
5244
+ for (const f of list) strip.width += Math.max(0, f.right - f.left);
5245
+ strip.lines += 1;
5246
+ }
5247
+ }
5248
+ return strips;
5249
+ }
5250
+
5251
+ /**
5252
+ * The box an inline box's fragment on `line` is a slice of (CSS
5253
+ * Fragmentation 3, 5.4): the box's fragments laid end to end in its
5254
+ * direction, as though it had not wrapped — as wide as
5255
+ * they are together, with the box's own borders and padding at its two
5256
+ * ends, placed so that this fragment is where it falls along it. So an
5257
+ * image `no-repeat` at the start is on the first fragment alone, and a
5258
+ * gradient runs once across them all. Undefined where the box is on one
5259
+ * line.
5260
+ */
5261
+ function stripFor(
5262
+ lines: readonly LineBox[],
5263
+ line: LineBox,
5264
+ box: Box,
5265
+ fragment: Frame,
5266
+ ): Frame | undefined {
5267
+ let strips = STRIPS.get(lines);
5268
+ if (!strips) {
5269
+ strips = stripsOf(lines);
5270
+ STRIPS.set(lines, strips);
5271
+ }
5272
+ const strip = strips.get(box);
5273
+ const before = strip?.before.get(line);
5274
+ if (!strip || strip.lines < 2 || before === undefined) return undefined;
5275
+ const x =
5276
+ box.style.direction === 'rtl'
5277
+ ? fragment.x + fragment.width + before - strip.width
5278
+ : fragment.x - before;
5279
+ return {
5280
+ ...fragment,
5281
+ x,
5282
+ width: strip.width,
5283
+ borderLeft: box.borderLeft,
5284
+ borderRight: box.borderRight,
5285
+ padLeft: box.padLeft,
5286
+ padRight: box.padRight,
5287
+ };
5288
+ }
5289
+
4818
5290
  /** An inline box's style on a line it does not both start and end on: no
4819
5291
  * rounded corner on a side it goes on from. Made once a style and a pair
4820
5292
  * of ends, so a repaint copies no style and the layers made of it are
@@ -4892,22 +5364,65 @@ function decoratedAncestors(box: Box): Box[] {
4892
5364
  return found;
4893
5365
  }
4894
5366
 
5367
+ /** A restyle in place (a `:hover`) gave an inline box a decoration where it
5368
+ * had none, or took it away, and the lists kept above do not know: every
5369
+ * box whose list could name it — inside it, through the inline boxes the
5370
+ * walk above climbs — looks again. A document built again has new boxes,
5371
+ * and so no lists. */
5372
+ export function forgetDecoratedAncestors(box: Box): void {
5373
+ const stack = [...box.children];
5374
+ while (stack.length) {
5375
+ const at = stack.pop()!;
5376
+ DECORATED.delete(at);
5377
+ if (at.kind === 'inline') stack.push(...at.children);
5378
+ }
5379
+ }
5380
+
4895
5381
  /**
4896
5382
  * The band the document selection covers on one line.
4897
5383
  *
4898
5384
  * Translucent under the glyphs rather than inverted over them, so the ink
4899
5385
  * keeps its contrast on either palette — the same call `<textarea>` and
4900
- * `<richtext>` both make.
5386
+ * `<richtext>` both make. From the top of the line to its bottom, and past
5387
+ * them over the glyphs where they are taller than the line (`selectionRows`).
4901
5388
  */
4902
5389
  function paintSelection(
4903
5390
  ctx: PaintContext,
4904
5391
  line: LineBox,
4905
5392
  options: PaintOptions,
4906
5393
  ): void {
5394
+ for (const band of selectedBands(line, options)) {
5395
+ const fill = band.style ? band.style.background : options.selectionColor;
5396
+ if (!fill || isTransparent(fill)) continue;
5397
+ ctx.fillStyle = fill;
5398
+ ctx.fillRect(band.x, band.y, band.width, band.height);
5399
+ }
5400
+ }
5401
+
5402
+ /** A stretch of selected text on one line, in window pixels, and the
5403
+ * `::selection` of the element it is in: null for the palette's. */
5404
+ interface SelectedBand extends Rect {
5405
+ style: SelectionStyle | null;
5406
+ /** The layout whose text it is. */
5407
+ layout: unknown;
5408
+ }
5409
+
5410
+ const NO_BANDS: SelectedBand[] = [];
5411
+
5412
+ /**
5413
+ * Where the document selection covers a line's text, a band for each
5414
+ * stretch of it one `::selection` styles, in window pixels as the band is
5415
+ * filled — rounded, so a band's text is drawn to its edges and no further
5416
+ * (`drawRecolored`).
5417
+ */
5418
+ function selectedBands(line: LineBox, options: PaintOptions): SelectedBand[] {
4907
5419
  const range = options.selection;
4908
- if (!range || range.end <= range.start || !options.selectionColor) return;
4909
- if (line.textEnd <= range.start || line.textStart >= range.end) return;
4910
- ctx.fillStyle = options.selectionColor;
5420
+ if (!range || range.end <= range.start) return NO_BANDS;
5421
+ if (line.textEnd <= range.start || line.textStart >= range.end) {
5422
+ return NO_BANDS;
5423
+ }
5424
+ const styler = options.selectionStyler;
5425
+ const out: SelectedBand[] = [];
4911
5426
  for (const text of line.texts) {
4912
5427
  const natural = text.layout.lines[text.layoutLine];
4913
5428
  if (!natural) continue;
@@ -4917,19 +5432,214 @@ function paintSelection(
4917
5432
  const offsets = layoutOffsets(text.layout);
4918
5433
  const layoutFrom = layoutOffsetOf(text, from);
4919
5434
  const layoutTo = layoutOffsetOf(text, to, true);
4920
- for (const band of lineBands(
4921
- text.layout,
4922
- natural,
4923
- offsets,
4924
- layoutFrom,
4925
- layoutTo,
4926
- )) {
4927
- ctx.fillRect(
4928
- Math.round(band.x + text.drawX + options.originX),
4929
- Math.round(line.y + options.originY),
4930
- Math.ceil(band.width),
4931
- Math.ceil(line.height),
4932
- );
5435
+ const rows = selectionRows(line, text, natural);
5436
+ const y = Math.round(rows.y + options.originY);
5437
+ const height = Math.ceil(rows.height);
5438
+ const pieces = styler
5439
+ ? stylePieces(text, natural, layoutFrom, layoutTo, styler)
5440
+ : [{ from: layoutFrom, to: layoutTo, style: null }];
5441
+ for (const piece of pieces) {
5442
+ for (const band of lineBands(
5443
+ text.layout,
5444
+ natural,
5445
+ offsets,
5446
+ piece.from,
5447
+ piece.to,
5448
+ )) {
5449
+ out.push({
5450
+ x: Math.round(band.x + text.drawX + options.originX),
5451
+ y,
5452
+ width: Math.ceil(band.width),
5453
+ height,
5454
+ style: piece.style,
5455
+ layout: text.layout,
5456
+ });
5457
+ }
4933
5458
  }
4934
5459
  }
5460
+ return out;
5461
+ }
5462
+
5463
+ /**
5464
+ * A line's selected text, `from` to `to` in the layout's offsets, cut where
5465
+ * the `::selection` over it changes: a run's is its element's, and a run of
5466
+ * no element's — an inline box's edge, laid out as a spacer — the one
5467
+ * beside it, so a styled band has no palette-coloured gap at a `<span>`.
5468
+ */
5469
+ function stylePieces(
5470
+ text: LineText,
5471
+ natural: LineText['layout']['lines'][number],
5472
+ from: number,
5473
+ to: number,
5474
+ styler: SelectionStyler,
5475
+ ): { from: number; to: number; style: SelectionStyle | null }[] {
5476
+ const runs = natural.runs
5477
+ .filter((run) => run.end > from && run.start < to)
5478
+ .sort((a, b) => a.start - b.start);
5479
+ const boxAt = text.spans.boxAt;
5480
+ const styles: (SelectionStyle | null | undefined)[] = runs.map((run) => {
5481
+ const box = boxAt?.call(text.spans, run.start) ?? null;
5482
+ return box ? styler(box) : undefined;
5483
+ });
5484
+ for (let i = 1; i < styles.length; i += 1) {
5485
+ if (styles[i] === undefined) styles[i] = styles[i - 1];
5486
+ }
5487
+ for (let i = styles.length - 2; i >= 0; i -= 1) {
5488
+ if (styles[i] === undefined) styles[i] = styles[i + 1];
5489
+ }
5490
+ const out: { from: number; to: number; style: SelectionStyle | null }[] = [];
5491
+ runs.forEach((run, i) => {
5492
+ const style = styles[i] ?? null;
5493
+ const last = out[out.length - 1];
5494
+ if (last && last.style === style) last.to = Math.min(to, run.end);
5495
+ else {
5496
+ out.push({
5497
+ from: Math.max(from, run.start),
5498
+ to: Math.min(to, run.end),
5499
+ style,
5500
+ });
5501
+ }
5502
+ });
5503
+ return out;
5504
+ }
5505
+
5506
+ /**
5507
+ * The layouts whose selected text a `::selection` gives a colour of its
5508
+ * own, each with the bands it is drawn in that colour (`drawRecolored`).
5509
+ * Null where none does, which is every document with no such rule.
5510
+ */
5511
+ function recoloredBands(
5512
+ lines: LineBox[],
5513
+ options: PaintOptions,
5514
+ ): Map<unknown, SelectedBand[]> | null {
5515
+ let out: Map<unknown, SelectedBand[]> | null = null;
5516
+ for (const line of lines) {
5517
+ for (const band of selectedBands(line, options)) {
5518
+ if (!band.style?.color || !(band.width > 0)) continue;
5519
+ out ??= new Map();
5520
+ const list = out.get(band.layout);
5521
+ if (list) list.push(band);
5522
+ else out.set(band.layout, [band]);
5523
+ }
5524
+ }
5525
+ return out;
5526
+ }
5527
+
5528
+ /**
5529
+ * A layout with some of its text selected in a `::selection`'s colour
5530
+ * (CSS Pseudo 4, 3.2): its own colours everywhere but those bands, and in
5531
+ * each band the glyphs in that band's colour — cast there as a hard shadow
5532
+ * from the layout drawn clear of the window, the way `castShadows` draws a
5533
+ * text shadow, since a layout draws in its runs' own colours. Drawn over
5534
+ * rather than in place of the text, the old glyphs' edges showed round the
5535
+ * new ones. Where the context cannot clip or cast, the text keeps its own
5536
+ * colours.
5537
+ */
5538
+ function drawRecolored(
5539
+ ctx: PaintContext,
5540
+ layout: LineText['layout'],
5541
+ left: number,
5542
+ top: number,
5543
+ bands: SelectedBand[],
5544
+ ): void {
5545
+ if (
5546
+ !ctx.save ||
5547
+ !ctx.restore ||
5548
+ !ctx.beginPath ||
5549
+ !ctx.rect ||
5550
+ !ctx.clip ||
5551
+ !('shadowBlur' in ctx)
5552
+ ) {
5553
+ layout.draw(ctx, left, top);
5554
+ return;
5555
+ }
5556
+ ctx.save();
5557
+ ctx.beginPath();
5558
+ for (const r of outsideOf(bands)) ctx.rect(r.x, r.y, r.width, r.height);
5559
+ ctx.clip();
5560
+ layout.draw(ctx, left, top);
5561
+ ctx.restore();
5562
+ let right = layout.width;
5563
+ for (const natural of layout.lines) {
5564
+ right = Math.max(right, natural.x + natural.width);
5565
+ }
5566
+ const shift = Math.ceil(left + right) + 1;
5567
+ const colors = new Map<string, SelectedBand[]>();
5568
+ for (const band of bands) {
5569
+ const color = band.style!.color!;
5570
+ const group = colors.get(color);
5571
+ if (group) group.push(band);
5572
+ else colors.set(color, [band]);
5573
+ }
5574
+ for (const [color, group] of colors) {
5575
+ ctx.save();
5576
+ ctx.beginPath();
5577
+ for (const r of group) ctx.rect(r.x, r.y, r.width, r.height);
5578
+ ctx.clip();
5579
+ ctx.shadowColor = color;
5580
+ ctx.shadowBlur = 0.01;
5581
+ ctx.shadowOffsetX = shift;
5582
+ ctx.shadowOffsetY = 0;
5583
+ layout.draw(ctx, left - shift, top);
5584
+ ctx.restore();
5585
+ }
5586
+ }
5587
+
5588
+ /** Everything but some rectangles, as rectangles: a strip between each two
5589
+ * of their edges, less what of it they cover. */
5590
+ function outsideOf(rects: readonly Rect[]): Rect[] {
5591
+ const far = COORD_LIMIT;
5592
+ const edges = new Set<number>();
5593
+ for (const r of rects) {
5594
+ edges.add(r.y);
5595
+ edges.add(r.y + r.height);
5596
+ }
5597
+ const ys = [...edges].sort((a, b) => a - b);
5598
+ const out: Rect[] = [
5599
+ { x: -far, y: -far, width: 2 * far, height: ys[0] + far },
5600
+ {
5601
+ x: -far,
5602
+ y: ys[ys.length - 1],
5603
+ width: 2 * far,
5604
+ height: far - ys[ys.length - 1],
5605
+ },
5606
+ ];
5607
+ for (let i = 0; i + 1 < ys.length; i += 1) {
5608
+ const y0 = ys[i];
5609
+ const y1 = ys[i + 1];
5610
+ const spans = rects
5611
+ .filter((r) => r.y <= y0 && r.y + r.height >= y1)
5612
+ .sort((a, b) => a.x - b.x);
5613
+ let x = -far;
5614
+ for (const r of spans) {
5615
+ if (r.x > x) out.push({ x, y: y0, width: r.x - x, height: y1 - y0 });
5616
+ x = Math.max(x, r.x + r.width);
5617
+ }
5618
+ if (x < far) out.push({ x, y: y0, width: far - x, height: y1 - y0 });
5619
+ }
5620
+ return out;
5621
+ }
5622
+
5623
+ /**
5624
+ * The rows a highlight over a line's text covers: the line box's, united
5625
+ * with the text's content area — its face's ascent above the baseline to
5626
+ * its descent below (CSS 2.1 10.6.1) — which is taller wherever
5627
+ * `line-height` is under the face's height. Blink unites the two in the
5628
+ * block direction (`ExpandSelectionRectToLineHeight`), so a selection over
5629
+ * a tall line fills it and one over tall glyphs on a short line covers
5630
+ * them: Zen Garden 215's 91px title on 20px lines had a band a fifth of
5631
+ * its letters' height through their middle.
5632
+ */
5633
+ export function selectionRows(
5634
+ line: LineBox,
5635
+ text: LineText,
5636
+ natural: { baseline: number; ascent?: number; descent?: number },
5637
+ ): { y: number; height: number } {
5638
+ const baseline = text.drawY + natural.baseline;
5639
+ const top = Math.min(line.y, baseline - (natural.ascent ?? 0));
5640
+ const bottom = Math.max(
5641
+ line.y + line.height,
5642
+ baseline + (natural.descent ?? 0),
5643
+ );
5644
+ return { y: top, height: bottom - top };
4935
5645
  }