@react-x11/components 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) 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/flow/node.d.ts +14 -6
  17. package/dist/flow/node.d.ts.map +1 -1
  18. package/dist/flow/node.js +82 -64
  19. package/dist/flow/node.js.map +1 -1
  20. package/dist/html/controls.d.ts +26 -7
  21. package/dist/html/controls.d.ts.map +1 -1
  22. package/dist/html/controls.js +28 -3
  23. package/dist/html/controls.js.map +1 -1
  24. package/dist/html/css/cascade.d.ts +32 -10
  25. package/dist/html/css/cascade.d.ts.map +1 -1
  26. package/dist/html/css/cascade.js +89 -31
  27. package/dist/html/css/cascade.js.map +1 -1
  28. package/dist/html/css/color.d.ts +19 -0
  29. package/dist/html/css/color.d.ts.map +1 -1
  30. package/dist/html/css/color.js +86 -0
  31. package/dist/html/css/color.js.map +1 -1
  32. package/dist/html/css/parse.d.ts +11 -0
  33. package/dist/html/css/parse.d.ts.map +1 -1
  34. package/dist/html/css/parse.js +114 -4
  35. package/dist/html/css/parse.js.map +1 -1
  36. package/dist/html/css/style.d.ts +45 -5
  37. package/dist/html/css/style.d.ts.map +1 -1
  38. package/dist/html/css/style.js +363 -14
  39. package/dist/html/css/style.js.map +1 -1
  40. package/dist/html/css/ua.js +22 -5
  41. package/dist/html/css/ua.js.map +1 -1
  42. package/dist/html/css/values.d.ts +10 -1
  43. package/dist/html/css/values.d.ts.map +1 -1
  44. package/dist/html/css/values.js +27 -2
  45. package/dist/html/css/values.js.map +1 -1
  46. package/dist/html/css/vars.d.ts +5 -0
  47. package/dist/html/css/vars.d.ts.map +1 -1
  48. package/dist/html/css/vars.js +2 -2
  49. package/dist/html/css/vars.js.map +1 -1
  50. package/dist/html/dom.d.ts.map +1 -1
  51. package/dist/html/dom.js +35 -2
  52. package/dist/html/dom.js.map +1 -1
  53. package/dist/html/index.d.ts.map +1 -1
  54. package/dist/html/index.js +38 -1
  55. package/dist/html/index.js.map +1 -1
  56. package/dist/html/layout/block.d.ts +11 -2
  57. package/dist/html/layout/block.d.ts.map +1 -1
  58. package/dist/html/layout/block.js +306 -25
  59. package/dist/html/layout/block.js.map +1 -1
  60. package/dist/html/layout/boxes.d.ts +61 -6
  61. package/dist/html/layout/boxes.d.ts.map +1 -1
  62. package/dist/html/layout/boxes.js +65 -16
  63. package/dist/html/layout/boxes.js.map +1 -1
  64. package/dist/html/layout/cache.d.ts +2 -2
  65. package/dist/html/layout/cache.d.ts.map +1 -1
  66. package/dist/html/layout/cache.js +8 -3
  67. package/dist/html/layout/cache.js.map +1 -1
  68. package/dist/html/layout/floats.d.ts +4 -0
  69. package/dist/html/layout/floats.d.ts.map +1 -1
  70. package/dist/html/layout/floats.js +26 -0
  71. package/dist/html/layout/floats.js.map +1 -1
  72. package/dist/html/layout/inline.d.ts +29 -2
  73. package/dist/html/layout/inline.d.ts.map +1 -1
  74. package/dist/html/layout/inline.js +1454 -125
  75. package/dist/html/layout/inline.js.map +1 -1
  76. package/dist/html/node.d.ts +33 -0
  77. package/dist/html/node.d.ts.map +1 -1
  78. package/dist/html/node.js +608 -86
  79. package/dist/html/node.js.map +1 -1
  80. package/dist/html/paint.d.ts +53 -1
  81. package/dist/html/paint.d.ts.map +1 -1
  82. package/dist/html/paint.js +746 -214
  83. package/dist/html/paint.js.map +1 -1
  84. package/dist/html/surfaces.d.ts +7 -2
  85. package/dist/html/surfaces.d.ts.map +1 -1
  86. package/dist/html/surfaces.js +16 -0
  87. package/dist/html/surfaces.js.map +1 -1
  88. package/dist/html/svg.d.ts.map +1 -1
  89. package/dist/html/svg.js +27 -20
  90. package/dist/html/svg.js.map +1 -1
  91. package/dist/internal/codelines.d.ts +42 -0
  92. package/dist/internal/codelines.d.ts.map +1 -0
  93. package/dist/internal/codelines.js +170 -0
  94. package/dist/internal/codelines.js.map +1 -0
  95. package/dist/internal/window.d.ts +22 -0
  96. package/dist/internal/window.d.ts.map +1 -1
  97. package/dist/internal/window.js +35 -8
  98. package/dist/internal/window.js.map +1 -1
  99. package/dist/markdown/index.d.ts.map +1 -1
  100. package/dist/markdown/index.js +98 -26
  101. package/dist/markdown/index.js.map +1 -1
  102. package/dist/rich-text-editor/index.d.ts.map +1 -1
  103. package/dist/rich-text-editor/index.js +3 -8
  104. package/dist/rich-text-editor/index.js.map +1 -1
  105. package/dist/rich-text-editor/markdown.d.ts.map +1 -1
  106. package/dist/rich-text-editor/markdown.js +30 -1
  107. package/dist/rich-text-editor/markdown.js.map +1 -1
  108. package/dist/rich-text-editor/replace.d.ts +13 -0
  109. package/dist/rich-text-editor/replace.d.ts.map +1 -0
  110. package/dist/rich-text-editor/replace.js +64 -0
  111. package/dist/rich-text-editor/replace.js.map +1 -0
  112. package/dist/richtext/node.d.ts +7 -0
  113. package/dist/richtext/node.d.ts.map +1 -1
  114. package/dist/richtext/node.js.map +1 -1
  115. package/dist/terminal-output/index.d.ts.map +1 -1
  116. package/dist/terminal-output/index.js +100 -25
  117. package/dist/terminal-output/index.js.map +1 -1
  118. package/dist/terminal-output/runs.d.ts +4 -1
  119. package/dist/terminal-output/runs.d.ts.map +1 -1
  120. package/dist/terminal-output/runs.js +5 -1
  121. package/dist/terminal-output/runs.js.map +1 -1
  122. package/package.json +5 -3
  123. package/src/code/index.ts +34 -32
  124. package/src/code-language/index.ts +1 -1
  125. package/src/code-language/runs.ts +111 -1
  126. package/src/codeblock/index.ts +48 -1
  127. package/src/flow/node.ts +80 -64
  128. package/src/html/controls.ts +50 -9
  129. package/src/html/css/cascade.ts +134 -43
  130. package/src/html/css/color.ts +87 -0
  131. package/src/html/css/parse.ts +107 -4
  132. package/src/html/css/style.ts +386 -23
  133. package/src/html/css/ua.ts +22 -5
  134. package/src/html/css/values.ts +31 -3
  135. package/src/html/css/vars.ts +2 -2
  136. package/src/html/dom.ts +38 -2
  137. package/src/html/index.ts +39 -1
  138. package/src/html/layout/block.ts +360 -22
  139. package/src/html/layout/boxes.ts +98 -23
  140. package/src/html/layout/cache.ts +8 -3
  141. package/src/html/layout/floats.ts +24 -0
  142. package/src/html/layout/inline.ts +1690 -119
  143. package/src/html/node.ts +648 -101
  144. package/src/html/paint.ts +883 -283
  145. package/src/html/surfaces.ts +21 -1
  146. package/src/html/svg.ts +30 -19
  147. package/src/internal/codelines.ts +200 -0
  148. package/src/internal/window.ts +36 -8
  149. package/src/markdown/index.ts +145 -26
  150. package/src/rich-text-editor/index.ts +3 -11
  151. package/src/rich-text-editor/markdown.ts +36 -1
  152. package/src/rich-text-editor/replace.ts +85 -0
  153. package/src/richtext/node.ts +7 -0
  154. package/src/terminal-output/index.ts +127 -34
  155. package/src/terminal-output/runs.ts +16 -2
