@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.
- package/dist/code/index.d.ts.map +1 -1
- package/dist/code/index.js +25 -24
- package/dist/code/index.js.map +1 -1
- package/dist/code-language/index.d.ts +1 -1
- package/dist/code-language/index.d.ts.map +1 -1
- package/dist/code-language/index.js +1 -1
- package/dist/code-language/index.js.map +1 -1
- package/dist/code-language/runs.d.ts +31 -0
- package/dist/code-language/runs.d.ts.map +1 -1
- package/dist/code-language/runs.js +105 -0
- package/dist/code-language/runs.js.map +1 -1
- package/dist/codeblock/index.d.ts +13 -0
- package/dist/codeblock/index.d.ts.map +1 -1
- package/dist/codeblock/index.js +37 -1
- package/dist/codeblock/index.js.map +1 -1
- package/dist/html/css/cascade.d.ts +32 -10
- package/dist/html/css/cascade.d.ts.map +1 -1
- package/dist/html/css/cascade.js +72 -27
- package/dist/html/css/cascade.js.map +1 -1
- package/dist/html/css/style.d.ts +7 -4
- package/dist/html/css/style.d.ts.map +1 -1
- package/dist/html/css/style.js +37 -6
- package/dist/html/css/style.js.map +1 -1
- package/dist/html/css/ua.js +8 -5
- package/dist/html/css/ua.js.map +1 -1
- package/dist/html/layout/block.d.ts.map +1 -1
- package/dist/html/layout/block.js +127 -9
- package/dist/html/layout/block.js.map +1 -1
- package/dist/html/layout/boxes.d.ts +27 -0
- package/dist/html/layout/boxes.d.ts.map +1 -1
- package/dist/html/layout/boxes.js +30 -2
- package/dist/html/layout/boxes.js.map +1 -1
- package/dist/html/layout/inline.d.ts +3 -0
- package/dist/html/layout/inline.d.ts.map +1 -1
- package/dist/html/layout/inline.js +236 -14
- package/dist/html/layout/inline.js.map +1 -1
- package/dist/html/node.d.ts.map +1 -1
- package/dist/html/node.js +118 -57
- package/dist/html/node.js.map +1 -1
- package/dist/html/paint.d.ts.map +1 -1
- package/dist/html/paint.js +203 -37
- package/dist/html/paint.js.map +1 -1
- package/dist/html/svg.d.ts.map +1 -1
- package/dist/html/svg.js +27 -20
- package/dist/html/svg.js.map +1 -1
- package/dist/internal/codelines.d.ts +42 -0
- package/dist/internal/codelines.d.ts.map +1 -0
- package/dist/internal/codelines.js +170 -0
- package/dist/internal/codelines.js.map +1 -0
- package/dist/markdown/index.d.ts.map +1 -1
- package/dist/markdown/index.js +98 -26
- package/dist/markdown/index.js.map +1 -1
- package/dist/rich-text-editor/index.d.ts.map +1 -1
- package/dist/rich-text-editor/index.js +3 -8
- package/dist/rich-text-editor/index.js.map +1 -1
- package/dist/rich-text-editor/markdown.d.ts.map +1 -1
- package/dist/rich-text-editor/markdown.js +30 -1
- package/dist/rich-text-editor/markdown.js.map +1 -1
- package/dist/rich-text-editor/replace.d.ts +13 -0
- package/dist/rich-text-editor/replace.d.ts.map +1 -0
- package/dist/rich-text-editor/replace.js +64 -0
- package/dist/rich-text-editor/replace.js.map +1 -0
- package/dist/terminal-output/index.d.ts.map +1 -1
- package/dist/terminal-output/index.js +100 -25
- package/dist/terminal-output/index.js.map +1 -1
- package/dist/terminal-output/runs.d.ts +4 -1
- package/dist/terminal-output/runs.d.ts.map +1 -1
- package/dist/terminal-output/runs.js +5 -1
- package/dist/terminal-output/runs.js.map +1 -1
- package/package.json +1 -1
- package/src/code/index.ts +34 -32
- package/src/code-language/index.ts +1 -1
- package/src/code-language/runs.ts +111 -1
- package/src/codeblock/index.ts +48 -1
- package/src/html/css/cascade.ts +117 -39
- package/src/html/css/style.ts +43 -15
- package/src/html/css/ua.ts +8 -5
- package/src/html/layout/block.ts +134 -8
- package/src/html/layout/boxes.ts +43 -2
- package/src/html/layout/inline.ts +257 -13
- package/src/html/node.ts +145 -79
- package/src/html/paint.ts +236 -45
- package/src/html/svg.ts +30 -19
- package/src/internal/codelines.ts +200 -0
- package/src/markdown/index.ts +145 -26
- package/src/rich-text-editor/index.ts +3 -11
- package/src/rich-text-editor/markdown.ts +36 -1
- package/src/rich-text-editor/replace.ts +85 -0
- package/src/terminal-output/index.ts +127 -34
- package/src/terminal-output/runs.ts +16 -2
package/src/markdown/index.ts
CHANGED
|
@@ -62,7 +62,7 @@ import {
|
|
|
62
62
|
import type { RichTextProps, TextRun } from '../richtext/index.js';
|
|
63
63
|
import {
|
|
64
64
|
codeBlockLook,
|
|
65
|
-
|
|
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
|
-
|
|
503
|
-
|
|
503
|
+
return h(CodeFence, {
|
|
504
|
+
key,
|
|
505
|
+
text,
|
|
504
506
|
lang,
|
|
507
|
+
look: ctx.look.code,
|
|
505
508
|
highlight: ctx.look.highlight,
|
|
506
|
-
|
|
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(
|
|
542
|
+
...codeBlockStyle(look),
|
|
515
543
|
overflow: 'scroll',
|
|
516
544
|
flexDirection: 'column',
|
|
517
545
|
},
|
|
518
546
|
},
|
|
519
|
-
|
|
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
|
-
|
|
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 =
|
|
623
|
-
const
|
|
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
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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
|
-
|
|
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
|
-
):
|
|
750
|
+
): ReactElement =>
|
|
652
751
|
hx(
|
|
653
752
|
'box',
|
|
654
753
|
{
|
|
@@ -676,17 +775,37 @@ function renderTable(
|
|
|
676
775
|
),
|
|
677
776
|
);
|
|
678
777
|
|
|
679
|
-
const
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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
|
-
{
|
|
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
|
|
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
|
-
|
|
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: (
|
|
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
|
|
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 {
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
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
|
-
?
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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));
|