@mui/internal-docs-infra 0.11.1-canary.15 → 0.11.1-canary.17
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/package.json +2 -2
- package/pipeline/parseSource/extendSyntaxTokens.mjs +501 -43
- package/pipeline/transformHtmlCodeInline/removeSuffixFromHighlightedNodes.d.mts +12 -0
- package/pipeline/transformHtmlCodeInline/removeSuffixFromHighlightedNodes.mjs +52 -0
- package/pipeline/transformHtmlCodeInline/transformHtmlCodeInline.mjs +22 -1
- package/useCode/EditableEngine.mjs +12 -5
- package/useCodeWindow/useCodeWindow.d.mts +8 -0
- package/useCodeWindow/useCodeWindow.mjs +88 -71
- package/useScrollAnchor/useScrollAnchor.mjs +28 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mui/internal-docs-infra",
|
|
3
|
-
"version": "0.11.1-canary.
|
|
3
|
+
"version": "0.11.1-canary.17",
|
|
4
4
|
"author": "MUI Team",
|
|
5
5
|
"description": "MUI Infra - internal documentation creation tools.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -768,5 +768,5 @@
|
|
|
768
768
|
"bin": {
|
|
769
769
|
"docs-infra": "./cli/index.mjs"
|
|
770
770
|
},
|
|
771
|
-
"gitSha": "
|
|
771
|
+
"gitSha": "5f0871264ec4d7c78d5fd7e62888de0f7bf61cda"
|
|
772
772
|
}
|
|
@@ -140,6 +140,334 @@ function enhanceStringSpan(element) {
|
|
|
140
140
|
}
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Tests whether a `pl-k` token's text consists entirely of symbol characters
|
|
145
|
+
* (no letters, digits, or underscore). Used to distinguish symbolic operators
|
|
146
|
+
* (`=`, `=>`, `&&`, `...`) from word keywords (`const`, `if`, `function`).
|
|
147
|
+
*/
|
|
148
|
+
function isSymbolicPunctuation(text) {
|
|
149
|
+
if (text.length === 0) {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
153
|
+
const code = text.charCodeAt(i);
|
|
154
|
+
// 0-9
|
|
155
|
+
if (code >= 48 && code <= 57) {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
// A-Z
|
|
159
|
+
if (code >= 65 && code <= 90) {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
// a-z
|
|
163
|
+
if (code >= 97 && code <= 122) {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
// _
|
|
167
|
+
if (code === 95) {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Splits a text value into nodes, wrapping bare identifier object-literal keys
|
|
176
|
+
* (e.g. `height` in `{ height: 400 }`) in a `<span>` carrying `di-op` plus, when
|
|
177
|
+
* `inJsx` is true, also `di-jv`. Returns `null` if no key pattern is found,
|
|
178
|
+
* leaving the original text node untouched.
|
|
179
|
+
*
|
|
180
|
+
* A key is detected as `[A-Za-z_$][\w$]*` immediately preceded by `{` or `,`
|
|
181
|
+
* (with optional whitespace) and followed by optional whitespace, then a single
|
|
182
|
+
* `:` not part of `::`. The leading-context check avoids tagging ternary/label
|
|
183
|
+
* patterns; the trailing check avoids `::` (TypeScript namespace, pseudo-elements).
|
|
184
|
+
*/
|
|
185
|
+
function splitObjectKeys(value, inJsx) {
|
|
186
|
+
const nodes = [];
|
|
187
|
+
let lastEnd = 0;
|
|
188
|
+
let i = 0;
|
|
189
|
+
while (i < value.length) {
|
|
190
|
+
const code = value.charCodeAt(i);
|
|
191
|
+
const isIdentStart = code >= 65 && code <= 90 || code >= 97 && code <= 122 || code === 95 || code === 36;
|
|
192
|
+
if (!isIdentStart) {
|
|
193
|
+
i += 1;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Find preceding non-whitespace character; must be `{` or `,` (object key context)
|
|
198
|
+
let prev = i - 1;
|
|
199
|
+
while (prev >= 0) {
|
|
200
|
+
const pc = value.charCodeAt(prev);
|
|
201
|
+
if (pc !== 32 && pc !== 9 && pc !== 10 && pc !== 13) {
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
prev -= 1;
|
|
205
|
+
}
|
|
206
|
+
if (prev < 0) {
|
|
207
|
+
i += 1;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
const prevCode = value.charCodeAt(prev);
|
|
211
|
+
if (prevCode !== 123 /* { */ && prevCode !== 44 /* , */) {
|
|
212
|
+
i += 1;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Consume identifier chars
|
|
217
|
+
let end = i + 1;
|
|
218
|
+
while (end < value.length) {
|
|
219
|
+
const ec = value.charCodeAt(end);
|
|
220
|
+
const isIdentPart = ec >= 48 && ec <= 57 || ec >= 65 && ec <= 90 || ec >= 97 && ec <= 122 || ec === 95 || ec === 36;
|
|
221
|
+
if (!isIdentPart) {
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
end += 1;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Skip whitespace, expect a single `:` (not `::`)
|
|
228
|
+
let after = end;
|
|
229
|
+
while (after < value.length) {
|
|
230
|
+
const ac = value.charCodeAt(after);
|
|
231
|
+
if (ac !== 32 && ac !== 9 && ac !== 10 && ac !== 13) {
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
after += 1;
|
|
235
|
+
}
|
|
236
|
+
if (after >= value.length || value.charCodeAt(after) !== 58 /* : */) {
|
|
237
|
+
i = end;
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (after + 1 < value.length && value.charCodeAt(after + 1) === 58) {
|
|
241
|
+
i = end;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (i > lastEnd) {
|
|
245
|
+
nodes.push({
|
|
246
|
+
type: 'text',
|
|
247
|
+
value: value.slice(lastEnd, i)
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
const className = inJsx ? ['di-op', 'di-jv'] : ['di-op'];
|
|
251
|
+
nodes.push({
|
|
252
|
+
type: 'element',
|
|
253
|
+
tagName: 'span',
|
|
254
|
+
properties: {
|
|
255
|
+
className
|
|
256
|
+
},
|
|
257
|
+
children: [{
|
|
258
|
+
type: 'text',
|
|
259
|
+
value: value.slice(i, end)
|
|
260
|
+
}]
|
|
261
|
+
});
|
|
262
|
+
lastEnd = end;
|
|
263
|
+
i = end;
|
|
264
|
+
}
|
|
265
|
+
if (nodes.length === 0) {
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
if (lastEnd < value.length) {
|
|
269
|
+
nodes.push({
|
|
270
|
+
type: 'text',
|
|
271
|
+
value: value.slice(lastEnd)
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
return nodes;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Tests whether a string starts with optional whitespace followed by `:`.
|
|
279
|
+
* Used to detect that a `pl-s` span sits in object property-key position.
|
|
280
|
+
*/
|
|
281
|
+
function startsWithColon(text) {
|
|
282
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
283
|
+
const ch = text.charCodeAt(i);
|
|
284
|
+
if (ch === 58) {
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
// space, tab, newline, carriage return
|
|
288
|
+
if (ch !== 32 && ch !== 9 && ch !== 10 && ch !== 13) {
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Frame in the template-literal interpolation stack. A `string` frame means we
|
|
297
|
+
* are inside template-string content; an `expr` frame means we are inside a
|
|
298
|
+
* `${ ... }` interpolation expression, tracking `{`/`}` nesting via `braceDepth`
|
|
299
|
+
* so the matching close brace can be found across object literals and lines.
|
|
300
|
+
*/
|
|
301
|
+
|
|
302
|
+
/** Creates an empty `di-te` interpolation-region span. */
|
|
303
|
+
function createInterpolationRegion() {
|
|
304
|
+
return {
|
|
305
|
+
type: 'element',
|
|
306
|
+
tagName: 'span',
|
|
307
|
+
properties: {
|
|
308
|
+
className: ['di-te']
|
|
309
|
+
},
|
|
310
|
+
children: []
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Creates a `di-td` delimiter span wrapping the given `${` or `}` glyph. */
|
|
315
|
+
function createInterpolationDelimiter(value) {
|
|
316
|
+
return {
|
|
317
|
+
type: 'element',
|
|
318
|
+
tagName: 'span',
|
|
319
|
+
properties: {
|
|
320
|
+
className: ['di-td']
|
|
321
|
+
},
|
|
322
|
+
children: [{
|
|
323
|
+
type: 'text',
|
|
324
|
+
value
|
|
325
|
+
}]
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** True when a node is a `pl-pds` span whose text is a backtick. */
|
|
330
|
+
function isBacktickDelimiter(node) {
|
|
331
|
+
return !!node && node.type === 'element' && node.tagName === 'span' && getFirstClass(node) === 'pl-pds' && getShallowTextContent(node) === '`';
|
|
332
|
+
}
|
|
333
|
+
function pushText(target, value) {
|
|
334
|
+
target.push({
|
|
335
|
+
type: 'text',
|
|
336
|
+
value
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Scans one text node of a template literal, splitting it around interpolation
|
|
342
|
+
* boundaries. In `string` mode it looks for `${` (opening a `di-te` region with a
|
|
343
|
+
* `di-td` delimiter); in `expr` mode it counts `{`/`}` to find the matching close
|
|
344
|
+
* (emitting the closing `di-td`). Mutates `stack` and `targets` in place as it
|
|
345
|
+
* crosses boundaries, appending nodes to the innermost current target.
|
|
346
|
+
*/
|
|
347
|
+
function processTemplateText(value, stack, targets) {
|
|
348
|
+
let i = 0;
|
|
349
|
+
let segStart = 0;
|
|
350
|
+
while (i < value.length) {
|
|
351
|
+
const top = stack[stack.length - 1];
|
|
352
|
+
const target = targets[targets.length - 1];
|
|
353
|
+
if (top.mode === 'string') {
|
|
354
|
+
const open = value.indexOf('${', i);
|
|
355
|
+
if (open === -1) {
|
|
356
|
+
break;
|
|
357
|
+
}
|
|
358
|
+
if (open > segStart) {
|
|
359
|
+
pushText(target, value.slice(segStart, open));
|
|
360
|
+
}
|
|
361
|
+
const region = createInterpolationRegion();
|
|
362
|
+
target.push(region);
|
|
363
|
+
region.children.push(createInterpolationDelimiter('${'));
|
|
364
|
+
stack.push({
|
|
365
|
+
mode: 'expr',
|
|
366
|
+
braceDepth: 1
|
|
367
|
+
});
|
|
368
|
+
targets.push(region.children);
|
|
369
|
+
i = open + 2;
|
|
370
|
+
segStart = i;
|
|
371
|
+
} else {
|
|
372
|
+
const code = value.charCodeAt(i);
|
|
373
|
+
if (code === 123 /* { */) {
|
|
374
|
+
top.braceDepth += 1;
|
|
375
|
+
i += 1;
|
|
376
|
+
} else if (code === 125 /* } */) {
|
|
377
|
+
top.braceDepth -= 1;
|
|
378
|
+
if (top.braceDepth === 0) {
|
|
379
|
+
if (i > segStart) {
|
|
380
|
+
pushText(target, value.slice(segStart, i));
|
|
381
|
+
}
|
|
382
|
+
target.push(createInterpolationDelimiter('}'));
|
|
383
|
+
stack.pop();
|
|
384
|
+
targets.pop();
|
|
385
|
+
i += 1;
|
|
386
|
+
segStart = i;
|
|
387
|
+
} else {
|
|
388
|
+
i += 1;
|
|
389
|
+
}
|
|
390
|
+
} else {
|
|
391
|
+
i += 1;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
if (segStart < value.length) {
|
|
396
|
+
pushText(targets[targets.length - 1], value.slice(segStart));
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Restructures one `pl-s` template-literal line, wrapping each `${ ... }`
|
|
402
|
+
* interpolation slice on the line in a `di-te` region with `di-td` delimiters.
|
|
403
|
+
*
|
|
404
|
+
* `entryStack` carries the interpolation state from previous lines (a single
|
|
405
|
+
* `string` frame for the opening line). `isOpener` is true for the line that
|
|
406
|
+
* holds the opening backtick. Because starry-night emits one `pl-s` span per line
|
|
407
|
+
* and the line gutter splits on top-level newlines, a region can never cross a
|
|
408
|
+
* line boundary — each line's slice is wrapped on its own, so a continuation
|
|
409
|
+
* line opens a fresh `di-te` with no leading `${`. Returns the stack to carry to
|
|
410
|
+
* the next line, or `null` once the closing backtick is consumed (run complete).
|
|
411
|
+
*/
|
|
412
|
+
function restructureTemplateLine(pls, entryStack, isOpener) {
|
|
413
|
+
const source = pls.children;
|
|
414
|
+
const out = [];
|
|
415
|
+
const stack = entryStack.map(frame => ({
|
|
416
|
+
...frame
|
|
417
|
+
}));
|
|
418
|
+
|
|
419
|
+
// Rebuild the physical target chain for the carried stack: each open `expr`
|
|
420
|
+
// frame gets a fresh `di-te` region on this line; nested `string` frames share
|
|
421
|
+
// their parent expression's region as the target.
|
|
422
|
+
const targets = [out];
|
|
423
|
+
for (let depth = 1; depth < stack.length; depth += 1) {
|
|
424
|
+
if (stack[depth].mode === 'expr') {
|
|
425
|
+
const region = createInterpolationRegion();
|
|
426
|
+
targets[depth - 1].push(region);
|
|
427
|
+
targets.push(region.children);
|
|
428
|
+
} else {
|
|
429
|
+
targets.push(targets[depth - 1]);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
let runEnded = false;
|
|
433
|
+
let index = 0;
|
|
434
|
+
if (isOpener) {
|
|
435
|
+
out.push(source[0]);
|
|
436
|
+
index = 1;
|
|
437
|
+
}
|
|
438
|
+
for (; index < source.length; index += 1) {
|
|
439
|
+
const node = source[index];
|
|
440
|
+
const target = targets[targets.length - 1];
|
|
441
|
+
if (node.type === 'text') {
|
|
442
|
+
processTemplateText(node.value, stack, targets);
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
if (isBacktickDelimiter(node)) {
|
|
446
|
+
if (stack[stack.length - 1].mode === 'expr') {
|
|
447
|
+
// A nested template literal opens inside the interpolation expression.
|
|
448
|
+
target.push(node);
|
|
449
|
+
stack.push({
|
|
450
|
+
mode: 'string'
|
|
451
|
+
});
|
|
452
|
+
targets.push(target);
|
|
453
|
+
} else if (stack.length === 1) {
|
|
454
|
+
// The outer template's closing backtick — the run is complete.
|
|
455
|
+
out.push(node);
|
|
456
|
+
runEnded = true;
|
|
457
|
+
} else {
|
|
458
|
+
// A nested template literal's closing backtick.
|
|
459
|
+
target.push(node);
|
|
460
|
+
stack.pop();
|
|
461
|
+
targets.pop();
|
|
462
|
+
}
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
target.push(node);
|
|
466
|
+
}
|
|
467
|
+
pls.children = out;
|
|
468
|
+
return runEnded ? null : stack;
|
|
469
|
+
}
|
|
470
|
+
|
|
143
471
|
/**
|
|
144
472
|
* Single-pass enhancement of a HAST children array. Processes each child exactly
|
|
145
473
|
* once, applying all per-element and sibling-context enhancements in one iteration.
|
|
@@ -148,6 +476,7 @@ function enhanceStringSpan(element) {
|
|
|
148
476
|
* Per-element enhancements (applied to individual spans):
|
|
149
477
|
* - `pl-c1` → `di-num`, `di-bool`, `di-n`, `di-this`, `di-bt` via enhanceConstantSpan
|
|
150
478
|
* - `pl-s` → `di-n` for empty strings via enhanceStringSpan
|
|
479
|
+
* - `pl-k` symbolic operators (`=`, `=>`, `&&`, `...`) → `di-pu`
|
|
151
480
|
*
|
|
152
481
|
* Sibling-context enhancements (depend on neighbor nodes or positional state):
|
|
153
482
|
* - CSS `&` nesting selector → wraps in `pl-ent` span
|
|
@@ -155,6 +484,9 @@ function enhanceStringSpan(element) {
|
|
|
155
484
|
* - CSS `property: value` → `di-cp` / `di-cv` based on colon position
|
|
156
485
|
* - HTML/JSX `<tag attr=value>` → `di-ak`, `di-ae`, `di-av`
|
|
157
486
|
* - JSX `<Component>` → `di-jsx` on component name spans
|
|
487
|
+
* - JSX `{expression}` → `di-jv` on `pl-smi`/`pl-v` identifier spans inside braces
|
|
488
|
+
* - JS `'key':` object property string → `di-ps` on `pl-s` spans
|
|
489
|
+
* - JS template literals → `di-te` region / `di-td` delimiters around `${ ... }`
|
|
158
490
|
*/
|
|
159
491
|
function enhanceChildren(children, isCss, isHtmlJsx, isJs, isTs, isJsx) {
|
|
160
492
|
// CSS declaration state: tracks position relative to { } : ; [ ]
|
|
@@ -165,9 +497,17 @@ function enhanceChildren(children, isCss, isHtmlJsx, isJs, isTs, isJsx) {
|
|
|
165
497
|
// HTML/JSX tag state: whether we're between < and >
|
|
166
498
|
let htmlInsideTag = false;
|
|
167
499
|
|
|
500
|
+
// JSX expression depth: how many `pl-pse` `{` braces are currently open.
|
|
501
|
+
// Identifiers (`pl-smi`, `pl-v`) inside an expression are tagged as JSX variables.
|
|
502
|
+
let jsxExpressionDepth = 0;
|
|
503
|
+
|
|
168
504
|
// Whether a span appeared between the last text node and the current position.
|
|
169
505
|
// Used to detect attribute context for = wrapping (replaces backward scanning).
|
|
170
506
|
let hasSpanSinceLastText = false;
|
|
507
|
+
|
|
508
|
+
// Template-literal interpolation state, carried across the per-line `pl-s` spans
|
|
509
|
+
// of one multi-line literal. `null` when not inside a template-literal run.
|
|
510
|
+
let templateRun = null;
|
|
171
511
|
for (let index = 0; index < children.length; index += 1) {
|
|
172
512
|
const child = children[index];
|
|
173
513
|
|
|
@@ -238,56 +578,91 @@ function enhanceChildren(children, isCss, isHtmlJsx, isJs, isTs, isJsx) {
|
|
|
238
578
|
}
|
|
239
579
|
}
|
|
240
580
|
|
|
241
|
-
// HTML/JSX: track < > tag boundaries and wrap bare = in attribute context
|
|
581
|
+
// HTML/JSX: track < > tag boundaries and wrap bare = in attribute context.
|
|
582
|
+
// A trailing `<` (next sibling = element) only enters tag mode when the next
|
|
583
|
+
// element looks like a JSX tag — `pl-ent` (HTML element) or `pl-c1` whose text
|
|
584
|
+
// isn't a TS built-in primitive. This avoids treating TS generics like
|
|
585
|
+
// `useState<number | null>` or `<T = string>` as JSX tags.
|
|
242
586
|
if (isHtmlJsx) {
|
|
243
587
|
for (let ci = 0; ci < value.length; ci += 1) {
|
|
244
588
|
if (value[ci] === '<') {
|
|
245
|
-
|
|
589
|
+
if (ci === value.length - 1) {
|
|
590
|
+
const nextChild = children[index + 1];
|
|
591
|
+
if (isJsx && nextChild && nextChild.type === 'element' && nextChild.tagName === 'span') {
|
|
592
|
+
const nextClass = getFirstClass(nextChild);
|
|
593
|
+
if (nextClass === 'pl-ent') {
|
|
594
|
+
htmlInsideTag = true;
|
|
595
|
+
} else if (nextClass === 'pl-c1') {
|
|
596
|
+
const nextText = getShallowTextContent(nextChild);
|
|
597
|
+
if (nextText && !BUILT_IN_TYPES.has(nextText)) {
|
|
598
|
+
htmlInsideTag = true;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
} else {
|
|
602
|
+
htmlInsideTag = true;
|
|
603
|
+
}
|
|
604
|
+
} else {
|
|
605
|
+
htmlInsideTag = true;
|
|
606
|
+
}
|
|
246
607
|
} else if (value[ci] === '>') {
|
|
247
608
|
htmlInsideTag = false;
|
|
248
609
|
}
|
|
249
610
|
}
|
|
250
|
-
|
|
251
|
-
const equalsIndex = value.indexOf('=');
|
|
252
|
-
if (equalsIndex !== -1) {
|
|
253
|
-
// Tag the following pl-s span as attribute value
|
|
254
|
-
const nextChild = children[index + 1];
|
|
255
|
-
if (nextChild && nextChild.type === 'element' && nextChild.tagName === 'span' && getFirstClass(nextChild) === 'pl-s') {
|
|
256
|
-
addClass(nextChild, 'di-av');
|
|
257
|
-
}
|
|
611
|
+
}
|
|
258
612
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
613
|
+
// Bare object-literal keys (e.g. `height` in `{ height: 400 }`) become di-op spans.
|
|
614
|
+
// Inside a JSX attribute expression they also receive di-jv. Done before the `=` split
|
|
615
|
+
// below, which only fires in attribute context (htmlInsideTag) — the two paths don't conflict.
|
|
616
|
+
// Children expressions (e.g. `<Comp>{children}</Comp>`) are excluded by the htmlInsideTag check.
|
|
617
|
+
if (isJs) {
|
|
618
|
+
const split = splitObjectKeys(value, isJsx && jsxExpressionDepth > 0 && htmlInsideTag);
|
|
619
|
+
if (split) {
|
|
620
|
+
children.splice(index, 1, ...split);
|
|
621
|
+
index += split.length - 1;
|
|
622
|
+
hasSpanSinceLastText = split[split.length - 1].type === 'element';
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
if (isHtmlJsx && htmlInsideTag && savedSpanFlag) {
|
|
627
|
+
const equalsIndex = value.indexOf('=');
|
|
628
|
+
if (equalsIndex !== -1) {
|
|
629
|
+
// Tag the following pl-s span as attribute value
|
|
630
|
+
const nextChild = children[index + 1];
|
|
631
|
+
if (nextChild && nextChild.type === 'element' && nextChild.tagName === 'span' && getFirstClass(nextChild) === 'pl-s') {
|
|
632
|
+
addClass(nextChild, 'di-av');
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Split text around = and wrap in di-ae span
|
|
636
|
+
const before = value.slice(0, equalsIndex);
|
|
637
|
+
const after = value.slice(equalsIndex + 1);
|
|
638
|
+
const equalsSpan = {
|
|
639
|
+
type: 'element',
|
|
640
|
+
tagName: 'span',
|
|
641
|
+
properties: {
|
|
642
|
+
className: ['di-ae']
|
|
643
|
+
},
|
|
644
|
+
children: [{
|
|
645
|
+
type: 'text',
|
|
646
|
+
value: '='
|
|
647
|
+
}]
|
|
648
|
+
};
|
|
649
|
+
const newNodes = [];
|
|
650
|
+
if (before) {
|
|
651
|
+
newNodes.push({
|
|
652
|
+
type: 'text',
|
|
653
|
+
value: before
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
newNodes.push(equalsSpan);
|
|
657
|
+
if (after) {
|
|
658
|
+
newNodes.push({
|
|
659
|
+
type: 'text',
|
|
660
|
+
value: after
|
|
661
|
+
});
|
|
290
662
|
}
|
|
663
|
+
children.splice(index, 1, ...newNodes);
|
|
664
|
+
index += newNodes.length - 1;
|
|
665
|
+
hasSpanSinceLastText = newNodes[newNodes.length - 1].type === 'element';
|
|
291
666
|
}
|
|
292
667
|
}
|
|
293
668
|
continue;
|
|
@@ -298,6 +673,34 @@ function enhanceChildren(children, isCss, isHtmlJsx, isJs, isTs, isJsx) {
|
|
|
298
673
|
continue;
|
|
299
674
|
}
|
|
300
675
|
|
|
676
|
+
// ── Template-literal interpolation (JS family) ──
|
|
677
|
+
// starry-night tokenizes a backtick string as a `pl-s` span (one per line for
|
|
678
|
+
// multi-line literals). Wrap each `${ ... }` slice in a `di-te` region with
|
|
679
|
+
// `di-td` delimiters so the interpolated expression resets from the string
|
|
680
|
+
// color. `templateRun` carries the brace/nesting state across the per-line
|
|
681
|
+
// `pl-s` spans; a run starts on the line whose first child is the opening
|
|
682
|
+
// backtick. Handled here, before the generic recursion, so the expression
|
|
683
|
+
// tokens are enhanced inside their regions and the outer `pl-s` is skipped.
|
|
684
|
+
if (isJs && child.tagName === 'span' && getFirstClass(child) === 'pl-s') {
|
|
685
|
+
const opensRun = templateRun === null && isBacktickDelimiter(child.children[0]);
|
|
686
|
+
if (templateRun !== null || opensRun) {
|
|
687
|
+
templateRun = restructureTemplateLine(child, templateRun ?? [{
|
|
688
|
+
mode: 'string'
|
|
689
|
+
}], opensRun);
|
|
690
|
+
// Empty backtick literals (`` `` ``) keep their nullish (`di-n`) classification.
|
|
691
|
+
enhanceStringSpan(child);
|
|
692
|
+
// Enhance the interpolated expressions (e.g. `di-num` on `${42}`) within
|
|
693
|
+
// each region; nested regions are reached by the recursion.
|
|
694
|
+
for (const region of child.children) {
|
|
695
|
+
if (region.type === 'element' && getFirstClass(region) === 'di-te') {
|
|
696
|
+
enhanceChildren(region.children, isCss, isHtmlJsx, isJs, isTs, isJsx);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
hasSpanSinceLastText = true;
|
|
700
|
+
continue;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
301
704
|
// Recurse into nested elements (frames, lines, nested spans)
|
|
302
705
|
if (child.children.length > 0) {
|
|
303
706
|
enhanceChildren(child.children, isCss, isHtmlJsx, isJs, isTs, isJsx);
|
|
@@ -314,6 +717,56 @@ function enhanceChildren(children, isCss, isHtmlJsx, isJs, isTs, isJsx) {
|
|
|
314
717
|
enhanceConstantSpan(child, isJs, isTs);
|
|
315
718
|
} else if (firstClass === 'pl-s') {
|
|
316
719
|
enhanceStringSpan(child);
|
|
720
|
+
} else if (firstClass === 'pl-k') {
|
|
721
|
+
const text = getShallowTextContent(child);
|
|
722
|
+
if (text && isSymbolicPunctuation(text)) {
|
|
723
|
+
addClass(child, 'di-pu');
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// ── JSX expression brace tracking ──
|
|
728
|
+
if (isJsx && firstClass === 'pl-pse') {
|
|
729
|
+
const text = getShallowTextContent(child);
|
|
730
|
+
if (text === '{') {
|
|
731
|
+
jsxExpressionDepth += 1;
|
|
732
|
+
} else if (text === '}' && jsxExpressionDepth > 0) {
|
|
733
|
+
jsxExpressionDepth -= 1;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// ── JSX variable: identifier-like spans inside an attribute expression ──
|
|
738
|
+
// - `pl-smi` plain identifier (e.g. `row` in `{row.name}`)
|
|
739
|
+
// - `pl-v` parameter / variable (e.g. arrow function params)
|
|
740
|
+
// - `pl-c1` after a `.` text node — member-access property (e.g. `name` in `{row.name}`).
|
|
741
|
+
// Skips numbers/booleans/nullish (which `enhanceConstantSpan` has already classified)
|
|
742
|
+
// and JSX components (handled below by the `<`/`</` detection).
|
|
743
|
+
//
|
|
744
|
+
// Restricted to attribute context (`htmlInsideTag`) so children expressions like
|
|
745
|
+
// `<Comp>{children}</Comp>` are not tagged.
|
|
746
|
+
if (isJsx && jsxExpressionDepth > 0 && htmlInsideTag) {
|
|
747
|
+
if (firstClass === 'pl-smi' || firstClass === 'pl-v') {
|
|
748
|
+
addClass(child, 'di-jv');
|
|
749
|
+
} else if (firstClass === 'pl-c1' && index > 0) {
|
|
750
|
+
const prev = children[index - 1];
|
|
751
|
+
if (prev.type === 'text' && prev.value.endsWith('.')) {
|
|
752
|
+
addClass(child, 'di-jv');
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// ── JS object property string: pl-s followed by text starting with `:` ──
|
|
758
|
+
// String keys (e.g. `'aria-label': value`) get the dedicated `di-op` class plus
|
|
759
|
+
// `di-ps` for the string-shape detail. Inside JSX expressions, also add `di-jv`
|
|
760
|
+
// so themes that style JSX variables can include string keys.
|
|
761
|
+
if (isJs && firstClass === 'pl-s') {
|
|
762
|
+
const next = children[index + 1];
|
|
763
|
+
if (next && next.type === 'text' && startsWithColon(next.value)) {
|
|
764
|
+
addClass(child, 'di-op');
|
|
765
|
+
addClass(child, 'di-ps');
|
|
766
|
+
if (isJsx && jsxExpressionDepth > 0 && htmlInsideTag) {
|
|
767
|
+
addClass(child, 'di-jv');
|
|
768
|
+
}
|
|
769
|
+
}
|
|
317
770
|
}
|
|
318
771
|
|
|
319
772
|
// ── CSS-specific enhancements ──
|
|
@@ -353,10 +806,15 @@ function enhanceChildren(children, isCss, isHtmlJsx, isJs, isTs, isJsx) {
|
|
|
353
806
|
if (isJsx && index > 0) {
|
|
354
807
|
const prev = children[index - 1];
|
|
355
808
|
|
|
356
|
-
// Opening/closing: text ending in < or </ followed by pl-c1
|
|
809
|
+
// Opening/closing: text ending in < or </ followed by pl-c1.
|
|
810
|
+
// Skip TS built-in types (e.g. `number` in `useState<number>`) so generic
|
|
811
|
+
// type arguments aren't mistaken for JSX components.
|
|
357
812
|
if (firstClass === 'pl-c1' && prev.type === 'text') {
|
|
358
813
|
if (prev.value.endsWith('<') || prev.value.endsWith('</')) {
|
|
359
|
-
|
|
814
|
+
const text = getShallowTextContent(child);
|
|
815
|
+
if (!text || !BUILT_IN_TYPES.has(text)) {
|
|
816
|
+
addClass(child, 'di-jsx');
|
|
817
|
+
}
|
|
360
818
|
}
|
|
361
819
|
}
|
|
362
820
|
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ElementContent } from 'hast';
|
|
2
|
+
/**
|
|
3
|
+
* Removes a suffix from the end of highlighted HAST nodes.
|
|
4
|
+
*
|
|
5
|
+
* Mirror of `removePrefixFromHighlightedNodes`: used to strip temporary
|
|
6
|
+
* trailing characters that were appended to the source before highlighting
|
|
7
|
+
* (e.g., closing `)` for object-literal wrapping).
|
|
8
|
+
*
|
|
9
|
+
* @param children - The array of HAST nodes to modify
|
|
10
|
+
* @param suffixLength - The number of characters to remove from the end
|
|
11
|
+
*/
|
|
12
|
+
export declare function removeSuffixFromHighlightedNodes(children: ElementContent[], suffixLength: number): void;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Removes a suffix from the end of highlighted HAST nodes.
|
|
3
|
+
*
|
|
4
|
+
* Mirror of `removePrefixFromHighlightedNodes`: used to strip temporary
|
|
5
|
+
* trailing characters that were appended to the source before highlighting
|
|
6
|
+
* (e.g., closing `)` for object-literal wrapping).
|
|
7
|
+
*
|
|
8
|
+
* @param children - The array of HAST nodes to modify
|
|
9
|
+
* @param suffixLength - The number of characters to remove from the end
|
|
10
|
+
*/
|
|
11
|
+
export function removeSuffixFromHighlightedNodes(children, suffixLength) {
|
|
12
|
+
let removedLength = 0;
|
|
13
|
+
while (removedLength < suffixLength && children.length > 0) {
|
|
14
|
+
const lastChild = children[children.length - 1];
|
|
15
|
+
if (lastChild.type === 'text') {
|
|
16
|
+
const textLength = lastChild.value.length;
|
|
17
|
+
if (removedLength + textLength <= suffixLength) {
|
|
18
|
+
children.pop();
|
|
19
|
+
removedLength += textLength;
|
|
20
|
+
} else {
|
|
21
|
+
const charsToRemove = suffixLength - removedLength;
|
|
22
|
+
lastChild.value = lastChild.value.slice(0, textLength - charsToRemove);
|
|
23
|
+
removedLength = suffixLength;
|
|
24
|
+
}
|
|
25
|
+
} else if (lastChild.type === 'element') {
|
|
26
|
+
const element = lastChild;
|
|
27
|
+
if (element.children && element.children.length > 0) {
|
|
28
|
+
const lastElementChild = element.children[element.children.length - 1];
|
|
29
|
+
if (lastElementChild.type === 'text') {
|
|
30
|
+
const textLength = lastElementChild.value.length;
|
|
31
|
+
if (removedLength + textLength <= suffixLength) {
|
|
32
|
+
element.children.pop();
|
|
33
|
+
removedLength += textLength;
|
|
34
|
+
if (element.children.length === 0) {
|
|
35
|
+
children.pop();
|
|
36
|
+
}
|
|
37
|
+
} else {
|
|
38
|
+
const charsToRemove = suffixLength - removedLength;
|
|
39
|
+
lastElementChild.value = lastElementChild.value.slice(0, textLength - charsToRemove);
|
|
40
|
+
removedLength = suffixLength;
|
|
41
|
+
}
|
|
42
|
+
} else {
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
} else {
|
|
46
|
+
children.pop();
|
|
47
|
+
}
|
|
48
|
+
} else {
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -3,8 +3,10 @@ import { visit } from 'unist-util-visit';
|
|
|
3
3
|
import { grammars } from "../parseSource/grammars.mjs";
|
|
4
4
|
import { extensionMap } from "../parseSource/grammarMaps.mjs";
|
|
5
5
|
import { extendSyntaxTokens } from "../parseSource/extendSyntaxTokens.mjs";
|
|
6
|
+
import { getLanguageCapabilitiesFromScope } from "../parseSource/languageCapabilities.mjs";
|
|
6
7
|
import { getHastTextContent } from "../hastUtils/index.mjs";
|
|
7
8
|
import { removePrefixFromHighlightedNodes } from "./removePrefixFromHighlightedNodes.mjs";
|
|
9
|
+
import { removeSuffixFromHighlightedNodes } from "./removeSuffixFromHighlightedNodes.mjs";
|
|
8
10
|
const STARRY_NIGHT_KEY = '__docs_infra_starry_night_instance__';
|
|
9
11
|
|
|
10
12
|
/**
|
|
@@ -67,7 +69,14 @@ export default function transformHtmlCodeInline(options = {}) {
|
|
|
67
69
|
const highlightingPrefix = typeof node.properties?.dataHighlightingPrefix === 'string' ? node.properties.dataHighlightingPrefix : undefined;
|
|
68
70
|
|
|
69
71
|
// Temporarily prepend the prefix for proper syntax highlighting
|
|
70
|
-
|
|
72
|
+
let sourceToHighlight = highlightingPrefix ? `${highlightingPrefix}${source}` : source;
|
|
73
|
+
|
|
74
|
+
// Inline JS-family snippets that look like a bare object literal (e.g. `{ height: 400 }`)
|
|
75
|
+
// are tokenized by starry-night as a block statement with labeled statements, which makes
|
|
76
|
+
// the keys appear as `pl-en` (entity name) tokens rather than property names. Wrap them
|
|
77
|
+
// in `(...)` so the snippet parses as an expression and `extendSyntaxTokens` can split
|
|
78
|
+
// out the keys via `splitObjectKeys`. The wrapping characters are stripped after highlighting.
|
|
79
|
+
let objectWrap = false;
|
|
71
80
|
|
|
72
81
|
// Determine language from className (e.g., 'language-ts')
|
|
73
82
|
const className = node.properties?.className;
|
|
@@ -103,6 +112,14 @@ export default function transformHtmlCodeInline(options = {}) {
|
|
|
103
112
|
if (!fileType || !extensionMap[fileType]) {
|
|
104
113
|
return;
|
|
105
114
|
}
|
|
115
|
+
const grammarScope = extensionMap[fileType];
|
|
116
|
+
if (!highlightingPrefix && getLanguageCapabilitiesFromScope(grammarScope).semantics === 'js') {
|
|
117
|
+
const trimmed = sourceToHighlight.trim();
|
|
118
|
+
if (trimmed.length >= 2 && trimmed.startsWith('{') && trimmed.endsWith('}')) {
|
|
119
|
+
sourceToHighlight = `(${sourceToHighlight})`;
|
|
120
|
+
objectWrap = true;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
106
123
|
|
|
107
124
|
// Apply syntax highlighting
|
|
108
125
|
const highlighted = starryNight.highlight(sourceToHighlight, extensionMap[fileType]);
|
|
@@ -116,6 +133,10 @@ export default function transformHtmlCodeInline(options = {}) {
|
|
|
116
133
|
if (highlightingPrefix && node.children.length > 0) {
|
|
117
134
|
removePrefixFromHighlightedNodes(node.children, highlightingPrefix.length);
|
|
118
135
|
}
|
|
136
|
+
if (objectWrap && node.children.length > 0) {
|
|
137
|
+
removePrefixFromHighlightedNodes(node.children, 1);
|
|
138
|
+
removeSuffixFromHighlightedNodes(node.children, 1);
|
|
139
|
+
}
|
|
119
140
|
}
|
|
120
141
|
|
|
121
142
|
// Mark this code element as inline highlighted (only for inline code, not pre>code)
|
|
@@ -1146,12 +1146,19 @@ export const createEditableEngine = ctx => {
|
|
|
1146
1146
|
state.repeatFlushId = null;
|
|
1147
1147
|
// The user may have moved focus or cleared the selection in the
|
|
1148
1148
|
// 100ms since the last repeat keydown (e.g. clicked elsewhere,
|
|
1149
|
-
// unmounted, blurred). The debounced flush is best-effort; if
|
|
1150
|
-
// there's no live selection inside the editable
|
|
1151
|
-
// — the next real event will pick up state.
|
|
1152
|
-
//
|
|
1149
|
+
// unmounted, blurred). The debounced flush is best-effort; if the
|
|
1150
|
+
// engine is gone or there's no live selection inside the editable
|
|
1151
|
+
// any more, skip — the next real event will pick up state.
|
|
1152
|
+
//
|
|
1153
|
+
// Bail out before touching `window`: a stray timer can fire after
|
|
1154
|
+
// teardown, and in a test environment the `window` global may already
|
|
1155
|
+
// be removed, so `window.getSelection()` would throw a `ReferenceError`
|
|
1156
|
+
// (an unhandled rejection that can mask real failures).
|
|
1157
|
+
if (state.disconnected || typeof window === 'undefined') {
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
1153
1160
|
const selection = window.getSelection();
|
|
1154
|
-
if (
|
|
1161
|
+
if (!selection || selection.rangeCount === 0 || !element.contains(selection.getRangeAt(0).startContainer)) {
|
|
1155
1162
|
return;
|
|
1156
1163
|
}
|
|
1157
1164
|
flushChanges();
|
|
@@ -48,6 +48,14 @@ export type UseCodeWindowResult<ToggleElement extends HTMLElement = HTMLElement,
|
|
|
48
48
|
* so the anchor stays put against the panel's own scroll rather than the
|
|
49
49
|
* page. When left unattached, the page is compensated — the right default
|
|
50
50
|
* for code that grows the document flow. Forwarded from `useScrollAnchor`.
|
|
51
|
+
*
|
|
52
|
+
* When attached, this element is also treated as the horizontal scroll
|
|
53
|
+
* owner: the scrollbar-gutter swap (`data-scrollbar-gutter`) and the
|
|
54
|
+
* collapse scroll-back run on it instead of the inner `<pre>`. Use this when
|
|
55
|
+
* the window owns both scroll axes so the horizontal scrollbar sits at the
|
|
56
|
+
* window's edge (in view) rather than at the bottom of the inner `<pre>`,
|
|
57
|
+
* which can extend past the window's height and scroll out of view. Your
|
|
58
|
+
* gutter CSS must then key off this element's attribute.
|
|
51
59
|
*/
|
|
52
60
|
scrollContainerRef: React.RefObject<ScrollElement | null>;
|
|
53
61
|
/**
|
|
@@ -53,33 +53,38 @@ function cancelScheduled(handle) {
|
|
|
53
53
|
* Smoothly slides the `<code>` element back to the left edge over `duration`
|
|
54
54
|
* ms using an ease-out cubic via the Web Animations API.
|
|
55
55
|
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
56
|
+
* `scrollEl` is whichever element owns the horizontal scroll — the inner
|
|
57
|
+
* `<pre>` by default, or an attached scroll container (see `scrollContainerRef`)
|
|
58
|
+
* when the code block is rendered inside a fixed-size window. `code` is this
|
|
59
|
+
* code window's own `<code>` (scoped to its container by the caller) so a shared
|
|
60
|
+
* scroll container holding several blocks animates the right one.
|
|
61
|
+
*
|
|
62
|
+
* Used during collapse instead of tweening `scrollEl.scrollLeft` because the
|
|
63
|
+
* scrollbar-gutter animation forces `overflow-x: hidden` on `scrollEl`, which
|
|
58
64
|
* snaps `scrollLeft` to 0 instantly. Animating a transform on the inner
|
|
59
65
|
* `code` element produces the same visual effect, isn't reset by the overflow
|
|
60
|
-
* change, and is naturally clipped by the
|
|
66
|
+
* change, and is naturally clipped by the scroll element's hidden overflow.
|
|
61
67
|
*
|
|
62
68
|
* Honors `prefers-reduced-motion` by snapping immediately.
|
|
63
69
|
*/
|
|
64
|
-
function smoothCollapseScrollLeft(
|
|
65
|
-
const startLeft =
|
|
70
|
+
function smoothCollapseScrollLeft(scrollEl, code, duration) {
|
|
71
|
+
const startLeft = scrollEl.scrollLeft;
|
|
66
72
|
if (startLeft <= 0) {
|
|
67
73
|
return null;
|
|
68
74
|
}
|
|
69
|
-
const code = pre.querySelector('code');
|
|
70
|
-
if (!code || typeof code.animate !== 'function') {
|
|
71
|
-
return null;
|
|
72
|
-
}
|
|
73
75
|
|
|
74
76
|
// Cancel any leftover scroll-back animation from a previous toggle so we
|
|
75
77
|
// don't end up with two transforms competing on the same element.
|
|
76
|
-
scrollbackAnimations.get(
|
|
77
|
-
scrollbackAnimations.delete(
|
|
78
|
+
scrollbackAnimations.get(scrollEl)?.cancel();
|
|
79
|
+
scrollbackAnimations.delete(scrollEl);
|
|
78
80
|
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
// Snap the actual scroll position back to the left edge now. When we can
|
|
82
|
+
// animate, the WAAPI transform below visually compensates by translating the
|
|
83
|
+
// element from `-startLeft` back to `0`; otherwise (no WAAPI, no `code`,
|
|
84
|
+
// reduced motion, or zero duration) this stands as an instant snap — still
|
|
85
|
+
// the correct collapsed end state.
|
|
86
|
+
scrollEl.scrollLeft = 0;
|
|
87
|
+
if (!code || typeof code.animate !== 'function' || prefersReducedMotion() || duration <= 0) {
|
|
83
88
|
return null;
|
|
84
89
|
}
|
|
85
90
|
const anim = code.animate([{
|
|
@@ -91,10 +96,10 @@ function smoothCollapseScrollLeft(pre, duration) {
|
|
|
91
96
|
easing: 'cubic-bezier(0, 0, 0.2, 1)',
|
|
92
97
|
fill: 'none'
|
|
93
98
|
});
|
|
94
|
-
scrollbackAnimations.set(
|
|
99
|
+
scrollbackAnimations.set(scrollEl, anim);
|
|
95
100
|
const onSettle = () => {
|
|
96
|
-
if (scrollbackAnimations.get(
|
|
97
|
-
scrollbackAnimations.delete(
|
|
101
|
+
if (scrollbackAnimations.get(scrollEl) === anim) {
|
|
102
|
+
scrollbackAnimations.delete(scrollEl);
|
|
98
103
|
}
|
|
99
104
|
};
|
|
100
105
|
anim.finished.then(onSettle, onSettle);
|
|
@@ -106,74 +111,76 @@ function isElementInViewport(element) {
|
|
|
106
111
|
}
|
|
107
112
|
|
|
108
113
|
/**
|
|
109
|
-
* Measures the horizontal scrollbar height of
|
|
114
|
+
* Measures the horizontal scrollbar height of the scroll element by
|
|
110
115
|
* temporarily forcing `overflow-x: scroll`.
|
|
111
116
|
*/
|
|
112
|
-
function measureScrollbarHeight(
|
|
113
|
-
const prevOverflow =
|
|
114
|
-
|
|
115
|
-
const scrollbarHeight =
|
|
116
|
-
|
|
117
|
+
function measureScrollbarHeight(scrollEl) {
|
|
118
|
+
const prevOverflow = scrollEl.style.overflowX;
|
|
119
|
+
scrollEl.style.overflowX = 'scroll';
|
|
120
|
+
const scrollbarHeight = scrollEl.offsetHeight - scrollEl.clientHeight;
|
|
121
|
+
scrollEl.style.overflowX = prevOverflow;
|
|
117
122
|
return scrollbarHeight;
|
|
118
123
|
}
|
|
119
|
-
function clearGutterState(
|
|
120
|
-
cancelScheduled(gutterCleanupTimers.get(
|
|
121
|
-
gutterCleanupTimers.delete(
|
|
122
|
-
const flipTimer = gutterFlipTimers.get(
|
|
124
|
+
function clearGutterState(scrollEl) {
|
|
125
|
+
cancelScheduled(gutterCleanupTimers.get(scrollEl));
|
|
126
|
+
gutterCleanupTimers.delete(scrollEl);
|
|
127
|
+
const flipTimer = gutterFlipTimers.get(scrollEl);
|
|
123
128
|
if (flipTimer !== undefined) {
|
|
124
129
|
clearTimeout(flipTimer);
|
|
125
|
-
gutterFlipTimers.delete(
|
|
130
|
+
gutterFlipTimers.delete(scrollEl);
|
|
126
131
|
}
|
|
127
|
-
|
|
132
|
+
scrollEl.removeAttribute(GUTTER_STATE_ATTRIBUTE);
|
|
128
133
|
}
|
|
129
|
-
function
|
|
130
|
-
scrollbackAnimations.get(
|
|
131
|
-
scrollbackAnimations.delete(
|
|
132
|
-
clearGutterState(
|
|
134
|
+
function cancelAllForScrollEl(scrollEl) {
|
|
135
|
+
scrollbackAnimations.get(scrollEl)?.cancel();
|
|
136
|
+
scrollbackAnimations.delete(scrollEl);
|
|
137
|
+
clearGutterState(scrollEl);
|
|
133
138
|
}
|
|
134
139
|
|
|
135
140
|
/**
|
|
136
141
|
* Drives a from→to transition on the `data-scrollbar-gutter` attribute of
|
|
137
|
-
*
|
|
138
|
-
* a real scrollbar and equivalent padding-bottom.
|
|
142
|
+
* the scroll element, which the consumer's CSS hooks into to animate the swap
|
|
143
|
+
* between a real scrollbar and equivalent padding-bottom.
|
|
144
|
+
*
|
|
145
|
+
* `scrollEl` is whichever element owns the horizontal scroll — the inner
|
|
146
|
+
* `<pre>` by default, or the attached `scrollContainerRef` when the code block
|
|
147
|
+
* is rendered inside a fixed-size window. `code` is this code window's own
|
|
148
|
+
* `<code>` (scoped to its container by the caller).
|
|
139
149
|
*
|
|
140
150
|
* Skips the animation when content doesn't overflow (no scrollbar exists)
|
|
141
151
|
* or when the browser uses overlay scrollbars (zero height).
|
|
142
152
|
*/
|
|
143
|
-
function animateScrollbarGutter(
|
|
144
|
-
const scrollbarHeight = measureScrollbarHeight(
|
|
153
|
+
function animateScrollbarGutter(scrollEl, code, from, to, durationMs) {
|
|
154
|
+
const scrollbarHeight = measureScrollbarHeight(scrollEl);
|
|
145
155
|
if (scrollbarHeight === 0) {
|
|
146
156
|
return; // Overlay scrollbars, nothing to do
|
|
147
157
|
}
|
|
148
158
|
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
// scrollWidth
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
return;
|
|
156
|
-
}
|
|
157
|
-
} else if (pre.scrollWidth <= pre.clientWidth) {
|
|
159
|
+
// Decide from this code window's own `<code>`, not from `scrollEl` — the
|
|
160
|
+
// scroll owner may be a shared container wrapping other content. `code`'s
|
|
161
|
+
// `scrollWidth` reflects hidden frames (via `min-width: fit-content`), so it
|
|
162
|
+
// predicts the post-expand width and still reflects the wide source during
|
|
163
|
+
// collapse; compare it against the scroll owner's visible width.
|
|
164
|
+
if (!code || code.scrollWidth <= scrollEl.clientWidth) {
|
|
158
165
|
return;
|
|
159
166
|
}
|
|
160
|
-
clearGutterState(
|
|
161
|
-
|
|
167
|
+
clearGutterState(scrollEl);
|
|
168
|
+
scrollEl.setAttribute(GUTTER_STATE_ATTRIBUTE, from);
|
|
162
169
|
|
|
163
170
|
// Move into the transition state on the next macrotask. Tracked so the
|
|
164
171
|
// flip can be cancelled if the component unmounts before it fires.
|
|
165
172
|
const flipTimer = setTimeout(() => {
|
|
166
|
-
gutterFlipTimers.delete(
|
|
167
|
-
|
|
173
|
+
gutterFlipTimers.delete(scrollEl);
|
|
174
|
+
scrollEl.setAttribute(GUTTER_STATE_ATTRIBUTE, to);
|
|
168
175
|
}, 0);
|
|
169
|
-
gutterFlipTimers.set(
|
|
176
|
+
gutterFlipTimers.set(scrollEl, flipTimer);
|
|
170
177
|
|
|
171
178
|
// Schedule cleanup on the animation timeline so DevTools throttling
|
|
172
179
|
// scales it together with the CSS transition.
|
|
173
|
-
const cleanup = scheduleOnAnimationTimeline(
|
|
174
|
-
clearGutterState(
|
|
180
|
+
const cleanup = scheduleOnAnimationTimeline(scrollEl, durationMs + 30, () => {
|
|
181
|
+
clearGutterState(scrollEl);
|
|
175
182
|
});
|
|
176
|
-
gutterCleanupTimers.set(
|
|
183
|
+
gutterCleanupTimers.set(scrollEl, cleanup);
|
|
177
184
|
}
|
|
178
185
|
const DEFAULT_ANCHOR_SELECTOR = '[data-frame-type="highlighted"], [data-frame-type="focus"]';
|
|
179
186
|
const DEFAULT_COLLAPSIBLE_SELECTOR = '[data-collapsible]';
|
|
@@ -218,7 +225,7 @@ export function useCodeWindow(options = {}) {
|
|
|
218
225
|
collapsibleProbeSelector = DEFAULT_COLLAPSIBLE_SELECTOR
|
|
219
226
|
} = options;
|
|
220
227
|
const toggleRef = React.useRef(null);
|
|
221
|
-
const
|
|
228
|
+
const lastScrollElRef = React.useRef(null);
|
|
222
229
|
const {
|
|
223
230
|
containerRef,
|
|
224
231
|
scrollContainerRef,
|
|
@@ -226,10 +233,10 @@ export function useCodeWindow(options = {}) {
|
|
|
226
233
|
} = useScrollAnchor();
|
|
227
234
|
React.useEffect(() => {
|
|
228
235
|
return () => {
|
|
229
|
-
const
|
|
230
|
-
if (
|
|
231
|
-
|
|
232
|
-
|
|
236
|
+
const scrollEl = lastScrollElRef.current;
|
|
237
|
+
if (scrollEl) {
|
|
238
|
+
cancelAllForScrollEl(scrollEl);
|
|
239
|
+
lastScrollElRef.current = null;
|
|
233
240
|
}
|
|
234
241
|
};
|
|
235
242
|
}, []);
|
|
@@ -247,32 +254,42 @@ export function useCodeWindow(options = {}) {
|
|
|
247
254
|
if (!anchor) {
|
|
248
255
|
return;
|
|
249
256
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
257
|
+
|
|
258
|
+
// The element whose horizontal scrollbar we smooth: the attached scroll
|
|
259
|
+
// container when one is provided (the code block lives inside a
|
|
260
|
+
// fixed-size window that owns both scroll axes), otherwise the inner
|
|
261
|
+
// `<pre>`, which scrolls horizontally on its own.
|
|
262
|
+
const scrollEl = scrollContainerRef.current ?? container.querySelector('pre');
|
|
263
|
+
// Scope content lookups to *this* code window's `container`, never to
|
|
264
|
+
// `scrollEl`: an attached scroll container may wrap several code blocks or
|
|
265
|
+
// unrelated content, so `scrollEl.querySelector('code')` could match the
|
|
266
|
+
// wrong block. The overflow decision and scroll-back both use this code.
|
|
267
|
+
const code = container.querySelector('code');
|
|
268
|
+
if (scrollEl) {
|
|
269
|
+
lastScrollElRef.current = scrollEl;
|
|
253
270
|
if (direction === 'collapse') {
|
|
254
271
|
// Smoothly return horizontal scroll to the left edge. We animate
|
|
255
272
|
// via a transform on the inner `code` element rather than
|
|
256
|
-
// tweening `
|
|
273
|
+
// tweening `scrollEl.scrollLeft`, because the gutter animation below
|
|
257
274
|
// sets `overflow-x: hidden` which would snap `scrollLeft` to 0
|
|
258
275
|
// instantly. Both animations start in the same frame: the
|
|
259
276
|
// scroll-back resets `scrollLeft` to 0 up front, so the gutter
|
|
260
277
|
// swap's `overflow-x` change has nothing left to snap.
|
|
261
|
-
smoothCollapseScrollLeft(
|
|
262
|
-
animateScrollbarGutter(
|
|
278
|
+
smoothCollapseScrollLeft(scrollEl, code, scrollBackDuration);
|
|
279
|
+
animateScrollbarGutter(scrollEl, code, 'collapse-from', 'collapse-to', collapseDuration);
|
|
263
280
|
}
|
|
264
281
|
if (direction === 'expand') {
|
|
265
282
|
// Cancel any in-flight collapse scroll-back so its leftover
|
|
266
283
|
// transform can't drift the code horizontally during expand.
|
|
267
|
-
scrollbackAnimations.get(
|
|
268
|
-
scrollbackAnimations.delete(
|
|
269
|
-
if (collapsibleProbeSelector &&
|
|
270
|
-
animateScrollbarGutter(
|
|
284
|
+
scrollbackAnimations.get(scrollEl)?.cancel();
|
|
285
|
+
scrollbackAnimations.delete(scrollEl);
|
|
286
|
+
if (collapsibleProbeSelector && container.querySelector(collapsibleProbeSelector)) {
|
|
287
|
+
animateScrollbarGutter(scrollEl, code, 'expand-from', 'expand-to', expandDuration);
|
|
271
288
|
}
|
|
272
289
|
}
|
|
273
290
|
}
|
|
274
291
|
rawAnchorScroll(anchor, direction === 'collapse' ? collapseDuration : expandDuration);
|
|
275
|
-
}, [containerRef, rawAnchorScroll, anchorSelector, collapsibleProbeSelector, collapseDuration, expandDuration, scrollBackDuration]);
|
|
292
|
+
}, [containerRef, scrollContainerRef, rawAnchorScroll, anchorSelector, collapsibleProbeSelector, collapseDuration, expandDuration, scrollBackDuration]);
|
|
276
293
|
return {
|
|
277
294
|
containerRef,
|
|
278
295
|
scrollContainerRef,
|
|
@@ -42,10 +42,16 @@ export function useScrollAnchor() {
|
|
|
42
42
|
activeSessionCleanupRef.current = null;
|
|
43
43
|
|
|
44
44
|
// Snapshot the scroll target at session start so a later ref change
|
|
45
|
-
// doesn't redirect compensation mid-flight.
|
|
46
|
-
|
|
45
|
+
// doesn't redirect compensation mid-flight. `scrollElement` is the attached
|
|
46
|
+
// container (if any); `scrollTarget` is what receives the user-interaction
|
|
47
|
+
// listeners (the container or the window).
|
|
48
|
+
const scrollElement = scrollContainerRef.current;
|
|
49
|
+
const scrollTarget = scrollElement ?? window;
|
|
47
50
|
const interactionTarget = scrollTarget;
|
|
48
|
-
|
|
51
|
+
|
|
52
|
+
// Mutable so it can be re-baselined when an attached container can't yet
|
|
53
|
+
// absorb a delta (see below).
|
|
54
|
+
let initialTop = anchor.getBoundingClientRect().top;
|
|
49
55
|
let active = true;
|
|
50
56
|
let cleanupTimer;
|
|
51
57
|
|
|
@@ -58,8 +64,25 @@ export function useScrollAnchor() {
|
|
|
58
64
|
return;
|
|
59
65
|
}
|
|
60
66
|
const delta = anchor.getBoundingClientRect().top - initialTop;
|
|
61
|
-
if (Math.abs(delta)
|
|
62
|
-
|
|
67
|
+
if (Math.abs(delta) <= 0.5) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (!scrollElement) {
|
|
71
|
+
window.scrollBy(0, delta);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const before = scrollElement.scrollTop;
|
|
75
|
+
scrollElement.scrollBy(0, delta);
|
|
76
|
+
const remainder = delta - (scrollElement.scrollTop - before);
|
|
77
|
+
if (Math.abs(remainder) > 0.5) {
|
|
78
|
+
// The container couldn't absorb this part — it isn't scrollable yet
|
|
79
|
+
// (its content hasn't exceeded its `max-height`). Re-baseline instead
|
|
80
|
+
// of forcing the difference elsewhere: scrolling the page would shift
|
|
81
|
+
// the surrounding layout, and carrying the delta forward would snap the
|
|
82
|
+
// anchor back the instant the container becomes scrollable. Accepting
|
|
83
|
+
// the small drift now keeps the surrounding layout still and lets the
|
|
84
|
+
// container hold the anchor smoothly from here on.
|
|
85
|
+
initialTop += remainder;
|
|
63
86
|
}
|
|
64
87
|
});
|
|
65
88
|
function cleanup() {
|