@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
@@ -62,7 +62,7 @@ import {
62
62
  import type { RichTextProps, TextRun } from '../richtext/index.js';
63
63
  import {
64
64
  codeBlockLook,
65
- codeBlockRuns,
65
+ CodeBlockRunCache,
66
66
  codeBlockStyle,
67
67
  codeTextStyle,
68
68
  } from '../codeblock/index.js';
@@ -77,6 +77,7 @@ import { runsOf, plainTextOf } from './spans.js';
77
77
  import type { InlineStyles } from './spans.js';
78
78
  import { useLinkClicks } from '../richtext/index.js';
79
79
  import { hx } from './hx.js';
80
+ import { CodeLines } from '../internal/codelines.js';
80
81
  import { useFontPrewarm } from '../internal/prewarm.js';
81
82
 
82
83
  export type {
@@ -499,24 +500,57 @@ function renderCode(
499
500
  custom({ lang, text, partial: ctx.live === true }),
500
501
  );
501
502
  }
502
- const code = ctx.look.code;
503
- const runs = codeBlockRuns(text, code, {
503
+ return h(CodeFence, {
504
+ key,
505
+ text,
504
506
  lang,
507
+ look: ctx.look.code,
505
508
  highlight: ctx.look.highlight,
506
- ...(ctx.resolveLanguage ? { resolveLanguage: ctx.resolveLanguage } : null),
509
+ resolveLanguage: ctx.resolveLanguage,
510
+ });
511
+ }
512
+
513
+ interface CodeFenceProps {
514
+ text: string;
515
+ lang: string;
516
+ look: CodeBlockLook;
517
+ highlight: boolean;
518
+ resolveLanguage?: MarkdownProps['resolveLanguage'];
519
+ }
520
+
521
+ /**
522
+ * A fenced block of code. A component rather than a render function so
523
+ * that it keeps a tokenizer between renders (`CodeBlockRunCache`): a fence
524
+ * being streamed is tokenized again from the line that changed, where it
525
+ * was tokenized whole for every token a model wrote.
526
+ */
527
+ function CodeFence(props: CodeFenceProps): ReactElement {
528
+ const { text, lang, look, highlight, resolveLanguage } = props;
529
+ const cache = React.useRef<CodeBlockRunCache | null>(null);
530
+ cache.current ??= new CodeBlockRunCache();
531
+ React.useEffect(() => () => cache.current?.dispose(), []);
532
+ const runs = cache.current.runs(text, look, {
533
+ lang,
534
+ highlight,
535
+ ...(resolveLanguage ? { resolveLanguage } : null),
507
536
  });
508
537
  // The block scrolls horizontally rather than wrapping — code is code.
509
538
  return hx(
510
539
  'box',
511
540
  {
512
- key,
513
541
  style: {
514
- ...codeBlockStyle(code),
542
+ ...codeBlockStyle(look),
515
543
  overflow: 'scroll',
516
544
  flexDirection: 'column',
517
545
  },
518
546
  },
519
- richtext('code', runs, codeTextStyle(code, false), false),
547
+ // in blocks of lines, so a fence that streams lays out the block its
548
+ // new lines land in (`../internal/codelines.ts`)
549
+ h(CodeLines, {
550
+ runs,
551
+ style: codeTextStyle(look, false),
552
+ wrap: false,
553
+ }),
520
554
  );
521
555
  }
522
556
 
@@ -609,35 +643,100 @@ function renderTable(
609
643
  ctx: RenderCtx,
610
644
  key: number,
611
645
  ): ReactNode {
612
- const look = ctx.look;
646
+ return h(MarkdownTable, { key, table, look: ctx.look, fonts: ctx.fonts });
647
+ }
648
+
649
+ interface MarkdownTableProps {
650
+ table: TableBlock;
651
+ look: Look;
652
+ fonts: FontsMeasureLike | null;
653
+ }
654
+
655
+ /** What a table keeps between renders. */
656
+ interface TableKept {
657
+ look: Look;
658
+ fonts: FontsMeasureLike | null;
659
+ /** Each cell's measured width, by what the cell holds. */
660
+ widthOf: Map<string, number>;
661
+ /** The column widths the rows were built at. */
662
+ widths: string;
663
+ /** Each row's element, by where it is and what it holds. */
664
+ rows: Map<string, ReactElement>;
665
+ }
666
+
667
+ /**
668
+ * A table. A component rather than a render function so that it keeps its
669
+ * rows between renders: a table that streams is a block whose source
670
+ * changes with every row, and it was measured cell by cell and rebuilt row
671
+ * by row for each — 115 ms a row at 800 rows, laying every row out again.
672
+ * Now a cell is measured once for what it holds, and a row keeps its
673
+ * element while it holds the same cells at the same column widths.
674
+ */
675
+ function MarkdownTable(props: MarkdownTableProps): ReactElement {
676
+ const { table, look, fonts } = props;
613
677
  const size = look.inline.size;
614
678
  const padX = Math.round(size * 0.6);
615
679
  const padY = Math.round(size * 0.35);
616
680
  const cols = table.align.length;
617
681
 
682
+ const keptRef = React.useRef<TableKept | null>(null);
683
+ if (
684
+ !keptRef.current ||
685
+ keptRef.current.look !== look ||
686
+ keptRef.current.fonts !== fonts
687
+ ) {
688
+ keptRef.current = {
689
+ look,
690
+ fonts,
691
+ widthOf: new Map(),
692
+ widths: '',
693
+ rows: new Map(),
694
+ };
695
+ }
696
+ const kept = keptRef.current;
697
+
618
698
  // Column widths: max-content per column, measured through the app's font
619
699
  // manager, capped so one long cell cannot starve the rest. Without a
620
700
  // font manager (the mock backend) an even fallback keeps the shape.
621
701
  const widths: number[] = new Array<number>(cols).fill(0);
622
- const headStyles = inlineStyles(ctx, { weight: 700 });
623
- const measure = (cells: InlineNode[][], s: InlineStyles): void => {
702
+ const headStyles: InlineStyles = { ...look.inline, weight: 700 };
703
+ const widthOf = new Map<string, number>();
704
+ const measure = (
705
+ cells: InlineNode[][],
706
+ s: InlineStyles,
707
+ head: boolean,
708
+ ): string[] => {
709
+ const keys: string[] = [];
624
710
  for (let c = 0; c < cols; c += 1) {
625
- let w: number;
626
- if (ctx.fonts) {
627
- w = ctx.fonts.layout(runsOf(cells[c], s), {
628
- family: s.family,
629
- size: s.size,
630
- }).width;
631
- } else {
632
- w = plainTextOf(cells[c]).length * size * 0.55;
711
+ const cellKey = `${head ? 'h' : 'b'}${JSON.stringify(cells[c])}`;
712
+ keys.push(cellKey);
713
+ let w = widthOf.get(cellKey) ?? kept.widthOf.get(cellKey);
714
+ if (w === undefined) {
715
+ if (fonts) {
716
+ w = fonts.layout(runsOf(cells[c], s), {
717
+ family: s.family,
718
+ size: s.size,
719
+ }).width;
720
+ } else {
721
+ w = plainTextOf(cells[c]).length * size * 0.55;
722
+ }
633
723
  }
724
+ widthOf.set(cellKey, w);
634
725
  widths[c] = Math.max(widths[c], Math.min(Math.ceil(w), size * 26));
635
726
  }
727
+ return keys;
636
728
  };
637
- measure(table.header, headStyles);
638
- for (const row of table.rows) measure(row, look.inline);
729
+ const headKeys = measure(table.header, headStyles, true);
730
+ const rowKeys = table.rows.map((row) => measure(row, look.inline, false));
731
+ kept.widthOf = widthOf;
639
732
  for (let c = 0; c < cols; c += 1)
640
733
  widths[c] = Math.max(widths[c] + padX * 2 + 2, size * 2.5);
734
+ // a column that grew is every row laid out again
735
+ const widthsKey = `${widths.join(',')}|${table.align.join(',')}`;
736
+ if (widthsKey !== kept.widths) {
737
+ kept.widths = widthsKey;
738
+ kept.rows = new Map();
739
+ }
641
740
 
642
741
  // Nothing here says "these are cells and those are rows": a copy reads the
643
742
  // boxes below, where two cells share a band of pixels and the next row
@@ -648,7 +747,7 @@ function renderTable(
648
747
  rowKey: string | number,
649
748
  bg?: string,
650
749
  divided?: boolean,
651
- ): ReactNode =>
750
+ ): ReactElement =>
652
751
  hx(
653
752
  'box',
654
753
  {
@@ -676,17 +775,37 @@ function renderTable(
676
775
  ),
677
776
  );
678
777
 
679
- const children: ReactNode[] = [];
680
- children.push(renderRow(table.header, headStyles, 'h', look.headerBg));
681
- table.rows.forEach((row, r) => {
682
- children.push(renderRow(row, look.inline, r, undefined, true));
778
+ const rows = new Map<string, ReactElement>();
779
+ const row = (
780
+ id: string,
781
+ cellKeys: string[],
782
+ make: () => ReactElement,
783
+ ): ReactElement => {
784
+ const rowKey = `${id}|${cellKeys.join('|')}`;
785
+ const element = kept.rows.get(rowKey) ?? make();
786
+ rows.set(rowKey, element);
787
+ return element;
788
+ };
789
+ const children: ReactElement[] = [];
790
+ children.push(
791
+ row('h', headKeys, () =>
792
+ renderRow(table.header, headStyles, 'h', look.headerBg),
793
+ ),
794
+ );
795
+ table.rows.forEach((cells, r) => {
796
+ children.push(
797
+ row(String(r), rowKeys[r]!, () =>
798
+ renderRow(cells, look.inline, r, undefined, true),
799
+ ),
800
+ );
683
801
  });
802
+ kept.rows = rows;
684
803
 
685
804
  // A wide table scrolls inside its own viewport instead of forcing the
686
805
  // document wider (the root stays vertical-only).
687
806
  return hx(
688
807
  'box',
689
- { key, style: { overflow: 'scroll', flexDirection: 'column' } },
808
+ { style: { overflow: 'scroll', flexDirection: 'column' } },
690
809
  hx(
691
810
  'box',
692
811
  {
@@ -53,7 +53,7 @@ import type { Style } from 'react-x11/style';
53
53
  import { redo, undo } from 'prosemirror-history';
54
54
  import { Fragment, Slice } from 'prosemirror-model';
55
55
  import type { Node as PMNode, Schema } from 'prosemirror-model';
56
- import { EditorState, Selection } from 'prosemirror-state';
56
+ import { EditorState } from 'prosemirror-state';
57
57
  import type { Command, Plugin, Transaction } from 'prosemirror-state';
58
58
  import type { EditorProps, EditorView } from 'prosemirror-view';
59
59
 
@@ -85,6 +85,7 @@ import { docFromText, markdownCodec, textFromDoc } from './markdown.js';
85
85
  import type { MarkdownCodec } from './markdown.js';
86
86
  import { registerEditorElements, ROOT_ELEMENT, TEXT_ELEMENT } from './nodes.js';
87
87
  import { renderBlockRange, renderBlocks } from './render.js';
88
+ import { replaceDocumentTr } from './replace.js';
88
89
  import type { ImageInfo, NodeViewProps, RenderContext } from './render.js';
89
90
  import type { RunStyle } from './inline.js';
90
91
  import { schema as defaultSchema } from './schema.js';
@@ -338,16 +339,7 @@ function replaceDocument(
338
339
  doc: PMNode,
339
340
  addToHistory: boolean,
340
341
  ): void {
341
- const { state } = view;
342
- const tr = state.tr.replaceWith(0, state.doc.content.size, doc.content);
343
- // keep the caret about where it was, inside what is there now
344
- tr.setSelection(
345
- Selection.near(
346
- tr.doc.resolve(Math.min(state.selection.head, tr.doc.content.size)),
347
- ),
348
- );
349
- if (!addToHistory) tr.setMeta('addToHistory', false);
350
- view.dispatch(tr);
342
+ view.dispatch(replaceDocumentTr(view.state, doc, addToHistory));
351
343
  }
352
344
 
353
345
  function inListOrCode(state: EditorState): boolean {
@@ -27,6 +27,7 @@ import { parse } from '../internal/markdown/parse.js';
27
27
  import { stringify } from '../internal/markdown/stringify.js';
28
28
  import type {
29
29
  BlockNode,
30
+ Document,
30
31
  InlineNode,
31
32
  LinkInline,
32
33
  ListItem,
@@ -43,11 +44,45 @@ export interface MarkdownCodec {
43
44
  /** The codec for `schema`: the reference names, TipTap's, or both. */
44
45
  export function markdownCodec(schema: Schema): MarkdownCodec {
45
46
  return {
46
- parse: (source) => docFromMarkdown(schema, source),
47
+ parse: markdownReader(schema),
47
48
  serialize: (doc) => markdownFromDoc(doc),
48
49
  };
49
50
  }
50
51
 
52
+ /**
53
+ * `docFromMarkdown` for a document read again and again: a value that
54
+ * streams in, or a controlled value an app keeps handing over. Each parse
55
+ * resumes from the last one (`ParseOptions.previous`), which hands back
56
+ * the blocks it did not have to read again as the same objects, and a
57
+ * block seen before becomes the nodes it became then. So a word streamed
58
+ * into a long document converts the paragraph it lands in, and the
59
+ * document's other blocks are the nodes the editor already holds.
60
+ */
61
+ function markdownReader(schema: Schema): (source: string) => PMNode {
62
+ let last: Document | null = null;
63
+ const converted = new WeakMap<BlockNode, PMNode[]>();
64
+ return (source) => {
65
+ const ast = parse(
66
+ source,
67
+ last ? { partial: false, previous: last } : { partial: false },
68
+ );
69
+ last = ast;
70
+ const blocks: PMNode[] = [];
71
+ for (const block of ast.blocks) {
72
+ let nodes = converted.get(block);
73
+ if (!nodes) {
74
+ nodes = blockToNodes(schema, block);
75
+ converted.set(block, nodes);
76
+ }
77
+ for (const node of nodes) blocks.push(node);
78
+ }
79
+ return (
80
+ schema.topNodeType.createAndFill(null, blocks) ??
81
+ schema.topNodeType.createAndFill()!
82
+ );
83
+ };
84
+ }
85
+
51
86
  // --- names -------------------------------------------------------------------
52
87
 
53
88
  /** The spellings each thing goes by: ProseMirror's reference schemas first,
@@ -0,0 +1,85 @@
1
+ // A document handed in from outside — a controlled `value`, the handle's
2
+ // `setValue` — put into the editor.
3
+ //
4
+ // Replacing the whole document with it made every block a new node: each
5
+ // one was keyed again and drawn again, and a plugin that walks what a
6
+ // transaction changed (prosemirror-tables' `fixTables`) walked all of it. A
7
+ // value that streams in — a model writing into the editor — did that for
8
+ // every word it gained: 75 ms a word at 3,200 paragraphs. Pure: no React,
9
+ // and `test/rich-text-editor-model.test.ts` asks it the rest.
10
+ import type { Node as PMNode } from 'prosemirror-model';
11
+ import { Selection } from 'prosemirror-state';
12
+ import type { EditorState, Transaction } from 'prosemirror-state';
13
+
14
+ /**
15
+ * The transaction that makes `state`'s document `doc`: a reset, as it
16
+ * always was. To the history, the selection and every plugin that maps a
17
+ * position through it, the whole document is replaced — so an undo reaches
18
+ * nothing from before, the draft an app just cleared included. What changes
19
+ * is which nodes the new document is made of: where it is the same as the
20
+ * old one, it is the old one's nodes, so the blocks the value left alone
21
+ * keep their keys and their drawn elements.
22
+ */
23
+ export function replaceDocumentTr(
24
+ state: EditorState,
25
+ doc: PMNode,
26
+ addToHistory: boolean,
27
+ ): Transaction {
28
+ // First the document as itself, over the whole of it: the step the
29
+ // history and the mapping see, the one they always saw — every position
30
+ // inside the document replaced. It puts back the node objects that were
31
+ // there, so nothing is drawn again for it.
32
+ const tr = state.tr.replaceWith(0, state.doc.content.size, state.doc.content);
33
+ // Then the change, in steps made against the same document.
34
+ for (const step of changedPart(state, doc)?.steps ?? []) tr.step(step);
35
+ // keep the caret about where it was, inside what is there now
36
+ tr.setSelection(
37
+ Selection.near(
38
+ tr.doc.resolve(Math.min(state.selection.head, tr.doc.content.size)),
39
+ ),
40
+ );
41
+ if (!addToHistory) tr.setMeta('addToHistory', false);
42
+ return tr;
43
+ }
44
+
45
+ /** A transaction replacing only where `doc` differs from `state`'s
46
+ * document; null when it does not. */
47
+ function changedPart(state: EditorState, doc: PMNode): Transaction | null {
48
+ const before = state.doc.content;
49
+ const start = before.findDiffStart(doc.content);
50
+ if (start === null) return null;
51
+ let { a: endA, b: endB } = before.findDiffEnd(doc.content)!;
52
+ // Where the change repeats what is beside it ("aa" to "aaa"), the ends
53
+ // found from the back reach past the start found from the front: move
54
+ // both ends on by the overlap, over content the two documents share.
55
+ const overlap = start - Math.min(endA, endB);
56
+ if (overlap > 0) {
57
+ endA += overlap;
58
+ endB += overlap;
59
+ }
60
+ let tr = state.tr.replace(start, endA, doc.slice(start, endB));
61
+ if (tr.doc.eq(doc)) return tr;
62
+ // A slice that opens inside a table can be fitted into another shape than
63
+ // the value's. Whole blocks always fit between the ones the two documents
64
+ // share: the top-level blocks the change reaches, then.
65
+ const from = blockStart(state.doc, start);
66
+ tr = state.tr.replaceWith(
67
+ from,
68
+ blockEnd(state.doc, endA),
69
+ doc.content.cut(from, blockEnd(doc, endB)),
70
+ );
71
+ if (tr.doc.eq(doc)) return tr;
72
+ return state.tr.replaceWith(0, state.doc.content.size, doc.content);
73
+ }
74
+
75
+ /** Where the top-level block `pos` is in starts; `pos` itself between two. */
76
+ function blockStart(doc: PMNode, pos: number): number {
77
+ const $pos = doc.resolve(pos);
78
+ return $pos.depth === 0 ? pos : $pos.before(1);
79
+ }
80
+
81
+ /** Where the top-level block `pos` is in ends; `pos` itself between two. */
82
+ function blockEnd(doc: PMNode, pos: number): number {
83
+ const $pos = doc.resolve(pos);
84
+ return $pos.depth === 0 ? pos : $pos.after(1);
85
+ }
@@ -32,6 +32,7 @@ import { ansiPalette, parseAnsi } from '../ansi/index.js';
32
32
  import type {
33
33
  AnsiDocument,
34
34
  AnsiInput,
35
+ AnsiLine,
35
36
  AnsiPaletteOptions,
36
37
  } from '../ansi/index.js';
37
38
  import {
@@ -40,13 +41,13 @@ import {
40
41
  useLinkClicks,
41
42
  useSelectionMenu,
42
43
  } from '../richtext/index.js';
43
- import type { RichTextProps, TextRun } from '../richtext/index.js';
44
+ import type { RichTextProps } from '../richtext/index.js';
44
45
  import {
45
46
  codeBlockLook,
46
47
  codeBlockStyle,
47
48
  codeTextStyle,
48
49
  } from '../codeblock/index.js';
49
- import { documentRuns } from './runs.js';
50
+ import { linesRuns } from './runs.js';
50
51
  import { hx } from './hx.js';
51
52
  import { useFontPrewarm } from '../internal/prewarm.js';
52
53
 
@@ -56,6 +57,27 @@ const h = React.createElement;
56
57
  // the note in `../richtext/node.ts`.
57
58
  registerRichText();
58
59
 
60
+ /**
61
+ * Lines a block of output holds.
62
+ *
63
+ * The output is a column of blocks rather than one element, so an append
64
+ * lays out again the block it lands in and not the whole capture: one
65
+ * `<richtext>` for all of it was laid out whole for every line appended,
66
+ * 0.9 s an append at 20,000 lines. A block keeps its element while its lines
67
+ * are the same objects, which the parser shares from one snapshot to the
68
+ * next. Blocks start at multiples of this in the capture's own numbering,
69
+ * `truncated` counted in, so `maxLines` dropping lines off the top changes
70
+ * only the first block.
71
+ */
72
+ const BLOCK_LINES = 256;
73
+
74
+ /** One block's lines, and the elements they were drawn as. */
75
+ interface Block {
76
+ lines: readonly AnsiLine[];
77
+ output: ReactElement;
78
+ gutter: ReactElement | null;
79
+ }
80
+
59
81
  /** What `data` accepts on top of raw bytes: a document parsed elsewhere. */
60
82
  export type TerminalOutputSource = AnsiInput | AnsiDocument;
61
83
 
@@ -273,41 +295,105 @@ export function TerminalOutput(props: TerminalOutputProps): ReactElement {
273
295
  [colors, look.color, theme.background],
274
296
  );
275
297
 
276
- const runs: TextRun[] = React.useMemo(
277
- () =>
278
- documentRuns(document, { family: look.family, size: look.size }, palette),
279
- [document, look.family, look.size, palette],
280
- );
281
-
282
- const outputProps: RichTextProps = {
283
- runs,
284
- style: codeTextStyle(look, wrap),
298
+ // Kept across renders while what a block is drawn with holds.
299
+ const blocks = React.useRef<{
300
+ look: typeof look;
301
+ palette: typeof palette;
302
+ wrap: boolean;
303
+ lineNumbers: boolean;
304
+ byIndex: Map<number, Block>;
305
+ } | null>(null);
306
+ if (
307
+ blocks.current?.look !== look ||
308
+ blocks.current.palette !== palette ||
309
+ blocks.current.wrap !== wrap ||
310
+ blocks.current.lineNumbers !== lineNumbers
311
+ ) {
312
+ blocks.current = { look, palette, wrap, lineNumbers, byIndex: new Map() };
313
+ }
314
+ const byIndex = blocks.current.byIndex;
315
+ const font = { family: look.family, size: look.size };
316
+ const outputStyle = codeTextStyle(look, wrap);
317
+ const gutterStyle: Style = {
318
+ lineHeight: look.lineHeight,
319
+ textAlign: 'right',
285
320
  };
286
- if (!wrap) outputProps.wrap = false;
287
321
 
288
- const lineCount = document.lines.length;
322
+ // The capture's own numbering: the first line shown is the one after
323
+ // every line `maxLines` has dropped.
324
+ const first = document.truncated;
325
+ const end = first + document.lines.length;
326
+ const outputs: ReactElement[] = [];
327
+ const gutters: ReactElement[] = [];
328
+ const kept = new Set<number>();
329
+ for (
330
+ let start = first - (first % BLOCK_LINES);
331
+ start < end || outputs.length === 0;
332
+ start += BLOCK_LINES
333
+ ) {
334
+ const index = start / BLOCK_LINES;
335
+ const from = Math.max(start, first) - first;
336
+ const to = Math.min(start + BLOCK_LINES, end) - first;
337
+ const lines = document.lines.slice(from, to);
338
+ let block = byIndex.get(index);
339
+ if (!block || !sameLines(block.lines, lines)) {
340
+ const outputProps: RichTextProps = {
341
+ runs: linesRuns(lines, font, palette),
342
+ style: outputStyle,
343
+ };
344
+ if (!wrap) outputProps.wrap = false;
345
+ block = {
346
+ lines,
347
+ output: h(RICHTEXT_ELEMENT, {
348
+ key: index,
349
+ ...outputProps,
350
+ } as Record<string, unknown>),
351
+ gutter: lineNumbers
352
+ ? h(RICHTEXT_ELEMENT, {
353
+ key: index,
354
+ // The numbers are chrome, not text: `selectable={false}` keeps
355
+ // them out of a drag and out of the copied text.
356
+ selectable: false,
357
+ runs: lines.map((_, i) => ({
358
+ text:
359
+ i === lines.length - 1
360
+ ? `${first + from + i + 1}`
361
+ : `${first + from + i + 1}\n`,
362
+ family: look.family,
363
+ size: look.size,
364
+ color: look.dim,
365
+ })),
366
+ wrap: false,
367
+ style: gutterStyle,
368
+ } as Record<string, unknown>)
369
+ : null,
370
+ };
371
+ byIndex.set(index, block);
372
+ }
373
+ kept.add(index);
374
+ outputs.push(block.output);
375
+ if (block.gutter) gutters.push(block.gutter);
376
+ }
377
+ for (const index of byIndex.keys()) {
378
+ if (!kept.has(index)) byIndex.delete(index);
379
+ }
380
+
381
+ // One column of numbers per column of output, block beside block: the
382
+ // same font and line height keep them in register.
289
383
  const gutter = lineNumbers
290
- ? h(RICHTEXT_ELEMENT, {
291
- // One richtext for all the numbers: same font, same line height, so
292
- // it stays in register with the output beside it. `selectable={false}`
293
- // is what keeps it out of a drag and out of the copied text.
294
- selectable: false,
295
- runs: Array.from({ length: lineCount }, (_, i) => ({
296
- text: i === lineCount - 1 ? `${i + 1}` : `${i + 1}\n`,
297
- family: look.family,
298
- size: look.size,
299
- color: look.dim,
300
- })),
301
- wrap: false,
302
- style: {
303
- lineHeight: look.lineHeight,
304
- textAlign: 'right',
305
- minWidth:
306
- Math.max(String(lineCount).length, 2) * Math.ceil(look.size * 0.62),
307
- marginRight: look.padding,
308
- flexShrink: 0,
384
+ ? hx(
385
+ 'box',
386
+ {
387
+ style: {
388
+ flexDirection: 'column',
389
+ minWidth:
390
+ Math.max(String(end).length, 2) * Math.ceil(look.size * 0.62),
391
+ marginRight: look.padding,
392
+ flexShrink: 0,
393
+ },
309
394
  },
310
- } as Record<string, unknown>)
395
+ ...gutters,
396
+ )
311
397
  : null;
312
398
 
313
399
  const rootStyle: Style = {
@@ -339,7 +425,14 @@ export function TerminalOutput(props: TerminalOutputProps): ReactElement {
339
425
  hx(
340
426
  'box',
341
427
  { style: { overflow: 'scroll', flexDirection: 'column', flexGrow: 1 } },
342
- h(RICHTEXT_ELEMENT, outputProps as unknown as Record<string, unknown>),
428
+ ...outputs,
343
429
  ),
344
430
  );
345
431
  }
432
+
433
+ /** Whether two runs of lines are the same objects, one for one. */
434
+ function sameLines(a: readonly AnsiLine[], b: readonly AnsiLine[]): boolean {
435
+ if (a.length !== b.length) return false;
436
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
437
+ return true;
438
+ }
@@ -5,7 +5,12 @@
5
5
  // parser said "ANSI 2", `../ansi/palette.ts` said which pixels that is, and
6
6
  // here it becomes a run the text layout can take.
7
7
  import { resolveAnsiColors } from '../ansi/index.js';
8
- import type { AnsiDocument, AnsiPalette, AnsiSpan } from '../ansi/index.js';
8
+ import type {
9
+ AnsiDocument,
10
+ AnsiLine,
11
+ AnsiPalette,
12
+ AnsiSpan,
13
+ } from '../ansi/index.js';
9
14
  import type { TextRun } from '../richtext/index.js';
10
15
 
11
16
  /** The font a capture is set in. The palette is separate because it changes
@@ -64,9 +69,18 @@ export function documentRuns(
64
69
  document: AnsiDocument,
65
70
  font: OutputFont,
66
71
  palette: AnsiPalette,
72
+ ): TextRun[] {
73
+ return linesRuns(document.lines, font, palette);
74
+ }
75
+
76
+ /** Some of a document's lines as runs, the way {@link documentRuns} makes
77
+ * all of them: a block of `<TerminalOutput>`'s output. */
78
+ export function linesRuns(
79
+ lines: readonly AnsiLine[],
80
+ font: OutputFont,
81
+ palette: AnsiPalette,
67
82
  ): TextRun[] {
68
83
  const runs: TextRun[] = [];
69
- const { lines } = document;
70
84
  for (let i = 0; i < lines.length; i++) {
71
85
  for (const span of lines[i]!.spans) {
72
86
  if (span.text) runs.push(spanRun(span, font, palette));