@motion-script/code 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/node.js ADDED
@@ -0,0 +1,882 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ import { createHighlighter } from "shiki";
8
+ import { rangeToCharOffsets } from "./code-range";
9
+ import { parseColor, Node, tween, property, resolvePadding, lerpEdgeInset } from "@motion-script/core";
10
+ let globalHighlighter = null;
11
+ let highlighterPromise = null;
12
+ function ensureHighlighter(themes = ['github-dark'], langs = ['typescript', 'javascript', 'json', 'python']) {
13
+ if (globalHighlighter)
14
+ return Promise.resolve(globalHighlighter);
15
+ if (!highlighterPromise) {
16
+ highlighterPromise = createHighlighter({ themes, langs }).then(h => {
17
+ globalHighlighter = h;
18
+ return h;
19
+ });
20
+ }
21
+ return highlighterPromise;
22
+ }
23
+ export async function initSyntaxHighlighter(themes = ['github-dark'], langs = ['typescript', 'javascript', 'json', 'python']) {
24
+ globalHighlighter = await ensureHighlighter(themes, langs);
25
+ }
26
+ let nextTokenId = 1;
27
+ let nextLineId = 1;
28
+ function makeIdToken(content, color) {
29
+ return { id: nextTokenId++, content, color };
30
+ }
31
+ function makeIdLine(tokens) {
32
+ return { id: nextLineId++, tokens };
33
+ }
34
+ function tokenizeCodeToIdLines(code, language, theme) {
35
+ if (!globalHighlighter) {
36
+ return code.split('\n').map(line => makeIdLine([makeIdToken(line, '#d1d5db')]));
37
+ }
38
+ const result = globalHighlighter.codeToTokens(code, { lang: language, theme: theme });
39
+ return result.tokens.map(line => makeIdLine(line.map(tok => makeIdToken(tok.content, tok.color))));
40
+ }
41
+ function sampleCurve(keys, values, p) {
42
+ if (p <= keys[0])
43
+ return values[0];
44
+ if (p >= keys[keys.length - 1])
45
+ return values[values.length - 1];
46
+ for (let i = 0; i < keys.length - 1; i++) {
47
+ if (p <= keys[i + 1]) {
48
+ const span = keys[i + 1] - keys[i];
49
+ const local = span === 0 ? 0 : (p - keys[i]) / span;
50
+ return values[i] + (values[i + 1] - values[i]) * local;
51
+ }
52
+ }
53
+ return values[values.length - 1];
54
+ }
55
+ export class Code extends Node {
56
+ tokenLines = [];
57
+ tokenized = false;
58
+ transitions = [];
59
+ // Persistent dim state set by highlight() — applied during render to all
60
+ // tokens whose id is NOT in the highlight set. null means "not highlighting".
61
+ highlightDimOpacity = null;
62
+ highlightedIds = new Set();
63
+ constructor(props) {
64
+ super(props);
65
+ this.applyProp("width", props.width ?? "hug");
66
+ this.applyProp("height", props.height ?? "hug");
67
+ ensureHighlighter();
68
+ this.tokenize();
69
+ }
70
+ set(props) {
71
+ super.set(props);
72
+ if (props.code !== undefined || props.language !== undefined || props.theme !== undefined) {
73
+ this.tokenized = false;
74
+ }
75
+ }
76
+ tokenize() {
77
+ this.tokenLines = tokenizeCodeToIdLines(this.code, this.language, this.theme);
78
+ this.tokenized = !!globalHighlighter;
79
+ }
80
+ prepare(storage) {
81
+ storage.requestLoader(async () => ensureHighlighter([this.theme], [this.language]));
82
+ }
83
+ // A token's advance width, honoring letterSpacing. The renderer applies the
84
+ // same letterSpacing when drawing (see drawSelf), so measure and draw stay
85
+ // in lockstep. fontWeight is left default; letterSpacing is the 5th arg.
86
+ tokenAdvance(scope, content) {
87
+ return scope.measureText(content, this.fontSize, this.fontFamily, undefined, this.letterSpacing);
88
+ }
89
+ // Horizontal gap between the line-number column and the code text. Kept in
90
+ // one place so gutterWidth() and the line-number x in drawSelf() agree.
91
+ // Sized in space-widths via the lineNumberGap prop so it scales with font.
92
+ gutterGap(scope) {
93
+ return scope.measureText(' ', this.fontSize, this.fontFamily) * this.lineNumberGap;
94
+ }
95
+ gutterWidth(scope) {
96
+ if (!this.showLineNumbers)
97
+ return 0;
98
+ const maxLine = Math.max(1, this.tokenLines.length);
99
+ const sample = String(maxLine);
100
+ const digitW = scope.measureText(sample, this.fontSize, this.fontFamily);
101
+ // digit column + the gap that separates the number from the code text.
102
+ return digitW + this.gutterGap(scope);
103
+ }
104
+ measure(constraints, scope) {
105
+ const pad = this.padding;
106
+ const wm = this.width;
107
+ const hm = this.height;
108
+ const lineH = this.fontSize * this.lineHeight;
109
+ const stateById = this.resolveTokenStates();
110
+ const lineHeightScales = this.resolveLineHeightScales();
111
+ let measuredW = 0;
112
+ let measuredH = 0;
113
+ const gutter = this.gutterWidth(scope);
114
+ for (const line of this.tokenLines) {
115
+ let lineW = 0;
116
+ for (const tok of line.tokens) {
117
+ const ws = stateById.get(tok.id)?.widthScale ?? 1;
118
+ lineW += this.tokenAdvance(scope, tok.content) * ws;
119
+ }
120
+ if (lineW > measuredW)
121
+ measuredW = lineW;
122
+ measuredH += lineH * (lineHeightScales.get(line.id) ?? 1);
123
+ }
124
+ measuredW += gutter;
125
+ const resolvedW = typeof wm === "number"
126
+ ? wm
127
+ : wm === "hug"
128
+ ? measuredW + pad.left + pad.right
129
+ : constraints.maxWidth ?? 0;
130
+ const resolvedH = typeof hm === "number"
131
+ ? hm
132
+ : hm === "hug"
133
+ ? measuredH + pad.top + pad.bottom
134
+ : constraints.maxHeight ?? 0;
135
+ return { width: resolvedW, height: resolvedH };
136
+ }
137
+ onRender(ctx) {
138
+ super.onRender(ctx);
139
+ if (!this.tokenized && globalHighlighter) {
140
+ this.tokenize();
141
+ }
142
+ this.drawSelf(ctx);
143
+ }
144
+ *append(code, duration, easing) {
145
+ const newLines = tokenizeCodeToIdLines(code, this.language, this.theme);
146
+ this.tokenLines = [...this.tokenLines, ...newLines];
147
+ const animTokens = [];
148
+ const lineHeightScales = new Map();
149
+ const introducedIds = new Set();
150
+ for (const line of newLines) {
151
+ lineHeightScales.set(line.id, { keys: [0, 1], values: [0, 1] });
152
+ for (const tok of line.tokens) {
153
+ introducedIds.add(tok.id);
154
+ animTokens.push(makeAnim(tok.id, {
155
+ opacity: { keys: [0, 1], values: [0, 1] },
156
+ }));
157
+ }
158
+ }
159
+ yield* this.runTransition({ tokens: animTokens, lineHeightScales, introducedIds }, duration, easing);
160
+ }
161
+ *prepend(code, duration, easing) {
162
+ const newLines = tokenizeCodeToIdLines(code, this.language, this.theme);
163
+ this.tokenLines = [...newLines, ...this.tokenLines];
164
+ const animTokens = [];
165
+ const lineHeightScales = new Map();
166
+ const introducedIds = new Set();
167
+ for (const line of newLines) {
168
+ lineHeightScales.set(line.id, { keys: [0, 1], values: [0, 1] });
169
+ for (const tok of line.tokens) {
170
+ introducedIds.add(tok.id);
171
+ animTokens.push(makeAnim(tok.id, {
172
+ opacity: { keys: [0, 1], values: [0, 1] },
173
+ }));
174
+ }
175
+ }
176
+ yield* this.runTransition({ tokens: animTokens, lineHeightScales, introducedIds }, duration, easing);
177
+ }
178
+ /**
179
+ * Highlight a range of code: tokens within the range stay at opacity 1,
180
+ * tokens outside dim to `opacity`. Persistent — call resetHighlight() to
181
+ * undo, or call highlight() again with a different range to cross-fade.
182
+ */
183
+ *highlight(codeRange, duration = 0.4, easing, opacity = 0.4) {
184
+ const matchIds = this.tokenIdsInRange(codeRange);
185
+ if (matchIds.size === 0) {
186
+ yield* tween(duration, () => { });
187
+ return;
188
+ }
189
+ const fromDim = this.highlightDimOpacity ?? 1;
190
+ const toDim = opacity;
191
+ const hadPrevious = this.highlightedIds.size > 0;
192
+ const previousIds = this.highlightedIds;
193
+ const animTokens = [];
194
+ for (const line of this.tokenLines) {
195
+ for (const tok of line.tokens) {
196
+ const wasHighlighted = !hadPrevious || previousIds.has(tok.id);
197
+ const isHighlighted = matchIds.has(tok.id);
198
+ const fromOp = wasHighlighted ? 1 : fromDim;
199
+ const toOp = isHighlighted ? 1 : toDim;
200
+ if (fromOp === toOp)
201
+ continue;
202
+ animTokens.push(makeAnim(tok.id, {
203
+ opacity: { keys: [0, 1], values: [fromOp, toOp] },
204
+ }));
205
+ }
206
+ }
207
+ try {
208
+ yield* this.runTransition({ tokens: animTokens }, duration, easing);
209
+ }
210
+ finally {
211
+ this.highlightDimOpacity = toDim;
212
+ this.highlightedIds = matchIds;
213
+ }
214
+ }
215
+ /**
216
+ * Fade all dimmed tokens back to opacity 1 and clear the persistent
217
+ * highlight state.
218
+ */
219
+ *resetHighlight(duration = 0.4, easing) {
220
+ if (this.highlightDimOpacity === null) {
221
+ yield* tween(duration, () => { });
222
+ return;
223
+ }
224
+ const fromDim = this.highlightDimOpacity;
225
+ const previousIds = this.highlightedIds;
226
+ const animTokens = [];
227
+ for (const line of this.tokenLines) {
228
+ for (const tok of line.tokens) {
229
+ const wasHighlighted = previousIds.has(tok.id);
230
+ const fromOp = wasHighlighted ? 1 : fromDim;
231
+ if (fromOp === 1)
232
+ continue;
233
+ animTokens.push(makeAnim(tok.id, {
234
+ opacity: { keys: [0, 1], values: [fromOp, 1] },
235
+ }));
236
+ }
237
+ }
238
+ try {
239
+ yield* this.runTransition({ tokens: animTokens }, duration, easing);
240
+ }
241
+ finally {
242
+ this.highlightDimOpacity = null;
243
+ this.highlightedIds = new Set();
244
+ }
245
+ }
246
+ /**
247
+ * Replace the tokens in `codeRange` with `next`, cross-fading widths and
248
+ * opacities.
249
+ */
250
+ *replace(codeRange, next, duration, easing) {
251
+ const span = this.rangeToTokenSpan(codeRange);
252
+ if (!span) {
253
+ yield* tween(duration, () => { });
254
+ return;
255
+ }
256
+ const replacementLineGroups = tokenizeCodeToIdLines(next, this.language, this.theme);
257
+ const replacementTokens = [];
258
+ for (let i = 0; i < replacementLineGroups.length; i++) {
259
+ for (const tok of replacementLineGroups[i].tokens)
260
+ replacementTokens.push(tok);
261
+ if (i < replacementLineGroups.length - 1) {
262
+ replacementTokens.push(makeIdToken('\n'));
263
+ }
264
+ }
265
+ const oldTokens = [];
266
+ for (let li = span.fromLine; li <= span.toLine; li++) {
267
+ const line = this.tokenLines[li];
268
+ const start = li === span.fromLine ? span.fromIdx : 0;
269
+ const end = li === span.toLine ? span.toIdx : line.tokens.length;
270
+ for (let ti = start; ti < end; ti++)
271
+ oldTokens.push(line.tokens[ti]);
272
+ }
273
+ const fromLine = this.tokenLines[span.fromLine];
274
+ const toLine = this.tokenLines[span.toLine];
275
+ const prefix = fromLine.tokens.slice(0, span.fromIdx);
276
+ const suffix = toLine.tokens.slice(span.toIdx);
277
+ const mergedLine = makeIdLine([
278
+ ...prefix,
279
+ ...oldTokens,
280
+ ...replacementTokens,
281
+ ...suffix,
282
+ ]);
283
+ mergedLine.id = fromLine.id;
284
+ const before = this.tokenLines.slice(0, span.fromLine);
285
+ const after = this.tokenLines.slice(span.toLine + 1);
286
+ this.tokenLines = [...before, mergedLine, ...after];
287
+ const oldIds = new Set(oldTokens.map(t => t.id));
288
+ const animTokens = [];
289
+ const introducedIds = new Set();
290
+ for (const tok of oldTokens) {
291
+ animTokens.push(makeAnim(tok.id, {
292
+ opacity: { keys: [0, 0.5, 1], values: [1, 0, 0] },
293
+ widthScale: { keys: [0, 0.5, 1], values: [1, 0, 0] },
294
+ }));
295
+ }
296
+ for (const tok of replacementTokens) {
297
+ introducedIds.add(tok.id);
298
+ animTokens.push(makeAnim(tok.id, {
299
+ opacity: { keys: [0, 0.5, 1], values: [0, 0, 1] },
300
+ widthScale: { keys: [0, 0.5, 1], values: [0, 1, 1] },
301
+ }));
302
+ }
303
+ try {
304
+ yield* this.runTransition({ tokens: animTokens, introducedIds }, duration, easing);
305
+ }
306
+ finally {
307
+ for (const line of this.tokenLines) {
308
+ line.tokens = line.tokens.filter(tok => !oldIds.has(tok.id));
309
+ }
310
+ }
311
+ }
312
+ /**
313
+ * Insert `code` at the given (line, col). Both are 1-indexed; col is the
314
+ * column BEFORE which the new content is inserted (col=1 means start of
315
+ * line). If `code` contains newlines, new lines are created in the middle
316
+ * of the existing line.
317
+ */
318
+ *insert(position, code, duration, easing) {
319
+ if (this.tokenLines.length === 0) {
320
+ this.tokenLines = [makeIdLine([])];
321
+ }
322
+ const [rawLine, rawCol] = position;
323
+ const lineIdx = Math.max(0, Math.min(this.tokenLines.length - 1, rawLine - 1));
324
+ const targetLine = this.tokenLines[lineIdx];
325
+ const lineText = targetLine.tokens.map(t => t.content).join('');
326
+ const col = Math.max(0, Math.min(lineText.length, rawCol - 1));
327
+ // Split the target line's tokens at the character offset `col`.
328
+ const { before: tokBefore, after: tokAfter } = splitTokensAt(targetLine.tokens, col);
329
+ const insertedLineGroups = tokenizeCodeToIdLines(code, this.language, this.theme);
330
+ const animTokens = [];
331
+ const lineHeightScales = new Map();
332
+ const introducedIds = new Set();
333
+ // Two curves:
334
+ // - `newLineIntro`: for tokens on a line that's growing in height
335
+ // (multi-line insert). Token opacity ramps linearly 0→1 to match
336
+ // the line's heightScale ramp, so text, line number, and row
337
+ // height all reveal together. No widthScale anim needed — there's
338
+ // no existing content at the same x.
339
+ // - `inlineIntro`: for tokens being spliced into an existing line
340
+ // (single-line insert). The line's height isn't animating, so the
341
+ // suffix has to make room horizontally — widthScale 0→1 over the
342
+ // first half, then opacity fades in over the second half.
343
+ const collectIntro = (toks, mode) => {
344
+ for (const tok of toks) {
345
+ introducedIds.add(tok.id);
346
+ if (mode === 'newLine') {
347
+ animTokens.push(makeAnim(tok.id, {
348
+ opacity: { keys: [0, 1], values: [0, 1] },
349
+ }));
350
+ }
351
+ else {
352
+ animTokens.push(makeAnim(tok.id, {
353
+ opacity: { keys: [0, 0.5, 1], values: [0, 0, 1] },
354
+ widthScale: { keys: [0, 0.5, 1], values: [0, 1, 1] },
355
+ }));
356
+ }
357
+ }
358
+ };
359
+ let newLines;
360
+ if (insertedLineGroups.length === 1) {
361
+ // Single-line insertion: splice tokens into the existing line.
362
+ const insertedTokens = insertedLineGroups[0].tokens;
363
+ collectIntro(insertedTokens, 'inline');
364
+ const merged = makeIdLine([...tokBefore, ...insertedTokens, ...tokAfter]);
365
+ merged.id = targetLine.id;
366
+ newLines = [merged];
367
+ }
368
+ else {
369
+ // Multi-line insertion. Split the inserted content into:
370
+ // first = prefix + insertedLineGroups[0] (sits on the original line)
371
+ // middle = insertedLineGroups[1..-2] (entirely new lines)
372
+ // last = insertedLineGroups[-1] + suffix (the original line's tail)
373
+ //
374
+ // Pre-existing tokens (tokBefore, tokAfter) keep their identity and
375
+ // shouldn't animate. Newly tokenized groups fade in.
376
+ //
377
+ // Critical for height animation: the line that contains the host
378
+ // line's pre-existing content must reuse the host id and NOT get a
379
+ // heightScale animation. The other produced lines are genuinely new
380
+ // and get the 0→1 reveal. We pick which side inherits the host id
381
+ // by where the cut lands — if tokAfter is empty (cut at end of
382
+ // line), the first produced line owns the original content. If
383
+ // tokBefore is empty (cut at start), the last produced line owns
384
+ // it. Otherwise both sides hold original content and the first
385
+ // keeps the host id by default.
386
+ const firstInserted = insertedLineGroups[0].tokens;
387
+ const middleInserted = insertedLineGroups.slice(1, -1).map(g => g.tokens);
388
+ const lastInserted = insertedLineGroups[insertedLineGroups.length - 1].tokens;
389
+ // The inheritor side (whichever produced line keeps the host id)
390
+ // doesn't get a height animation, so its inserted tokens need the
391
+ // inline (width-collapse) intro. The genuinely-new lines get the
392
+ // newLine intro (token fade matches the line's height ramp).
393
+ const cutAtStart = tokBefore.length === 0;
394
+ const firstLine = makeIdLine([...tokBefore, ...firstInserted]);
395
+ const middleLines = middleInserted.map(toks => makeIdLine(toks));
396
+ const lastLine = makeIdLine([...lastInserted, ...tokAfter]);
397
+ const inheritor = cutAtStart ? lastLine : firstLine;
398
+ inheritor.id = targetLine.id;
399
+ collectIntro(firstInserted, firstLine === inheritor ? 'inline' : 'newLine');
400
+ for (const m of middleInserted)
401
+ collectIntro(m, 'newLine');
402
+ collectIntro(lastInserted, lastLine === inheritor ? 'inline' : 'newLine');
403
+ // Every produced line OTHER than the inheritor is genuinely new and
404
+ // grows in height from 0→1.
405
+ const allProduced = [firstLine, ...middleLines, lastLine];
406
+ for (const ln of allProduced) {
407
+ if (ln.id !== targetLine.id) {
408
+ lineHeightScales.set(ln.id, { keys: [0, 1], values: [0, 1] });
409
+ }
410
+ }
411
+ newLines = allProduced;
412
+ }
413
+ this.tokenLines = [
414
+ ...this.tokenLines.slice(0, lineIdx),
415
+ ...newLines,
416
+ ...this.tokenLines.slice(lineIdx + 1),
417
+ ];
418
+ yield* this.runTransition({ tokens: animTokens, lineHeightScales, introducedIds }, duration, easing);
419
+ }
420
+ /**
421
+ * Remove the tokens in `codeRange`. If the range spans whole lines, those
422
+ * lines collapse their height; partial line ranges only remove tokens
423
+ * (the surrounding text reflows).
424
+ */
425
+ *remove(codeRange, duration, easing) {
426
+ const span = this.rangeToTokenSpan(codeRange);
427
+ if (!span) {
428
+ yield* tween(duration, () => { });
429
+ return;
430
+ }
431
+ const removedTokens = [];
432
+ const fullyRemovedLineIds = [];
433
+ // A line is "fully removed" when the range covers every one of its
434
+ // tokens. Fully-removed lines get a height collapse + linear fade.
435
+ // Partially-removed lines keep their height; their removed tokens
436
+ // shrink horizontally so the surrounding text reflows.
437
+ for (let li = span.fromLine; li <= span.toLine; li++) {
438
+ const line = this.tokenLines[li];
439
+ const start = li === span.fromLine ? span.fromIdx : 0;
440
+ const end = li === span.toLine ? span.toIdx : line.tokens.length;
441
+ for (let ti = start; ti < end; ti++)
442
+ removedTokens.push(line.tokens[ti]);
443
+ if (start === 0 && end === line.tokens.length) {
444
+ fullyRemovedLineIds.push(line.id);
445
+ }
446
+ }
447
+ const animTokens = [];
448
+ const lineHeightScales = new Map();
449
+ const fullyRemovedLineIdSet = new Set(fullyRemovedLineIds);
450
+ const fullyRemovedTokenIds = new Set();
451
+ for (const line of this.tokenLines) {
452
+ if (fullyRemovedLineIdSet.has(line.id)) {
453
+ for (const tok of line.tokens)
454
+ fullyRemovedTokenIds.add(tok.id);
455
+ }
456
+ }
457
+ // Whole-line removal: linear fade matching the linear height collapse,
458
+ // so text, line number, and row height all close together.
459
+ for (const lineId of fullyRemovedLineIds) {
460
+ lineHeightScales.set(lineId, { keys: [0, 1], values: [1, 0] });
461
+ }
462
+ for (const tok of removedTokens) {
463
+ if (fullyRemovedTokenIds.has(tok.id)) {
464
+ animTokens.push(makeAnim(tok.id, {
465
+ opacity: { keys: [0, 1], values: [1, 0] },
466
+ }));
467
+ }
468
+ else {
469
+ // Partial-line removal: still need width to collapse so the
470
+ // surrounding text on the line doesn't leave a gap.
471
+ animTokens.push(makeAnim(tok.id, {
472
+ opacity: { keys: [0, 0.5, 1], values: [1, 0, 0] },
473
+ widthScale: { keys: [0, 0.5, 1], values: [1, 0, 0] },
474
+ }));
475
+ }
476
+ }
477
+ const removedTokenIds = new Set(removedTokens.map(t => t.id));
478
+ try {
479
+ yield* this.runTransition({ tokens: animTokens, lineHeightScales }, duration, easing);
480
+ }
481
+ finally {
482
+ // Drop the tokens that animated out, then drop any lines that are
483
+ // either marked-fully-removed or have ended up empty.
484
+ const remaining = [];
485
+ for (const line of this.tokenLines) {
486
+ if (fullyRemovedLineIdSet.has(line.id))
487
+ continue;
488
+ line.tokens = line.tokens.filter(tok => !removedTokenIds.has(tok.id));
489
+ remaining.push(line);
490
+ }
491
+ this.tokenLines = remaining;
492
+ // Clean up highlight state pointing at removed tokens.
493
+ for (const id of removedTokenIds)
494
+ this.highlightedIds.delete(id);
495
+ }
496
+ }
497
+ /**
498
+ * Find every range matching the literal string `text` in the current
499
+ * source. Multi-line matches are supported.
500
+ */
501
+ findAllRanges(text) {
502
+ const ranges = [];
503
+ if (!text)
504
+ return ranges;
505
+ const source = this.joinedSource();
506
+ const lineLens = this.lineLengths();
507
+ let from = 0;
508
+ while (true) {
509
+ const idx = source.indexOf(text, from);
510
+ if (idx === -1)
511
+ break;
512
+ ranges.push(charOffsetsToRange(idx, idx + text.length, lineLens));
513
+ from = idx + Math.max(1, text.length);
514
+ }
515
+ return ranges;
516
+ }
517
+ /**
518
+ * Find the `index`th range matching `text`. Returns null if not found.
519
+ */
520
+ findRangeAt(text, index) {
521
+ const all = this.findAllRanges(text);
522
+ return all[index] ?? null;
523
+ }
524
+ /**
525
+ * Find the first range matching `text`. Returns null if not found.
526
+ */
527
+ findFirstRange(text) {
528
+ return this.findRangeAt(text, 0);
529
+ }
530
+ *runTransition(partial, duration, easing) {
531
+ const transition = {
532
+ tokens: partial.tokens,
533
+ lineHeightScales: partial.lineHeightScales ?? new Map(),
534
+ progress: 0,
535
+ introducedIds: partial.introducedIds ?? new Set(),
536
+ };
537
+ this.transitions.push(transition);
538
+ try {
539
+ yield* tween(duration, (rawT) => {
540
+ transition.progress = easing ? easing(rawT) : rawT;
541
+ });
542
+ }
543
+ finally {
544
+ this.transitions = this.transitions.filter(t => t !== transition);
545
+ }
546
+ }
547
+ joinedSource() {
548
+ return this.tokenLines
549
+ .map(line => line.tokens.map(t => t.content).join(''))
550
+ .join('\n');
551
+ }
552
+ lineLengths() {
553
+ return this.tokenLines.map(line => line.tokens.reduce((acc, t) => acc + t.content.length, 0));
554
+ }
555
+ /**
556
+ * Resolve a CodeRange to the set of token ids whose content overlaps the
557
+ * range. Tokens that partially overlap are included.
558
+ */
559
+ tokenIdsInRange(codeRange) {
560
+ const result = new Set();
561
+ const lineLens = this.lineLengths();
562
+ if (lineLens.length === 0)
563
+ return result;
564
+ const { start: rStart, end: rEnd } = rangeToCharOffsets(codeRange, lineLens);
565
+ if (rEnd <= rStart)
566
+ return result;
567
+ // Walk tokens with running offsets in the joined string.
568
+ let off = 0;
569
+ for (let li = 0; li < this.tokenLines.length; li++) {
570
+ const line = this.tokenLines[li];
571
+ for (const tok of line.tokens) {
572
+ const tStart = off;
573
+ const tEnd = off + tok.content.length;
574
+ if (tEnd > rStart && tStart < rEnd) {
575
+ result.add(tok.id);
576
+ }
577
+ off = tEnd;
578
+ }
579
+ if (li < this.tokenLines.length - 1)
580
+ off += 1; // newline
581
+ }
582
+ return result;
583
+ }
584
+ /**
585
+ * Resolve a CodeRange to a structural (fromLine, fromIdx)..(toLine, toIdx)
586
+ * token span. Snaps to whole tokens (any token that overlaps the range is
587
+ * included). Returns null if no tokens overlap.
588
+ */
589
+ rangeToTokenSpan(codeRange) {
590
+ const lineLens = this.lineLengths();
591
+ if (lineLens.length === 0)
592
+ return null;
593
+ const { start: rStart, end: rEnd } = rangeToCharOffsets(codeRange, lineLens);
594
+ if (rEnd <= rStart)
595
+ return null;
596
+ let off = 0;
597
+ let fromLine = -1, fromIdx = -1, toLine = -1, toIdx = -1;
598
+ for (let li = 0; li < this.tokenLines.length; li++) {
599
+ const line = this.tokenLines[li];
600
+ for (let ti = 0; ti < line.tokens.length; ti++) {
601
+ const tok = line.tokens[ti];
602
+ const tStart = off;
603
+ const tEnd = off + tok.content.length;
604
+ if (tEnd > rStart && tStart < rEnd) {
605
+ if (fromLine === -1) {
606
+ fromLine = li;
607
+ fromIdx = ti;
608
+ }
609
+ toLine = li;
610
+ toIdx = ti + 1;
611
+ }
612
+ off = tEnd;
613
+ }
614
+ if (li < this.tokenLines.length - 1)
615
+ off += 1;
616
+ }
617
+ if (fromLine === -1)
618
+ return null;
619
+ return { fromLine, fromIdx, toLine, toIdx };
620
+ }
621
+ drawSelf(draw) {
622
+ const pad = this.padding;
623
+ const lineH = this.fontSize * this.lineHeight;
624
+ const stateById = this.resolveTokenStates();
625
+ const lineHeightScales = this.resolveLineHeightScales();
626
+ const lineWidths = this.tokenLines.map(line => {
627
+ let w = 0;
628
+ for (const tok of line.tokens) {
629
+ const ws = stateById.get(tok.id)?.widthScale ?? 1;
630
+ w += this.tokenAdvance(draw, tok.content) * ws;
631
+ }
632
+ return w;
633
+ });
634
+ const maxLineWidth = Math.max(...lineWidths, 0);
635
+ const gutter = this.gutterWidth(draw);
636
+ const blockWidth = maxLineWidth + gutter + pad.left + pad.right;
637
+ const lineHeights = this.tokenLines.map(line => lineH * (lineHeightScales.get(line.id) ?? 1));
638
+ const totalContentHeight = lineHeights.reduce((a, b) => a + b, 0);
639
+ const blockHeight = totalContentHeight + pad.top + pad.bottom;
640
+ const startX = -blockWidth / 2 + pad.left + gutter;
641
+ const startY = -blockHeight / 2 + pad.top;
642
+ // Gutter line-number color: a muted version of the standard text color.
643
+ const lineNumColor = [0.45, 0.5, 0.55, 1];
644
+ let yCursor = startY;
645
+ // Visible line counter — only fully-non-collapsed lines get a number.
646
+ // We number every modeled line (1-indexed) regardless of hScale so that
647
+ // animations that collapse a line still show its label fading out, which
648
+ // matches normal editor behaviour.
649
+ for (let lineIdx = 0; lineIdx < this.tokenLines.length; lineIdx++) {
650
+ const line = this.tokenLines[lineIdx];
651
+ const hScale = lineHeightScales.get(line.id) ?? 1;
652
+ // The renderer centers each single-token block on the (x, y) we pass
653
+ // (it shifts by -blockWidth/2, -blockHeight/2). So we anchor every
654
+ // token at the *center* of its cell, not its top-left/baseline:
655
+ // - y: the vertical middle of this line's slot. The slot is
656
+ // lineHeights[lineIdx] tall (= lineH * hScale); the full-size
657
+ // glyph block stays centered in it as the slot collapses.
658
+ // - x: the horizontal middle of each token (set per-token below).
659
+ // Passing lineHeight makes the token block's height deterministic
660
+ // (fontSize * lineHeight) rather than the font's natural metrics, so
661
+ // the vertical center lands exactly on the slot center.
662
+ const centerY = yCursor + lineHeights[lineIdx] / 2;
663
+ if (this.showLineNumbers) {
664
+ const label = String(lineIdx + 1);
665
+ const labelW = draw.measureText(label, this.fontSize, this.fontFamily);
666
+ // Right-align the number so its right edge sits one gutterGap to
667
+ // the left of where the code text begins.
668
+ const gx = -blockWidth / 2 + pad.left + (gutter - labelW) - this.gutterGap(draw);
669
+ // When a highlight is active, the number dims along with the code
670
+ // unless the WHOLE line is highlighted. We take the min opacity of
671
+ // the line's tokens: a fully-highlighted line is all 1s (bright),
672
+ // any dimmed token drags the number down. This also tweens for
673
+ // free during highlight()/resetHighlight() transitions.
674
+ const lineHighlightOpacity = this.lineHighlightOpacity(line, stateById);
675
+ draw.text({
676
+ text: label,
677
+ fontSize: this.fontSize,
678
+ fontFamily: this.fontFamily,
679
+ lineHeight: this.lineHeight,
680
+ x: gx + labelW / 2,
681
+ y: centerY,
682
+ align: 'left',
683
+ })
684
+ .fill([{ type: "color", color: lineNumColor, opacity: hScale * lineHighlightOpacity }]);
685
+ }
686
+ let x = startX;
687
+ for (const token of line.tokens) {
688
+ if (token.content.length === 0)
689
+ continue;
690
+ const color = token.color ? parseColor(token.color) : [0.82, 0.84, 0.86, 1];
691
+ const state = stateById.get(token.id);
692
+ const opacity = (state?.opacity ?? 1) * hScale;
693
+ const offsetY = state?.offsetY ?? 0;
694
+ const widthScale = state?.widthScale ?? 1;
695
+ // Advance width includes letterSpacing, matching what the
696
+ // renderer lays down when drawing with the same letterSpacing.
697
+ const tokWidth = this.tokenAdvance(draw, token.content);
698
+ if (opacity > 0 && widthScale > 0) {
699
+ draw.text({
700
+ text: token.content,
701
+ fontSize: this.fontSize,
702
+ fontFamily: this.fontFamily,
703
+ lineHeight: this.lineHeight,
704
+ letterSpacing: this.letterSpacing,
705
+ // Token is drawn at its natural width regardless of
706
+ // widthScale (widthScale only shrinks the advance), so
707
+ // its visual center is always x + tokWidth/2.
708
+ x: x + tokWidth / 2,
709
+ y: centerY + offsetY,
710
+ align: 'left',
711
+ })
712
+ .fill([{ type: "color", color, opacity }]);
713
+ }
714
+ x += tokWidth * widthScale;
715
+ }
716
+ yCursor += lineHeights[lineIdx];
717
+ }
718
+ }
719
+ // Is a highlight currently engaged — either a persistent dim is set, or a
720
+ // highlight()/resetHighlight() cross-fade is mid-flight? Intro/exit
721
+ // animations (append/insert/remove) are NOT highlights, so the line-number
722
+ // dimming below stays inert for them and only `hScale` affects the number.
723
+ isHighlightActive() {
724
+ if (this.highlightDimOpacity !== null)
725
+ return true;
726
+ return this.transitions.some(tr => tr.introducedIds.size === 0
727
+ && tr.lineHeightScales.size === 0);
728
+ }
729
+ // Opacity multiplier for a line's number under the active highlight. The
730
+ // number stays bright only when the WHOLE line is highlighted, so we take
731
+ // the min token opacity on the line. Returns 1 (no dimming) when no
732
+ // highlight is active, so insert/remove intros don't drag the number down.
733
+ lineHighlightOpacity(line, stateById) {
734
+ if (!this.isHighlightActive())
735
+ return 1;
736
+ let min = 1;
737
+ for (const tok of line.tokens) {
738
+ if (tok.content.length === 0)
739
+ continue;
740
+ const op = stateById.get(tok.id)?.opacity ?? 1;
741
+ if (op < min)
742
+ min = op;
743
+ }
744
+ return min;
745
+ }
746
+ resolveTokenStates() {
747
+ const out = new Map();
748
+ if (this.highlightDimOpacity !== null) {
749
+ const dim = this.highlightDimOpacity;
750
+ for (const line of this.tokenLines) {
751
+ for (const tok of line.tokens) {
752
+ const isHighlighted = this.highlightedIds.has(tok.id);
753
+ out.set(tok.id, { opacity: isHighlighted ? 1 : dim, offsetY: 0, widthScale: 1 });
754
+ }
755
+ }
756
+ }
757
+ for (const tr of this.transitions) {
758
+ const t = tr.progress;
759
+ for (const at of tr.tokens) {
760
+ const op = sampleCurve(at.opacityKeys, at.opacity, t);
761
+ const oy = sampleCurve(at.offsetYKeys, at.offsetY, t);
762
+ const ws = sampleCurve(at.widthScaleKeys, at.widthScale, t);
763
+ const prev = out.get(at.id);
764
+ out.set(at.id, {
765
+ opacity: op,
766
+ offsetY: oy,
767
+ widthScale: prev ? prev.widthScale * ws : ws,
768
+ });
769
+ }
770
+ }
771
+ return out;
772
+ }
773
+ resolveLineHeightScales() {
774
+ const out = new Map();
775
+ for (const tr of this.transitions) {
776
+ const t = tr.progress;
777
+ for (const [lineId, curve] of tr.lineHeightScales) {
778
+ const v = sampleCurve(curve.keys, curve.values, t);
779
+ out.set(lineId, (out.get(lineId) ?? 1) * v);
780
+ }
781
+ }
782
+ return out;
783
+ }
784
+ }
785
+ __decorate([
786
+ property({ default: "" })
787
+ ], Code.prototype, "code", void 0);
788
+ __decorate([
789
+ property({ default: "typescript" })
790
+ ], Code.prototype, "language", void 0);
791
+ __decorate([
792
+ property({ default: "JetBrains Mono" })
793
+ ], Code.prototype, "fontFamily", void 0);
794
+ __decorate([
795
+ property({ default: "github-dark" })
796
+ ], Code.prototype, "theme", void 0);
797
+ __decorate([
798
+ property({ default: 16 })
799
+ ], Code.prototype, "fontSize", void 0);
800
+ __decorate([
801
+ property({ default: 1.6 })
802
+ ], Code.prototype, "lineHeight", void 0);
803
+ __decorate([
804
+ property({ default: 1.1 })
805
+ ], Code.prototype, "letterSpacing", void 0);
806
+ __decorate([
807
+ property({ default: false })
808
+ ], Code.prototype, "showLineNumbers", void 0);
809
+ __decorate([
810
+ property({ default: 4 })
811
+ ], Code.prototype, "lineNumberGap", void 0);
812
+ __decorate([
813
+ property({ default: 0, mapper: resolvePadding, tween: lerpEdgeInset })
814
+ ], Code.prototype, "padding", void 0);
815
+ function makeAnim(id, curves) {
816
+ const op = curves.opacity ?? { keys: [0, 1], values: [1, 1] };
817
+ const oy = curves.offsetY ?? { keys: [0, 1], values: [0, 0] };
818
+ const ws = curves.widthScale ?? { keys: [0, 1], values: [1, 1] };
819
+ return {
820
+ id,
821
+ opacity: op.values,
822
+ opacityKeys: op.keys,
823
+ offsetY: oy.values,
824
+ offsetYKeys: oy.keys,
825
+ widthScale: ws.values,
826
+ widthScaleKeys: ws.keys,
827
+ };
828
+ }
829
+ /**
830
+ * Split `tokens` at character offset `col` (relative to the joined content of
831
+ * the tokens). If `col` falls inside a token, that token is split into two new
832
+ * tokens with fresh ids (the original id is discarded — it didn't exist as a
833
+ * single unit on either side of the cut).
834
+ */
835
+ function splitTokensAt(tokens, col) {
836
+ const before = [];
837
+ const after = [];
838
+ let off = 0;
839
+ for (const tok of tokens) {
840
+ const tStart = off;
841
+ const tEnd = off + tok.content.length;
842
+ if (tEnd <= col) {
843
+ before.push(tok);
844
+ }
845
+ else if (tStart >= col) {
846
+ after.push(tok);
847
+ }
848
+ else {
849
+ const cut = col - tStart;
850
+ before.push(makeIdToken(tok.content.slice(0, cut), tok.color));
851
+ after.push(makeIdToken(tok.content.slice(cut), tok.color));
852
+ }
853
+ off = tEnd;
854
+ }
855
+ return { before, after };
856
+ }
857
+ /**
858
+ * Convert [start, end) character offsets in joined source to a CodeRange.
859
+ * Lines/cols in the returned range are 1-indexed.
860
+ */
861
+ function charOffsetsToRange(start, end, lineLengths) {
862
+ let off = 0;
863
+ let startLine = 1, startCol = 1, endLine = 1, endCol = 1;
864
+ let foundStart = false;
865
+ for (let li = 0; li < lineLengths.length; li++) {
866
+ const lineStart = off;
867
+ const lineEnd = off + lineLengths[li];
868
+ if (!foundStart && start >= lineStart && start <= lineEnd) {
869
+ startLine = li + 1;
870
+ startCol = (start - lineStart) + 1;
871
+ foundStart = true;
872
+ }
873
+ if (end >= lineStart && end <= lineEnd) {
874
+ endLine = li + 1;
875
+ endCol = (end - lineStart) + 1;
876
+ break;
877
+ }
878
+ off = lineEnd + 1;
879
+ }
880
+ return { startLine, startCol, endLine, endCol };
881
+ }
882
+ //# sourceMappingURL=node.js.map