@principal-ai/subsystems-react 0.48.0 → 0.50.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/graphify/consolidated.d.ts +7 -1
- package/dist/graphify/consolidated.d.ts.map +1 -1
- package/dist/pierre/PierreFileView.d.ts +1 -1
- package/dist/pierre/PierreFileView.d.ts.map +1 -1
- package/dist/pierre/PierreFileView.js +2 -1
- package/dist/pierre/PierreFileView.js.map +1 -1
- package/dist/pierre/PierreSnippetView.d.ts +1 -1
- package/dist/pierre/PierreSnippetView.d.ts.map +1 -1
- package/dist/pierre/PierreSnippetView.js +2 -1
- package/dist/pierre/PierreSnippetView.js.map +1 -1
- package/dist/pierre/PierreTrailCodeView.d.ts +7 -1
- package/dist/pierre/PierreTrailCodeView.d.ts.map +1 -1
- package/dist/pierre/PierreTrailCodeView.js +71 -18
- package/dist/pierre/PierreTrailCodeView.js.map +1 -1
- package/dist/pierre/pierreBackground.d.ts +36 -2
- package/dist/pierre/pierreBackground.d.ts.map +1 -1
- package/dist/pierre/pierreBackground.js +40 -13
- package/dist/pierre/pierreBackground.js.map +1 -1
- package/dist/pierre/visibleStep.d.ts +48 -0
- package/dist/pierre/visibleStep.d.ts.map +1 -0
- package/dist/pierre/visibleStep.js +53 -0
- package/dist/pierre/visibleStep.js.map +1 -0
- package/dist/subsystem/FileDrawer.d.ts +10 -1
- package/dist/subsystem/FileDrawer.d.ts.map +1 -1
- package/dist/subsystem/FileDrawer.js +61 -40
- package/dist/subsystem/FileDrawer.js.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.d.ts +6 -0
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +21 -10
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/formatDeclaration.d.ts.map +1 -1
- package/dist/subsystem/formatDeclaration.js +10 -2
- package/dist/subsystem/formatDeclaration.js.map +1 -1
- package/dist/subsystem/tokenizeComponent.js +4 -4
- package/dist/subsystem/tokenizeComponent.js.map +1 -1
- package/package.json +3 -3
- package/src/graphify/consolidated.ts +7 -1
- package/src/pierre/PierreFileView.tsx +2 -1
- package/src/pierre/PierreSnippetView.tsx +2 -1
- package/src/pierre/PierreTrailCodeView.tsx +81 -22
- package/src/pierre/pierreBackground.ts +62 -12
- package/src/pierre/visibleStep.test.ts +108 -0
- package/src/pierre/visibleStep.ts +76 -0
- package/src/stories/Subsystem/ComponentGraph/Flows.stories.tsx +6 -0
- package/src/subsystem/FileDrawer.tsx +112 -63
- package/src/subsystem/SubsystemComponentGraph.tsx +29 -5
- package/src/subsystem/formatDeclaration.ts +10 -2
- package/src/subsystem/tokenizeComponent.ts +4 -4
|
@@ -42,6 +42,7 @@ import type { SubsystemOpenFileOptions } from '../subsystem/declarationRef';
|
|
|
42
42
|
import { buildPierreOptions, PIERRE_FILE_STYLE } from './pierreBackground';
|
|
43
43
|
import { pierreLangForPath } from './pierreFileLang';
|
|
44
44
|
import { resolvePierreSyntaxThemeName } from './pierreSyntaxTheme';
|
|
45
|
+
import { resolveVisibleStepIndex, type VisibleStepRect } from './visibleStep';
|
|
45
46
|
import { fileUnavailableNotice, isFileUnavailableError } from './fileAvailability';
|
|
46
47
|
import {
|
|
47
48
|
remapSnippetLineNumbers,
|
|
@@ -67,6 +68,16 @@ function stepIndexFromItemContext(context: unknown): number | null {
|
|
|
67
68
|
return Number.isFinite(index) ? index : null;
|
|
68
69
|
}
|
|
69
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Assumed height of the compact file header, in px.
|
|
73
|
+
*
|
|
74
|
+
* Must track the header chrome passed to `buildPierreOptions` (fontSizes[0] at
|
|
75
|
+
* line-height 1.15 plus 2px vertical padding) — CodeView sizes its
|
|
76
|
+
* virtualization window and sticky offset from this, and the library's own
|
|
77
|
+
* default of 44 is more than double the compact header.
|
|
78
|
+
*/
|
|
79
|
+
const TRAIL_HEADER_HEIGHT = 18;
|
|
80
|
+
|
|
70
81
|
/** Pointer/dotted-underline affordance for a clickable construct token. */
|
|
71
82
|
function paintSymbolToken(el: HTMLElement | undefined, active: boolean): void {
|
|
72
83
|
if (el == null) return;
|
|
@@ -88,6 +99,12 @@ export interface PierreTrailCodeViewProps {
|
|
|
88
99
|
background?: string;
|
|
89
100
|
/** Open the step's full source file (header button or double-click snippet body). */
|
|
90
101
|
onOpenFile?: (path: string, opts?: SubsystemOpenFileOptions) => void;
|
|
102
|
+
/**
|
|
103
|
+
* Fires when scrolling brings a different step to the top of the view, with
|
|
104
|
+
* that step's index. Drives a progress readout that tracks the viewport
|
|
105
|
+
* rather than the selected step. Only fires on change, not per scroll event.
|
|
106
|
+
*/
|
|
107
|
+
onVisibleStepChange?: (index: number) => void;
|
|
91
108
|
/**
|
|
92
109
|
* Aliases of components marked `proposed`. A step whose file can't be read
|
|
93
110
|
* but whose endpoint is proposed reads as "planned" rather than "missing".
|
|
@@ -193,12 +210,15 @@ export function PierreTrailCodeView({
|
|
|
193
210
|
contextLines = 8,
|
|
194
211
|
background,
|
|
195
212
|
onOpenFile,
|
|
213
|
+
onVisibleStepChange,
|
|
196
214
|
proposedAliases,
|
|
197
215
|
resolveSymbol,
|
|
198
216
|
onSymbolClick,
|
|
199
217
|
}: PierreTrailCodeViewProps) {
|
|
200
218
|
const { theme, mode } = useTheme();
|
|
201
219
|
const viewRef = useRef<CodeViewHandle<undefined>>(null);
|
|
220
|
+
// CodeView's scroll container — also the box we measure items against.
|
|
221
|
+
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
202
222
|
const [load, setLoad] = useState<FileLoadState>({ status: 'loading' });
|
|
203
223
|
|
|
204
224
|
// Keep the newest symbol callbacks in refs so the Pierre options object stays
|
|
@@ -207,6 +227,43 @@ export function PierreTrailCodeView({
|
|
|
207
227
|
resolveSymbolRef.current = resolveSymbol;
|
|
208
228
|
const onSymbolClickRef = useRef(onSymbolClick);
|
|
209
229
|
onSymbolClickRef.current = onSymbolClick;
|
|
230
|
+
const onVisibleStepChangeRef = useRef(onVisibleStepChange);
|
|
231
|
+
onVisibleStepChangeRef.current = onVisibleStepChange;
|
|
232
|
+
const visibleStepRef = useRef<number | null>(null);
|
|
233
|
+
|
|
234
|
+
// Report which step the viewport is on. The geometry lives in `visibleStep` so
|
|
235
|
+
// it can be tested — the answer depends on how tall the final snippet is
|
|
236
|
+
// relative to the viewport, which is not something to eyeball.
|
|
237
|
+
const reportVisibleStep = useCallback(() => {
|
|
238
|
+
const container = containerRef.current;
|
|
239
|
+
const viewer = viewRef.current?.getInstance();
|
|
240
|
+
if (container == null || viewer == null) return;
|
|
241
|
+
const rect = container.getBoundingClientRect();
|
|
242
|
+
const rects: VisibleStepRect[] = [];
|
|
243
|
+
for (const rendered of viewer.getRenderedItems()) {
|
|
244
|
+
const index = Number.parseInt(rendered.id.split(':').pop() ?? '', 10);
|
|
245
|
+
if (!Number.isFinite(index)) continue;
|
|
246
|
+
const itemRect = rendered.element.getBoundingClientRect();
|
|
247
|
+
rects.push({ index, top: itemRect.top, bottom: itemRect.bottom });
|
|
248
|
+
}
|
|
249
|
+
const next = resolveVisibleStepIndex(
|
|
250
|
+
{
|
|
251
|
+
top: rect.top,
|
|
252
|
+
bottom: rect.bottom,
|
|
253
|
+
scrollTop: container.scrollTop,
|
|
254
|
+
clientHeight: container.clientHeight,
|
|
255
|
+
scrollHeight: container.scrollHeight,
|
|
256
|
+
},
|
|
257
|
+
rects,
|
|
258
|
+
);
|
|
259
|
+
if (next == null || next === visibleStepRef.current) return;
|
|
260
|
+
visibleStepRef.current = next;
|
|
261
|
+
onVisibleStepChangeRef.current?.(next);
|
|
262
|
+
}, []);
|
|
263
|
+
|
|
264
|
+
const onCodeViewScroll = useCallback(() => {
|
|
265
|
+
reportVisibleStep();
|
|
266
|
+
}, [reportVisibleStep]);
|
|
210
267
|
|
|
211
268
|
// A step onto a proposed component is planned work; label its missing file
|
|
212
269
|
// accordingly instead of showing a bare "not found".
|
|
@@ -346,32 +403,19 @@ export function PierreTrailCodeView({
|
|
|
346
403
|
context?: { item?: { id?: string } },
|
|
347
404
|
) => {
|
|
348
405
|
const id = context?.item?.id;
|
|
406
|
+
// Item-level renders don't move the viewport, but the first paint does
|
|
407
|
+
// decide which step is on screen.
|
|
408
|
+
reportVisibleStep();
|
|
349
409
|
if (id == null) return;
|
|
350
410
|
const sliceStart = sliceStartByItemId.get(id);
|
|
351
411
|
if (sliceStart == null) return;
|
|
352
412
|
remapSnippetLineNumbers(node, sliceStart);
|
|
353
413
|
},
|
|
354
|
-
[sliceStartByItemId],
|
|
414
|
+
[sliceStartByItemId, reportVisibleStep],
|
|
355
415
|
);
|
|
356
416
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
const index = Number.parseInt(item.id.split(':').pop() ?? '', 10);
|
|
360
|
-
if (!trail.steps[index]) return null;
|
|
361
|
-
return (
|
|
362
|
-
<span
|
|
363
|
-
style={{
|
|
364
|
-
fontFamily: theme.fonts.monospace,
|
|
365
|
-
fontSize: theme.fontSizes[0],
|
|
366
|
-
color: theme.colors.textSecondary,
|
|
367
|
-
marginRight: 8,
|
|
368
|
-
}}
|
|
369
|
-
>
|
|
370
|
-
{index + 1}.
|
|
371
|
-
</span>
|
|
372
|
-
);
|
|
373
|
-
};
|
|
374
|
-
}, [trail.steps, theme]);
|
|
417
|
+
// No step numbers in the file header: the sidebar already numbers the steps,
|
|
418
|
+
// and the drawer header carries a segmented progress readout.
|
|
375
419
|
|
|
376
420
|
const renderHeaderMetadata = useMemo(() => {
|
|
377
421
|
return (item: CodeViewItem) => {
|
|
@@ -495,15 +539,29 @@ export function PierreTrailCodeView({
|
|
|
495
539
|
light: resolvePierreSyntaxThemeName('light'),
|
|
496
540
|
},
|
|
497
541
|
stickyHeaders: true,
|
|
542
|
+
// CodeView never injects `unsafeCSS` into its own shadow root, but it
|
|
543
|
+
// forwards the option to each file item (CODE_VIEW_FILE_OPTION_KEYS),
|
|
544
|
+
// which does — so this retints the file surfaces and headers. It cannot
|
|
545
|
+
// reach CodeView's host, hence the spread only carries
|
|
546
|
+
// PIERRE_BASE_OPTIONS and `disableFileHeader` must follow it.
|
|
547
|
+
...buildPierreOptions(background, {
|
|
548
|
+
background: theme.colors.backgroundSecondary,
|
|
549
|
+
fontSize: `${theme.fontSizes[0]}px`,
|
|
550
|
+
lineHeight: '1.15',
|
|
551
|
+
padding: '2px 8px',
|
|
552
|
+
}),
|
|
498
553
|
disableFileHeader: false,
|
|
554
|
+
// The library assumes a 44px header (DEFAULT_VIRTUAL_FILE_METRICS); the
|
|
555
|
+
// compact chrome above is ~18px. CodeView uses this for its
|
|
556
|
+
// virtualization window and sticky offset, so leaving it at 44 drifts.
|
|
557
|
+
itemMetrics: { diffHeaderHeight: TRAIL_HEADER_HEIGHT },
|
|
499
558
|
layout: { paddingTop: 0, paddingBottom: 0, gap: 4 },
|
|
500
559
|
onPostRender,
|
|
501
560
|
...(onOpenFile ? { onLineClick } : {}),
|
|
502
561
|
...symbolHandlers,
|
|
503
|
-
...(background ? buildPierreOptions(background) : {}),
|
|
504
562
|
...(mode === 'light' || mode === 'dark' ? { themeType: mode } : {}),
|
|
505
563
|
};
|
|
506
|
-
}, [background, mode, onPostRender, onOpenFile, onLineClick, symbolHandlers]);
|
|
564
|
+
}, [background, theme.colors.backgroundSecondary, theme.fontSizes, mode, onPostRender, onOpenFile, onLineClick, symbolHandlers]);
|
|
507
565
|
|
|
508
566
|
useEffect(() => {
|
|
509
567
|
if (load.status !== 'ready' || stepIndex == null) return;
|
|
@@ -549,10 +607,11 @@ export function PierreTrailCodeView({
|
|
|
549
607
|
return (
|
|
550
608
|
<CodeViewLoose
|
|
551
609
|
ref={viewRef}
|
|
610
|
+
containerRef={containerRef}
|
|
552
611
|
items={items}
|
|
553
612
|
options={options}
|
|
613
|
+
onScroll={onCodeViewScroll}
|
|
554
614
|
selectedLines={selectedLines}
|
|
555
|
-
renderHeaderPrefix={renderHeaderPrefix}
|
|
556
615
|
renderHeaderMetadata={renderHeaderMetadata}
|
|
557
616
|
renderAnnotation={renderAnnotation}
|
|
558
617
|
style={{ ...PIERRE_FILE_STYLE, height: '100%', overflow: 'auto' }}
|
|
@@ -6,28 +6,78 @@ export const PIERRE_BASE_OPTIONS = {
|
|
|
6
6
|
disableFileHeader: true,
|
|
7
7
|
} as const;
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
/** Chrome overrides for the per-file header (`[data-diffs-header]`). */
|
|
10
|
+
export interface PierreHeaderStyle {
|
|
11
|
+
/** Fill behind the header row. */
|
|
12
|
+
background?: string;
|
|
13
|
+
/** Rule under the header row. */
|
|
14
|
+
borderBottom?: string;
|
|
15
|
+
/** Explicit size — the library leaves the header to inherit its line box. */
|
|
16
|
+
fontSize?: string;
|
|
17
|
+
lineHeight?: string;
|
|
18
|
+
/** Vertical padding. The library ships none, so this is the only breathing room. */
|
|
19
|
+
padding?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Retint Pierre's code surface to `background`, optionally restyling the file
|
|
24
|
+
* header.
|
|
25
|
+
*
|
|
26
|
+
* Every background in `@pierre/diffs` resolves to `--diffs-bg`, which is
|
|
27
|
+
* `light-dark(var(--diffs-light-bg, #fff), var(--diffs-dark-bg, #000))` — so
|
|
28
|
+
* overriding both inputs re-tints the whole surface from one variable and lets
|
|
29
|
+
* the diff / selection / hover layers keep recomputing their `color-mix` tints
|
|
30
|
+
* against it.
|
|
31
|
+
*
|
|
32
|
+
* `:host` still needs a direct override: the theme layer writes a literal
|
|
33
|
+
* `background-color` there from the Shiki theme, which does not go through
|
|
34
|
+
* `--diffs-bg`.
|
|
35
|
+
*
|
|
36
|
+
* Header rules land in each file item's shadow root, which is where
|
|
37
|
+
* `FileRenderer` puts the header — `CodeView` forwards `unsafeCSS` to its file
|
|
38
|
+
* items even though it never injects it into its own shadow root.
|
|
39
|
+
*
|
|
40
|
+
* Changing header height also invalidates `itemMetrics.diffHeaderHeight`, which
|
|
41
|
+
* `CodeView` uses for its virtualization window and sticky offset. Keep the two
|
|
42
|
+
* in step when overriding `fontSize` / `lineHeight` / `padding`.
|
|
43
|
+
*/
|
|
44
|
+
export function buildBackgroundCSS(
|
|
45
|
+
background: string,
|
|
46
|
+
header?: PierreHeaderStyle,
|
|
47
|
+
): string {
|
|
48
|
+
const headerDeclarations = header
|
|
49
|
+
? Object.entries(header)
|
|
50
|
+
.map(([property, value]) => ` ${kebab(property)}: ${value};`)
|
|
51
|
+
.join("\n")
|
|
52
|
+
: "";
|
|
53
|
+
|
|
10
54
|
return `
|
|
11
55
|
:host {
|
|
56
|
+
--diffs-light-bg: ${background};
|
|
57
|
+
--diffs-dark-bg: ${background};
|
|
12
58
|
background: ${background} !important;
|
|
13
59
|
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
}
|
|
21
|
-
[data-line] span {
|
|
22
|
-
background: ${background} !important;
|
|
60
|
+
${
|
|
61
|
+
headerDeclarations
|
|
62
|
+
? `[data-diffs-header] {
|
|
63
|
+
${headerDeclarations}
|
|
64
|
+
}`
|
|
65
|
+
: ""
|
|
23
66
|
}
|
|
24
67
|
`;
|
|
25
68
|
}
|
|
26
69
|
|
|
27
|
-
|
|
70
|
+
function kebab(property: string): string {
|
|
71
|
+
return property.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function buildPierreOptions(
|
|
75
|
+
background?: string,
|
|
76
|
+
header?: PierreHeaderStyle,
|
|
77
|
+
) {
|
|
28
78
|
if (!background) return PIERRE_BASE_OPTIONS;
|
|
29
79
|
return {
|
|
30
80
|
...PIERRE_BASE_OPTIONS,
|
|
31
|
-
unsafeCSS: buildBackgroundCSS(background),
|
|
81
|
+
unsafeCSS: buildBackgroundCSS(background, header),
|
|
32
82
|
};
|
|
33
83
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
AT_END_TOLERANCE_PX,
|
|
5
|
+
resolveVisibleStepIndex,
|
|
6
|
+
type VisibleStepRect,
|
|
7
|
+
type VisibleStepViewport,
|
|
8
|
+
} from "./visibleStep";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Four steps in *content* coordinates. Steps 2 and 3 are short tails, which is
|
|
12
|
+
* the case that matters: neither can fill a 600px viewport, so at full scroll
|
|
13
|
+
* the topmost thing on screen is step 1 while the true position is step 3.
|
|
14
|
+
*/
|
|
15
|
+
const CONTENT_RECTS = [
|
|
16
|
+
{ index: 0, top: 0, bottom: 200 },
|
|
17
|
+
{ index: 1, top: 200, bottom: 900 },
|
|
18
|
+
{ index: 2, top: 900, bottom: 1100 },
|
|
19
|
+
{ index: 3, top: 1100, bottom: 1200 },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const CONTENT_HEIGHT = 1200;
|
|
23
|
+
const CLIENT_HEIGHT = 600;
|
|
24
|
+
/** Furthest the view can scroll with this content. */
|
|
25
|
+
const MAX_SCROLL = CONTENT_HEIGHT - CLIENT_HEIGHT;
|
|
26
|
+
|
|
27
|
+
/** Project the content layout into viewport coordinates at `scrollTop`. */
|
|
28
|
+
function rectsAt(scrollTop: number): VisibleStepRect[] {
|
|
29
|
+
return CONTENT_RECTS.map((rect) => ({
|
|
30
|
+
index: rect.index,
|
|
31
|
+
top: rect.top - scrollTop,
|
|
32
|
+
bottom: rect.bottom - scrollTop,
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function viewport(
|
|
37
|
+
scrollTop: number,
|
|
38
|
+
overrides: Partial<VisibleStepViewport> = {},
|
|
39
|
+
): VisibleStepViewport {
|
|
40
|
+
return {
|
|
41
|
+
top: 0,
|
|
42
|
+
bottom: CLIENT_HEIGHT,
|
|
43
|
+
scrollTop,
|
|
44
|
+
clientHeight: CLIENT_HEIGHT,
|
|
45
|
+
scrollHeight: CONTENT_HEIGHT,
|
|
46
|
+
...overrides,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function at(scrollTop: number, overrides: Partial<VisibleStepViewport> = {}) {
|
|
51
|
+
return resolveVisibleStepIndex(
|
|
52
|
+
viewport(scrollTop, overrides),
|
|
53
|
+
rectsAt(scrollTop),
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('resolveVisibleStepIndex', () => {
|
|
58
|
+
test('reports the topmost step on screen', () => {
|
|
59
|
+
expect(at(0)).toBe(0);
|
|
60
|
+
expect(at(250)).toBe(1);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('reports the last step once the end of the content is on screen', () => {
|
|
64
|
+
// The regression: at full scroll step 1's tail is still the topmost thing
|
|
65
|
+
// on screen, but the end of the content has been reached.
|
|
66
|
+
expect(at(MAX_SCROLL)).toBe(3);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('does not report the end while content remains below', () => {
|
|
70
|
+
expect(at(300)).toBe(1);
|
|
71
|
+
// Extra scrollable content below must not be mistaken for the end: step 3
|
|
72
|
+
// is still well below the viewport here.
|
|
73
|
+
expect(at(400, { scrollHeight: CONTENT_HEIGHT + 4000 })).toBe(1);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('trusts the scroll extent when the container box is not authoritative', () => {
|
|
77
|
+
// Nested scroller: the container's own box reports a bottom edge far above
|
|
78
|
+
// the real end, so only scrollTop/clientHeight/scrollHeight say we're done.
|
|
79
|
+
expect(at(MAX_SCROLL, { bottom: 100 })).toBe(3);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('ignores the scroll extent when the container does not actually scroll', () => {
|
|
83
|
+
// An ancestor scrolls instead: scrollTop is pinned at 0 and scrollHeight
|
|
84
|
+
// equals clientHeight, which must not read as 'at the end' on first paint.
|
|
85
|
+
const notAScroller = { scrollTop: 0, clientHeight: 600, scrollHeight: 600 };
|
|
86
|
+
expect(at(0, { ...notAScroller, bottom: 100 })).toBe(0);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('tolerates a bottom edge a fraction of a pixel off', () => {
|
|
90
|
+
expect(
|
|
91
|
+
at(MAX_SCROLL, { scrollHeight: CONTENT_HEIGHT + AT_END_TOLERANCE_PX }),
|
|
92
|
+
).toBe(3);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('returns null when nothing is on screen', () => {
|
|
96
|
+
expect(resolveVisibleStepIndex(viewport(0), [])).toBeNull();
|
|
97
|
+
expect(
|
|
98
|
+
resolveVisibleStepIndex(viewport(0), [
|
|
99
|
+
{ index: 0, top: -800, bottom: -400 },
|
|
100
|
+
]),
|
|
101
|
+
).toBeNull();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('uses the highest index even when rects arrive out of order', () => {
|
|
105
|
+
const shuffled = [...rectsAt(MAX_SCROLL)].reverse();
|
|
106
|
+
expect(resolveVisibleStepIndex(viewport(MAX_SCROLL), shuffled)).toBe(3);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* visibleStep — pick which step a scroll position should report as current.
|
|
3
|
+
*
|
|
4
|
+
* Kept separate from the component so the geometry can be tested directly:
|
|
5
|
+
* the answer depends on how tall the final snippet is relative to the
|
|
6
|
+
* viewport, which is not something to eyeball.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** A mounted CodeView item reduced to what the choice depends on. */
|
|
10
|
+
export interface VisibleStepRect {
|
|
11
|
+
/** Step index parsed from the item id. */
|
|
12
|
+
index: number;
|
|
13
|
+
/** Viewport-relative top edge, in px. */
|
|
14
|
+
top: number;
|
|
15
|
+
/** Viewport-relative bottom edge, in px. */
|
|
16
|
+
bottom: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The scroll container's box plus its scroll metrics, in px. */
|
|
20
|
+
export interface VisibleStepViewport {
|
|
21
|
+
top: number;
|
|
22
|
+
bottom: number;
|
|
23
|
+
scrollTop: number;
|
|
24
|
+
clientHeight: number;
|
|
25
|
+
scrollHeight: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Sub-pixel slack so a bottom edge that lands a hair off still counts. */
|
|
29
|
+
export const AT_END_TOLERANCE_PX = 2;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Index of the step to report as current.
|
|
33
|
+
*
|
|
34
|
+
* Normally the topmost step still on screen. The exception is the end of the
|
|
35
|
+
* content: when the final snippet is shorter than the viewport, reaching the
|
|
36
|
+
* bottom still leaves the previous step's tail on screen, and reporting that
|
|
37
|
+
* would strand a progress readout one step short of full.
|
|
38
|
+
*
|
|
39
|
+
* 'At the end' is accepted two ways, because either can be the authoritative
|
|
40
|
+
* one depending on how the view is nested:
|
|
41
|
+
* - the lowest mounted item's bottom edge has entered the container's box, or
|
|
42
|
+
* - the container's own scroll offset has reached its scroll extent.
|
|
43
|
+
*
|
|
44
|
+
* The second signal is only consulted when the container actually scrolls. If
|
|
45
|
+
* an ancestor scrolls instead, `scrollHeight` equals `clientHeight` and
|
|
46
|
+
* `scrollTop` is pinned at 0, which would otherwise read as 'at the end' from
|
|
47
|
+
* the very first pixel.
|
|
48
|
+
*
|
|
49
|
+
* Returns `null` when nothing measurable is on screen.
|
|
50
|
+
*/
|
|
51
|
+
export function resolveVisibleStepIndex(
|
|
52
|
+
viewport: VisibleStepViewport,
|
|
53
|
+
rects: readonly VisibleStepRect[],
|
|
54
|
+
): number | null {
|
|
55
|
+
let topIndex: number | null = null;
|
|
56
|
+
let lastIndex: number | null = null;
|
|
57
|
+
let lowestBottom = Number.NEGATIVE_INFINITY;
|
|
58
|
+
|
|
59
|
+
for (const rect of rects) {
|
|
60
|
+
if (topIndex == null && rect.bottom > viewport.top) topIndex = rect.index;
|
|
61
|
+
if (lastIndex == null || rect.index > lastIndex) lastIndex = rect.index;
|
|
62
|
+
if (rect.bottom > lowestBottom) lowestBottom = rect.bottom;
|
|
63
|
+
}
|
|
64
|
+
if (topIndex == null) return null;
|
|
65
|
+
|
|
66
|
+
const reachedLastItem = lowestBottom <= viewport.bottom + AT_END_TOLERANCE_PX;
|
|
67
|
+
const containerScrolls = viewport.scrollHeight > viewport.clientHeight;
|
|
68
|
+
const reachedScrollEnd =
|
|
69
|
+
containerScrolls &&
|
|
70
|
+
viewport.scrollTop + viewport.clientHeight >=
|
|
71
|
+
viewport.scrollHeight - AT_END_TOLERANCE_PX;
|
|
72
|
+
|
|
73
|
+
return reachedLastItem || reachedScrollEnd
|
|
74
|
+
? (lastIndex ?? topIndex)
|
|
75
|
+
: topIndex;
|
|
76
|
+
}
|
|
@@ -173,6 +173,7 @@ function FlowsDemo() {
|
|
|
173
173
|
proposedAliases,
|
|
174
174
|
resolveSymbol,
|
|
175
175
|
onSymbolClick,
|
|
176
|
+
onVisibleStepChange,
|
|
176
177
|
}: TrailViewerContext) => (
|
|
177
178
|
<PierreTrailCodeView
|
|
178
179
|
trail={trail}
|
|
@@ -183,6 +184,7 @@ function FlowsDemo() {
|
|
|
183
184
|
proposedAliases={proposedAliases}
|
|
184
185
|
resolveSymbol={resolveSymbol}
|
|
185
186
|
onSymbolClick={onSymbolClick}
|
|
187
|
+
onVisibleStepChange={onVisibleStepChange}
|
|
186
188
|
/>
|
|
187
189
|
),
|
|
188
190
|
[],
|
|
@@ -281,6 +283,7 @@ function ProposedMissingStepDemo() {
|
|
|
281
283
|
proposedAliases,
|
|
282
284
|
resolveSymbol,
|
|
283
285
|
onSymbolClick,
|
|
286
|
+
onVisibleStepChange,
|
|
284
287
|
}: TrailViewerContext) => (
|
|
285
288
|
<PierreTrailCodeView
|
|
286
289
|
trail={trail}
|
|
@@ -291,6 +294,7 @@ function ProposedMissingStepDemo() {
|
|
|
291
294
|
proposedAliases={proposedAliases}
|
|
292
295
|
resolveSymbol={resolveSymbol}
|
|
293
296
|
onSymbolClick={onSymbolClick}
|
|
297
|
+
onVisibleStepChange={onVisibleStepChange}
|
|
294
298
|
/>
|
|
295
299
|
),
|
|
296
300
|
[],
|
|
@@ -480,6 +484,7 @@ function ClickableConstructsDemo() {
|
|
|
480
484
|
proposedAliases,
|
|
481
485
|
resolveSymbol,
|
|
482
486
|
onSymbolClick,
|
|
487
|
+
onVisibleStepChange,
|
|
483
488
|
}: TrailViewerContext) => (
|
|
484
489
|
<PierreTrailCodeView
|
|
485
490
|
trail={trail}
|
|
@@ -490,6 +495,7 @@ function ClickableConstructsDemo() {
|
|
|
490
495
|
proposedAliases={proposedAliases}
|
|
491
496
|
resolveSymbol={resolveSymbol}
|
|
492
497
|
onSymbolClick={onSymbolClick}
|
|
498
|
+
onVisibleStepChange={onVisibleStepChange}
|
|
493
499
|
/>
|
|
494
500
|
),
|
|
495
501
|
[],
|