@react-x11/components 0.10.0 → 0.11.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 (90) hide show
  1. package/dist/code/index.d.ts.map +1 -1
  2. package/dist/code/index.js +25 -24
  3. package/dist/code/index.js.map +1 -1
  4. package/dist/code-language/index.d.ts +1 -1
  5. package/dist/code-language/index.d.ts.map +1 -1
  6. package/dist/code-language/index.js +1 -1
  7. package/dist/code-language/index.js.map +1 -1
  8. package/dist/code-language/runs.d.ts +31 -0
  9. package/dist/code-language/runs.d.ts.map +1 -1
  10. package/dist/code-language/runs.js +105 -0
  11. package/dist/code-language/runs.js.map +1 -1
  12. package/dist/codeblock/index.d.ts +13 -0
  13. package/dist/codeblock/index.d.ts.map +1 -1
  14. package/dist/codeblock/index.js +37 -1
  15. package/dist/codeblock/index.js.map +1 -1
  16. package/dist/html/css/cascade.d.ts +32 -10
  17. package/dist/html/css/cascade.d.ts.map +1 -1
  18. package/dist/html/css/cascade.js +72 -27
  19. package/dist/html/css/cascade.js.map +1 -1
  20. package/dist/html/css/style.d.ts +7 -4
  21. package/dist/html/css/style.d.ts.map +1 -1
  22. package/dist/html/css/style.js +37 -6
  23. package/dist/html/css/style.js.map +1 -1
  24. package/dist/html/css/ua.js +8 -5
  25. package/dist/html/css/ua.js.map +1 -1
  26. package/dist/html/layout/block.d.ts.map +1 -1
  27. package/dist/html/layout/block.js +127 -9
  28. package/dist/html/layout/block.js.map +1 -1
  29. package/dist/html/layout/boxes.d.ts +27 -0
  30. package/dist/html/layout/boxes.d.ts.map +1 -1
  31. package/dist/html/layout/boxes.js +30 -2
  32. package/dist/html/layout/boxes.js.map +1 -1
  33. package/dist/html/layout/inline.d.ts +3 -0
  34. package/dist/html/layout/inline.d.ts.map +1 -1
  35. package/dist/html/layout/inline.js +236 -14
  36. package/dist/html/layout/inline.js.map +1 -1
  37. package/dist/html/node.d.ts.map +1 -1
  38. package/dist/html/node.js +118 -57
  39. package/dist/html/node.js.map +1 -1
  40. package/dist/html/paint.d.ts.map +1 -1
  41. package/dist/html/paint.js +203 -37
  42. package/dist/html/paint.js.map +1 -1
  43. package/dist/html/svg.d.ts.map +1 -1
  44. package/dist/html/svg.js +27 -20
  45. package/dist/html/svg.js.map +1 -1
  46. package/dist/internal/codelines.d.ts +42 -0
  47. package/dist/internal/codelines.d.ts.map +1 -0
  48. package/dist/internal/codelines.js +170 -0
  49. package/dist/internal/codelines.js.map +1 -0
  50. package/dist/markdown/index.d.ts.map +1 -1
  51. package/dist/markdown/index.js +98 -26
  52. package/dist/markdown/index.js.map +1 -1
  53. package/dist/rich-text-editor/index.d.ts.map +1 -1
  54. package/dist/rich-text-editor/index.js +3 -8
  55. package/dist/rich-text-editor/index.js.map +1 -1
  56. package/dist/rich-text-editor/markdown.d.ts.map +1 -1
  57. package/dist/rich-text-editor/markdown.js +30 -1
  58. package/dist/rich-text-editor/markdown.js.map +1 -1
  59. package/dist/rich-text-editor/replace.d.ts +13 -0
  60. package/dist/rich-text-editor/replace.d.ts.map +1 -0
  61. package/dist/rich-text-editor/replace.js +64 -0
  62. package/dist/rich-text-editor/replace.js.map +1 -0
  63. package/dist/terminal-output/index.d.ts.map +1 -1
  64. package/dist/terminal-output/index.js +100 -25
  65. package/dist/terminal-output/index.js.map +1 -1
  66. package/dist/terminal-output/runs.d.ts +4 -1
  67. package/dist/terminal-output/runs.d.ts.map +1 -1
  68. package/dist/terminal-output/runs.js +5 -1
  69. package/dist/terminal-output/runs.js.map +1 -1
  70. package/package.json +1 -1
  71. package/src/code/index.ts +34 -32
  72. package/src/code-language/index.ts +1 -1
  73. package/src/code-language/runs.ts +111 -1
  74. package/src/codeblock/index.ts +48 -1
  75. package/src/html/css/cascade.ts +117 -39
  76. package/src/html/css/style.ts +43 -15
  77. package/src/html/css/ua.ts +8 -5
  78. package/src/html/layout/block.ts +134 -8
  79. package/src/html/layout/boxes.ts +43 -2
  80. package/src/html/layout/inline.ts +257 -13
  81. package/src/html/node.ts +145 -79
  82. package/src/html/paint.ts +236 -45
  83. package/src/html/svg.ts +30 -19
  84. package/src/internal/codelines.ts +200 -0
  85. package/src/markdown/index.ts +145 -26
  86. package/src/rich-text-editor/index.ts +3 -11
  87. package/src/rich-text-editor/markdown.ts +36 -1
  88. package/src/rich-text-editor/replace.ts +85 -0
  89. package/src/terminal-output/index.ts +127 -34
  90. package/src/terminal-output/runs.ts +16 -2
