@motionscript/code 0.1.0-alpha.9 → 0.1.1-alpha.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/CHANGELOG.md +23 -0
- package/README.md +2 -2
- package/dist/browser/index.js +8 -8
- package/dist/browser/index.js.map +3 -3
- package/dist/browser/manifest.json +1 -1
- package/dist/code-range.d.ts +7 -47
- package/dist/code-range.d.ts.map +1 -1
- package/dist/code-range.js +8 -45
- package/dist/code-range.js.map +1 -1
- package/dist/diff.d.ts +5 -44
- package/dist/diff.d.ts.map +1 -1
- package/dist/diff.js +22 -69
- package/dist/diff.js.map +1 -1
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +3 -7
- package/dist/engine.js.map +1 -1
- package/dist/highlight.d.ts +13 -31
- package/dist/highlight.d.ts.map +1 -1
- package/dist/highlight.js +25 -87
- package/dist/highlight.js.map +1 -1
- package/dist/layout.d.ts +7 -26
- package/dist/layout.d.ts.map +1 -1
- package/dist/layout.js +5 -20
- package/dist/layout.js.map +1 -1
- package/dist/measure-cache.d.ts +5 -27
- package/dist/measure-cache.d.ts.map +1 -1
- package/dist/measure-cache.js +0 -0
- package/dist/measure-cache.js.map +1 -1
- package/dist/node.d.ts +4 -11
- package/dist/node.d.ts.map +1 -1
- package/dist/node.js +18 -41
- package/dist/node.js.map +1 -1
- package/dist/nodes.d.ts +0 -12
- package/dist/nodes.d.ts.map +1 -1
- package/dist/nodes.js +0 -12
- package/dist/nodes.js.map +1 -1
- package/dist/render.d.ts +5 -14
- package/dist/render.d.ts.map +1 -1
- package/dist/render.js +13 -54
- package/dist/render.js.map +1 -1
- package/dist/style.d.ts +6 -61
- package/dist/style.d.ts.map +1 -1
- package/dist/style.js +4 -40
- package/dist/style.js.map +1 -1
- package/dist/tokens.d.ts +1 -10
- package/dist/tokens.d.ts.map +1 -1
- package/dist/tokens.js +3 -38
- package/dist/tokens.js.map +1 -1
- package/dist/transitions.d.ts +16 -68
- package/dist/transitions.d.ts.map +1 -1
- package/dist/transitions.js +5 -17
- package/dist/transitions.js.map +1 -1
- package/package.json +4 -4
- package/src/code-range.ts +9 -53
- package/src/diff.ts +26 -92
- package/src/engine.ts +3 -7
- package/src/highlight.ts +25 -98
- package/src/layout.ts +10 -35
- package/src/measure-cache.ts +0 -0
- package/src/node.ts +21 -57
- package/src/nodes.ts +0 -12
- package/src/render.ts +17 -61
- package/src/style.ts +9 -64
- package/src/tokens.ts +4 -46
- package/src/transitions.ts +16 -72
package/src/diff.ts
CHANGED
|
@@ -1,65 +1,32 @@
|
|
|
1
1
|
import type { IdLine, IdToken } from "./tokens";
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
* The result of diffing one listing structure against another: the *next*
|
|
5
|
-
* structure, with token and line ids carried over from the previous one
|
|
6
|
-
* wherever the content survived, plus the three sets a transition needs to know
|
|
7
|
-
* which role each token plays.
|
|
8
|
-
*
|
|
9
|
-
* Identity is the whole product here. `const variable = 3` becoming
|
|
10
|
-
* `const number = 3` should move `const` and `= 3` rather than dissolve and
|
|
11
|
-
* rebuild them, and a token can only *move* if the frame after the edit still
|
|
12
|
-
* knows it is the same token.
|
|
13
|
-
*/
|
|
14
|
-
/**
|
|
15
|
-
* How a `Code` node decides which token became which across an edit. Given
|
|
16
|
-
* the old and new tokenized structures, return the carried-over structure
|
|
17
|
-
* plus the added/removed/renamed-line bookkeeping a transition needs.
|
|
18
|
-
* `diffCode` below is the default implementation (exported as
|
|
19
|
-
* `defaultCodeDiff`) — pass a `diffStrategy` of this shape to `<Code>` to
|
|
20
|
-
* replace it with your own.
|
|
21
|
-
*/
|
|
3
|
+
/** Decides which token became which across an edit; `diffCode` (exported as `defaultCodeDiff`) is the default. */
|
|
22
4
|
export type CodeDiffStrategy = (from: IdLine[], to: IdLine[]) => CodeEdit;
|
|
23
5
|
|
|
6
|
+
/** The next structure with ids carried over wherever content survived, so a surviving token moves rather than dissolves and rebuilds. */
|
|
24
7
|
export interface CodeEdit {
|
|
25
|
-
/** The new structure
|
|
8
|
+
/** The new structure; ids are rewritten in place onto the tokens handed in. */
|
|
26
9
|
lines: IdLine[];
|
|
27
|
-
/** Ids present only in the old structure — these fade out. */
|
|
28
10
|
removedIds: Set<number>;
|
|
29
|
-
/** Ids present only in the new structure — these fade in. */
|
|
30
11
|
addedIds: Set<number>;
|
|
31
|
-
/**
|
|
12
|
+
/** Lines that did not exist before; they enter as a whole row. */
|
|
32
13
|
newLineIds: Set<number>;
|
|
33
14
|
/** Previous colour of a surviving token whose highlighting changed, for the cross-fade. */
|
|
34
15
|
fromColorById: Map<number, string | undefined>;
|
|
35
16
|
}
|
|
36
17
|
|
|
37
|
-
/**
|
|
38
|
-
* How similar two lines must be before they are treated as *one line that was
|
|
39
|
-
* edited* rather than as one line deleted and another inserted.
|
|
40
|
-
*
|
|
41
|
-
* Below this, a row's tokens have so little in common that carrying ids across
|
|
42
|
-
* would animate glyphs sliding between unrelated words. Above it, the edit reads
|
|
43
|
-
* the way it was made.
|
|
44
|
-
*/
|
|
18
|
+
/** Below this similarity two lines are one deleted and one inserted, not one edited: carrying ids across would slide glyphs between unrelated words. */
|
|
45
19
|
const LINE_MATCH_FLOOR = 0.45;
|
|
46
20
|
|
|
47
21
|
/**
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
* lines and 741 ms at 1,000, paid again for each edit command when it is built.
|
|
53
|
-
* So the lines are first paired where they cannot be in doubt (see
|
|
54
|
-
* {@link alignLines}), and only a run left between two such anchors is scored.
|
|
55
|
-
* A run past the first limit, or what is left of the second, is not compared: its
|
|
56
|
-
* lines leave and enter as whole rows. That is the budget, and an edit that stays
|
|
57
|
-
* inside it is diffed exactly as before.
|
|
22
|
+
* Most line pairs scored in one run of changed lines, and across a whole edit.
|
|
23
|
+
* Scoring every pair is quadratic (741 ms at 1,000 lines), so only a run between
|
|
24
|
+
* two anchors (see {@link alignLines}) is scored; past the budget its lines leave
|
|
25
|
+
* and enter as whole rows.
|
|
58
26
|
*/
|
|
59
|
-
|
|
60
|
-
|
|
27
|
+
const LINE_ALIGN_CELLS = 40_000;
|
|
28
|
+
const EDIT_ALIGN_CELLS = 160_000;
|
|
61
29
|
|
|
62
|
-
/** Weight a token carries when judging similarity: its visible characters. */
|
|
63
30
|
function tokenWeight(tok: IdToken): number {
|
|
64
31
|
return tok.content.trim().length;
|
|
65
32
|
}
|
|
@@ -71,12 +38,9 @@ function lineWeight(line: IdLine): number {
|
|
|
71
38
|
}
|
|
72
39
|
|
|
73
40
|
/**
|
|
74
|
-
* Dice coefficient over the
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
* Two blank lines are identical (1); a blank line against a non-blank one shares
|
|
78
|
-
* nothing (0), which keeps the blank rows a reformat introduces from anchoring
|
|
79
|
-
* themselves onto real code.
|
|
41
|
+
* Dice coefficient over the lines' token multisets, weighted by visible characters.
|
|
42
|
+
* Two blank lines are identical (1) but a blank against a non-blank shares nothing
|
|
43
|
+
* (0), so blank rows a reformat introduces never anchor onto real code.
|
|
80
44
|
*/
|
|
81
45
|
function similarity(a: IdLine, b: IdLine): number {
|
|
82
46
|
const wa = lineWeight(a);
|
|
@@ -103,22 +67,15 @@ function similarity(a: IdLine, b: IdLine): number {
|
|
|
103
67
|
}
|
|
104
68
|
|
|
105
69
|
/**
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
* twice over: two lines that differ by one word are the same line, and two
|
|
110
|
-
* tokens that differ only in colour are the same token. Scoring the pairing and
|
|
111
|
-
* letting anything that scores ≤ 0 fall out expresses that directly, and because
|
|
112
|
-
* skips cost nothing the alignment stays in order.
|
|
113
|
-
*
|
|
114
|
-
* Returns the matched index pairs, in order.
|
|
70
|
+
* Matched index pairs, in order, maximising total `score`; a pair scoring <= 0 is
|
|
71
|
+
* skipped. An LCS would pair only identical elements, but two lines that differ by
|
|
72
|
+
* one word are the same line.
|
|
115
73
|
*/
|
|
116
74
|
function align(
|
|
117
75
|
aLength: number,
|
|
118
76
|
bLength: number,
|
|
119
77
|
score: (i: number, j: number) => number,
|
|
120
78
|
): Array<[number, number]> {
|
|
121
|
-
// dp[i][j] = best score aligning a[0..i) with b[0..j).
|
|
122
79
|
const width = bLength + 1;
|
|
123
80
|
const dp = new Float64Array((aLength + 1) * width);
|
|
124
81
|
const pairScore = new Float64Array(aLength * bLength);
|
|
@@ -156,19 +113,11 @@ function align(
|
|
|
156
113
|
return pairs;
|
|
157
114
|
}
|
|
158
115
|
|
|
159
|
-
/**
|
|
160
|
-
* Match the tokens of one line against another's.
|
|
161
|
-
*
|
|
162
|
-
* Scored by visible characters rather than token count, so an alignment that
|
|
163
|
-
* keeps `items.reduce` together beats one that keeps four more spaces. A token
|
|
164
|
-
* pairs only when its content is unchanged — a changed word is a different word,
|
|
165
|
-
* and should be seen to be replaced.
|
|
166
|
-
*/
|
|
116
|
+
/** Scored by visible characters, so keeping `items.reduce` beats keeping four spaces; a changed word is a different token. */
|
|
167
117
|
function alignTokens(from: IdToken[], to: IdToken[]): Array<[number, number]> {
|
|
168
118
|
return align(from.length, to.length, (i, j) => {
|
|
169
119
|
if (from[i].content !== to[j].content) return 0;
|
|
170
|
-
// Whitespace
|
|
171
|
-
// enough that it never outranks real content.
|
|
120
|
+
// Whitespace pairs weakly: it holds indentation together but never outranks content.
|
|
172
121
|
return tokenWeight(to[j]) || 0.25;
|
|
173
122
|
});
|
|
174
123
|
}
|
|
@@ -179,7 +128,7 @@ function sameTokens(a: IdLine, b: IdLine): boolean {
|
|
|
179
128
|
return true;
|
|
180
129
|
}
|
|
181
130
|
|
|
182
|
-
/**
|
|
131
|
+
/** Text with token boundaries, so identical lines tell apart from similar ones. */
|
|
183
132
|
function signature(line: IdLine): string {
|
|
184
133
|
let out = "";
|
|
185
134
|
for (const tok of line.tokens) out += tok.content + "\u0001";
|
|
@@ -187,14 +136,10 @@ function signature(line: IdLine): string {
|
|
|
187
136
|
}
|
|
188
137
|
|
|
189
138
|
/**
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
* the longest run of anchors that keeps their order splits the rest into runs of
|
|
195
|
-
* changed lines, each scored against itself within the budget (see
|
|
196
|
-
* {@link LINE_ALIGN_CELLS}). A one-line edit to a long listing therefore never
|
|
197
|
-
* builds a table bigger than the lines around it.
|
|
139
|
+
* Pairs lines in order: identical head and tail outright, then the longest ordered
|
|
140
|
+
* run of lines occurring once on each side as anchors, with each run between
|
|
141
|
+
* anchors scored within the budget. A one-line edit to a long listing therefore
|
|
142
|
+
* never builds a table bigger than the lines around it.
|
|
198
143
|
*/
|
|
199
144
|
function alignLines(from: IdLine[], to: IdLine[]): Array<[number, number]> {
|
|
200
145
|
const fromSig = from.map(signature);
|
|
@@ -234,11 +179,7 @@ function alignLines(from: IdLine[], to: IdLine[]): Array<[number, number]> {
|
|
|
234
179
|
return pairs;
|
|
235
180
|
}
|
|
236
181
|
|
|
237
|
-
/**
|
|
238
|
-
* The lines that occur once in `from[fa, fb)` and once in `to[ta, tb)`, reduced to
|
|
239
|
-
* the longest subsequence that is increasing on both sides (patience sorting).
|
|
240
|
-
* Blank lines are never anchors: they say nothing about where they belong.
|
|
241
|
-
*/
|
|
182
|
+
/** Lines occurring once on each side, reduced to the longest subsequence increasing on both (patience sorting); blanks never anchor. */
|
|
242
183
|
function anchorLines(
|
|
243
184
|
fromSig: string[],
|
|
244
185
|
toSig: string[],
|
|
@@ -264,7 +205,6 @@ function anchorLines(
|
|
|
264
205
|
seenTo.add(sig);
|
|
265
206
|
}
|
|
266
207
|
|
|
267
|
-
// In `to` order, each candidate carries its `from` position.
|
|
268
208
|
const candidates: Array<[number, number]> = [];
|
|
269
209
|
for (let j = ta; j < tb; j++) {
|
|
270
210
|
const i = fromAt.get(toSig[j]);
|
|
@@ -291,13 +231,7 @@ function anchorLines(
|
|
|
291
231
|
return chain.reverse();
|
|
292
232
|
}
|
|
293
233
|
|
|
294
|
-
/**
|
|
295
|
-
* Carry `from`'s identities onto `to`, and report what changed.
|
|
296
|
-
*
|
|
297
|
-
* `to` is freshly tokenized and owns ids nobody has seen yet, so rewriting them
|
|
298
|
-
* in place is safe and is what makes the transition cheap: after this, the two
|
|
299
|
-
* structures share an id for every token that survived the edit.
|
|
300
|
-
*/
|
|
234
|
+
/** Carries `from`'s ids onto `to`; `to` is freshly tokenized, so rewriting its ids in place is safe. */
|
|
301
235
|
export function diffCode(from: IdLine[], to: IdLine[]): CodeEdit {
|
|
302
236
|
const removedIds = new Set<number>();
|
|
303
237
|
const addedIds = new Set<number>();
|
package/src/engine.ts
CHANGED
|
@@ -1,10 +1,6 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// `@motionscript/code` instead. What gets copied is whatever this
|
|
5
|
-
// package's registry.json declares, which for this component is node.ts;
|
|
6
|
-
// src/tests/registry.test.ts checks that anything it
|
|
7
|
-
// reaches for is either copied alongside it or re-exported from here.
|
|
1
|
+
// Everything node.ts imports besides its own declaration, so a copy of the node can take
|
|
2
|
+
// just node.ts and import this from `@motionscript/code`. registry.test.ts checks that
|
|
3
|
+
// whatever node.ts reaches for is copied alongside it or re-exported from here.
|
|
8
4
|
export * from './transitions';
|
|
9
5
|
export * from './layout';
|
|
10
6
|
export { drawCode } from './render';
|
package/src/highlight.ts
CHANGED
|
@@ -8,22 +8,7 @@ import {
|
|
|
8
8
|
compileStyle,
|
|
9
9
|
} from "./style";
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
* Lezer-backed syntax highlighting for the Code component, replacing the former
|
|
13
|
-
* Shiki wiring. This mirrors Motion Canvas's `LezerHighlighter`:
|
|
14
|
-
*
|
|
15
|
-
* - {@link prepareHighlight} parses code once and builds a `from:to` → color map
|
|
16
|
-
* by walking {@link highlightTree}.
|
|
17
|
-
* - {@link tokenizeCode} splits code at parse-tree leaf boundaries.
|
|
18
|
-
* - {@link highlightAt} resolves the color (and run length) for a character index.
|
|
19
|
-
*
|
|
20
|
-
* Parsers load lazily and asynchronously (dynamic import) so the bundle doesn't
|
|
21
|
-
* pull in every grammar. The node's asset loader awaits {@link ensureHighlighter}
|
|
22
|
-
* before tokenizing; until a parser resolves, callers fall back to plain text and
|
|
23
|
-
* re-tokenize once {@link canHighlight} returns true.
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
/** Dynamic-import factories for each supported language → a Lezer {@link Parser}. */
|
|
11
|
+
// Dynamic imports keep the bundle from pulling in every grammar.
|
|
27
12
|
const LANGUAGE_LOADERS: Record<string, () => Promise<Parser>> = {
|
|
28
13
|
typescript: async () => (await import("@lezer/javascript")).parser.configure({ dialect: "ts" }),
|
|
29
14
|
tsx: async () => (await import("@lezer/javascript")).parser.configure({ dialect: "ts jsx" }),
|
|
@@ -43,7 +28,6 @@ const LANGUAGE_LOADERS: Record<string, () => Promise<Parser>> = {
|
|
|
43
28
|
yaml: async () => (await import("@lezer/yaml")).parser,
|
|
44
29
|
};
|
|
45
30
|
|
|
46
|
-
// Aliases so common language strings resolve to a loader above.
|
|
47
31
|
const LANGUAGE_ALIASES: Record<string, string> = {
|
|
48
32
|
ts: "typescript",
|
|
49
33
|
js: "javascript",
|
|
@@ -81,17 +65,12 @@ function resolveLanguageKey(language: string): string {
|
|
|
81
65
|
return LANGUAGE_ALIASES[key] ?? key;
|
|
82
66
|
}
|
|
83
67
|
|
|
84
|
-
//
|
|
85
|
-
// concurrent Code nodes asking for the same language share one dynamic import.
|
|
68
|
+
// Keyed by resolved language, so concurrent nodes share one import.
|
|
86
69
|
const loadedParsers = new Map<string, Parser>();
|
|
87
70
|
const inFlightLangs = new Map<string, Promise<Parser | null>>();
|
|
88
71
|
|
|
89
|
-
export const DEFAULT_THEMES = [DefaultHighlightStyle.name];
|
|
90
72
|
export const DEFAULT_LANGS = ["typescript", "javascript", "json", "python"];
|
|
91
73
|
|
|
92
|
-
// Theme registry, seeded with the built-ins (Motion Canvas default + GitHub
|
|
93
|
-
// dark/light). The `theme` prop is a string name or a CodeHighlightStyle object;
|
|
94
|
-
// apps can register additional styles by name and reference them by that name.
|
|
95
74
|
const themesByName = new Map<string, CodeHighlightStyle>(Object.entries(BUILTIN_THEMES));
|
|
96
75
|
|
|
97
76
|
/** Register a custom {@link CodeHighlightStyle} so Code nodes can reference it by name. */
|
|
@@ -99,11 +78,7 @@ export function registerCodeTheme(style: CodeHighlightStyle): void {
|
|
|
99
78
|
themesByName.set(style.name, style);
|
|
100
79
|
}
|
|
101
80
|
|
|
102
|
-
/**
|
|
103
|
-
* Resolve a theme reference (a registered name, or a CodeHighlightStyle object)
|
|
104
|
-
* to a concrete style. Unknown names fall back to the Motion Canvas default so a
|
|
105
|
-
* stray theme string never throws, with one warning per name.
|
|
106
|
-
*/
|
|
81
|
+
/** An unknown theme name falls back to the default style with one warning, never a throw. */
|
|
107
82
|
export function resolveTheme(theme: CodeTheme): CodeHighlightStyle {
|
|
108
83
|
if (typeof theme !== "string") return theme;
|
|
109
84
|
const style = themesByName.get(theme);
|
|
@@ -116,24 +91,13 @@ export function resolveTheme(theme: CodeTheme): CodeHighlightStyle {
|
|
|
116
91
|
return DefaultHighlightStyle;
|
|
117
92
|
}
|
|
118
93
|
|
|
119
|
-
/** Has `language` finished loading its parser? */
|
|
120
|
-
function isLanguageLoaded(language: string): boolean {
|
|
121
|
-
return loadedParsers.has(resolveLanguageKey(language));
|
|
122
|
-
}
|
|
123
|
-
|
|
124
94
|
/**
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
* the node renders them as plain text, with one warning per language. A grammar
|
|
130
|
-
* whose import is rejected is reported the same way and is tried again by the
|
|
131
|
-
* next call.
|
|
95
|
+
* Resolves once every requested language's parser is loaded. An unknown language
|
|
96
|
+
* resolves without loading (`canHighlight` stays false and it draws as plain
|
|
97
|
+
* text); a grammar whose import rejects is reported the same way and retried by
|
|
98
|
+
* the next call. Each warns once.
|
|
132
99
|
*/
|
|
133
|
-
export function ensureHighlighter(
|
|
134
|
-
_themes: string[] = DEFAULT_THEMES,
|
|
135
|
-
langs: string[] = DEFAULT_LANGS,
|
|
136
|
-
): Promise<void> {
|
|
100
|
+
export function ensureHighlighter(langs: string[] = DEFAULT_LANGS): Promise<void> {
|
|
137
101
|
const jobs: Promise<unknown>[] = [];
|
|
138
102
|
|
|
139
103
|
for (const lang of langs) {
|
|
@@ -142,7 +106,6 @@ export function ensureHighlighter(
|
|
|
142
106
|
|
|
143
107
|
const loader = LANGUAGE_LOADERS[key];
|
|
144
108
|
if (!loader) {
|
|
145
|
-
// Unsupported language; leave it unhighlighted, and say so.
|
|
146
109
|
if (!PLAIN_LANGUAGES.has(key)) {
|
|
147
110
|
warnOnce(
|
|
148
111
|
"language",
|
|
@@ -177,33 +140,22 @@ export function ensureHighlighter(
|
|
|
177
140
|
return jobs.length === 0 ? Promise.resolve() : Promise.all(jobs).then(() => undefined);
|
|
178
141
|
}
|
|
179
142
|
|
|
180
|
-
/**
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
* languages explicitly. Returns once the parser is ready (or immediately for an
|
|
184
|
-
* unsupported language). The `theme` arg is accepted for backwards compatibility.
|
|
185
|
-
*/
|
|
186
|
-
export async function loadCodeLanguage(language: string, _theme?: string): Promise<void> {
|
|
187
|
-
await ensureHighlighter(DEFAULT_THEMES, [language]);
|
|
143
|
+
/** Loads one language; resolves at once for an unsupported one. */
|
|
144
|
+
export async function loadCodeLanguage(language: string): Promise<void> {
|
|
145
|
+
await ensureHighlighter([language]);
|
|
188
146
|
}
|
|
189
147
|
|
|
190
|
-
export async function initSyntaxHighlighter(
|
|
191
|
-
|
|
192
|
-
langs: string[] = DEFAULT_LANGS,
|
|
193
|
-
): Promise<void> {
|
|
194
|
-
await ensureHighlighter(themes, langs);
|
|
148
|
+
export async function initSyntaxHighlighter(langs: string[] = DEFAULT_LANGS): Promise<void> {
|
|
149
|
+
await ensureHighlighter(langs);
|
|
195
150
|
}
|
|
196
151
|
|
|
197
|
-
/** True once `language`
|
|
198
|
-
export function canHighlight(language: string
|
|
199
|
-
return
|
|
152
|
+
/** True once `language`'s parser has loaded. */
|
|
153
|
+
export function canHighlight(language: string): boolean {
|
|
154
|
+
return loadedParsers.has(resolveLanguageKey(language));
|
|
200
155
|
}
|
|
201
156
|
|
|
202
|
-
/** A parsed-and-prepared highlight cache for one snapshot of code. */
|
|
203
157
|
interface HighlightCache {
|
|
204
158
|
tree: Tree;
|
|
205
|
-
code: string;
|
|
206
|
-
// `${from}:${to}` node id → color, exactly as Motion Canvas keys it.
|
|
207
159
|
colorLookup: Map<string, string>;
|
|
208
160
|
}
|
|
209
161
|
|
|
@@ -211,12 +163,7 @@ function nodeId(from: number, to: number): string {
|
|
|
211
163
|
return `${from}:${to}`;
|
|
212
164
|
}
|
|
213
165
|
|
|
214
|
-
/**
|
|
215
|
-
* Parse `code` and build the per-node color map. Mirrors Motion Canvas's
|
|
216
|
-
* `LezerHighlighter.prepare`: for each highlighted range, walk the subtree and
|
|
217
|
-
* record the color against every contained node's `from:to` id, so a later
|
|
218
|
-
* {@link highlightAt} lookup by `resolveInner` index finds it.
|
|
219
|
-
*/
|
|
166
|
+
/** Maps every node inside a highlighted range to its color by `from:to` id, for {@link highlightAt}. */
|
|
220
167
|
function prepareHighlight(code: string, language: string, style: CodeHighlightStyle): HighlightCache | null {
|
|
221
168
|
const parser = loadedParsers.get(resolveLanguageKey(language));
|
|
222
169
|
if (!parser) return null;
|
|
@@ -226,8 +173,7 @@ function prepareHighlight(code: string, language: string, style: CodeHighlightSt
|
|
|
226
173
|
const colorLookup = new Map<string, string>();
|
|
227
174
|
|
|
228
175
|
highlightTree(tree, highlighter, (from, to, classes) => {
|
|
229
|
-
//
|
|
230
|
-
const color = classes;
|
|
176
|
+
const color = classes; // the tag highlighter emits the color as the "class"
|
|
231
177
|
if (!color) return;
|
|
232
178
|
const cursor = tree.cursorAt(from, 1);
|
|
233
179
|
do {
|
|
@@ -235,32 +181,22 @@ function prepareHighlight(code: string, language: string, style: CodeHighlightSt
|
|
|
235
181
|
} while (cursor.next() && cursor.to <= to);
|
|
236
182
|
});
|
|
237
183
|
|
|
238
|
-
return { tree,
|
|
184
|
+
return { tree, colorLookup };
|
|
239
185
|
}
|
|
240
186
|
|
|
241
|
-
/**
|
|
242
|
-
* Resolve the color and run length for the token starting at `index`. Mirrors
|
|
243
|
-
* Motion Canvas's `LezerHighlighter.highlight`: resolve the innermost node, look
|
|
244
|
-
* up its color, and report how far the run extends (`skipAhead`).
|
|
245
|
-
*/
|
|
187
|
+
/** The color and run length (`skipAhead`) of the innermost node at `index`. */
|
|
246
188
|
function highlightAt(index: number, cache: HighlightCache): { color: string | null; skipAhead: number } {
|
|
247
189
|
const node = cache.tree.resolveInner(index, 1);
|
|
248
190
|
const color = cache.colorLookup.get(nodeId(node.from, node.to));
|
|
249
191
|
if (color) {
|
|
250
192
|
return { color, skipAhead: node.to - index };
|
|
251
193
|
}
|
|
252
|
-
//
|
|
194
|
+
// Uncolored: skip a whole leaf rather than re-resolving every character.
|
|
253
195
|
const skipAhead = node.firstChild ? 0 : node.to - index;
|
|
254
196
|
return { color: null, skipAhead };
|
|
255
197
|
}
|
|
256
198
|
|
|
257
|
-
/**
|
|
258
|
-
* Split `code` into structural tokens at parse-tree leaf boundaries, including the
|
|
259
|
-
* gaps between leaves (whitespace/punctuation). Mirrors Motion Canvas's
|
|
260
|
-
* `LezerHighlighter.tokenize`; this segmentation is what the token-level
|
|
261
|
-
* append/insert/erase/replace animations diff against, so matching it keeps the
|
|
262
|
-
* animation behavior identical to Motion Canvas.
|
|
263
|
-
*/
|
|
199
|
+
/** Splits at parse-tree leaf boundaries, gaps between leaves included; the edit animations diff against this segmentation. */
|
|
264
200
|
function tokenizeCode(code: string, language: string): string[] {
|
|
265
201
|
const parser = loadedParsers.get(resolveLanguageKey(language));
|
|
266
202
|
if (!parser) return [code];
|
|
@@ -283,13 +219,8 @@ function tokenizeCode(code: string, language: string): string[] {
|
|
|
283
219
|
}
|
|
284
220
|
|
|
285
221
|
/**
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
* {@link highlightAt} (per-run color), then breaks tokens at newlines so each
|
|
289
|
-
* output line is a row of single-line, single-color tokens.
|
|
290
|
-
*
|
|
291
|
-
* Callers must have checked {@link canHighlight} first; this returns a single
|
|
292
|
-
* uncolored line per source line if the parser isn't loaded.
|
|
222
|
+
* Per-line rows of single-line, single-color tokens. Without a loaded parser it
|
|
223
|
+
* returns one uncolored token per source line, so callers check {@link canHighlight}.
|
|
293
224
|
*/
|
|
294
225
|
export function highlightToTokens(
|
|
295
226
|
code: string,
|
|
@@ -304,19 +235,15 @@ export function highlightToTokens(
|
|
|
304
235
|
const pushLine = () => lines.push([]);
|
|
305
236
|
|
|
306
237
|
if (!cache) {
|
|
307
|
-
// Parser unavailable: one uncolored token per source line.
|
|
308
238
|
return code.split("\n").map(line => [{ content: line, color: style.defaultColor }]);
|
|
309
239
|
}
|
|
310
240
|
|
|
311
|
-
// Walk the structural tokens, coloring each by its starting index and
|
|
312
|
-
// splitting on newlines so no token spans rows.
|
|
313
241
|
let index = 0;
|
|
314
242
|
for (const token of tokenizeCode(code, language)) {
|
|
315
243
|
let offset = 0;
|
|
316
244
|
while (offset < token.length) {
|
|
317
245
|
const { color, skipAhead } = highlightAt(index + offset, cache);
|
|
318
|
-
//
|
|
319
|
-
// structural token and never across a newline (lines are drawn rows).
|
|
246
|
+
// A run never passes its structural token or a newline.
|
|
320
247
|
let runEnd = offset + Math.max(skipAhead, 1);
|
|
321
248
|
if (runEnd > token.length) runEnd = token.length;
|
|
322
249
|
const newlineRel = token.indexOf("\n", offset);
|
package/src/layout.ts
CHANGED
|
@@ -19,7 +19,7 @@ export interface CodeMetrics {
|
|
|
19
19
|
lineNumberGap: number;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
/**
|
|
22
|
+
/** A token's LEFT edge and the vertical CENTRE of its line's slot. */
|
|
23
23
|
export interface TokenBox {
|
|
24
24
|
x: number;
|
|
25
25
|
y: number;
|
|
@@ -37,38 +37,25 @@ export interface TokenBox {
|
|
|
37
37
|
* the left margin springing apart.
|
|
38
38
|
*/
|
|
39
39
|
export interface CodeLayout {
|
|
40
|
-
/** Token id → placement. */
|
|
41
40
|
tokens: Map<number, TokenBox>;
|
|
42
|
-
/**
|
|
41
|
+
/** By line index. */
|
|
43
42
|
lineY: number[];
|
|
44
|
-
/**
|
|
45
|
-
* Width of each line's ink, by line index — the sum of its tokens' advances,
|
|
46
|
-
* so a blank line is `0` and a short line is short.
|
|
47
|
-
*
|
|
48
|
-
* Returned rather than kept local because it is the only thing that knows a
|
|
49
|
-
* listing is **ragged**: `innerW` is the longest line, and everything that
|
|
50
|
-
* asks where the text actually is (hit testing, for one) would otherwise have
|
|
51
|
-
* to treat the block as a rectangle the width of its longest row.
|
|
52
|
-
*/
|
|
43
|
+
/** Ink width per line (blank is 0): the only record that a listing is ragged, which hit testing needs. */
|
|
53
44
|
lineW: number[];
|
|
54
|
-
/** Line id → its index in this structure (i.e. its line *number* minus one). */
|
|
55
45
|
lineIndex: Map<number, number>;
|
|
56
46
|
gutter: number;
|
|
57
47
|
gutterGap: number;
|
|
58
48
|
/** Content size, no padding. */
|
|
59
49
|
innerW: number;
|
|
60
50
|
innerH: number;
|
|
61
|
-
/** Content
|
|
51
|
+
/** Content plus padding. */
|
|
62
52
|
blockW: number;
|
|
63
53
|
blockH: number;
|
|
64
|
-
/** Left edge of the code column
|
|
54
|
+
/** Left edge of the code column, after padding and gutter. */
|
|
65
55
|
startX: number;
|
|
66
56
|
}
|
|
67
57
|
|
|
68
|
-
/**
|
|
69
|
-
* Signature of everything a layout depends on other than the token structure
|
|
70
|
-
* itself. A cached layout survives exactly as long as this is unchanged.
|
|
71
|
-
*/
|
|
58
|
+
/** Everything a layout depends on besides the token structure; a cached layout lives while it is unchanged. */
|
|
72
59
|
export function metricsSignature(m: CodeMetrics): string {
|
|
73
60
|
const p = m.padding;
|
|
74
61
|
return [
|
|
@@ -78,16 +65,12 @@ export function metricsSignature(m: CodeMetrics): string {
|
|
|
78
65
|
].join("|");
|
|
79
66
|
}
|
|
80
67
|
|
|
81
|
-
/**
|
|
82
|
-
* Horizontal gap between the line-number column and the code text. Sized in
|
|
83
|
-
* space-widths so it scales with the font, and measured with letterSpacing 0 —
|
|
84
|
-
* line numbers and the gap don't carry the code's letter-spacing.
|
|
85
|
-
*/
|
|
68
|
+
/** In space-widths, measured with letterSpacing 0: line numbers and the gap don't carry the code's. */
|
|
86
69
|
function gutterGapOf(scope: MeasureContext2D | RenderContext2D, m: CodeMetrics, cache: TokenAdvanceCache): number {
|
|
87
70
|
return cache.advance(scope, " ", m.fontSize, m.fontFamily, 0) * m.lineNumberGap;
|
|
88
71
|
}
|
|
89
72
|
|
|
90
|
-
/**
|
|
73
|
+
/** Gap included. */
|
|
91
74
|
function gutterOf(
|
|
92
75
|
scope: MeasureContext2D | RenderContext2D,
|
|
93
76
|
m: CodeMetrics,
|
|
@@ -100,13 +83,7 @@ function gutterOf(
|
|
|
100
83
|
return cache.advance(scope, sample, m.fontSize, m.fontFamily, 0) + gap;
|
|
101
84
|
}
|
|
102
85
|
|
|
103
|
-
/**
|
|
104
|
-
* Place every token of `lines`.
|
|
105
|
-
*
|
|
106
|
-
* Pure in (lines, metrics, font measurements), which is what lets a caller cache
|
|
107
|
-
* one per structure rather than recomputing it per frame — a transition's two
|
|
108
|
-
* endpoints are both fixed for its whole duration.
|
|
109
|
-
*/
|
|
86
|
+
/** Pure in (lines, metrics, measurements), so a caller caches one per structure. */
|
|
110
87
|
export function layoutCode(
|
|
111
88
|
lines: IdLine[],
|
|
112
89
|
m: CodeMetrics,
|
|
@@ -133,9 +110,7 @@ export function layoutCode(
|
|
|
133
110
|
const blockW = innerW + m.padding.left + m.padding.right;
|
|
134
111
|
const blockH = innerH + m.padding.top + m.padding.bottom;
|
|
135
112
|
const startX = -blockW / 2 + m.padding.left + gutter;
|
|
136
|
-
// y-up
|
|
137
|
-
// cursor starts at the top edge (+half height, minus top padding) and steps
|
|
138
|
-
// DOWNWARD by subtracting a line height per row.
|
|
113
|
+
// y-up: the first line sits at the TOP, and each row steps down by a line height.
|
|
139
114
|
const startY = blockH / 2 - m.padding.top;
|
|
140
115
|
|
|
141
116
|
const tokens = new Map<number, TokenBox>();
|
package/src/measure-cache.ts
CHANGED
|
Binary file
|