@motionscript/code 0.0.0-stage → 0.1.0-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 +5 -0
- package/LICENSE +201 -0
- package/README.md +76 -3
- package/dist/browser/chunks/chunk-HET2RCSD.js +2 -0
- package/dist/browser/chunks/chunk-HET2RCSD.js.map +7 -0
- package/dist/browser/chunks/chunk-SYZUZB3M.js +2 -0
- package/dist/browser/chunks/chunk-SYZUZB3M.js.map +7 -0
- package/dist/browser/chunks/dist-3F5TQHM6.js +2 -0
- package/dist/browser/chunks/dist-3F5TQHM6.js.map +7 -0
- package/dist/browser/chunks/dist-3SIR23P4.js +2 -0
- package/dist/browser/chunks/dist-3SIR23P4.js.map +7 -0
- package/dist/browser/chunks/dist-AQRNACF3.js +2 -0
- package/dist/browser/chunks/dist-AQRNACF3.js.map +7 -0
- package/dist/browser/chunks/dist-B46VH2YP.js +2 -0
- package/dist/browser/chunks/dist-B46VH2YP.js.map +7 -0
- package/dist/browser/chunks/dist-FH6BHJ6A.js +7 -0
- package/dist/browser/chunks/dist-FH6BHJ6A.js.map +7 -0
- package/dist/browser/chunks/dist-FQDUBZKU.js +2 -0
- package/dist/browser/chunks/dist-FQDUBZKU.js.map +7 -0
- package/dist/browser/chunks/dist-GPNEVCJI.js +2 -0
- package/dist/browser/chunks/dist-GPNEVCJI.js.map +7 -0
- package/dist/browser/chunks/dist-LRSXSDHT.js +2 -0
- package/dist/browser/chunks/dist-LRSXSDHT.js.map +7 -0
- package/dist/browser/chunks/dist-N4CNBNHL.js +2 -0
- package/dist/browser/chunks/dist-N4CNBNHL.js.map +7 -0
- package/dist/browser/chunks/dist-OXJDTPQ6.js +2 -0
- package/dist/browser/chunks/dist-OXJDTPQ6.js.map +7 -0
- package/dist/browser/chunks/dist-SH7H4MRK.js +2 -0
- package/dist/browser/chunks/dist-SH7H4MRK.js.map +7 -0
- package/dist/browser/chunks/dist-UG3NTKWL.js +2 -0
- package/dist/browser/chunks/dist-UG3NTKWL.js.map +7 -0
- package/dist/browser/chunks/dist-W5ZXIMV7.js +2 -0
- package/dist/browser/chunks/dist-W5ZXIMV7.js.map +7 -0
- package/dist/browser/index.js +8 -0
- package/dist/browser/index.js.map +7 -0
- package/dist/browser/manifest.json +11 -0
- package/dist/code-range.d.ts +69 -0
- package/dist/code-range.d.ts.map +1 -0
- package/dist/code-range.js +124 -0
- package/dist/code-range.js.map +1 -0
- package/dist/diff.d.ts +42 -0
- package/dist/diff.d.ts.map +1 -0
- package/dist/diff.js +179 -0
- package/dist/diff.js.map +1 -0
- package/dist/engine.d.ts +15 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +17 -0
- package/dist/engine.js.map +1 -0
- package/dist/highlight.d.ts +43 -0
- package/dist/highlight.d.ts.map +1 -0
- package/dist/highlight.js +243 -0
- package/dist/highlight.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/layout.d.ts +77 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/layout.js +74 -0
- package/dist/layout.js.map +1 -0
- package/dist/measure-cache.d.ts +38 -0
- package/dist/measure-cache.d.ts.map +1 -0
- package/dist/measure-cache.js +0 -0
- package/dist/measure-cache.js.map +1 -0
- package/dist/node.d.ts +293 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +733 -0
- package/dist/node.js.map +1 -0
- package/dist/nodes.d.ts +18 -0
- package/dist/nodes.d.ts.map +1 -0
- package/dist/nodes.js +18 -0
- package/dist/nodes.js.map +1 -0
- package/dist/render.d.ts +28 -0
- package/dist/render.d.ts.map +1 -0
- package/dist/render.js +223 -0
- package/dist/render.js.map +1 -0
- package/dist/style.d.ts +88 -0
- package/dist/style.d.ts.map +1 -0
- package/dist/style.js +203 -0
- package/dist/style.js.map +1 -0
- package/dist/tokens.d.ts +24 -0
- package/dist/tokens.d.ts.map +1 -0
- package/dist/tokens.js +50 -0
- package/dist/tokens.js.map +1 -0
- package/dist/transitions.d.ts +115 -0
- package/dist/transitions.d.ts.map +1 -0
- package/dist/transitions.js +85 -0
- package/dist/transitions.js.map +1 -0
- package/package.json +82 -3
- package/registry.json +6 -0
- package/src/code-range.ts +156 -0
- package/src/diff.ts +216 -0
- package/src/engine.ts +21 -0
- package/src/highlight.ts +291 -0
- package/src/index.ts +3 -0
- package/src/layout.ts +157 -0
- package/src/measure-cache.ts +0 -0
- package/src/node.ts +834 -0
- package/src/nodes.ts +18 -0
- package/src/render.ts +247 -0
- package/src/style.ts +251 -0
- package/src/tokens.ts +75 -0
- package/src/transitions.ts +184 -0
package/src/nodes.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Code } from "./node";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Every node type this package publishes.
|
|
5
|
+
*
|
|
6
|
+
* A host registers these by handing the array to an engine —
|
|
7
|
+
* `new Engine(platform, { nodes: NODES })` — which reads each class's `@node()`
|
|
8
|
+
* key. The decorator only *declares* that key: nothing is registered by the mere
|
|
9
|
+
* act of importing a module, so a registry holds exactly what its owner asked
|
|
10
|
+
* for and two engines in one process can differ about it.
|
|
11
|
+
*
|
|
12
|
+
* Listing class **values** is also what survives bundling. This package declares
|
|
13
|
+
* `sideEffects: false`, which licenses a bundler to drop a module nothing
|
|
14
|
+
* imports a value from, and a document names a node type by string — so
|
|
15
|
+
* `import "./node"` for its side effect is exactly what gets shaken out, where an
|
|
16
|
+
* array of bindings is a data dependency that cannot be.
|
|
17
|
+
*/
|
|
18
|
+
export const NODES = [Code];
|
package/src/render.ts
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { RenderContext2D, Graphics2D, NormalizedColor, parseColor, lerpNumber } from "@motionscript/core";
|
|
2
|
+
import type { CodeLayout, CodeMetrics } from "./layout";
|
|
3
|
+
import type { IdLine } from "./tokens";
|
|
4
|
+
import { windowProgress, moveProgress } from "./transitions";
|
|
5
|
+
import type { TokenState, StructuralTransition } from "./transitions";
|
|
6
|
+
import type { TokenAdvanceCache } from "./measure-cache";
|
|
7
|
+
|
|
8
|
+
/** Colour of a token the grammar had no opinion about. */
|
|
9
|
+
const DEFAULT_TOKEN_COLOR: NormalizedColor = [0.82, 0.84, 0.86, 1];
|
|
10
|
+
/** Gutter line-number colour: a muted version of the standard text colour. */
|
|
11
|
+
const LINE_NUMBER_COLOR: NormalizedColor = [0.45, 0.5, 0.55, 1];
|
|
12
|
+
|
|
13
|
+
// `parseColor` walks a CSS string; a listing asks for the same handful of theme
|
|
14
|
+
// colours on every token of every frame, so the parse is memoized. Module-level,
|
|
15
|
+
// because the answer depends only on the string.
|
|
16
|
+
const colorCache = new Map<string, NormalizedColor>();
|
|
17
|
+
function tokenColor(css: string | undefined): NormalizedColor {
|
|
18
|
+
if (!css) return DEFAULT_TOKEN_COLOR;
|
|
19
|
+
let hit = colorCache.get(css);
|
|
20
|
+
if (!hit) {
|
|
21
|
+
hit = parseColor(css);
|
|
22
|
+
colorCache.set(css, hit);
|
|
23
|
+
}
|
|
24
|
+
return hit;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function lerpColor(from: NormalizedColor, to: NormalizedColor, t: number): NormalizedColor {
|
|
28
|
+
if (t <= 0) return from;
|
|
29
|
+
if (t >= 1) return to;
|
|
30
|
+
return [
|
|
31
|
+
from[0] + (to[0] - from[0]) * t,
|
|
32
|
+
from[1] + (to[1] - from[1]) * t,
|
|
33
|
+
from[2] + (to[2] - from[2]) * t,
|
|
34
|
+
from[3] + (to[3] - from[3]) * t,
|
|
35
|
+
];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Opacity multiplier for a line's number under the active highlight. The
|
|
39
|
+
// number stays bright only when the WHOLE line is highlighted, so we take the
|
|
40
|
+
// min token opacity on the line. Returns 1 (no dimming) when no highlight is
|
|
41
|
+
// active, so an edit's entries don't drag the number down.
|
|
42
|
+
function lineHighlightOpacity(line: IdLine, stateById: Map<number, TokenState>, highlightActive: boolean): number {
|
|
43
|
+
if (!highlightActive) return 1;
|
|
44
|
+
let min = 1;
|
|
45
|
+
for (const tok of line.tokens) {
|
|
46
|
+
if (tok.content.length === 0) continue;
|
|
47
|
+
const op = stateById.get(tok.id)?.opacity ?? 1;
|
|
48
|
+
if (op < min) min = op;
|
|
49
|
+
}
|
|
50
|
+
return min;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Everything a `Code` node's own instance state supplies to draw one frame. */
|
|
54
|
+
export interface DrawCodeState {
|
|
55
|
+
tokenLines: IdLine[];
|
|
56
|
+
metrics: CodeMetrics;
|
|
57
|
+
advanceCache: TokenAdvanceCache;
|
|
58
|
+
/** The one or two layouts this frame draws from — see `frameLayout` in `layout.ts`. */
|
|
59
|
+
from: CodeLayout;
|
|
60
|
+
to: CodeLayout;
|
|
61
|
+
edit: StructuralTransition | null;
|
|
62
|
+
/** Per-token opacity from `resolveTokenStates` (persistent highlight + any active dim transition). */
|
|
63
|
+
tokenStates: Map<number, TokenState>;
|
|
64
|
+
/** Whether a highlight (persistent dim, or a highlight()/resetHighlight() cross-fade) is currently engaged. */
|
|
65
|
+
highlightActive: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Draw one frame of a code listing: the settled structure, or — mid
|
|
70
|
+
* structural edit — the two structures it is interpolating between (what
|
|
71
|
+
* stays reflows, what is leaving fades out where it was, what is arriving
|
|
72
|
+
* fades/slides in where it lands). Pure over `state`; touches nothing but
|
|
73
|
+
* `draw`.
|
|
74
|
+
*/
|
|
75
|
+
export function drawCode(draw: RenderContext2D, state: DrawCodeState): void {
|
|
76
|
+
const { tokenLines, metrics, advanceCache, from, to, edit, tokenStates: dim } = state;
|
|
77
|
+
|
|
78
|
+
const progress = edit ? edit.progress : 1;
|
|
79
|
+
const pOut = edit ? windowProgress(edit.phases.out, progress) : 1;
|
|
80
|
+
const pMove = moveProgress(edit);
|
|
81
|
+
const pIn = edit ? windowProgress(edit.phases.in, progress) : 1;
|
|
82
|
+
|
|
83
|
+
const blockW = edit ? lerpNumber(from.blockW, to.blockW, pMove) : to.blockW;
|
|
84
|
+
const gutter = edit ? lerpNumber(from.gutter, to.gutter, pMove) : to.gutter;
|
|
85
|
+
const gutterGap = to.gutterGap || from.gutterGap;
|
|
86
|
+
// Right edge of the line-number column: one gap to the left of where the
|
|
87
|
+
// code text begins.
|
|
88
|
+
const numberRight = -blockW / 2 + metrics.padding.left + gutter - gutterGap;
|
|
89
|
+
|
|
90
|
+
const drawNumber = (label: string, y: number, opacity: number): void => {
|
|
91
|
+
if (opacity <= 0) return;
|
|
92
|
+
const labelW = advanceCache.advance(draw, label, metrics.fontSize, metrics.fontFamily, 0);
|
|
93
|
+
draw.draw(new Graphics2D()
|
|
94
|
+
.text({
|
|
95
|
+
text: label,
|
|
96
|
+
fontSize: metrics.fontSize,
|
|
97
|
+
fontFamily: metrics.fontFamily,
|
|
98
|
+
lineHeight: metrics.lineHeight,
|
|
99
|
+
x: numberRight - labelW / 2,
|
|
100
|
+
y,
|
|
101
|
+
textAlign: 'left',
|
|
102
|
+
})
|
|
103
|
+
.fill([{ type: "solid", color: LINE_NUMBER_COLOR, opacity }]));
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const drawToken = (text: string, x: number, y: number, color: NormalizedColor, opacity: number): void => {
|
|
107
|
+
if (opacity <= 0 || text.length === 0) return;
|
|
108
|
+
const width = advanceCache.advance(draw, text, metrics.fontSize, metrics.fontFamily, metrics.letterSpacing);
|
|
109
|
+
draw.draw(new Graphics2D()
|
|
110
|
+
.text({
|
|
111
|
+
text,
|
|
112
|
+
fontSize: metrics.fontSize,
|
|
113
|
+
fontFamily: metrics.fontFamily,
|
|
114
|
+
lineHeight: metrics.lineHeight,
|
|
115
|
+
letterSpacing: metrics.letterSpacing,
|
|
116
|
+
// The renderer centres a single-token block on the (x, y) it is
|
|
117
|
+
// given, so every token is anchored at the centre of its cell
|
|
118
|
+
// rather than at its left edge. Passing lineHeight makes the
|
|
119
|
+
// block's height deterministic (fontSize × lineHeight) rather
|
|
120
|
+
// than the font's natural metrics, so the vertical centre
|
|
121
|
+
// lands exactly on the slot centre.
|
|
122
|
+
x: x + width / 2,
|
|
123
|
+
y,
|
|
124
|
+
textAlign: 'left',
|
|
125
|
+
})
|
|
126
|
+
.fill([{ type: "solid", color, opacity }]));
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// What survived the edit, and what it brought with it — drawn from the
|
|
130
|
+
// structure the edit lands on.
|
|
131
|
+
for (let i = 0; i < tokenLines.length; i++) {
|
|
132
|
+
const line = tokenLines[i];
|
|
133
|
+
const toIndex = to.lineIndex.get(line.id);
|
|
134
|
+
if (toIndex === undefined) continue;
|
|
135
|
+
const toY = to.lineY[toIndex];
|
|
136
|
+
|
|
137
|
+
const entering = edit?.enteringLineIds.has(line.id) ?? false;
|
|
138
|
+
let rowY = toY;
|
|
139
|
+
let rowAlpha = 1;
|
|
140
|
+
if (edit && entering) {
|
|
141
|
+
// A wholly new row fades as one piece, in the place it lands — the
|
|
142
|
+
// line arrives, it does not travel to get there, and it is not a
|
|
143
|
+
// spray of glyphs each finding its own way in. See
|
|
144
|
+
// `StructuralTransition.enteringLineIds`.
|
|
145
|
+
rowAlpha = pIn;
|
|
146
|
+
} else if (edit) {
|
|
147
|
+
const fromIndex = from.lineIndex.get(line.id);
|
|
148
|
+
if (fromIndex !== undefined) rowY = lerpNumber(from.lineY[fromIndex], toY, pMove);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
for (const token of line.tokens) {
|
|
152
|
+
const box = to.tokens.get(token.id);
|
|
153
|
+
if (!box) continue;
|
|
154
|
+
|
|
155
|
+
let x = box.x;
|
|
156
|
+
let alpha = rowAlpha;
|
|
157
|
+
let color = tokenColor(token.color);
|
|
158
|
+
|
|
159
|
+
if (edit && !entering) {
|
|
160
|
+
if (edit.addedIds.has(token.id)) {
|
|
161
|
+
// Spliced into a row that already existed: the reflow
|
|
162
|
+
// already opened the space, so this only has to appear.
|
|
163
|
+
alpha = pIn;
|
|
164
|
+
} else {
|
|
165
|
+
const was = from.tokens.get(token.id);
|
|
166
|
+
if (was) x = lerpNumber(was.x, box.x, pMove);
|
|
167
|
+
if (edit.fromColorById.has(token.id)) {
|
|
168
|
+
color = lerpColor(tokenColor(edit.fromColorById.get(token.id)), color, pMove);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
drawToken(token.content, x, rowY, color, alpha * (dim.get(token.id)?.opacity ?? 1));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// What the edit takes away, drawn from the structure it is leaving — it
|
|
178
|
+
// has no place in the new one, which is the whole reason it is fading.
|
|
179
|
+
if (edit && pOut < 1) {
|
|
180
|
+
for (let i = 0; i < edit.from.length; i++) {
|
|
181
|
+
const line = edit.from[i];
|
|
182
|
+
const fromIndex = from.lineIndex.get(line.id);
|
|
183
|
+
if (fromIndex === undefined) continue;
|
|
184
|
+
const toIndex = to.lineIndex.get(line.id);
|
|
185
|
+
// A row that survived the edit is still moving, so its outgoing
|
|
186
|
+
// tokens travel with it; a deleted row stays where it was and
|
|
187
|
+
// dissolves while the rows below close over it.
|
|
188
|
+
const y = toIndex === undefined
|
|
189
|
+
? from.lineY[fromIndex]
|
|
190
|
+
: lerpNumber(from.lineY[fromIndex], to.lineY[toIndex], pMove);
|
|
191
|
+
|
|
192
|
+
for (const token of line.tokens) {
|
|
193
|
+
if (!edit.removedIds.has(token.id)) continue;
|
|
194
|
+
const box = from.tokens.get(token.id);
|
|
195
|
+
if (!box) continue;
|
|
196
|
+
drawToken(token.content, box.x, y, tokenColor(token.color), 1 - pOut);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (metrics.showLineNumbers) drawLineNumbers(state, drawNumber, pMove, pIn, pOut);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Draw the gutter — one number per *slot*, not one per row.
|
|
206
|
+
*
|
|
207
|
+
* That distinction is the whole of it. A line number is an ordinal of the
|
|
208
|
+
* block: "3" means the third line, and it is there for exactly as long as the
|
|
209
|
+
* listing has a third line. Drawing it alongside the row it labels — the
|
|
210
|
+
* obvious thing, since that is where it appears — tied it to the row's identity
|
|
211
|
+
* instead, so a line rewritten past the point the diff could pair it took its
|
|
212
|
+
* number down with it and brought an identical one back up. The number blinked
|
|
213
|
+
* although it had never gone away, and every substantially rewritten line did
|
|
214
|
+
* it at once.
|
|
215
|
+
*
|
|
216
|
+
* Numbering the slots means only the numbers at the end move at all: those the
|
|
217
|
+
* block grew to reach fade in, those it shrank past fade out, and every number
|
|
218
|
+
* both structures have simply travels to its new row height. Which is also the
|
|
219
|
+
* honest reading of the edit — inserting a line in the middle does not make a
|
|
220
|
+
* new number appear there, it makes the block one line longer.
|
|
221
|
+
*/
|
|
222
|
+
function drawLineNumbers(
|
|
223
|
+
state: DrawCodeState,
|
|
224
|
+
drawNumber: (label: string, y: number, opacity: number) => void,
|
|
225
|
+
pMove: number,
|
|
226
|
+
pIn: number,
|
|
227
|
+
pOut: number,
|
|
228
|
+
): void {
|
|
229
|
+
const { tokenLines, from, to, edit, tokenStates: dim, highlightActive } = state;
|
|
230
|
+
|
|
231
|
+
const toCount = tokenLines.length;
|
|
232
|
+
const fromCount = edit ? edit.from.length : toCount;
|
|
233
|
+
|
|
234
|
+
for (let i = 0; i < Math.max(fromCount, toCount); i++) {
|
|
235
|
+
const inFrom = i < fromCount;
|
|
236
|
+
const inTo = i < toCount;
|
|
237
|
+
// Highlight dimming follows whatever content occupies the slot, which
|
|
238
|
+
// is the new listing wherever there is one.
|
|
239
|
+
const line = inTo ? tokenLines[i] : edit?.from[i];
|
|
240
|
+
const dimming = line ? lineHighlightOpacity(line, dim, highlightActive) : 1;
|
|
241
|
+
|
|
242
|
+
if (!edit) drawNumber(String(i + 1), to.lineY[i], dimming);
|
|
243
|
+
else if (inFrom && inTo) drawNumber(String(i + 1), lerpNumber(from.lineY[i], to.lineY[i], pMove), dimming);
|
|
244
|
+
else if (inTo) drawNumber(String(i + 1), to.lineY[i], pIn * dimming);
|
|
245
|
+
else drawNumber(String(i + 1), from.lineY[i], (1 - pOut) * dimming);
|
|
246
|
+
}
|
|
247
|
+
}
|
package/src/style.ts
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { Highlighter, Tag, tagHighlighter, tags as t } from "@lezer/highlight";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A single style rule: map one or more Lezer highlight {@link Tag}s to a color.
|
|
5
|
+
* This mirrors a `@codemirror/language` HighlightStyle entry, but instead of
|
|
6
|
+
* emitting CSS class names it carries the color directly — we render to a canvas,
|
|
7
|
+
* not the DOM, so there is no stylesheet to resolve against.
|
|
8
|
+
*/
|
|
9
|
+
export interface CodeStyleRule {
|
|
10
|
+
tag: Tag | readonly Tag[];
|
|
11
|
+
color: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A code theme: an ordered list of tag→color rules plus a fallback color for any
|
|
16
|
+
* text the parser produces no highlight tag for (operators, punctuation, plain
|
|
17
|
+
* identifiers in some grammars, etc.). Pass a custom one via the Code node's
|
|
18
|
+
* `theme` prop to recolor highlighting without touching the highlighter wiring.
|
|
19
|
+
*
|
|
20
|
+
* @remarks
|
|
21
|
+
* Later rules win when several match the same tag, matching how
|
|
22
|
+
* `@lezer/highlight`'s {@link tagHighlighter} resolves precedence.
|
|
23
|
+
*/
|
|
24
|
+
export interface CodeHighlightStyle {
|
|
25
|
+
/** Stable name, used as the highlighter cache key. */
|
|
26
|
+
name: string;
|
|
27
|
+
/** Color applied to text with no matching tag. */
|
|
28
|
+
defaultColor: string;
|
|
29
|
+
rules: CodeStyleRule[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Motion Canvas's default highlight palette (a Nord-derived theme), ported from
|
|
34
|
+
* `@motion-canvas/2d`'s DefaultHighlightStyle so highlighting matches Motion
|
|
35
|
+
* Canvas token-for-token. Colors and tag groupings are kept identical; only the
|
|
36
|
+
* shape changes (color strings in place of CSS rules).
|
|
37
|
+
*/
|
|
38
|
+
export const DefaultHighlightStyle: CodeHighlightStyle = {
|
|
39
|
+
name: "motion-canvas-default",
|
|
40
|
+
defaultColor: "#d8dee9",
|
|
41
|
+
rules: [
|
|
42
|
+
{ tag: t.keyword, color: "#5e81ac" },
|
|
43
|
+
{ tag: [t.name, t.deleted, t.character, t.propertyName, t.macroName], color: "#88c0d0" },
|
|
44
|
+
{ tag: [t.variableName], color: "#8fbcbb" },
|
|
45
|
+
{ tag: [t.function(t.variableName)], color: "#8fbcbb" },
|
|
46
|
+
{ tag: [t.labelName], color: "#81a1c1" },
|
|
47
|
+
{ tag: [t.color, t.constant(t.name), t.standard(t.name)], color: "#5e81ac" },
|
|
48
|
+
{ tag: [t.definition(t.name), t.separator], color: "#a3be8c" },
|
|
49
|
+
{ tag: [t.brace], color: "#8fbcbb" },
|
|
50
|
+
{ tag: [t.annotation], color: "#d30102" },
|
|
51
|
+
{ tag: [t.number, t.changed, t.annotation, t.modifier, t.self, t.namespace], color: "#b48ead" },
|
|
52
|
+
{ tag: [t.typeName, t.className], color: "#ECEFF4" },
|
|
53
|
+
{ tag: [t.operator, t.operatorKeyword], color: "#a3be8c" },
|
|
54
|
+
{ tag: [t.tagName], color: "#b48ead" },
|
|
55
|
+
{ tag: [t.squareBracket], color: "#ECEFF4" },
|
|
56
|
+
{ tag: [t.angleBracket], color: "#ECEFF4" },
|
|
57
|
+
{ tag: [t.attributeName], color: "#eceff4" },
|
|
58
|
+
{ tag: [t.regexp], color: "#5e81ac" },
|
|
59
|
+
{ tag: [t.quote], color: "#b48ead" },
|
|
60
|
+
{ tag: [t.string], color: "#a3be8c" },
|
|
61
|
+
{ tag: t.link, color: "#a3be8c" },
|
|
62
|
+
{ tag: [t.url, t.escape, t.special(t.string)], color: "#8fbcbb" },
|
|
63
|
+
{ tag: [t.meta], color: "#88c0d0" },
|
|
64
|
+
{ tag: [t.monospace], color: "#d8dee9" },
|
|
65
|
+
{ tag: [t.comment], color: "#4c566a" },
|
|
66
|
+
{ tag: t.strong, color: "#5e81ac" },
|
|
67
|
+
{ tag: t.emphasis, color: "#5e81ac" },
|
|
68
|
+
{ tag: t.heading, color: "#5e81ac" },
|
|
69
|
+
{ tag: t.special(t.heading1), color: "#5e81ac" },
|
|
70
|
+
{ tag: t.heading1, color: "#5e81ac" },
|
|
71
|
+
{ tag: [t.heading2, t.heading3, t.heading4], color: "#5e81ac" },
|
|
72
|
+
{ tag: [t.heading5, t.heading6], color: "#5e81ac" },
|
|
73
|
+
{ tag: [t.atom, t.bool, t.special(t.variableName)], color: "#d08770" },
|
|
74
|
+
{ tag: [t.processingInstruction, t.inserted], color: "#8fbcbb" },
|
|
75
|
+
{ tag: [t.contentSeparator], color: "#ebcb8b" },
|
|
76
|
+
{ tag: t.invalid, color: "#434c5e" },
|
|
77
|
+
],
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* GitHub Dark highlight palette, adapted from `@uiw/codemirror-theme-github`.
|
|
82
|
+
* `defaultColor` is GitHub's editor foreground (`#c9d1d9`), used for any text the
|
|
83
|
+
* parser tags don't cover. DOM-only style props (backgroundColor, textDecoration,
|
|
84
|
+
* fontWeight, fontStyle) are dropped — we only color tokens on the canvas.
|
|
85
|
+
*/
|
|
86
|
+
export const GithubDarkStyle: CodeHighlightStyle = {
|
|
87
|
+
name: "github-dark",
|
|
88
|
+
defaultColor: "#c9d1d9",
|
|
89
|
+
rules: [
|
|
90
|
+
{ tag: [t.standard(t.tagName), t.tagName], color: "#7ee787" },
|
|
91
|
+
{ tag: [t.comment, t.bracket], color: "#8b949e" },
|
|
92
|
+
{ tag: [t.className, t.propertyName], color: "#d2a8ff" },
|
|
93
|
+
{ tag: [t.variableName, t.attributeName, t.number, t.operator], color: "#79c0ff" },
|
|
94
|
+
{ tag: [t.keyword, t.typeName, t.typeOperator], color: "#ff7b72" },
|
|
95
|
+
{ tag: [t.string, t.meta, t.regexp], color: "#a5d6ff" },
|
|
96
|
+
{ tag: [t.name, t.quote], color: "#7ee787" },
|
|
97
|
+
{ tag: [t.heading, t.strong], color: "#d2a8ff" },
|
|
98
|
+
{ tag: [t.emphasis], color: "#d2a8ff" },
|
|
99
|
+
{ tag: [t.deleted], color: "#ffdcd7" },
|
|
100
|
+
{ tag: [t.atom, t.bool, t.special(t.variableName)], color: "#ffab70" },
|
|
101
|
+
{ tag: t.invalid, color: "#f97583" },
|
|
102
|
+
],
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* GitHub Light highlight palette, adapted from `@uiw/codemirror-theme-github`.
|
|
107
|
+
* `defaultColor` is GitHub's editor foreground (`#24292e`). See {@link GithubDarkStyle}
|
|
108
|
+
* for the note on dropped DOM-only style props.
|
|
109
|
+
*/
|
|
110
|
+
export const GithubLightStyle: CodeHighlightStyle = {
|
|
111
|
+
name: "github-light",
|
|
112
|
+
defaultColor: "#24292e",
|
|
113
|
+
rules: [
|
|
114
|
+
{ tag: [t.standard(t.tagName), t.tagName], color: "#116329" },
|
|
115
|
+
{ tag: [t.comment, t.bracket], color: "#6a737d" },
|
|
116
|
+
{ tag: [t.className, t.propertyName], color: "#6f42c1" },
|
|
117
|
+
{ tag: [t.variableName, t.attributeName, t.number, t.operator], color: "#005cc5" },
|
|
118
|
+
{ tag: [t.keyword, t.typeName, t.typeOperator], color: "#d73a49" },
|
|
119
|
+
{ tag: [t.string, t.meta, t.regexp], color: "#032f62" },
|
|
120
|
+
{ tag: [t.name, t.quote], color: "#22863a" },
|
|
121
|
+
{ tag: [t.heading, t.strong], color: "#24292e" },
|
|
122
|
+
{ tag: [t.emphasis], color: "#24292e" },
|
|
123
|
+
{ tag: [t.deleted], color: "#b31d28" },
|
|
124
|
+
{ tag: [t.atom, t.bool, t.special(t.variableName)], color: "#e36209" },
|
|
125
|
+
{ tag: [t.url, t.escape, t.regexp, t.link], color: "#032f62" },
|
|
126
|
+
{ tag: t.invalid, color: "#cb2431" },
|
|
127
|
+
],
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* VS Code Dark+ highlight palette, adapted from the `@uiw/codemirror-theme-vscode`
|
|
132
|
+
* snippet. `defaultColor` is VS Code's editor foreground (`#9cdcfe`). DOM-only
|
|
133
|
+
* style props are dropped; later rules win, matching {@link tagHighlighter}.
|
|
134
|
+
*/
|
|
135
|
+
export const VscodeDarkStyle: CodeHighlightStyle = {
|
|
136
|
+
name: "vscode-dark",
|
|
137
|
+
defaultColor: "#9cdcfe",
|
|
138
|
+
rules: [
|
|
139
|
+
{
|
|
140
|
+
tag: [
|
|
141
|
+
t.keyword, t.operatorKeyword, t.modifier, t.color,
|
|
142
|
+
t.constant(t.name), t.standard(t.name), t.standard(t.tagName),
|
|
143
|
+
t.special(t.brace), t.atom, t.bool, t.special(t.variableName),
|
|
144
|
+
],
|
|
145
|
+
color: "#569cd6",
|
|
146
|
+
},
|
|
147
|
+
{ tag: [t.controlKeyword, t.moduleKeyword], color: "#c586c0" },
|
|
148
|
+
{
|
|
149
|
+
tag: [
|
|
150
|
+
t.name, t.deleted, t.character, t.macroName, t.propertyName,
|
|
151
|
+
t.variableName, t.labelName, t.definition(t.name),
|
|
152
|
+
],
|
|
153
|
+
color: "#9cdcfe",
|
|
154
|
+
},
|
|
155
|
+
{ tag: t.heading, color: "#9cdcfe" },
|
|
156
|
+
{ tag: [t.typeName, t.className, t.tagName, t.changed, t.annotation, t.self, t.namespace], color: "#4ec9b0" },
|
|
157
|
+
{ tag: [t.function(t.variableName), t.function(t.propertyName)], color: "#dcdcaa" },
|
|
158
|
+
{ tag: [t.number], color: "#b5cea8" },
|
|
159
|
+
{ tag: [t.operator, t.punctuation, t.separator, t.url, t.escape], color: "#d4d4d4" },
|
|
160
|
+
{ tag: [t.regexp], color: "#d16969" },
|
|
161
|
+
{ tag: [t.special(t.string), t.processingInstruction, t.string, t.inserted], color: "#ce9178" },
|
|
162
|
+
{ tag: [t.angleBracket], color: "#808080" },
|
|
163
|
+
{ tag: [t.meta, t.comment], color: "#6a9955" },
|
|
164
|
+
{ tag: t.link, color: "#6a9955" },
|
|
165
|
+
{ tag: t.invalid, color: "#ff0000" },
|
|
166
|
+
],
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* VS Code Light+ highlight palette, adapted from the `@uiw/codemirror-theme-vscode`
|
|
171
|
+
* snippet. `defaultColor` is VS Code's editor foreground (`#383a42`). See
|
|
172
|
+
* {@link VscodeDarkStyle} for the note on dropped DOM-only style props.
|
|
173
|
+
*/
|
|
174
|
+
export const VscodeLightStyle: CodeHighlightStyle = {
|
|
175
|
+
name: "vscode-light",
|
|
176
|
+
defaultColor: "#383a42",
|
|
177
|
+
rules: [
|
|
178
|
+
{
|
|
179
|
+
tag: [
|
|
180
|
+
t.keyword, t.operatorKeyword, t.modifier, t.color,
|
|
181
|
+
t.constant(t.name), t.standard(t.name), t.standard(t.tagName),
|
|
182
|
+
t.special(t.brace), t.atom, t.bool, t.special(t.variableName),
|
|
183
|
+
],
|
|
184
|
+
color: "#0000ff",
|
|
185
|
+
},
|
|
186
|
+
{ tag: [t.moduleKeyword, t.controlKeyword], color: "#af00db" },
|
|
187
|
+
{
|
|
188
|
+
tag: [
|
|
189
|
+
t.name, t.deleted, t.character, t.macroName, t.propertyName,
|
|
190
|
+
t.variableName, t.labelName, t.definition(t.name),
|
|
191
|
+
],
|
|
192
|
+
color: "#0070c1",
|
|
193
|
+
},
|
|
194
|
+
{ tag: t.heading, color: "#0070c1" },
|
|
195
|
+
{ tag: [t.typeName, t.className, t.tagName, t.changed, t.annotation, t.self, t.namespace], color: "#267f99" },
|
|
196
|
+
{ tag: [t.function(t.variableName), t.function(t.propertyName)], color: "#795e26" },
|
|
197
|
+
{ tag: [t.number], color: "#098658" },
|
|
198
|
+
{ tag: [t.operator, t.punctuation, t.separator, t.url, t.escape], color: "#383a42" },
|
|
199
|
+
{ tag: [t.regexp], color: "#af00db" },
|
|
200
|
+
{ tag: [t.special(t.string), t.processingInstruction, t.string, t.inserted], color: "#a31515" },
|
|
201
|
+
{ tag: [t.angleBracket], color: "#383a42" },
|
|
202
|
+
{ tag: [t.meta, t.comment], color: "#008000" },
|
|
203
|
+
{ tag: t.link, color: "#4078f2" },
|
|
204
|
+
{ tag: t.invalid, color: "#e45649" },
|
|
205
|
+
],
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The built-in themes, keyed by their string name. {@link CodeThemeName} is the
|
|
210
|
+
* union of these keys — pass one of those strings (or any {@link CodeHighlightStyle}
|
|
211
|
+
* object) to a Code node's `theme` prop.
|
|
212
|
+
*/
|
|
213
|
+
export const BUILTIN_THEMES = {
|
|
214
|
+
// Literal keys (not computed from `.name`) so `keyof` yields a string-literal
|
|
215
|
+
// union for CodeThemeName; these must stay in sync with each style's `name`.
|
|
216
|
+
"motion-canvas-default": DefaultHighlightStyle,
|
|
217
|
+
"github-dark": GithubDarkStyle,
|
|
218
|
+
"github-light": GithubLightStyle,
|
|
219
|
+
"vscode-dark": VscodeDarkStyle,
|
|
220
|
+
"vscode-light": VscodeLightStyle,
|
|
221
|
+
} satisfies Record<string, CodeHighlightStyle>;
|
|
222
|
+
|
|
223
|
+
/** Names of the built-in themes, e.g. `'github-dark' | 'github-light'`. */
|
|
224
|
+
export type CodeThemeName = keyof typeof BUILTIN_THEMES;
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* What a Code node's `theme` prop accepts: a built-in theme name (with
|
|
228
|
+
* autocomplete), any other registered theme name, or a {@link CodeHighlightStyle}
|
|
229
|
+
* object passed inline. The `string & {}` keeps the literal-name suggestions while
|
|
230
|
+
* still allowing arbitrary registered names.
|
|
231
|
+
*/
|
|
232
|
+
export type CodeTheme = CodeThemeName | (string & {}) | CodeHighlightStyle;
|
|
233
|
+
|
|
234
|
+
// Cache one compiled Highlighter per style name. Compiling builds a tag-rule
|
|
235
|
+
// matcher; the parsed result is pure data so it's safe to share across Code nodes.
|
|
236
|
+
const compiledByName = new Map<string, Highlighter>();
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Compile a {@link CodeHighlightStyle} into a `@lezer/highlight` {@link Highlighter}
|
|
240
|
+
* whose "class" output is the literal color string for each tag. Cached by style
|
|
241
|
+
* name so repeated lookups (every tokenize) don't rebuild the matcher.
|
|
242
|
+
*/
|
|
243
|
+
export function compileStyle(style: CodeHighlightStyle): Highlighter {
|
|
244
|
+
const cached = compiledByName.get(style.name);
|
|
245
|
+
if (cached) return cached;
|
|
246
|
+
const highlighter = tagHighlighter(
|
|
247
|
+
style.rules.map(rule => ({ tag: rule.tag, class: rule.color })),
|
|
248
|
+
);
|
|
249
|
+
compiledByName.set(style.name, highlighter);
|
|
250
|
+
return highlighter;
|
|
251
|
+
}
|
package/src/tokens.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { canHighlight, highlightToTokens } from "./highlight";
|
|
2
|
+
import { CodeTheme } from "./style";
|
|
3
|
+
|
|
4
|
+
// IdToken is our internal wrapper around a highlighter token that gives every
|
|
5
|
+
// token a stable identity across line insertions/removals. All animation state
|
|
6
|
+
// is keyed by `id`, so concurrent edits at different positions don't clobber
|
|
7
|
+
// each other while one is mid-animation.
|
|
8
|
+
export interface IdToken {
|
|
9
|
+
id: number;
|
|
10
|
+
content: string;
|
|
11
|
+
color?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// IdLine wraps a row of tokens with its own stable id, so per-line animations
|
|
15
|
+
// (heightScale during insert/remove) survive line insertions/removals.
|
|
16
|
+
export interface IdLine {
|
|
17
|
+
id: number;
|
|
18
|
+
tokens: IdToken[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Module-global, monotonic id counters shared across all Code nodes — ids only
|
|
22
|
+
// need to be unique within a node, but a global counter is the simplest way to
|
|
23
|
+
// guarantee that and never collides.
|
|
24
|
+
let nextTokenId = 1;
|
|
25
|
+
let nextLineId = 1;
|
|
26
|
+
|
|
27
|
+
export function makeIdToken(content: string, color?: string): IdToken {
|
|
28
|
+
return { id: nextTokenId++, content, color };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function makeIdLine(tokens: IdToken[]): IdLine {
|
|
32
|
+
return { id: nextLineId++, tokens };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function tokenizeCodeToIdLines(code: string, language: string, theme: CodeTheme): IdLine[] {
|
|
36
|
+
// Until the language parser has loaded (it streams in via the asset loader),
|
|
37
|
+
// fall back to flat, uncolored lines instead of throwing on an unloaded
|
|
38
|
+
// grammar. The node re-tokenizes once the load completes.
|
|
39
|
+
if (!canHighlight(language, theme)) {
|
|
40
|
+
return code.split('\n').map(line => makeIdLine([makeIdToken(line, '#d1d5db')]));
|
|
41
|
+
}
|
|
42
|
+
return highlightToTokens(code, language, theme).map(line =>
|
|
43
|
+
makeIdLine(line.map(tok => makeIdToken(tok.content, tok.color))),
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Split `tokens` at character offset `col` (relative to the joined content of
|
|
49
|
+
* the tokens). If `col` falls inside a token, that token is split into two new
|
|
50
|
+
* tokens with fresh ids (the original id is discarded — it didn't exist as a
|
|
51
|
+
* single unit on either side of the cut).
|
|
52
|
+
*/
|
|
53
|
+
export function splitTokensAt(
|
|
54
|
+
tokens: IdToken[],
|
|
55
|
+
col: number,
|
|
56
|
+
): { before: IdToken[]; after: IdToken[] } {
|
|
57
|
+
const before: IdToken[] = [];
|
|
58
|
+
const after: IdToken[] = [];
|
|
59
|
+
let off = 0;
|
|
60
|
+
for (const tok of tokens) {
|
|
61
|
+
const tStart = off;
|
|
62
|
+
const tEnd = off + tok.content.length;
|
|
63
|
+
if (tEnd <= col) {
|
|
64
|
+
before.push(tok);
|
|
65
|
+
} else if (tStart >= col) {
|
|
66
|
+
after.push(tok);
|
|
67
|
+
} else {
|
|
68
|
+
const cut = col - tStart;
|
|
69
|
+
before.push(makeIdToken(tok.content.slice(0, cut), tok.color));
|
|
70
|
+
after.push(makeIdToken(tok.content.slice(cut), tok.color));
|
|
71
|
+
}
|
|
72
|
+
off = tEnd;
|
|
73
|
+
}
|
|
74
|
+
return { before, after };
|
|
75
|
+
}
|