@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.
- 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/flow/node.d.ts +14 -6
- package/dist/flow/node.d.ts.map +1 -1
- package/dist/flow/node.js +82 -64
- package/dist/flow/node.js.map +1 -1
- package/dist/html/controls.d.ts +26 -7
- package/dist/html/controls.d.ts.map +1 -1
- package/dist/html/controls.js +28 -3
- package/dist/html/controls.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 +89 -31
- package/dist/html/css/cascade.js.map +1 -1
- package/dist/html/css/color.d.ts +19 -0
- package/dist/html/css/color.d.ts.map +1 -1
- package/dist/html/css/color.js +86 -0
- package/dist/html/css/color.js.map +1 -1
- package/dist/html/css/parse.d.ts +11 -0
- package/dist/html/css/parse.d.ts.map +1 -1
- package/dist/html/css/parse.js +114 -4
- package/dist/html/css/parse.js.map +1 -1
- package/dist/html/css/style.d.ts +45 -5
- package/dist/html/css/style.d.ts.map +1 -1
- package/dist/html/css/style.js +363 -14
- package/dist/html/css/style.js.map +1 -1
- package/dist/html/css/ua.js +22 -5
- package/dist/html/css/ua.js.map +1 -1
- package/dist/html/css/values.d.ts +10 -1
- package/dist/html/css/values.d.ts.map +1 -1
- package/dist/html/css/values.js +27 -2
- package/dist/html/css/values.js.map +1 -1
- package/dist/html/css/vars.d.ts +5 -0
- package/dist/html/css/vars.d.ts.map +1 -1
- package/dist/html/css/vars.js +2 -2
- package/dist/html/css/vars.js.map +1 -1
- package/dist/html/dom.d.ts.map +1 -1
- package/dist/html/dom.js +35 -2
- package/dist/html/dom.js.map +1 -1
- package/dist/html/index.d.ts.map +1 -1
- package/dist/html/index.js +38 -1
- package/dist/html/index.js.map +1 -1
- package/dist/html/layout/block.d.ts +11 -2
- package/dist/html/layout/block.d.ts.map +1 -1
- package/dist/html/layout/block.js +306 -25
- package/dist/html/layout/block.js.map +1 -1
- package/dist/html/layout/boxes.d.ts +61 -6
- package/dist/html/layout/boxes.d.ts.map +1 -1
- package/dist/html/layout/boxes.js +65 -16
- package/dist/html/layout/boxes.js.map +1 -1
- package/dist/html/layout/cache.d.ts +2 -2
- package/dist/html/layout/cache.d.ts.map +1 -1
- package/dist/html/layout/cache.js +8 -3
- package/dist/html/layout/cache.js.map +1 -1
- package/dist/html/layout/floats.d.ts +4 -0
- package/dist/html/layout/floats.d.ts.map +1 -1
- package/dist/html/layout/floats.js +26 -0
- package/dist/html/layout/floats.js.map +1 -1
- package/dist/html/layout/inline.d.ts +29 -2
- package/dist/html/layout/inline.d.ts.map +1 -1
- package/dist/html/layout/inline.js +1454 -125
- package/dist/html/layout/inline.js.map +1 -1
- package/dist/html/node.d.ts +33 -0
- package/dist/html/node.d.ts.map +1 -1
- package/dist/html/node.js +608 -86
- package/dist/html/node.js.map +1 -1
- package/dist/html/paint.d.ts +53 -1
- package/dist/html/paint.d.ts.map +1 -1
- package/dist/html/paint.js +746 -214
- package/dist/html/paint.js.map +1 -1
- package/dist/html/surfaces.d.ts +7 -2
- package/dist/html/surfaces.d.ts.map +1 -1
- package/dist/html/surfaces.js +16 -0
- package/dist/html/surfaces.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/internal/window.d.ts +22 -0
- package/dist/internal/window.d.ts.map +1 -1
- package/dist/internal/window.js +35 -8
- package/dist/internal/window.js.map +1 -1
- 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/richtext/node.d.ts +7 -0
- package/dist/richtext/node.d.ts.map +1 -1
- package/dist/richtext/node.js.map +1 -1
- 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 +5 -3
- 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/flow/node.ts +80 -64
- package/src/html/controls.ts +50 -9
- package/src/html/css/cascade.ts +134 -43
- package/src/html/css/color.ts +87 -0
- package/src/html/css/parse.ts +107 -4
- package/src/html/css/style.ts +386 -23
- package/src/html/css/ua.ts +22 -5
- package/src/html/css/values.ts +31 -3
- package/src/html/css/vars.ts +2 -2
- package/src/html/dom.ts +38 -2
- package/src/html/index.ts +39 -1
- package/src/html/layout/block.ts +360 -22
- package/src/html/layout/boxes.ts +98 -23
- package/src/html/layout/cache.ts +8 -3
- package/src/html/layout/floats.ts +24 -0
- package/src/html/layout/inline.ts +1690 -119
- package/src/html/node.ts +648 -101
- package/src/html/paint.ts +883 -283
- package/src/html/surfaces.ts +21 -1
- package/src/html/svg.ts +30 -19
- package/src/internal/codelines.ts +200 -0
- package/src/internal/window.ts +36 -8
- 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/richtext/node.ts +7 -0
- package/src/terminal-output/index.ts +127 -34
- package/src/terminal-output/runs.ts +16 -2
package/src/html/surfaces.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
//
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
+
}
|
package/src/internal/window.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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
|
-
|
|
329
|
-
idleTimer.current =
|
|
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
|
-
|
|
369
|
-
idleTimer.current =
|
|
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 =
|
|
399
|
+
idleTimer.current = windowClock.arm(tick, IDLE_MS);
|
|
372
400
|
}
|
|
373
401
|
});
|
|
374
|
-
useEffect(() => () =>
|
|
402
|
+
useEffect(() => () => windowClock.disarm(idleTimer.current), []);
|
|
375
403
|
|
|
376
404
|
const {
|
|
377
405
|
heights,
|
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 {
|