@@ -8,7 +8,7 @@
8
8
  import { Surface } from 'react-x11/ntk';
9
9
 
10
10
  /** ntk's `Surface`, as this uses it. */
11
- interface SurfaceLike {
11
+ export interface SurfaceLike {
12
12
  getContext(kind: '2d'): unknown;
13
13
  destroy?(): void;
14
14
  }
@@ -18,6 +18,26 @@ interface Kept {
18
18
  pixels: number;
19
19
  }
20
20
 
21
+ /**
22
+ * A transparent surface of its own, the caller's to destroy once drawn: a
23
+ * masked element is drawn on one and composited. Null where the backend
24
+ * has no offscreen surface — the headless mock.
25
+ */
26
+ export function newSurface(
27
+ app: unknown,
28
+ width: number,
29
+ height: number,
30
+ ): SurfaceLike | null {
31
+ try {
32
+ return new Surface(app as never, {
33
+ width,
34
+ height,
35
+ }) as unknown as SurfaceLike;
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
21
41
  /**
22
42
  * Surfaces by key, the least recently drawn given up first once they hold
23
43
  * more than `budget` pixels. One too large for a quarter of the budget is
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
+ }
@@ -99,6 +99,34 @@ const VEL_GAP_MS = 250;
99
99
  * settle. */
100
100
  const FAST_V = 1.5;
101
101
 
102
+ /**
103
+ * The window's time and its timer: what a scroll's speed is measured with,
104
+ * and what decides it has stopped (`IDLE_MS`) and paces the band's growth
105
+ * after it (`GROW_MS`). A test holds both (`holdClock` in
106
+ * test/held-clock.ts). On the real clock a runner that spends longer than
107
+ * `IDLE_MS` over one step of a flick ends the flick there, and the settle
108
+ * that follows measures the rows and moves the scroll by what they
109
+ * measured — outside the `act` the step ran in, so a look at the rows finds
110
+ * the new offset against the layout before it, every row a few pixels off.
111
+ * A test of a flick cannot tell that from rows that moved.
112
+ *
113
+ * Not the catch-up's clock: `catchupSince` is epoch milliseconds, which a
114
+ * scroll hint is handed as `since`. The shape of the map's `glideClock`,
115
+ * and exported from this module only, which has no subpath: this is for
116
+ * the tests.
117
+ */
118
+ export const windowClock = {
119
+ now(): number {
120
+ return Date.now();
121
+ },
122
+ arm(step: () => void, ms: number): DelayTick {
123
+ return later(step, ms);
124
+ },
125
+ disarm(handle: DelayTick): void {
126
+ cancelLater(handle);
127
+ },
128
+ };
129
+
102
130
  /** More rows than this entering the window in one render is a flood — a
103
131
  * teleport or a hard flick — and floods build skeletons first. An ordinary
104
132
  * notch brings in a handful and never trips this. */
@@ -302,7 +330,7 @@ export function useVirtualWindow(inputs: VirtualWindowInputs): VirtualWindow {
302
330
  // estimate adaptation) runs on.
303
331
  bump((n) => n + 1);
304
332
  if (wantsGrowth.current) {
305
- idleTimer.current = later(tick, GROW_MS);
333
+ idleTimer.current = windowClock.arm(tick, GROW_MS);
306
334
  }
307
335
  }, []);
308
336
 
@@ -312,7 +340,7 @@ export function useVirtualWindow(inputs: VirtualWindowInputs): VirtualWindow {
312
340
  // one has no slice to rebuild, and re-rendering it on a scroll it
313
341
  // already drew would be work for nothing.
314
342
  if (!inp.current.virtualizing) return;
315
- const now = Date.now();
343
+ const now = windowClock.now();
316
344
  const s = vel.current;
317
345
  const dt = now - s.t;
318
346
  if (dt > 0 && dt < VEL_GAP_MS) {
@@ -325,8 +353,8 @@ export function useVirtualWindow(inputs: VirtualWindowInputs): VirtualWindow {
325
353
  s.t = now;
326
354
  s.top = top;
327
355
  active.current = true;
328
- cancelLater(idleTimer.current);
329
- idleTimer.current = later(tick, IDLE_MS);
356
+ windowClock.disarm(idleTimer.current);
357
+ idleTimer.current = windowClock.arm(tick, IDLE_MS);
330
358
  setView((prev) => (prev.top === top ? prev : { ...prev, top }));
331
359
  },
332
360
  [tick],
@@ -365,13 +393,13 @@ export function useVirtualWindow(inputs: VirtualWindowInputs): VirtualWindow {
365
393
  // idle delay to fill them in would be a visible pause.
366
394
  useEffect(() => {
367
395
  if (hasSkeletons.current) {
368
- cancelLater(idleTimer.current);
369
- idleTimer.current = later(tick, GROW_MS);
396
+ windowClock.disarm(idleTimer.current);
397
+ idleTimer.current = windowClock.arm(tick, GROW_MS);
370
398
  } else if (wantsGrowth.current && idleTimer.current === null) {
371
- idleTimer.current = later(tick, IDLE_MS);
399
+ idleTimer.current = windowClock.arm(tick, IDLE_MS);
372
400
  }
373
401
  });
374
- useEffect(() => () => cancelLater(idleTimer.current), []);
402
+ useEffect(() => () => windowClock.disarm(idleTimer.current), []);
375
403
 
376
404
  const {
377
405
  heights,
@@ -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 {