@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/node.ts
ADDED
|
@@ -0,0 +1,834 @@
|
|
|
1
|
+
import {
|
|
2
|
+
node,
|
|
3
|
+
RenderContext2D, RenderPass2D, Clip, EasingFunction, NodeConfig, Size2D, SizeConstraints,
|
|
4
|
+
Node2D, Node2DProps, MeasureContext2D, InsetsResolved, InsetsProps,
|
|
5
|
+
property, insetsOps, lerpNumber,
|
|
6
|
+
command, driveCommand, type AssetScope, type Command, type CommandArgs, type Node, type TweenStepper, type Vector2,
|
|
7
|
+
} from "@motionscript/core";
|
|
8
|
+
import {
|
|
9
|
+
CodeRange,
|
|
10
|
+
rangeToCharOffsets,
|
|
11
|
+
charOffsetsToRange,
|
|
12
|
+
TokenAdvanceCache,
|
|
13
|
+
IdLine,
|
|
14
|
+
tokenizeCodeToIdLines,
|
|
15
|
+
defaultCodeDiff,
|
|
16
|
+
type CodeDiffStrategy,
|
|
17
|
+
CodeLayout,
|
|
18
|
+
CodeMetrics,
|
|
19
|
+
layoutCode,
|
|
20
|
+
metricsSignature,
|
|
21
|
+
AnimToken,
|
|
22
|
+
CodeTransition,
|
|
23
|
+
StructuralTransition,
|
|
24
|
+
makeAnim,
|
|
25
|
+
defaultCodePhases,
|
|
26
|
+
type CodePhaseStrategy,
|
|
27
|
+
resolveTokenStates,
|
|
28
|
+
moveProgress,
|
|
29
|
+
canHighlight,
|
|
30
|
+
ensureHighlighter,
|
|
31
|
+
CodeTheme,
|
|
32
|
+
DefaultHighlightStyle,
|
|
33
|
+
drawCode,
|
|
34
|
+
} from "./engine";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A listing and nothing else — no paint slots, and no box behind the tokens.
|
|
38
|
+
*
|
|
39
|
+
* It did carry a `fill`/`overlay`/`stroke`/`shadow` set and the corner radius
|
|
40
|
+
* that shaped them, so a block could draw its own panel. That was one node doing
|
|
41
|
+
* two jobs, and the second one is a `Rect`'s: a listing set on a card is a `Code`
|
|
42
|
+
* inside a hugging `Rect`, which tracks the block's size for the same reason
|
|
43
|
+
* every other hugging container tracks its child's. What is left here is the
|
|
44
|
+
* listing — its source, how it is coloured, the face it is set in, and the space
|
|
45
|
+
* it keeps around itself.
|
|
46
|
+
*/
|
|
47
|
+
export interface CodeProps extends Node2DProps {
|
|
48
|
+
code: string;
|
|
49
|
+
language: string;
|
|
50
|
+
fontSize: number;
|
|
51
|
+
fontFamily: string;
|
|
52
|
+
/** A built-in/registered theme name (e.g. `'github-dark'`), or a {@link CodeHighlightStyle} object. */
|
|
53
|
+
theme: CodeTheme;
|
|
54
|
+
lineHeight: number;
|
|
55
|
+
letterSpacing: number;
|
|
56
|
+
showLineNumbers: boolean;
|
|
57
|
+
lineNumberGap: number;
|
|
58
|
+
padding: InsetsProps;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Construction-time-only options: pluggable engine behavior, set once when
|
|
63
|
+
* the node is created. Deliberately *not* part of {@link CodeProps} — every
|
|
64
|
+
* `@property` field accepts either a value or a zero-arg reactive binding
|
|
65
|
+
* (`value | (() => value)`, see `PropInputs`), and the node's own reactive
|
|
66
|
+
* write path (`_writeProp`) treats any function value as the latter. A
|
|
67
|
+
* strategy *is* a function, so it can't be told apart from a binding that
|
|
68
|
+
* computes one — it has to stay outside the reactive prop system entirely.
|
|
69
|
+
*/
|
|
70
|
+
export interface CodeStrategies {
|
|
71
|
+
/** Decides which token became which across an edit. Defaults to `defaultCodeDiff`. */
|
|
72
|
+
diffStrategy?: CodeDiffStrategy;
|
|
73
|
+
/** Decides when (in normalized transition time) each part of an edit happens. Defaults to `defaultCodePhases`. */
|
|
74
|
+
phaseStrategy?: CodePhaseStrategy;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
@node({
|
|
78
|
+
key: "code",
|
|
79
|
+
parentKey: "node",
|
|
80
|
+
forkable: true,
|
|
81
|
+
layout: {
|
|
82
|
+
defaultWidthMode: "hug",
|
|
83
|
+
defaultHeightMode: "hug",
|
|
84
|
+
acceptsChildren: false,
|
|
85
|
+
},
|
|
86
|
+
})
|
|
87
|
+
export class Code extends Node2D<CodeProps> {
|
|
88
|
+
|
|
89
|
+
@property({ default: "" }) declare readonly code: string;
|
|
90
|
+
@property({ default: "typescript" }) declare readonly language: string;
|
|
91
|
+
@property({ default: "Fira Mono" }) declare readonly fontFamily: string;
|
|
92
|
+
@property({ default: DefaultHighlightStyle.name }) declare readonly theme: CodeTheme;
|
|
93
|
+
@property({ default: 16 }) declare readonly fontSize: number;
|
|
94
|
+
@property({ default: 1.6 }) declare readonly lineHeight: number;
|
|
95
|
+
// Extra horizontal space added after every glyph (in px), like CSS
|
|
96
|
+
// letter-spacing. Folded into the advance a token is measured at, so
|
|
97
|
+
// measurement and drawing stay in lockstep.
|
|
98
|
+
@property({ default: 1.1 }) declare readonly letterSpacing: number;
|
|
99
|
+
@property({ default: false }) declare readonly showLineNumbers: boolean;
|
|
100
|
+
// Horizontal gap between the line-number column and the code text, expressed
|
|
101
|
+
// in space-widths (so it scales with fontSize). Only applies when
|
|
102
|
+
// showLineNumbers is on.
|
|
103
|
+
@property({ default: 2 }) declare readonly lineNumberGap: number;
|
|
104
|
+
@property({ default: 0, mapper: insetsOps.resolve, tween: insetsOps.lerp }) declare readonly padding: InsetsResolved;
|
|
105
|
+
|
|
106
|
+
/** The settled structure. During an edit, the structure the edit lands on. */
|
|
107
|
+
private tokenLines: IdLine[] = [];
|
|
108
|
+
private tokenized: boolean = false;
|
|
109
|
+
|
|
110
|
+
private transitions: CodeTransition[] = [];
|
|
111
|
+
|
|
112
|
+
// Caches expensive scope.measureText() calls; cleared when the font
|
|
113
|
+
// signature (fontSize|fontFamily) changes. See TokenAdvanceCache.
|
|
114
|
+
private advanceCache = new TokenAdvanceCache();
|
|
115
|
+
|
|
116
|
+
// Layout cache for *settled* frames. Bumped whenever tokenLines is
|
|
117
|
+
// reassigned, so a cache hit only ever happens on identical content. Frames
|
|
118
|
+
// inside a structural edit are served from that edit's own two cached
|
|
119
|
+
// layouts instead (see StructuralTransition).
|
|
120
|
+
private structureVersion = 0;
|
|
121
|
+
private layoutCacheKey: string | null = null;
|
|
122
|
+
private layoutCache: CodeLayout | null = null;
|
|
123
|
+
|
|
124
|
+
// Persistent dim state set by highlight() — applied during render to all
|
|
125
|
+
// tokens whose id is NOT in the highlight set. null means "not highlighting".
|
|
126
|
+
private highlightDimOpacity: number | null = null;
|
|
127
|
+
private highlightedIds: Set<number> = new Set();
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Which token became which across an edit, and when each part of that
|
|
131
|
+
* edit happens. Construction-time-only (see {@link CodeStrategies}) —
|
|
132
|
+
* resolved once here rather than left as `?? defaultX` at every call site.
|
|
133
|
+
*/
|
|
134
|
+
private readonly diffStrategy: CodeDiffStrategy;
|
|
135
|
+
private readonly phaseStrategy: CodePhaseStrategy;
|
|
136
|
+
|
|
137
|
+
constructor(props: NodeConfig<Code, CodeProps> & CodeStrategies) {
|
|
138
|
+
super(props);
|
|
139
|
+
this.diffStrategy = props.diffStrategy ?? defaultCodeDiff;
|
|
140
|
+
this.phaseStrategy = props.phaseStrategy ?? defaultCodePhases;
|
|
141
|
+
this.applyProp("width", props.width ?? "hug");
|
|
142
|
+
this.applyProp("height", props.height ?? "hug");
|
|
143
|
+
|
|
144
|
+
// Best-effort, for a highlighter constructed outside the asset pipeline
|
|
145
|
+
// entirely (a bare `new Code(...)` in a test or a headless measure). On
|
|
146
|
+
// the timeline the authoritative load is the one `declareAssets` declares,
|
|
147
|
+
// which `AssetManager.loadAt` awaits *before* this node is laid out — so
|
|
148
|
+
// there the tokens are right the first time rather than upgrading later.
|
|
149
|
+
// (Theme is a synchronous color style; only the language parser loads.)
|
|
150
|
+
ensureHighlighter(undefined, [this.language]).catch(() => { });
|
|
151
|
+
this.tokenize();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
set(props: { [K in keyof CodeProps]?: CodeProps[K] | (() => CodeProps[K]) }): void {
|
|
155
|
+
super.set(props);
|
|
156
|
+
if (props.code !== undefined || props.language !== undefined || props.theme !== undefined) {
|
|
157
|
+
this.tokenized = false;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private tokenize(): void {
|
|
162
|
+
this.setLines(tokenizeCodeToIdLines(this.code, this.language, this.theme));
|
|
163
|
+
// Only consider ourselves tokenized once we actually highlighted; while
|
|
164
|
+
// the language is still loading we keep retrying on each render.
|
|
165
|
+
this.tokenized = canHighlight(this.language, this.theme);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Swap in a new settled structure, invalidating the layout cache.
|
|
170
|
+
*
|
|
171
|
+
* The one place `tokenLines` is written, because the cache key is a version
|
|
172
|
+
* counter rather than a hash of the content — a write that skipped the bump
|
|
173
|
+
* would keep serving the previous frame's geometry for the new listing.
|
|
174
|
+
*/
|
|
175
|
+
private setLines(lines: IdLine[]): void {
|
|
176
|
+
if (this.tokenLines === lines) return;
|
|
177
|
+
this.tokenLines = lines;
|
|
178
|
+
this.structureVersion++;
|
|
179
|
+
this.layoutCache = null;
|
|
180
|
+
this.layoutCacheKey = null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The monospaced face this block measures and draws with, and the syntax
|
|
185
|
+
* grammar it tokenizes with.
|
|
186
|
+
*
|
|
187
|
+
* Both belong to *layout*: token x positions are measured against the face,
|
|
188
|
+
* and how many tokens there are depends on the grammar. Declared here, the
|
|
189
|
+
* grammar goes on the timeline as an ordinary asset: `AssetManager.loadAt`
|
|
190
|
+
* waits for it before the frame is laid out, so the first measurement is the
|
|
191
|
+
* right one. Nothing is freed on eviction — parsers are cheap to keep
|
|
192
|
+
* resident. The loader touches no node: `renderContent` re-tokenizes once
|
|
193
|
+
* the grammar is there.
|
|
194
|
+
*/
|
|
195
|
+
override declareAssets(assets: AssetScope): void {
|
|
196
|
+
super.declareAssets(assets);
|
|
197
|
+
assets.font(this.fontFamily);
|
|
198
|
+
assets.loader(`shiki:lang:${this.language}`, grammarLoader(this.language), { gate: "layout" });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── Geometry ────────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
private metrics(): CodeMetrics {
|
|
204
|
+
return {
|
|
205
|
+
fontSize: this.fontSize,
|
|
206
|
+
fontFamily: this.fontFamily,
|
|
207
|
+
lineHeight: this.lineHeight,
|
|
208
|
+
letterSpacing: this.letterSpacing,
|
|
209
|
+
padding: this.padding,
|
|
210
|
+
showLineNumbers: this.showLineNumbers,
|
|
211
|
+
lineNumberGap: this.lineNumberGap,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The structural edit currently in flight, if any. */
|
|
216
|
+
private activeEdit(): StructuralTransition | null {
|
|
217
|
+
for (let i = this.transitions.length - 1; i >= 0; i--) {
|
|
218
|
+
const tr = this.transitions[i];
|
|
219
|
+
if (tr.kind === "structural") return tr;
|
|
220
|
+
}
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private settledLayout(m: CodeMetrics, scope: MeasureContext2D | RenderContext2D): CodeLayout {
|
|
225
|
+
const key = `${this.structureVersion}|${metricsSignature(m)}|${this.advanceCache.signature(m.fontSize, m.fontFamily)}`;
|
|
226
|
+
if (this.layoutCache && this.layoutCacheKey === key) return this.layoutCache;
|
|
227
|
+
const layout = layoutCode(this.tokenLines, m, this.advanceCache, scope);
|
|
228
|
+
this.layoutCache = layout;
|
|
229
|
+
this.layoutCacheKey = key;
|
|
230
|
+
return layout;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The one or two layouts this frame is drawn from.
|
|
235
|
+
*
|
|
236
|
+
* A settled frame has one. A frame inside an edit has two — where every
|
|
237
|
+
* token was, and where it is going — and the frame is a point between them.
|
|
238
|
+
* That is the whole reason an insert no longer piles its glyphs at the left
|
|
239
|
+
* margin: a token's destination is *computed*, not approximated by collapsing
|
|
240
|
+
* the advance of everything around it.
|
|
241
|
+
*
|
|
242
|
+
* Both endpoints are fixed for the edit's duration, so they are built once
|
|
243
|
+
* and cached on the transition rather than rebuilt per frame.
|
|
244
|
+
*/
|
|
245
|
+
private frameLayout(scope: MeasureContext2D | RenderContext2D): {
|
|
246
|
+
from: CodeLayout;
|
|
247
|
+
to: CodeLayout;
|
|
248
|
+
edit: StructuralTransition | null;
|
|
249
|
+
} {
|
|
250
|
+
const m = this.metrics();
|
|
251
|
+
const edit = this.activeEdit();
|
|
252
|
+
if (!edit) {
|
|
253
|
+
const layout = this.settledLayout(m, scope);
|
|
254
|
+
return { from: layout, to: layout, edit: null };
|
|
255
|
+
}
|
|
256
|
+
const key = `${metricsSignature(m)}|${this.advanceCache.signature(m.fontSize, m.fontFamily)}`;
|
|
257
|
+
if (edit.layoutKey !== key || !edit.fromLayout || !edit.toLayout) {
|
|
258
|
+
edit.fromLayout = layoutCode(edit.from, m, this.advanceCache, scope);
|
|
259
|
+
edit.toLayout = layoutCode(edit.to, m, this.advanceCache, scope);
|
|
260
|
+
edit.layoutKey = key;
|
|
261
|
+
}
|
|
262
|
+
return { from: edit.fromLayout, to: edit.toLayout, edit };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
override measure(constraints: SizeConstraints, scope: MeasureContext2D): Partial<Size2D> {
|
|
266
|
+
this.advanceCache.sync(this.advanceCache.signature(this.fontSize, this.fontFamily));
|
|
267
|
+
const wm = this.width;
|
|
268
|
+
const hm = this.height;
|
|
269
|
+
|
|
270
|
+
const { from, to, edit } = this.frameLayout(scope);
|
|
271
|
+
const t = moveProgress(edit);
|
|
272
|
+
const innerW = edit ? lerpNumber(from.innerW, to.innerW, t) : to.innerW;
|
|
273
|
+
const innerH = edit ? lerpNumber(from.innerH, to.innerH, t) : to.innerH;
|
|
274
|
+
|
|
275
|
+
const resolvedW = typeof wm === "number"
|
|
276
|
+
? wm
|
|
277
|
+
: wm === "hug"
|
|
278
|
+
? innerW + this.padding.left + this.padding.right
|
|
279
|
+
: constraints.maxWidth ?? 0;
|
|
280
|
+
|
|
281
|
+
const resolvedH = typeof hm === "number"
|
|
282
|
+
? hm
|
|
283
|
+
: hm === "hug"
|
|
284
|
+
? innerH + this.padding.top + this.padding.bottom
|
|
285
|
+
: constraints.maxHeight ?? 0;
|
|
286
|
+
|
|
287
|
+
return { width: resolvedW, height: resolvedH };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
protected override renderContent(ctx: RenderPass2D): void {
|
|
291
|
+
// Refuse the ambient `<DefaultTextStyle>` / theme typography defaults for
|
|
292
|
+
// everything drawn inside this node.
|
|
293
|
+
//
|
|
294
|
+
// Those defaults describe the *document's* prose — a display face, a
|
|
295
|
+
// heading weight, a paragraph line-height — and a code block is not prose.
|
|
296
|
+
// Its own props are the vocabulary: `fontFamily` defaults to a monospaced
|
|
297
|
+
// face because column alignment depends on it, `letterSpacing` and
|
|
298
|
+
// `lineHeight` are tuned against that face, and every token is laid out at
|
|
299
|
+
// an x measured from those exact values. A scene-wide serif family or a
|
|
300
|
+
// 700 weight arriving through the draw scope would shape glyphs the
|
|
301
|
+
// geometry was never measured for, and the block would come apart
|
|
302
|
+
// column-by-column rather than merely look different.
|
|
303
|
+
ctx.pushTextStyle(null);
|
|
304
|
+
try {
|
|
305
|
+
// Keep retrying until the language+theme have actually loaded, so a frame
|
|
306
|
+
// that rendered as plain text upgrades to full highlighting the moment the
|
|
307
|
+
// asset loader resolves. Never mid-edit: re-tokenizing mints fresh ids,
|
|
308
|
+
// and an in-flight transition is keyed by the ones it captured.
|
|
309
|
+
if (!this.tokenized && !this.activeEdit() && canHighlight(this.language, this.theme)) {
|
|
310
|
+
this.tokenize();
|
|
311
|
+
}
|
|
312
|
+
super.renderContent(ctx);
|
|
313
|
+
} finally {
|
|
314
|
+
ctx.popTextStyle();
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
protected override renderSelf(ctx: RenderContext2D): void {
|
|
319
|
+
this.drawSelf(ctx);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** A listing holds a listing — `append`/`insert`/`erase` are how it grows. */
|
|
323
|
+
protected override acceptsChild(_child: Node): boolean {
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* The node's laid-out rect as a clip outline, so `clip` and any backdrop
|
|
329
|
+
* effect are confined to the block rather than to nothing — a listing set on
|
|
330
|
+
* a card is exactly where a backdrop blur is asked for.
|
|
331
|
+
*/
|
|
332
|
+
protected override clipSelf(): Clip {
|
|
333
|
+
return new Clip().rect({
|
|
334
|
+
width: this.layoutBounds.width,
|
|
335
|
+
height: this.layoutBounds.height,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* A listing is grabbed by its **text**, line by ragged line, rather than by
|
|
341
|
+
* the rectangle the lines were laid out in.
|
|
342
|
+
*
|
|
343
|
+
* The default narrows a node's grab region to the outline it declares, and
|
|
344
|
+
* this node's outline is a box it no longer paints anything into. Code is
|
|
345
|
+
* mostly whitespace: a ten-line snippet with one long line is a rectangle
|
|
346
|
+
* that is largely empty, and in an editor every one of those empty pixels
|
|
347
|
+
* swallows a click meant for whatever sits behind or beneath it. Short of the
|
|
348
|
+
* text itself there is nothing there to have clicked.
|
|
349
|
+
*
|
|
350
|
+
* So the region is the union of the lines' own extents — each a line-height
|
|
351
|
+
* slot as wide as that line's ink — plus the gutter where line numbers are
|
|
352
|
+
* showing, since those are drawn glyphs too.
|
|
353
|
+
*
|
|
354
|
+
* Answered from the **cached** layout, which is what the last frame drew
|
|
355
|
+
* from, because a hit test arrives with no measurement scope and text cannot
|
|
356
|
+
* be measured without one. Where there is no usable cache — before the first
|
|
357
|
+
* frame, or mid-edit, when the geometry is an interpolation of two structures
|
|
358
|
+
* held on the transition — the box is the honest answer rather than a
|
|
359
|
+
* silhouette measured against stale metrics.
|
|
360
|
+
*/
|
|
361
|
+
protected override hitTestSelf(local: Vector2, tolerance: number): boolean {
|
|
362
|
+
const layout = this.hitLayout();
|
|
363
|
+
if (!layout) return super.hitTestSelf(local, tolerance);
|
|
364
|
+
|
|
365
|
+
// Never *wider* than the box: `hug` sizes to the ink, but a fixed width
|
|
366
|
+
// narrower than the longest line would otherwise be hit outside itself.
|
|
367
|
+
if (!super.hitTestSelf(local, tolerance)) return false;
|
|
368
|
+
|
|
369
|
+
const half = (this.fontSize * this.lineHeight) / 2;
|
|
370
|
+
// Line numbers sit in the gutter, to the left of every line's own start,
|
|
371
|
+
// so a numbered blank line is still grabbable — by its number.
|
|
372
|
+
const gutter = this.showLineNumbers ? layout.gutter : 0;
|
|
373
|
+
const left = layout.startX - gutter - tolerance;
|
|
374
|
+
for (let i = 0; i < layout.lineY.length; i++) {
|
|
375
|
+
const width = (layout.lineW[i] ?? 0) + gutter;
|
|
376
|
+
if (width <= 0) continue;
|
|
377
|
+
if (Math.abs(local.y - layout.lineY[i]) > half + tolerance) continue;
|
|
378
|
+
if (local.x >= left && local.x <= layout.startX + (layout.lineW[i] ?? 0) + tolerance) {
|
|
379
|
+
return true;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return false;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* The layout a hit test may be answered from: the settled cache, and only
|
|
387
|
+
* while it still matches the node's current metrics.
|
|
388
|
+
*
|
|
389
|
+
* The key is the one {@link settledLayout} writes minus its scope-dependent
|
|
390
|
+
* half — every input to it (`metricsSignature`, the advance cache's own
|
|
391
|
+
* signature) is readable without a measurer, which is precisely what makes
|
|
392
|
+
* the check possible here.
|
|
393
|
+
*/
|
|
394
|
+
private hitLayout(): CodeLayout | null {
|
|
395
|
+
if (!this.layoutCache || this.activeEdit()) return null;
|
|
396
|
+
const m = this.metrics();
|
|
397
|
+
const key = `${this.structureVersion}|${metricsSignature(m)}|${this.advanceCache.signature(m.fontSize, m.fontFamily)}`;
|
|
398
|
+
return this.layoutCacheKey === key ? this.layoutCache : null;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// ── Editing commands ────────────────────────────────────────────────────
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Append `code` to the end of the listing.
|
|
405
|
+
*
|
|
406
|
+
* Stated as *what the source becomes*; {@link editTo} works out the rest —
|
|
407
|
+
* which is why appending a line that closes a block re-highlights the lines
|
|
408
|
+
* above it, instead of colouring the new text as its own program.
|
|
409
|
+
*/
|
|
410
|
+
@command({ args: [{ key: "code", kind: "text", multiline: true }] })
|
|
411
|
+
append(args: CommandArgs<{ code: string }> & { duration: number }): Command<Record<string, never>> {
|
|
412
|
+
const { code } = args.data as { code: string };
|
|
413
|
+
return this.editTo(this.joinedSource() + code, args.duration, args.easing);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Insert `code` above the listing. */
|
|
417
|
+
@command({ args: [{ key: "code", kind: "text", multiline: true }] })
|
|
418
|
+
prepend(args: CommandArgs<{ code: string }> & { duration: number }): Command<Record<string, never>> {
|
|
419
|
+
const { code } = args.data as { code: string };
|
|
420
|
+
return this.editTo(code + this.joinedSource(), args.duration, args.easing);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Insert `code` at the given (line, col). Both are 1-indexed; col is the
|
|
425
|
+
* column BEFORE which the new content is inserted (col=1 means start of
|
|
426
|
+
* line). If `code` contains newlines, new lines are created in the middle
|
|
427
|
+
* of the existing line.
|
|
428
|
+
*/
|
|
429
|
+
@command({
|
|
430
|
+
args: [
|
|
431
|
+
// Two controls, one argument: the method takes a `[line, col]`
|
|
432
|
+
// tuple, so the pair a host draws is declared *inside* the value it
|
|
433
|
+
// composes into rather than flattened beside it.
|
|
434
|
+
{
|
|
435
|
+
key: "position", kind: "point", properties: {
|
|
436
|
+
line: { kind: "number", default: 1, min: 1, step: 1 },
|
|
437
|
+
col: { kind: "number", default: 1, min: 1, step: 1 },
|
|
438
|
+
},
|
|
439
|
+
},
|
|
440
|
+
{ key: "code", kind: "text", default: "", multiline: true },
|
|
441
|
+
],
|
|
442
|
+
})
|
|
443
|
+
insert(
|
|
444
|
+
args: CommandArgs<{ position: [number, number]; code: string }> & { duration: number },
|
|
445
|
+
): Command<Record<string, never>> {
|
|
446
|
+
const { position, code } = args.data as { position: [number, number]; code: string };
|
|
447
|
+
const source = this.joinedSource();
|
|
448
|
+
const offset = this.offsetAt(position);
|
|
449
|
+
return this.editTo(source.slice(0, offset) + code + source.slice(offset), args.duration, args.easing);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Erase the code in `codeRange`.
|
|
454
|
+
*
|
|
455
|
+
* Named `erase` rather than `remove` because a `Code` is also a node, and
|
|
456
|
+
* `Node.remove(child)` takes a child out of the tree. Two methods spelled
|
|
457
|
+
* the same on one object, doing entirely different things, is worse than one
|
|
458
|
+
* of them having a slightly less obvious name.
|
|
459
|
+
*
|
|
460
|
+
* A range that covers whole lines takes their line breaks with it, so the
|
|
461
|
+
* rows below close up; a range inside a line takes only the characters, and
|
|
462
|
+
* the rest of the line reflows around the hole.
|
|
463
|
+
*/
|
|
464
|
+
@command({ key: "remove", args: [{ key: "target", kind: "selector" }] })
|
|
465
|
+
erase(
|
|
466
|
+
args: CommandArgs<{ target: CodeRange }> & { duration: number },
|
|
467
|
+
): Command<Record<string, never>> {
|
|
468
|
+
const { target: codeRange } = args.data as { target: CodeRange };
|
|
469
|
+
const { duration, easing } = args;
|
|
470
|
+
const source = this.joinedSource();
|
|
471
|
+
let { start, end } = rangeToCharOffsets(codeRange, this.lineLengths());
|
|
472
|
+
// `lines(2)` resolves to the *characters* of line 2, not to the row —
|
|
473
|
+
// taking those alone would leave an empty line behind where an editor
|
|
474
|
+
// would have closed the gap. This is also what makes a blank row
|
|
475
|
+
// removable at all: it has no characters, so its range is empty, and
|
|
476
|
+
// "delete nothing" is not what `lines(2)` was asking for.
|
|
477
|
+
const atLineStart = start === 0 || source[start - 1] === "\n";
|
|
478
|
+
const atLineEnd = end === source.length || source[end] === "\n";
|
|
479
|
+
if (atLineStart && atLineEnd) {
|
|
480
|
+
if (end < source.length) end += 1;
|
|
481
|
+
else if (start > 0) start -= 1;
|
|
482
|
+
} else if (end <= start) {
|
|
483
|
+
return driveCommand(duration, () => { });
|
|
484
|
+
}
|
|
485
|
+
if (end <= start) return driveCommand(duration, () => { });
|
|
486
|
+
return this.editTo(source.slice(0, start) + source.slice(end), duration, easing);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** Replace the code in `codeRange` with `replacement`. */
|
|
490
|
+
@command({ args: [{ key: "target", kind: "selector" }, { key: "replacement", kind: "text", default: "", multiline: true }] })
|
|
491
|
+
replace(
|
|
492
|
+
args: CommandArgs<{ target: CodeRange; replacement: string }> & { duration: number },
|
|
493
|
+
): Command<Record<string, never>> {
|
|
494
|
+
const { target: codeRange, replacement } = args.data as { target: CodeRange; replacement: string };
|
|
495
|
+
const source = this.joinedSource();
|
|
496
|
+
const { start, end } = rangeToCharOffsets(codeRange, this.lineLengths());
|
|
497
|
+
return this.editTo(source.slice(0, start) + replacement + source.slice(end), args.duration, args.easing);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Highlight a range of code — or several at once (e.g.
|
|
502
|
+
* `CodeRanges.lines(2).word(5, 1, 3)`) — highlighted as one set: tokens
|
|
503
|
+
* within any of the ranges stay at opacity 1, tokens outside dim to
|
|
504
|
+
* `dim`. Persistent — call resetHighlight() to undo, or call highlight()
|
|
505
|
+
* again with a different range to cross-fade.
|
|
506
|
+
*/
|
|
507
|
+
@command({ args: [{ key: "target", kind: "selector" }, { key: "dim", kind: "number", default: 0.4, min: 0, max: 1, step: 0.01, scale: 100, unit: "%" }] })
|
|
508
|
+
highlight(
|
|
509
|
+
args: CommandArgs<{ target: CodeRange | Iterable<CodeRange>; dim?: number }> & { duration: number },
|
|
510
|
+
): Command<Record<string, never>> {
|
|
511
|
+
const { target: codeRange, dim } = args.data as { target: CodeRange | Iterable<CodeRange>; dim?: number };
|
|
512
|
+
const { duration, easing } = args;
|
|
513
|
+
const toDim = dim ?? 0.4;
|
|
514
|
+
const matchIds = this.tokenIdsInRanges(normalizeCodeRanges(codeRange));
|
|
515
|
+
if (matchIds.size === 0) return driveCommand(duration, () => { });
|
|
516
|
+
|
|
517
|
+
const fromDim = this.highlightDimOpacity ?? 1;
|
|
518
|
+
const hadPrevious = this.highlightedIds.size > 0;
|
|
519
|
+
const previousIds = this.highlightedIds;
|
|
520
|
+
|
|
521
|
+
const animTokens: AnimToken[] = [];
|
|
522
|
+
for (const line of this.tokenLines) {
|
|
523
|
+
for (const tok of line.tokens) {
|
|
524
|
+
const wasHighlighted = !hadPrevious || previousIds.has(tok.id);
|
|
525
|
+
const isHighlighted = matchIds.has(tok.id);
|
|
526
|
+
const fromOp = wasHighlighted ? 1 : fromDim;
|
|
527
|
+
const toOp = isHighlighted ? 1 : toDim;
|
|
528
|
+
if (fromOp === toOp) continue;
|
|
529
|
+
animTokens.push(makeAnim(tok.id, { keys: [0, 1], values: [fromOp, toOp] }));
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Which tokens are dimmed is state the *next* highlight reads to know
|
|
534
|
+
// what it is cross-fading from, so it is committed at the end and put
|
|
535
|
+
// back below it — a `finally` could only ever have run forwards.
|
|
536
|
+
return this.runDim(animTokens, duration, easing, (done) => {
|
|
537
|
+
this.highlightDimOpacity = done ? toDim : (hadPrevious ? fromDim : null);
|
|
538
|
+
this.highlightedIds = done ? matchIds : previousIds;
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Fade all dimmed tokens back to opacity 1 and clear the persistent
|
|
544
|
+
* highlight state.
|
|
545
|
+
*/
|
|
546
|
+
@command()
|
|
547
|
+
resetHighlight(args: CommandArgs<Record<string, never>> & { duration: number }): Command<Record<string, never>> {
|
|
548
|
+
const { duration, easing } = args;
|
|
549
|
+
if (this.highlightDimOpacity === null) return driveCommand(duration, () => { });
|
|
550
|
+
|
|
551
|
+
const fromDim = this.highlightDimOpacity;
|
|
552
|
+
const previousIds = this.highlightedIds;
|
|
553
|
+
|
|
554
|
+
const animTokens: AnimToken[] = [];
|
|
555
|
+
for (const line of this.tokenLines) {
|
|
556
|
+
for (const tok of line.tokens) {
|
|
557
|
+
const fromOp = previousIds.has(tok.id) ? 1 : fromDim;
|
|
558
|
+
if (fromOp === 1) continue;
|
|
559
|
+
animTokens.push(makeAnim(tok.id, { keys: [0, 1], values: [fromOp, 1] }));
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
return this.runDim(animTokens, duration, easing, (done) => {
|
|
564
|
+
this.highlightDimOpacity = done ? null : fromDim;
|
|
565
|
+
this.highlightedIds = done ? new Set() : previousIds;
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Animate the listing to a new source.
|
|
571
|
+
*
|
|
572
|
+
* The engine behind every editing command, and behind `to({ code })`. Every
|
|
573
|
+
* edit is expressed as the source it produces, tokenized, and then *diffed*
|
|
574
|
+
* against what is on screen — so a token that survived the edit keeps its
|
|
575
|
+
* identity and simply travels to its new column, and only what genuinely
|
|
576
|
+
* changed is faded.
|
|
577
|
+
*
|
|
578
|
+
* The result runs in three phases (see `phaseStrategy`): what is leaving
|
|
579
|
+
* fades out, what stays reflows, and only then does what is new arrive.
|
|
580
|
+
* Playing them together is what made a replacement read as a cross-dissolve
|
|
581
|
+
* of two unrelated listings rather than as an edit being made.
|
|
582
|
+
*/
|
|
583
|
+
private editTo(next: string, duration: number, easing?: EasingFunction): Command<Record<string, never>> {
|
|
584
|
+
const from = this.tokenLines;
|
|
585
|
+
const fromCode = this.joinedSource();
|
|
586
|
+
if (next === fromCode) return driveCommand(duration, () => { });
|
|
587
|
+
|
|
588
|
+
const edit = this.diffStrategy(from, tokenizeCodeToIdLines(next, this.language, this.theme));
|
|
589
|
+
|
|
590
|
+
const transition: StructuralTransition = {
|
|
591
|
+
kind: "structural",
|
|
592
|
+
from,
|
|
593
|
+
to: edit.lines,
|
|
594
|
+
removedIds: edit.removedIds,
|
|
595
|
+
addedIds: edit.addedIds,
|
|
596
|
+
enteringLineIds: edit.newLineIds,
|
|
597
|
+
fromColorById: edit.fromColorById,
|
|
598
|
+
phases: this.phaseStrategy(edit.removedIds.size > 0, edit.addedIds.size > 0),
|
|
599
|
+
progress: 0,
|
|
600
|
+
layoutKey: null,
|
|
601
|
+
fromLayout: null,
|
|
602
|
+
toLayout: null,
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
// A tokenize triggered by the grammar landing mid-edit would mint fresh
|
|
606
|
+
// ids underneath this transition, and the transition is keyed by the ones
|
|
607
|
+
// it captured — so the retry in `onRender` is gated on there being no edit
|
|
608
|
+
// in flight, which means this flag has to be honest now rather than once
|
|
609
|
+
// the edit settles.
|
|
610
|
+
this.tokenized = canHighlight(this.language, this.theme);
|
|
611
|
+
|
|
612
|
+
return driveCommand(duration, (t) => {
|
|
613
|
+
const running = t > 0 && t < 1;
|
|
614
|
+
const index = this.transitions.indexOf(transition);
|
|
615
|
+
if (running && index < 0) this.transitions.push(transition);
|
|
616
|
+
else if (!running && index >= 0) this.transitions.splice(index, 1);
|
|
617
|
+
|
|
618
|
+
transition.progress = easing ? easing(t) : t;
|
|
619
|
+
|
|
620
|
+
// The settle, in both directions: asked for a time before the edit
|
|
621
|
+
// starts, the listing is what preceded it; at or past the end, the
|
|
622
|
+
// result. A `finally` could only ever have run forwards.
|
|
623
|
+
if (t <= 0) {
|
|
624
|
+
this.setLines(from);
|
|
625
|
+
this._writeProp("code", fromCode);
|
|
626
|
+
} else {
|
|
627
|
+
this.setLines(edit.lines);
|
|
628
|
+
this._writeProp("code", next);
|
|
629
|
+
}
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Put a dim transition on the stack, drive its progress, take it off and
|
|
635
|
+
* commit at the end.
|
|
636
|
+
*
|
|
637
|
+
* A {@link Command} rather than a generator, which is what makes a listing
|
|
638
|
+
* scrubbable: membership is a function of `t` (on the stack for `0 < t < 1`,
|
|
639
|
+
* off it outside), `settle(done)` replaces the `finally` and takes *which
|
|
640
|
+
* way*, and `progress` is assigned from `t` rather than accumulated.
|
|
641
|
+
*/
|
|
642
|
+
private runDim(
|
|
643
|
+
tokens: AnimToken[],
|
|
644
|
+
duration: number,
|
|
645
|
+
easing?: EasingFunction,
|
|
646
|
+
settle?: (done: boolean) => void,
|
|
647
|
+
): Command<Record<string, never>> {
|
|
648
|
+
const transition = { kind: "dim" as const, tokens, progress: 0 };
|
|
649
|
+
|
|
650
|
+
return driveCommand(duration, (t) => {
|
|
651
|
+
const running = t > 0 && t < 1;
|
|
652
|
+
const index = this.transitions.indexOf(transition);
|
|
653
|
+
if (running && index < 0) this.transitions.push(transition);
|
|
654
|
+
else if (!running && index >= 0) this.transitions.splice(index, 1);
|
|
655
|
+
|
|
656
|
+
transition.progress = easing ? easing(t) : t;
|
|
657
|
+
settle?.(t >= 1);
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Route a tweened `code` through the diff engine.
|
|
663
|
+
*
|
|
664
|
+
* `to({ code })` is the form an author reaches for first — it is how every
|
|
665
|
+
* other prop is animated — and a bare string prop would otherwise land in the
|
|
666
|
+
* generic tween's discrete-snap bucket and simply cut at the end. Intercepted
|
|
667
|
+
* here it becomes the same three-phase edit the named commands produce, while
|
|
668
|
+
* the rest of the step's props tween alongside it untouched.
|
|
669
|
+
*/
|
|
670
|
+
override _prepareStep(
|
|
671
|
+
to: Partial<CodeProps>,
|
|
672
|
+
duration: number,
|
|
673
|
+
easing?: EasingFunction,
|
|
674
|
+
): TweenStepper {
|
|
675
|
+
const next = to.code;
|
|
676
|
+
if (typeof next !== "string" || next === this.joinedSource()) {
|
|
677
|
+
return super._prepareStep(to, duration, easing);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
const rest: Partial<CodeProps> = { ...to };
|
|
681
|
+
delete rest.code;
|
|
682
|
+
// Prepared before the edit, so a step that also moves `fontSize` has
|
|
683
|
+
// snapshotted its own start value against the listing that is still on
|
|
684
|
+
// screen.
|
|
685
|
+
const others = super._prepareStep(rest, duration, easing);
|
|
686
|
+
const edit = this.editTo(next, duration, easing)._stepper();
|
|
687
|
+
|
|
688
|
+
return {
|
|
689
|
+
seek: (elapsed: number) => {
|
|
690
|
+
others.seek(elapsed);
|
|
691
|
+
edit.seek(elapsed);
|
|
692
|
+
},
|
|
693
|
+
advance: (dt: number): boolean => {
|
|
694
|
+
const othersDone = others.advance(dt);
|
|
695
|
+
const editDone = edit.advance(dt);
|
|
696
|
+
return othersDone && editDone;
|
|
697
|
+
},
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// ── Source queries ──────────────────────────────────────────────────────
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Find every range matching the literal string `text` in the current
|
|
705
|
+
* source. Multi-line matches are supported.
|
|
706
|
+
*/
|
|
707
|
+
findAllRanges(text: string): CodeRange[] {
|
|
708
|
+
const ranges: CodeRange[] = [];
|
|
709
|
+
if (!text) return ranges;
|
|
710
|
+
const source = this.joinedSource();
|
|
711
|
+
const lineLens = this.lineLengths();
|
|
712
|
+
let from = 0;
|
|
713
|
+
while (true) {
|
|
714
|
+
const idx = source.indexOf(text, from);
|
|
715
|
+
if (idx === -1) break;
|
|
716
|
+
ranges.push(charOffsetsToRange(idx, idx + text.length, lineLens));
|
|
717
|
+
from = idx + Math.max(1, text.length);
|
|
718
|
+
}
|
|
719
|
+
return ranges;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/** Find the `index`th range matching `text`. Returns null if not found. */
|
|
723
|
+
findRangeAt(text: string, index: number): CodeRange | null {
|
|
724
|
+
const all = this.findAllRanges(text);
|
|
725
|
+
return all[index] ?? null;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/** Find the first range matching `text`. Returns null if not found. */
|
|
729
|
+
findFirstRange(text: string): CodeRange | null {
|
|
730
|
+
return this.findRangeAt(text, 0);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
private joinedSource(): string {
|
|
734
|
+
return this.tokenLines
|
|
735
|
+
.map(line => line.tokens.map(t => t.content).join(''))
|
|
736
|
+
.join('\n');
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
private lineLengths(): number[] {
|
|
740
|
+
return this.tokenLines.map(line =>
|
|
741
|
+
line.tokens.reduce((acc, t) => acc + t.content.length, 0),
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/** Character offset of a 1-indexed (line, col), clamped into the document. */
|
|
746
|
+
private offsetAt(position: [number, number]): number {
|
|
747
|
+
const lens = this.lineLengths();
|
|
748
|
+
if (lens.length === 0) return 0;
|
|
749
|
+
const [rawLine, rawCol] = position;
|
|
750
|
+
const li = Math.max(0, Math.min(lens.length - 1, rawLine - 1));
|
|
751
|
+
let offset = 0;
|
|
752
|
+
for (let k = 0; k < li; k++) offset += lens[k] + 1;
|
|
753
|
+
return offset + Math.max(0, Math.min(lens[li], rawCol - 1));
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Resolve one or more CodeRanges to the set of token ids whose content
|
|
758
|
+
* overlaps any of them. Tokens that partially overlap are included.
|
|
759
|
+
*/
|
|
760
|
+
private tokenIdsInRanges(codeRanges: CodeRange[]): Set<number> {
|
|
761
|
+
const result = new Set<number>();
|
|
762
|
+
const lineLens = this.lineLengths();
|
|
763
|
+
if (lineLens.length === 0) return result;
|
|
764
|
+
const offsets = codeRanges
|
|
765
|
+
.map(r => rangeToCharOffsets(r, lineLens))
|
|
766
|
+
.filter(({ start, end }) => end > start);
|
|
767
|
+
if (offsets.length === 0) return result;
|
|
768
|
+
|
|
769
|
+
// Walk tokens with running offsets in the joined string, testing each
|
|
770
|
+
// token against every requested range's offsets.
|
|
771
|
+
let off = 0;
|
|
772
|
+
for (let li = 0; li < this.tokenLines.length; li++) {
|
|
773
|
+
const line = this.tokenLines[li];
|
|
774
|
+
for (const tok of line.tokens) {
|
|
775
|
+
const tStart = off;
|
|
776
|
+
const tEnd = off + tok.content.length;
|
|
777
|
+
if (offsets.some(({ start, end }) => tEnd > start && tStart < end)) result.add(tok.id);
|
|
778
|
+
off = tEnd;
|
|
779
|
+
}
|
|
780
|
+
if (li < this.tokenLines.length - 1) off += 1; // newline
|
|
781
|
+
}
|
|
782
|
+
return result;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// ── Drawing ─────────────────────────────────────────────────────────────
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* Thin delegator: everything about *how* a frame is painted (settled or
|
|
789
|
+
* mid structural-edit, entry/exit motion, colour cross-fade) lives in
|
|
790
|
+
* `drawCode` (the engine's `render.ts`) as a pure function over plain
|
|
791
|
+
* state — this just gathers that state from the node's own instance
|
|
792
|
+
* fields and caches.
|
|
793
|
+
*/
|
|
794
|
+
protected drawSelf(draw: RenderContext2D): void {
|
|
795
|
+
this.advanceCache.sync(this.advanceCache.signature(this.fontSize, this.fontFamily));
|
|
796
|
+
drawCode(draw, {
|
|
797
|
+
tokenLines: this.tokenLines,
|
|
798
|
+
metrics: this.metrics(),
|
|
799
|
+
advanceCache: this.advanceCache,
|
|
800
|
+
...this.frameLayout(draw),
|
|
801
|
+
tokenStates: resolveTokenStates(this.tokenLines, this.transitions, this.highlightDimOpacity, this.highlightedIds),
|
|
802
|
+
highlightActive: this.highlightDimOpacity !== null || this.transitions.some(tr => tr.kind === "dim"),
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* Normalize `highlight()`'s `codeRange` argument to a plain array: a single
|
|
809
|
+
* `CodeRange` (a plain object, not iterable) becomes a one-element array; a
|
|
810
|
+
* `CodeRangeChain`/plain array of ranges spreads as-is. `CodeRange` and
|
|
811
|
+
* `CodeRangeChain` are distinguished by iterability, not `instanceof` — a
|
|
812
|
+
* bare array of `CodeRange`s (no chain) works the same way.
|
|
813
|
+
*/
|
|
814
|
+
function normalizeCodeRanges(codeRange: CodeRange | Iterable<CodeRange>): CodeRange[] {
|
|
815
|
+
const maybeIterable = codeRange as Partial<Iterable<CodeRange>>;
|
|
816
|
+
if (typeof maybeIterable[Symbol.iterator] === "function") {
|
|
817
|
+
return [...(codeRange as Iterable<CodeRange>)];
|
|
818
|
+
}
|
|
819
|
+
return [codeRange as CodeRange];
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
const grammarLoaders = new Map<string, () => Promise<void>>();
|
|
823
|
+
|
|
824
|
+
// One function per language: blocks sharing a grammar declare one key, and a
|
|
825
|
+
// second load function for a key already declared is ignored with a warning.
|
|
826
|
+
function grammarLoader(language: string): () => Promise<void> {
|
|
827
|
+
let load = grammarLoaders.get(language);
|
|
828
|
+
if (load === undefined) {
|
|
829
|
+
load = () => ensureHighlighter(undefined, [language]);
|
|
830
|
+
grammarLoaders.set(language, load);
|
|
831
|
+
}
|
|
832
|
+
return load;
|
|
833
|
+
}
|
|
834
|
+
|