package/src/html/paint.ts CHANGED
@@ -30,13 +30,13 @@ import { alphaOf, inkColor, isTransparent, resolve } from './css/values.js';
30
30
  import { blend, borderShades } from './css/color.js';
31
31
  import type { Len } from './css/values.js';
32
32
  import type {
33
+ BackgroundClip,
33
34
  BackgroundRepeat,
34
35
  BoxShadow,
35
36
  ComputedStyle,
36
37
  ImageRepeat,
37
38
  LinearGradient,
38
39
  RepeatMode,
39
- VisualBox,
40
40
  } from './css/style.js';
41
41
  import {
42
42
  CONTAIN_LAYOUT,
@@ -56,6 +56,7 @@ import {
56
56
  FADED_BLOCKS,
57
57
  PAINT_ORDER,
58
58
  INLINE_OFFSETS,
59
+ MOVED_OFF_LINES,
59
60
  SHADOWED_TEXT,
60
61
  SHIFTED_LINES,
61
62
  TEXT_RAISES,
@@ -401,7 +402,7 @@ export function paintDocument(
401
402
  ): void {
402
403
  if (!canFill(ctx)) return;
403
404
  ctx.save();
404
- const canvas = canvasBackground(tree.root);
405
+ const canvas = canvasBackground(tree);
405
406
  if (canvas) paintCanvas(ctx, canvas, tree.root, options);
406
407
  paintBox(ctx, tree.root, {
407
408
  ...options,
@@ -411,27 +412,51 @@ export function paintDocument(
411
412
  ctx.restore();
412
413
  }
413
414
 
415
+ /** What covers the canvas: a style's background, the box that would have
416
+ * painted it and now paints none, and the box its image is placed by. */
417
+ interface CanvasBackground {
418
+ style: ComputedStyle;
419
+ source: Box | null;
420
+ anchor: Box;
421
+ }
422
+
414
423
  /**
415
424
  * Whose background covers the canvas (CSS 2.1 14.2): the root element's,
416
425
  * or — where `<html>` has neither a colour nor an image — the first
417
426
  * `<body>`'s, which then paints no background of its own. A document with
418
427
  * no `<html>` tag has the root box standing in for its root element, and a
419
428
  * `<body>` in it is still the body: mail often starts at `<body style>`.
420
- * `anchor` is the box the image is positioned against: the root element's,
421
- * whichever box it came from.
429
+ * With neither tag, the root box stands in for the body, and the `<html>`
430
+ * around it is implied: an `html { … }` or a `:root { … }` background, which
431
+ * a test's reference sets on a document with no tags at all, has no box
432
+ * but the canvas to be painted on. `anchor` is the box the image is
433
+ * positioned against: the root element's, whichever box it came from.
422
434
  */
423
- function canvasBackground(root: Box): { source: Box; anchor: Box } | null {
424
- const has = (b: Box) =>
425
- !isTransparent(b.style.backgroundColor) ||
426
- !!b.style.backgroundImage ||
427
- !!b.style.backgroundGradient ||
428
- !!b.style.backgroundImages;
435
+ function canvasBackground(tree: BoxTree): CanvasBackground | null {
436
+ const root = tree.root;
437
+ const has = (s: ComputedStyle) =>
438
+ !isTransparent(s.backgroundColor) ||
439
+ !!s.backgroundImage ||
440
+ !!s.backgroundGradient ||
441
+ !!s.backgroundImages;
442
+ const implied = tree.impliedHtml;
443
+ if (implied) {
444
+ // an `<html>` that is not displayed has no background to give
445
+ if (implied.display === 'none') return null;
446
+ if (has(implied)) return { style: implied, source: null, anchor: root };
447
+ // containment on either keeps the body's to the body (CSS Containment 2)
448
+ if (implied.contain || root.style.contain) return null;
449
+ return has(root.style)
450
+ ? { style: root.style, source: root, anchor: root }
451
+ : null;
452
+ }
429
453
  const top = childNamed(root, 'html') ?? root;
430
- if (has(top)) return { source: top, anchor: top };
454
+ if (has(top.style)) return { style: top.style, source: top, anchor: top };
431
455
  const body = childNamed(top, 'body');
432
- // containment on either keeps the body's to the body (CSS Containment 2)
433
456
  if (!body || top.style.contain || body.style.contain) return null;
434
- return has(body) ? { source: body, anchor: top } : null;
457
+ return has(body.style)
458
+ ? { style: body.style, source: body, anchor: top }
459
+ : null;
435
460
  }
436
461
 
437
462
  /** A box's child element of that name, or one inside the anonymous boxes
@@ -450,7 +475,7 @@ function childNamed(box: Box, name: string): Box | null {
450
475
 
451
476
  function paintCanvas(
452
477
  ctx: PaintContext,
453
- { source, anchor }: { source: Box; anchor: Box },
478
+ { style: source, anchor }: CanvasBackground,
454
479
  root: Box,
455
480
  options: PaintOptions,
456
481
  ): void {
@@ -468,8 +493,8 @@ function paintCanvas(
468
493
  Math.ceil(whole.height),
469
494
  );
470
495
  if (!area) return;
471
- const layers = layersOf(source.style) ?? [source.style];
472
- const visible = source.style.visibility === 'visible';
496
+ const layers = layersOf(source) ?? [source];
497
+ const visible = source.visibility === 'visible';
473
498
  for (let i = layers.length - 1; i >= 0; i -= 1) {
474
499
  const style = layers[i];
475
500
  if (!isTransparent(style.backgroundColor) && visible) {
@@ -568,20 +593,37 @@ function paintLayers(
568
593
  image?: (layer: ComputedStyle) => void,
569
594
  ): void {
570
595
  // a background painted through the text is painted with it
571
- // (`paintClippedText`), and not as a box
572
- if (box.style.backgroundClipText) return;
573
- const layers = layersOf(box.style);
596
+ // (`paintClippedText`), and not as a box — but for what `border-area
597
+ // text` paints in the border as well
598
+ const style = box.style;
599
+ if (style.backgroundClipText && style.backgroundClip !== 'border-area') {
600
+ return;
601
+ }
602
+ const layers = layersOf(style);
574
603
  if (!layers) {
575
- paintBackground(ctx, box, options, box.style);
576
- if (image && box.style.backgroundImage) image(box.style);
604
+ paintLayer(ctx, box, options, style, image);
577
605
  return;
578
606
  }
579
607
  for (let i = layers.length - 1; i >= 0; i -= 1) {
580
- paintBackground(ctx, box, options, layers[i]);
581
- if (image && layers[i].backgroundImage) image(layers[i]);
608
+ paintLayer(ctx, box, options, layers[i], image);
582
609
  }
583
610
  }
584
611
 
612
+ function paintLayer(
613
+ ctx: PaintContext,
614
+ box: Frame,
615
+ options: PaintOptions,
616
+ layer: ComputedStyle,
617
+ image?: (layer: ComputedStyle) => void,
618
+ ): void {
619
+ // painted over the border box, inside what the border paints
620
+ const area = layer.backgroundClip === 'border-area';
621
+ if (area && !pushBorderArea(ctx, box, options)) return;
622
+ paintBackground(ctx, box, options, layer);
623
+ if (image && layer.backgroundImage) image(layer);
624
+ if (area) ctx.restore();
625
+ }
626
+
585
627
  /** A box's padding box in window coordinates. */
586
628
  function paddingBox(box: Box, options: PaintOptions): Rect {
587
629
  return {
@@ -837,22 +879,7 @@ function paintOwnBackground(
837
879
  const style = box.style;
838
880
  if (style.boxShadow) paintShadows(ctx, box, options, false);
839
881
  if (box !== options.canvasSource) {
840
- paintLayers(ctx, box, options, (layer) => {
841
- const painted = clipArea(box, options, layer, true);
842
- const area =
843
- painted &&
844
- clampRect(options, painted.x, painted.y, painted.w, painted.h);
845
- if (area) {
846
- paintBackgroundImage(
847
- ctx,
848
- layer,
849
- area,
850
- originBox(box, options, layer),
851
- options,
852
- painted.corners,
853
- );
854
- }
855
- });
882
+ paintLayers(ctx, box, options, frameImages(ctx, box, options));
856
883
  }
857
884
  if (style.boxShadow) paintShadows(ctx, box, options, true);
858
885
  if (!box.bordersCollapsed && !paintBorderImage(ctx, box, options)) {
@@ -861,6 +888,34 @@ function paintOwnBackground(
861
888
  if (box.kind === 'table') paintPartBackgrounds(ctx, box, options);
862
889
  }
863
890
 
891
+ /**
892
+ * How `paintLayers` draws a frame's image layers: each in the area its
893
+ * `background-clip` names, from the box its `background-origin` names
894
+ * (CSS Backgrounds 3, 3.7 and 3.8) — a block's, or an inline box's
895
+ * fragment's, whose background is its images as much as its colour.
896
+ */
897
+ function frameImages(
898
+ ctx: PaintContext,
899
+ frame: Frame,
900
+ options: PaintOptions,
901
+ ): (layer: ComputedStyle) => void {
902
+ return (layer) => {
903
+ const painted = clipArea(frame, options, layer, true);
904
+ const area =
905
+ painted && clampRect(options, painted.x, painted.y, painted.w, painted.h);
906
+ if (area) {
907
+ paintBackgroundImage(
908
+ ctx,
909
+ layer,
910
+ area,
911
+ originBox(frame, options, layer),
912
+ options,
913
+ painted.corners,
914
+ );
915
+ }
916
+ };
917
+ }
918
+
864
919
  /** How far past its shape a shadow's blur shows: three standard deviations,
865
920
  * the blur being two (CSS Backgrounds 3, 7.1.1). */
866
921
  function shadowReach(blur: number): number {
@@ -1634,12 +1689,13 @@ function spreadCorners(
1634
1689
  }
1635
1690
 
1636
1691
  /** How far in from a box's border box the box a `<visual-box>` names is:
1637
- * top, right, bottom and left. */
1692
+ * top, right, bottom and left — none for `border-area`, whose layer is
1693
+ * painted over the border box inside what the border paints. */
1638
1694
  function edgeInsets(
1639
1695
  box: Frame,
1640
- which: VisualBox,
1696
+ which: BackgroundClip,
1641
1697
  ): readonly [number, number, number, number] {
1642
- if (which === 'border-box') return NO_INSETS;
1698
+ if (which === 'border-box' || which === 'border-area') return NO_INSETS;
1643
1699
  const t = box.borderTop;
1644
1700
  const r = box.borderRight;
1645
1701
  const b = box.borderBottom;
@@ -3559,6 +3615,94 @@ function roundedRing(
3559
3615
  return true;
3560
3616
  }
3561
3617
 
3618
+ /**
3619
+ * Clip to what a box's border paints, for a layer of `background-clip:
3620
+ * border-area` (CSS Backgrounds 4, 2.1): each side's width and style, and
3621
+ * not its colour — a transparent border still has an area, which is what
3622
+ * the value is for. The shapes are the border painter's own, so that the
3623
+ * background shows exactly where that border would: the ring `roundedRing`
3624
+ * fills where a rounded border is solid, the ring the trapezoids of a 3D
3625
+ * style make, and otherwise the rectangles `fillEdge` fills a side at a
3626
+ * time — the dots, the dashes, the two lines of a double border. All of
3627
+ * them run clockwise and a ring's inside the other way, so the clip is the
3628
+ * union by the non-zero rule, the one every context's `clip` takes. False
3629
+ * where the border paints nothing or the context cannot clip, and nothing
3630
+ * is pushed.
3631
+ */
3632
+ function pushBorderArea(
3633
+ ctx: PaintContext,
3634
+ box: Frame,
3635
+ options: PaintOptions,
3636
+ ): boolean {
3637
+ const t = box.borderTop;
3638
+ const r = box.borderRight;
3639
+ const b = box.borderBottom;
3640
+ const l = box.borderLeft;
3641
+ if (!(t || r || b || l) || !ctx.beginPath || !ctx.rect || !ctx.clip) {
3642
+ return false;
3643
+ }
3644
+ const s = box.style;
3645
+ const left = box.x + options.originX;
3646
+ const top = frameY(box) + options.originY;
3647
+ const x = Math.round(left);
3648
+ const y = Math.round(top);
3649
+ const w = Math.round(left + box.width) - x;
3650
+ const h = Math.round(top + frameHeight(box)) - y;
3651
+ if (w <= 0 || h <= 0) return false;
3652
+ const area = clampRect(options, x, y, w, h);
3653
+ if (!area) return false;
3654
+ const corners = cornersOf(s, w, h);
3655
+ ctx.save();
3656
+ ctx.beginPath();
3657
+ if (corners && canCurve(ctx) && solidBorder(box)) {
3658
+ roundedRect(ctx, area.x, area.y, area.w, area.h, corners, true);
3659
+ const iw = area.w - l - r;
3660
+ const ih = area.h - t - b;
3661
+ if (iw > 0 && ih > 0) {
3662
+ const inner = insetCorners(corners, t, r, b, l);
3663
+ roundedRect(ctx, area.x + l, area.y + t, iw, ih, inner, true, true);
3664
+ }
3665
+ } else if (sculpted(s)) {
3666
+ // a band a side, which the trapezoids cover between them
3667
+ ctx.rect(area.x, area.y, area.w, t);
3668
+ ctx.rect(area.x, area.y + area.h - b, area.w, b);
3669
+ ctx.rect(area.x, area.y + t, l, area.h - t - b);
3670
+ ctx.rect(area.x + area.w - r, area.y + t, r, area.h - t - b);
3671
+ } else {
3672
+ const path = { fillRect: ctx.rect.bind(ctx) };
3673
+ const edge = (
3674
+ ex: number,
3675
+ ey: number,
3676
+ ew: number,
3677
+ eh: number,
3678
+ style: ComputedStyle['borderTopStyle'],
3679
+ horizontal: boolean,
3680
+ ): void => {
3681
+ const rect = clampRect(options, ex, ey, ew, eh);
3682
+ if (rect) fillEdge(path, rect, horizontal ? ex : ey, style, horizontal);
3683
+ };
3684
+ const between = h - t - b;
3685
+ if (t > 0) edge(x, y, w, t, s.borderTopStyle, true);
3686
+ if (b > 0) edge(x, y + h - b, w, b, s.borderBottomStyle, true);
3687
+ if (l > 0) edge(x, y + t, l, between, s.borderLeftStyle, false);
3688
+ if (r > 0) edge(x + w - r, y + t, r, between, s.borderRightStyle, false);
3689
+ }
3690
+ ctx.clip();
3691
+ return true;
3692
+ }
3693
+
3694
+ /** Whether every side a box has a border on is solid, which a rounded box
3695
+ * draws as one ring (`roundedRing`). */
3696
+ function solidBorder(box: Frame): boolean {
3697
+ const s = box.style;
3698
+ return (
3699
+ (!box.borderTop || s.borderTopStyle === 'solid') &&
3700
+ (!box.borderRight || s.borderRightStyle === 'solid') &&
3701
+ (!box.borderBottom || s.borderBottomStyle === 'solid') &&
3702
+ (!box.borderLeft || s.borderLeftStyle === 'solid')
3703
+ );
3704
+ }
3705
+
3562
3706
  /** The border styles drawn in two shades, as though lit from the top left. */
3563
3707
  const SCULPTED = new Set(['groove', 'ridge', 'inset', 'outset']);
3564
3708
 
@@ -3757,7 +3901,7 @@ function paintCollapsedBorders(
3757
3901
  }
3758
3902
 
3759
3903
  function fillEdge(
3760
- ctx: PaintContext,
3904
+ ctx: Pick<FillContext, 'fillRect'>,
3761
3905
  rect: { x: number; y: number; w: number; h: number },
3762
3906
  phaseOrigin: number,
3763
3907
  style: ComputedStyle['borderTopStyle'],
@@ -3994,6 +4138,17 @@ function paintLines(ctx: PaintContext, box: Box, options: PaintOptions): void {
3994
4138
  if (line.y >= bottom) break;
3995
4139
  if (line.y + line.height > top) visible.push(line);
3996
4140
  }
4141
+ // and a line the damage misses whose text or inline-block `position:
4142
+ // relative` moved into it: drawn by its line, it went undrawn with it
4143
+ const moved = MOVED_OFF_LINES.get(lines);
4144
+ if (moved && reachedOff(visible, moved, top, bottom)) {
4145
+ const drawn = new Set(visible);
4146
+ for (const line of moved) {
4147
+ if (movedInto(line, top, bottom)) drawn.add(line);
4148
+ }
4149
+ visible.length = 0;
4150
+ for (const line of lines) if (drawn.has(line)) visible.push(line);
4151
+ }
3997
4152
  } else {
3998
4153
  visible.push(...lines);
3999
4154
  }
@@ -4060,6 +4215,39 @@ function paintLines(ctx: PaintContext, box: Box, options: PaintOptions): void {
4060
4215
  }
4061
4216
  }
4062
4217
 
4218
+ /** Whether any of the lines moved content was taken off, and the damage
4219
+ * does not already draw, has it within the damage's rows. */
4220
+ function reachedOff(
4221
+ visible: readonly LineBox[],
4222
+ moved: ReadonlySet<LineBox>,
4223
+ top: number,
4224
+ bottom: number,
4225
+ ): boolean {
4226
+ for (const line of moved) {
4227
+ if (!visible.includes(line) && movedInto(line, top, bottom)) return true;
4228
+ }
4229
+ return false;
4230
+ }
4231
+
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 {
4236
+ for (const placed of line.atomics) {
4237
+ const box = placed.box;
4238
+ if (box.boundsY < bottom && box.boundsY + box.boundsHeight > top) {
4239
+ return true;
4240
+ }
4241
+ }
4242
+ for (const text of line.texts) {
4243
+ const natural = text.layout.lines[text.layoutLine];
4244
+ if (!natural) continue;
4245
+ const y = text.drawY + natural.y;
4246
+ if (y < bottom && y + natural.height > top) return true;
4247
+ }
4248
+ return false;
4249
+ }
4250
+
4063
4251
  /** An inline box's fragment whose padding or border reaches up over the
4064
4252
  * lines before its own, and where its own line starts. */
4065
4253
  interface Bleed {
@@ -4091,7 +4279,7 @@ function paintBleeds(
4091
4279
  ctx.beginPath();
4092
4280
  ctx.rect(left, top, Math.ceil(fragment.width) + 2, bottom - top);
4093
4281
  ctx.clip();
4094
- paintLayers(ctx, fragment, options);
4282
+ paintLayers(ctx, fragment, options, frameImages(ctx, fragment, options));
4095
4283
  paintBorders(ctx, fragment, options);
4096
4284
  ctx.restore();
4097
4285
  }
@@ -4615,7 +4803,10 @@ function paintInlineBoxes(
4615
4803
  style,
4616
4804
  };
4617
4805
  if (fragment.width <= 0) continue;
4618
- paintLayers(ctx, fragment, options);
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
4809
+ paintLayers(ctx, fragment, options, frameImages(ctx, fragment, options));
4619
4810
  paintBorders(ctx, fragment, options);
4620
4811
  if (box.style.outlineStyle !== 'none') paintOutline(ctx, fragment, options);
4621
4812
  if (bleeds && fragment.y < line.y - 0.5) {
package/src/html/svg.ts CHANGED
@@ -83,24 +83,32 @@ export function svgSizeHint(value: string): string | null {
83
83
  return m[2] ? v : `${parseFloat(m[1])}px`;
84
84
  }
85
85
 
86
- /** A percentage length as a fraction, or 1 for anything else. */
87
- function percentOf(value: string | undefined): number {
88
- const v = value?.trim();
89
- if (!v?.endsWith('%')) return 1;
90
- const n = parseFloat(v);
91
- return Number.isFinite(n) && n >= 0 ? n / 100 : 1;
92
- }
93
-
94
- function viewBoxOf(el: Element): [number, number, number, number] | null {
86
+ function viewBoxParts(el: Element): [number, number, number, number] | null {
95
87
  const parts = (svgAttr(el, 'viewBox') ?? '')
96
88
  .trim()
97
89
  .split(/[\s,]+/)
98
90
  .map(Number);
99
91
  if (parts.length !== 4 || !parts.every(Number.isFinite)) return null;
100
- // a zero or negative extent is an error: as though there were none
101
- return parts[2] > 0 && parts[3] > 0
102
- ? [parts[0], parts[1], parts[2], parts[3]]
103
- : null;
92
+ return [parts[0], parts[1], parts[2], parts[3]];
93
+ }
94
+
95
+ function viewBoxOf(el: Element): [number, number, number, number] | null {
96
+ const parts = viewBoxParts(el);
97
+ // a zero or negative extent: as though there were none (see `drawsNothing`)
98
+ return parts && parts[2] > 0 && parts[3] > 0 ? parts : null;
99
+ }
100
+
101
+ /** Whether a `viewBox` with a zero width or height disables the drawing,
102
+ * as SVG has it: a negative one is an error and as though there were no
103
+ * `viewBox`, but a zero one is a drawing of nothing. */
104
+ function drawsNothing(el: Element): boolean {
105
+ const parts = viewBoxParts(el);
106
+ return (
107
+ parts !== null &&
108
+ parts[2] >= 0 &&
109
+ parts[3] >= 0 &&
110
+ (parts[2] === 0 || parts[3] === 0)
111
+ );
104
112
  }
105
113
 
106
114
  /**
@@ -210,6 +218,7 @@ export class SvgDrawing {
210
218
  // the mock backend has no path API, and SvgView draws paths
211
219
  if (!ctx.beginPath || !ctx.rect || !ctx.clip || !ctx.fill) return;
212
220
  const root = this._root;
221
+ if (drawsNothing(root)) return;
213
222
  if (this._standalone) {
214
223
  // the root's background is the canvas's, and an image's canvas is
215
224
  // all of the rectangle, wherever its viewport and `viewBox` put the
@@ -222,12 +231,14 @@ export class SvgDrawing {
222
231
  ctx.fillRect(x, y, w, h);
223
232
  ctx.restore();
224
233
  }
225
- // an image's root is sized in the rectangle it is drawn into, which
226
- // is its viewport: `width="40%"` is two fifths of it. An inline one's
227
- // percentages are its box's, already (`svgSizeHint`).
228
- w *= percentOf(svgAttr(root, 'width'));
229
- h *= percentOf(svgAttr(root, 'height'));
230
- if (!(w > 0 && h > 0)) return;
234
+ // An image's root fills the rectangle it is drawn into, whatever its
235
+ // own `width` and `height` say: they are what its intrinsic size was
236
+ // read from (`svgIntrinsics`), and the rectangle is the concrete size
237
+ // the image was then given — a background's tile, an `<img>`'s box.
238
+ // Blink sizes an SVG embedded as an image to its container the same
239
+ // way. Drawn as two fifths of it, `width="40%"` left the rest of a
240
+ // `background-size: contain` empty. An inline root's percentages are
241
+ // its box's, already (`svgSizeHint`).
231
242
  }
232
243
  const box = viewBoxOf(root);
233
244
  // the viewport in user units, which a percentage is of
@@ -0,0 +1,200 @@
1
+ // Code drawn as a column of blocks of lines, under `<Code>` and
2
+ // `<Markdown>`'s fences.
3
+ //
4
+ // One `<richtext>` for a whole block of code is laid out whole whenever any
5
+ // of it changes, and a code block that streams — a model writing a fence a
6
+ // token at a time, a log of source — changes at its end every time: 0.4 s
7
+ // for a line appended to 5,000 in either component. As blocks, a change
8
+ // lays out the blocks it touches. The runs are tokenized afresh on every
9
+ // change, so a block is kept by what it holds rather than by identity: while
10
+ // its runs are the same text in the same styles, its element is the one
11
+ // already drawn.
12
+ import React from 'react';
13
+ import type { ReactElement } from 'react';
14
+ import type { Style } from 'react-x11/style';
15
+
16
+ import { RICHTEXT_ELEMENT } from '../richtext/index.js';
17
+ import type { RichTextProps, TextRun } from '../richtext/index.js';
18
+
19
+ const h = React.createElement;
20
+
21
+ /** Lines a block of code holds. */
22
+ export const CODE_BLOCK_LINES = 256;
23
+
24
+ /**
25
+ * Runs split into blocks of `lines` whole lines: the text cut at every
26
+ * `lines`th newline, that newline left out, so the blocks stacked are the
27
+ * text again. A newline that ends the whole text stays in its block, where
28
+ * it draws the empty last line it always drew.
29
+ */
30
+ export function codeBlocks(
31
+ runs: readonly TextRun[],
32
+ lines = CODE_BLOCK_LINES,
33
+ ): TextRun[][] {
34
+ let last = runs.length - 1;
35
+ while (last >= 0 && runs[last]!.text === '') last--;
36
+ const blocks: TextRun[][] = [];
37
+ let block: TextRun[] = [];
38
+ let count = 0;
39
+ for (let r = 0; r < runs.length; r++) {
40
+ const run = runs[r]!;
41
+ let from = 0;
42
+ let search = 0;
43
+ for (;;) {
44
+ const nl = run.text.indexOf('\n', search);
45
+ if (nl < 0) break;
46
+ search = nl + 1;
47
+ if (++count < lines) continue;
48
+ // the text's own last newline is a line of its own, and stays
49
+ if (r === last && nl === run.text.length - 1) break;
50
+ if (nl > from) block.push(piece(run, from, nl));
51
+ blocks.push(block);
52
+ block = [];
53
+ count = 0;
54
+ from = nl + 1;
55
+ }
56
+ if (from < run.text.length) {
57
+ block.push(from === 0 ? run : piece(run, from, run.text.length));
58
+ }
59
+ }
60
+ blocks.push(block);
61
+ return blocks;
62
+ }
63
+
64
+ function piece(run: TextRun, from: number, to: number): TextRun {
65
+ return { ...run, text: run.text.slice(from, to) };
66
+ }
67
+
68
+ /** Whether two runs are the same text in the same style, field for field. */
69
+ function sameRun(a: TextRun, b: TextRun): boolean {
70
+ if (a === b) return true;
71
+ const x = a as unknown as Record<string, unknown>;
72
+ const y = b as unknown as Record<string, unknown>;
73
+ for (const key in x) if (x[key] !== y[key]) return false;
74
+ for (const key in y) if (!(key in x) && y[key] !== undefined) return false;
75
+ return true;
76
+ }
77
+
78
+ function sameRuns(a: readonly TextRun[], b: readonly TextRun[]): boolean {
79
+ if (a.length !== b.length) return false;
80
+ for (let i = 0; i < a.length; i++) if (!sameRun(a[i]!, b[i]!)) return false;
81
+ return true;
82
+ }
83
+
84
+ function sameStyle(a: Style, b: Style): boolean {
85
+ return sameRun(a as unknown as TextRun, b as unknown as TextRun);
86
+ }
87
+
88
+ export interface CodeLinesProps {
89
+ runs: readonly TextRun[];
90
+ /** Every block's style, compared by value: a style object built afresh
91
+ * each render keeps the blocks. */
92
+ style: Style;
93
+ /** `false` lays each block out unwrapped, as `<richtext>` takes it. */
94
+ wrap?: boolean;
95
+ }
96
+
97
+ /** The blocks' elements from the last render, and what they were built of. */
98
+ interface Kept {
99
+ style: Style;
100
+ wrap: boolean | undefined;
101
+ blocks: TextRun[][];
102
+ elements: ReactElement[];
103
+ }
104
+
105
+ /**
106
+ * The runs of a block of code, drawn as blocks of lines (`codeBlocks`),
107
+ * each a `<richtext>` kept while its runs are the same. A selection runs
108
+ * across the blocks and copies as one text, since core joins text elements
109
+ * stacked one above the other with a newline.
110
+ */
111
+ export function CodeLines(props: CodeLinesProps): ReactElement {
112
+ const { style, wrap } = props;
113
+ const kept = React.useRef<Kept | null>(null);
114
+ const was =
115
+ kept.current &&
116
+ kept.current.wrap === wrap &&
117
+ sameStyle(kept.current.style, style)
118
+ ? kept.current
119
+ : null;
120
+ const blocks = codeBlocks(props.runs);
121
+ const elements = blocks.map((block, i) => {
122
+ const before = was?.blocks[i];
123
+ if (before && sameRuns(before, block)) {
124
+ blocks[i] = before;
125
+ return was.elements[i]!;
126
+ }
127
+ const blockProps: RichTextProps = { runs: block, style };
128
+ if (wrap === false) blockProps.wrap = false;
129
+ return h(RICHTEXT_ELEMENT, {
130
+ key: i,
131
+ ...blockProps,
132
+ } as Record<string, unknown>);
133
+ });
134
+ kept.current = { style, wrap, blocks, elements };
135
+ return h(React.Fragment, null, ...elements);
136
+ }
137
+
138
+ export interface CodeGutterProps {
139
+ /** Lines in the code beside it. */
140
+ lines: number;
141
+ /** How a number is set: the code's family and size, a dimmer colour. */
142
+ run: Omit<TextRun, 'text'>;
143
+ style: Style;
144
+ }
145
+
146
+ /**
147
+ * The line numbers beside `CodeLines`, blocked the same way so that the two
148
+ * stay in register a block at a time, and a number not rebuilt for a line
149
+ * appended far below it. Each number is followed by a newline, the last as
150
+ * well, as the gutter always was.
151
+ */
152
+ export function CodeGutter(props: CodeGutterProps): ReactElement {
153
+ const { lines, run, style } = props;
154
+ const kept = React.useRef<{
155
+ run: Omit<TextRun, 'text'>;
156
+ style: Style;
157
+ byStart: Map<string, ReactElement>;
158
+ } | null>(null);
159
+ if (
160
+ !kept.current ||
161
+ !sameRun(kept.current.run as TextRun, run as TextRun) ||
162
+ !sameStyle(kept.current.style, style)
163
+ ) {
164
+ kept.current = { run, style, byStart: new Map() };
165
+ }
166
+ const byStart = kept.current.byStart;
167
+ const elements: ReactElement[] = [];
168
+ const used = new Set<string>();
169
+ for (let start = 0; start < lines || start === 0; start += CODE_BLOCK_LINES) {
170
+ const end = Math.min(start + CODE_BLOCK_LINES, lines);
171
+ const key = `${start}:${end}`;
172
+ used.add(key);
173
+ let element = byStart.get(key);
174
+ if (!element) {
175
+ const runs: TextRun[] = [];
176
+ for (let i = start; i < end; i++) {
177
+ runs.push({ ...run, text: `${i + 1}\n` } as TextRun);
178
+ }
179
+ // the newline that ends a block is the gap to the next one
180
+ if (end < lines && runs.length > 0) {
181
+ const tail = runs[runs.length - 1]!;
182
+ runs[runs.length - 1] = { ...tail, text: tail.text.slice(0, -1) };
183
+ }
184
+ element = h(RICHTEXT_ELEMENT, {
185
+ key: start,
186
+ // The numbering is chrome, not text: `selectable={false}` keeps it
187
+ // out of a drag and out of the copied text.
188
+ selectable: false,
189
+ runs,
190
+ wrap: false,
191
+ style,
192
+ } as Record<string, unknown>);
193
+ byStart.set(key, element);
194
+ }
195
+ elements.push(element);
196
+ if (lines === 0) break;
197
+ }
198
+ for (const key of byStart.keys()) if (!used.has(key)) byStart.delete(key);
199
+ return h(React.Fragment, null, ...elements);
200
+ }