@motionscript/code 0.1.0-alpha.5 → 0.1.0-alpha.6

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