@principal-ai/subsystems-react 0.20.5 → 0.21.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/README.md +1 -1
- package/dist/graphify/anchor.d.ts +17 -0
- package/dist/graphify/anchor.d.ts.map +1 -1
- package/dist/graphify/anchor.js +41 -0
- package/dist/graphify/anchor.js.map +1 -1
- package/dist/graphify/consolidated.d.ts +40 -1
- package/dist/graphify/consolidated.d.ts.map +1 -1
- package/dist/graphify/construct.d.ts +23 -0
- package/dist/graphify/construct.d.ts.map +1 -0
- package/dist/graphify/construct.js +77 -0
- package/dist/graphify/construct.js.map +1 -0
- package/dist/graphify/index.d.ts +2 -2
- package/dist/graphify/index.d.ts.map +1 -1
- package/dist/graphify/index.js +1 -1
- package/dist/graphify/index.js.map +1 -1
- package/dist/index.d.ts +7 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/pierre/PierreWalkthroughCodeView.d.ts +22 -0
- package/dist/pierre/PierreWalkthroughCodeView.d.ts.map +1 -0
- package/dist/pierre/PierreWalkthroughCodeView.js +198 -0
- package/dist/pierre/PierreWalkthroughCodeView.js.map +1 -0
- package/dist/pierre/index.d.ts +2 -2
- package/dist/pierre/index.js +1 -1
- package/dist/pierre/pierreFileLang.d.ts +1 -1
- package/dist/pierre/pierreFileLang.js +1 -1
- package/dist/stories/Subsystem/ComponentGraph/fixtures.d.ts +14 -2
- package/dist/stories/Subsystem/ComponentGraph/fixtures.d.ts.map +1 -1
- package/dist/stories/Subsystem/ComponentGraph/fixtures.js +86 -20
- package/dist/stories/Subsystem/ComponentGraph/fixtures.js.map +1 -1
- package/dist/subsystem/ComponentDeclaration.js +1 -1
- package/dist/subsystem/ComponentDeclaration.js.map +1 -1
- package/dist/subsystem/FileDrawer.d.ts +2 -2
- package/dist/subsystem/FileDrawer.js +2 -2
- package/dist/subsystem/SubsystemComponentGraph.d.ts +25 -24
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +220 -194
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/formatDeclaration.d.ts +2 -1
- package/dist/subsystem/formatDeclaration.d.ts.map +1 -1
- package/dist/subsystem/formatDeclaration.js +57 -33
- package/dist/subsystem/formatDeclaration.js.map +1 -1
- package/dist/subsystem/model.d.ts +109 -42
- package/dist/subsystem/model.d.ts.map +1 -1
- package/dist/subsystem/model.js +107 -5
- package/dist/subsystem/model.js.map +1 -1
- package/dist/subsystem/nodes.d.ts.map +1 -1
- package/dist/subsystem/nodes.js +43 -18
- package/dist/subsystem/nodes.js.map +1 -1
- package/dist/subsystem/tokenizeComponent.js +2 -2
- package/dist/subsystem/tokenizeComponent.js.map +1 -1
- package/package.json +3 -3
- package/src/graphify/anchor.test.ts +31 -2
- package/src/graphify/anchor.ts +46 -0
- package/src/graphify/consolidated.ts +43 -1
- package/src/graphify/{kind.test.ts → construct.test.ts} +20 -20
- package/src/graphify/{kind.ts → construct.ts} +17 -17
- package/src/graphify/index.ts +5 -2
- package/src/index.ts +20 -9
- package/src/pierre/{PierreThroughlineCodeView.tsx → PierreWalkthroughCodeView.tsx} +39 -47
- package/src/pierre/index.ts +2 -2
- package/src/pierre/pierreFileLang.ts +1 -1
- package/src/stories/Pierre/CodeView.stories.tsx +3 -3
- package/src/stories/Subsystem/ComponentDeclarationAudit.stories.tsx +14 -14
- package/src/stories/Subsystem/ComponentGraph/Appearance.stories.tsx +13 -15
- package/src/stories/Subsystem/ComponentGraph/Basics.stories.tsx +8 -8
- package/src/stories/Subsystem/ComponentGraph/Captures.stories.tsx +16 -20
- package/src/stories/Subsystem/ComponentGraph/CustomEntities.stories.tsx +11 -10
- package/src/stories/Subsystem/ComponentGraph/DetailPanel.stories.tsx +13 -14
- package/src/stories/Subsystem/ComponentGraph/Flows.stories.tsx +31 -66
- package/src/stories/Subsystem/ComponentGraph/FrameworkStereotype.stories.tsx +2 -2
- package/src/stories/Subsystem/ComponentGraph/GraphTitle.stories.tsx +4 -4
- package/src/stories/Subsystem/ComponentGraph/Processes.stories.tsx +3 -3
- package/src/stories/Subsystem/ComponentGraph/Proposed.stories.tsx +254 -0
- package/src/stories/Subsystem/ComponentGraph/Scenarios.stories.tsx +518 -33
- package/src/stories/Subsystem/ComponentGraph/Spotlights.stories.tsx +13 -13
- package/src/stories/Subsystem/ComponentGraph/fixtures.ts +105 -21
- package/src/subsystem/ComponentDeclaration.tsx +1 -1
- package/src/subsystem/FileDrawer.tsx +2 -2
- package/src/subsystem/SubsystemComponentGraph.tsx +284 -254
- package/src/subsystem/formatDeclaration.test.ts +76 -1
- package/src/subsystem/formatDeclaration.ts +57 -33
- package/src/subsystem/model.test.ts +49 -15
- package/src/subsystem/model.ts +210 -51
- package/src/subsystem/nodes.tsx +63 -22
- package/src/subsystem/tokenizeComponent.ts +2 -2
|
@@ -36,13 +36,16 @@ import { ChevronDown, ChevronUp, Pause, Play, X } from 'lucide-react';
|
|
|
36
36
|
import { IndustryMarkdownSlide } from 'themed-markdown';
|
|
37
37
|
import {
|
|
38
38
|
buildSubsystemGraph,
|
|
39
|
+
deriveGraphEdges,
|
|
39
40
|
MECHANISM_COLOR,
|
|
40
41
|
MECHANISM_DESCRIPTIONS,
|
|
41
42
|
subsystemGraphLayoutKey,
|
|
43
|
+
walkthroughStepGraphEdgeId,
|
|
42
44
|
type SubsystemComponentEdge,
|
|
43
45
|
type SubsystemComponent,
|
|
44
46
|
type SubsystemEdgeMechanism,
|
|
45
|
-
type
|
|
47
|
+
type SubsystemRelation,
|
|
48
|
+
type SubsystemWalkthrough,
|
|
46
49
|
} from './model';
|
|
47
50
|
import type { SubsystemOpenFileOptions } from './declarationRef';
|
|
48
51
|
import { SubsystemComponentNode, SubsystemGroupNode, SubsystemEdge, SUBSYSTEM_CALLBACKS, hexWithAlpha, EDGE_DIM_ALPHA, fileMatchForNode, flowElementVisibility } from './nodes';
|
|
@@ -58,33 +61,34 @@ const EDGE_LABEL_MAX_EDGE_FRACTION = 0.55;
|
|
|
58
61
|
/** Rough monospace width at fontSize 10 + horizontal padding/border. */
|
|
59
62
|
const EDGE_LABEL_CHAR_PX = 6.2;
|
|
60
63
|
const EDGE_LABEL_PAD_PX = 18;
|
|
61
|
-
/** Pause (ms) between steps when a
|
|
62
|
-
const
|
|
64
|
+
/** Pause (ms) between steps when a walkthrough autoplays. */
|
|
65
|
+
const WALKTHROUGH_PLAY_PAUSE_MS = 2500;
|
|
63
66
|
|
|
64
|
-
/** Context passed to `
|
|
65
|
-
export interface
|
|
66
|
-
|
|
67
|
+
/** Context passed to `renderWalkthroughViewer` when a flow/step is focused. */
|
|
68
|
+
export interface WalkthroughViewerContext {
|
|
69
|
+
walkthrough: SubsystemWalkthrough;
|
|
67
70
|
/** Focused step index; `null` means the whole flow (no specific step). */
|
|
68
71
|
stepIndex: number | null;
|
|
69
72
|
}
|
|
70
73
|
|
|
71
74
|
type DrawerTarget =
|
|
72
75
|
| { kind: 'file'; file: string; startLine?: number }
|
|
73
|
-
| { kind: '
|
|
76
|
+
| { kind: 'walkthrough'; walkthroughId: string; stepIndex: number | null };
|
|
74
77
|
|
|
75
78
|
export interface SubsystemComponentGraphProps {
|
|
76
79
|
components: SubsystemComponent[];
|
|
77
|
-
edges
|
|
80
|
+
/** Topology relations (structural / module / type). Display edges are derived with walkthrough hops. */
|
|
81
|
+
relations: SubsystemRelation[];
|
|
78
82
|
/**
|
|
79
|
-
* Ordered
|
|
80
|
-
*
|
|
81
|
-
* the flows panel lists each
|
|
83
|
+
* Ordered runtime walkthroughs — one walkthrough per flow. When present the
|
|
84
|
+
* sidebar's bottom half offers a Files/Walkthroughs toggle:
|
|
85
|
+
* the flows panel lists each walkthrough's steps (`symbol` or `file:line`);
|
|
82
86
|
* clicking a flow row toggles its steps; clicking a step focuses that
|
|
83
|
-
* step's edge and (when `
|
|
87
|
+
* step's edge and (when `renderWalkthroughViewer` is set) opens the bottom
|
|
84
88
|
* drawer on that flow's snippets. Opened flows stay on the canvas
|
|
85
89
|
* (unselected ones dimmed); everything else is hidden.
|
|
86
90
|
*/
|
|
87
|
-
|
|
91
|
+
walkthroughs?: SubsystemWalkthrough[];
|
|
88
92
|
onSelect?: (componentId: string) => void;
|
|
89
93
|
/** Called when an edge is clicked (relationship / mechanism + refs seam). */
|
|
90
94
|
onEdgeSelect?: (edge: SubsystemComponentEdge) => void;
|
|
@@ -97,29 +101,29 @@ export interface SubsystemComponentGraphProps {
|
|
|
97
101
|
title?: string;
|
|
98
102
|
/**
|
|
99
103
|
* Suppresses the sidebar entirely (title, description, file tree,
|
|
100
|
-
*
|
|
104
|
+
* walkthroughs) for graph-only embeds. Pair with `graphTitle` to keep the
|
|
101
105
|
* subsystem name visible as an overlay on the canvas.
|
|
102
106
|
*/
|
|
103
107
|
hideSidebar?: boolean;
|
|
104
108
|
/**
|
|
105
|
-
* How
|
|
109
|
+
* How walkthrough step highlighting behaves on the canvas.
|
|
106
110
|
* - `focus` (default): zoom to the step, hide non-participants, open drawer
|
|
107
111
|
* - `dim`: keep the full graph, dim non-participants (same as hovering a step)
|
|
108
112
|
*/
|
|
109
|
-
|
|
113
|
+
walkthroughStepMode?: 'focus' | 'dim';
|
|
110
114
|
/**
|
|
111
|
-
* When true, cycles
|
|
112
|
-
* (no zoom, no drawer). Loops across all
|
|
115
|
+
* When true, cycles walkthrough steps automatically using `dim` highlighting
|
|
116
|
+
* (no zoom, no drawer). Loops across all walkthroughs that have steps.
|
|
113
117
|
* Useful for graph-only embeds (`hideSidebar`).
|
|
114
118
|
*/
|
|
115
|
-
|
|
119
|
+
autoPlayWalkthroughs?: boolean;
|
|
116
120
|
/** Pause between autoplay steps in ms. @default 2500 */
|
|
117
|
-
|
|
121
|
+
walkthroughAutoPlayIntervalMs?: number;
|
|
118
122
|
/**
|
|
119
|
-
* When false, focusing a
|
|
123
|
+
* When false, focusing a walkthrough/step does not call `fitView`.
|
|
120
124
|
* @default true
|
|
121
125
|
*/
|
|
122
|
-
|
|
126
|
+
zoomOnWalkthroughFocus?: boolean;
|
|
123
127
|
/**
|
|
124
128
|
* Subsystem title rendered as a non-interactive overlay chip on the graph
|
|
125
129
|
* canvas (top-center). Does not trigger the sidebar — for graph-only
|
|
@@ -127,11 +131,11 @@ export interface SubsystemComponentGraphProps {
|
|
|
127
131
|
*/
|
|
128
132
|
graphTitle?: string;
|
|
129
133
|
/**
|
|
130
|
-
* When true, shows the active
|
|
134
|
+
* When true, shows the active walkthrough's title as a non-interactive
|
|
131
135
|
* overlay chip on the canvas (under `graphTitle` when both are set).
|
|
132
|
-
* Uses the focused or hover-highlighted
|
|
136
|
+
* Uses the focused or hover-highlighted walkthrough.
|
|
133
137
|
*/
|
|
134
|
-
|
|
138
|
+
showWalkthroughTitle?: boolean;
|
|
135
139
|
/** Markdown description rendered in the sidebar. */
|
|
136
140
|
description?: string;
|
|
137
141
|
/** Rendered over the graph canvas only (not the title/legend sidebar). */
|
|
@@ -147,11 +151,11 @@ export interface SubsystemComponentGraphProps {
|
|
|
147
151
|
*/
|
|
148
152
|
renderFileViewer?: (file: string, opts?: SubsystemOpenFileOptions) => ReactNode;
|
|
149
153
|
/**
|
|
150
|
-
* Host-injected multi-snippet viewer for a focused
|
|
154
|
+
* Host-injected multi-snippet viewer for a focused walkthrough. When set,
|
|
151
155
|
* clicking a flow step opens the bottom drawer with this content and
|
|
152
156
|
* updates it as the focused step changes.
|
|
153
157
|
*/
|
|
154
|
-
|
|
158
|
+
renderWalkthroughViewer?: (ctx: WalkthroughViewerContext) => ReactNode;
|
|
155
159
|
/**
|
|
156
160
|
* Legacy component-keyed variant, kept for backward compatibility. When
|
|
157
161
|
* `renderFileViewer` is absent, drawer content resolves via the first
|
|
@@ -194,33 +198,37 @@ const FileDrawerContent = memo(function FileDrawerContent({
|
|
|
194
198
|
return <>{render(file, startLine != null ? { startLine } : undefined)}</>;
|
|
195
199
|
});
|
|
196
200
|
|
|
197
|
-
const
|
|
201
|
+
const WalkthroughDrawerContent = memo(function WalkthroughDrawerContent({
|
|
198
202
|
render,
|
|
199
|
-
|
|
203
|
+
walkthrough,
|
|
200
204
|
stepIndex,
|
|
201
205
|
}: {
|
|
202
|
-
render: (ctx:
|
|
203
|
-
|
|
206
|
+
render: (ctx: WalkthroughViewerContext) => ReactNode;
|
|
207
|
+
walkthrough: SubsystemWalkthrough;
|
|
204
208
|
stepIndex: number | null;
|
|
205
209
|
}) {
|
|
206
|
-
return <>{render({
|
|
210
|
+
return <>{render({ walkthrough, stepIndex })}</>;
|
|
207
211
|
});
|
|
208
212
|
|
|
209
213
|
interface InnerProps extends SubsystemComponentGraphProps {
|
|
210
214
|
measured: { w: number; h: number } | null;
|
|
211
215
|
}
|
|
212
216
|
|
|
213
|
-
function Inner({ components,
|
|
217
|
+
function Inner({ components, relations, walkthroughs, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, title, hideSidebar, walkthroughStepMode = 'focus', autoPlayWalkthroughs = false, walkthroughAutoPlayIntervalMs = WALKTHROUGH_PLAY_PAUSE_MS, zoomOnWalkthroughFocus = true, graphTitle, showWalkthroughTitle = false, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, renderFileView, renderFileViewer, renderWalkthroughViewer, onFileSelect, onVerifyComponent, componentVerification }: InnerProps) {
|
|
214
218
|
const { theme } = useTheme();
|
|
215
219
|
const { fitView } = useReactFlow();
|
|
216
220
|
const viewport = useViewport();
|
|
221
|
+
const graphEdges = useMemo(
|
|
222
|
+
() => deriveGraphEdges({ relations, walkthroughs }),
|
|
223
|
+
[relations, walkthroughs],
|
|
224
|
+
);
|
|
217
225
|
const [built, setBuilt] = useState<{ nodes: Node[]; edges: Edge[] }>({
|
|
218
226
|
nodes: [],
|
|
219
227
|
edges: [],
|
|
220
228
|
});
|
|
221
229
|
const [layoutReady, setLayoutReady] = useState(false);
|
|
222
230
|
const [selected, setSelected] = useState<SubsystemComponent | null>(null);
|
|
223
|
-
/** Bottom drawer: single file or
|
|
231
|
+
/** Bottom drawer: single file or walkthrough multi-snippet mode. */
|
|
224
232
|
const [drawerTarget, setDrawerTarget] = useState<DrawerTarget | null>(null);
|
|
225
233
|
// Mirror so step-focus can decide whether to wait for the drawer open
|
|
226
234
|
// transition before fitView (avoids framing against full-height canvas).
|
|
@@ -230,27 +238,27 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
230
238
|
const pendingFocusFitRef = useRef<number | null>(null);
|
|
231
239
|
// Component the pointer is over (null on leave) → transient tree highlight.
|
|
232
240
|
const [hoveredComponentId, setHoveredComponentId] = useState<string | null>(null);
|
|
233
|
-
//
|
|
241
|
+
// Walkthrough focus — selected flow (or step) is full strength; other
|
|
234
242
|
// opened-flow members stay visible but dimmed; everything else is hidden.
|
|
235
|
-
const [
|
|
243
|
+
const [focusedWalkthroughId, setFocusedWalkthroughId] = useState<string | null>(null);
|
|
236
244
|
// `null` = whole flow focused; a number = that single step's edge focused.
|
|
237
245
|
const [focusedStepIndex, setFocusedStepIndex] = useState<number | null>(null);
|
|
238
|
-
// Hovered
|
|
246
|
+
// Hovered walkthrough in the flows panel: dims every canvas node/edge not
|
|
239
247
|
// involved in the hover preview (or selected ∪ hovered when a step is
|
|
240
248
|
// focused). `stepIndex: null` = whole flow (collapsed title hover);
|
|
241
249
|
// a number = that step. Transient — no drawer. Camera only reframes when
|
|
242
250
|
// a step is already selected.
|
|
243
|
-
const [
|
|
244
|
-
|
|
251
|
+
const [hoveredWalkthroughStep, setHoveredWalkthroughStep] = useState<{
|
|
252
|
+
walkthroughId: string;
|
|
245
253
|
stepIndex: number | null;
|
|
246
254
|
} | null>(null);
|
|
247
|
-
// Sidebar bottom half: which panel is shown when
|
|
248
|
-
const [sidebarView, setSidebarView] = useState<'files' | '
|
|
249
|
-
|
|
255
|
+
// Sidebar bottom half: which panel is shown when walkthroughs exist.
|
|
256
|
+
const [sidebarView, setSidebarView] = useState<'files' | 'walkthroughs'>(() =>
|
|
257
|
+
walkthroughs?.length ? 'walkthroughs' : 'files',
|
|
250
258
|
);
|
|
251
|
-
//
|
|
259
|
+
// Walkthrough flows the user has expanded (via the title row). Closed by
|
|
252
260
|
// default so a graph with several flows doesn't dump every step list at once.
|
|
253
|
-
const [
|
|
261
|
+
const [expandedWalkthroughs, setExpandedWalkthroughs] = useState<Set<string>>(new Set());
|
|
254
262
|
// Sidebar description visibility. Hidden by default so the files/flows
|
|
255
263
|
// panel gets the vertical room; the title-row toggle reveals it.
|
|
256
264
|
const [descriptionVisible, setDescriptionVisible] = useState(false);
|
|
@@ -314,36 +322,36 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
314
322
|
? { file: drawerTarget.file, startLine: drawerTarget.startLine }
|
|
315
323
|
: null;
|
|
316
324
|
|
|
317
|
-
const
|
|
318
|
-
if (drawerTarget?.kind !== '
|
|
319
|
-
return
|
|
320
|
-
}, [drawerTarget,
|
|
325
|
+
const focusedWalkthrough = useMemo(() => {
|
|
326
|
+
if (drawerTarget?.kind !== 'walkthrough' || !walkthroughs) return null;
|
|
327
|
+
return walkthroughs.find((t) => t.id === drawerTarget.walkthroughId) ?? null;
|
|
328
|
+
}, [drawerTarget, walkthroughs]);
|
|
321
329
|
|
|
322
|
-
//
|
|
323
|
-
const
|
|
324
|
-
if (!
|
|
325
|
-
const id =
|
|
330
|
+
// Walkthrough shown on the canvas title chip (focus or hover/autoplay highlight).
|
|
331
|
+
const overlayWalkthroughTitle = useMemo(() => {
|
|
332
|
+
if (!showWalkthroughTitle || !walkthroughs?.length) return null;
|
|
333
|
+
const id = focusedWalkthroughId ?? hoveredWalkthroughStep?.walkthroughId;
|
|
326
334
|
if (!id) return null;
|
|
327
|
-
return
|
|
335
|
+
return walkthroughs.find((t) => t.id === id)?.title ?? null;
|
|
328
336
|
}, [
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
337
|
+
showWalkthroughTitle,
|
|
338
|
+
walkthroughs,
|
|
339
|
+
focusedWalkthroughId,
|
|
340
|
+
hoveredWalkthroughStep,
|
|
333
341
|
]);
|
|
334
342
|
|
|
335
343
|
// Active step for the bottom-of-title progress + annotation chip.
|
|
336
|
-
const
|
|
337
|
-
if (!
|
|
338
|
-
const tlId =
|
|
344
|
+
const overlayWalkthroughStep = useMemo(() => {
|
|
345
|
+
if (!showWalkthroughTitle || !walkthroughs?.length) return null;
|
|
346
|
+
const tlId = focusedWalkthroughId ?? hoveredWalkthroughStep?.walkthroughId ?? null;
|
|
339
347
|
let stepIndex: number | null = null;
|
|
340
|
-
if (
|
|
348
|
+
if (focusedWalkthroughId != null) {
|
|
341
349
|
stepIndex = focusedStepIndex;
|
|
342
|
-
} else if (
|
|
343
|
-
stepIndex =
|
|
350
|
+
} else if (hoveredWalkthroughStep != null) {
|
|
351
|
+
stepIndex = hoveredWalkthroughStep.stepIndex;
|
|
344
352
|
}
|
|
345
353
|
if (tlId == null || stepIndex == null) return null;
|
|
346
|
-
const tl =
|
|
354
|
+
const tl = walkthroughs.find((t) => t.id === tlId);
|
|
347
355
|
const step = tl?.steps[stepIndex];
|
|
348
356
|
if (!step || !tl) return null;
|
|
349
357
|
return {
|
|
@@ -352,24 +360,24 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
352
360
|
annotation: step.annotation,
|
|
353
361
|
};
|
|
354
362
|
}, [
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
363
|
+
showWalkthroughTitle,
|
|
364
|
+
walkthroughs,
|
|
365
|
+
focusedWalkthroughId,
|
|
358
366
|
focusedStepIndex,
|
|
359
|
-
|
|
367
|
+
hoveredWalkthroughStep,
|
|
360
368
|
]);
|
|
361
369
|
|
|
362
370
|
const drawerTitle = useMemo(() => {
|
|
363
371
|
if (!drawerTarget) return null;
|
|
364
372
|
if (drawerTarget.kind === 'file') return drawerTarget.file;
|
|
365
|
-
const tl =
|
|
373
|
+
const tl = focusedWalkthrough;
|
|
366
374
|
if (!tl) return null;
|
|
367
375
|
if (drawerTarget.stepIndex == null) return tl.title;
|
|
368
376
|
const step = tl.steps[drawerTarget.stepIndex];
|
|
369
377
|
if (!step) return tl.title;
|
|
370
378
|
const site = `${step.file.split('/').pop() ?? step.file}:${step.line}`;
|
|
371
379
|
return `${tl.title} · ${site}`;
|
|
372
|
-
}, [drawerTarget,
|
|
380
|
+
}, [drawerTarget, focusedWalkthrough]);
|
|
373
381
|
|
|
374
382
|
// Refresh selected component when the components list updates (e.g. verify
|
|
375
383
|
// writes back declarationRef).
|
|
@@ -383,13 +391,15 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
383
391
|
// The pane stays hidden until Pass 2 completes. Key off layout-affecting
|
|
384
392
|
// fields only — declarationRef updates after verify must not re-run ELK.
|
|
385
393
|
const layoutKey = useMemo(
|
|
386
|
-
() => subsystemGraphLayoutKey({ components,
|
|
387
|
-
[components,
|
|
394
|
+
() => subsystemGraphLayoutKey({ components, relations, walkthroughs }),
|
|
395
|
+
[components, relations, walkthroughs],
|
|
388
396
|
);
|
|
389
397
|
const componentsRef = useRef(components);
|
|
390
|
-
const
|
|
398
|
+
const relationsRef = useRef(relations);
|
|
399
|
+
const walkthroughsRef = useRef(walkthroughs);
|
|
391
400
|
componentsRef.current = components;
|
|
392
|
-
|
|
401
|
+
relationsRef.current = relations;
|
|
402
|
+
walkthroughsRef.current = walkthroughs;
|
|
393
403
|
|
|
394
404
|
// Track measured dimensions from React Flow's dimension changes.
|
|
395
405
|
// These arrive as { type: 'dimensions', id, dimensions } in onNodesChange.
|
|
@@ -406,7 +416,11 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
406
416
|
prevMeasuredSigRef.current = '';
|
|
407
417
|
// Bump generation so any in-flight Pass 2 from the prior layoutKey is ignored.
|
|
408
418
|
pass2GenRef.current += 1;
|
|
409
|
-
const doc = {
|
|
419
|
+
const doc = {
|
|
420
|
+
components: componentsRef.current,
|
|
421
|
+
relations: relationsRef.current,
|
|
422
|
+
walkthroughs: walkthroughsRef.current,
|
|
423
|
+
};
|
|
410
424
|
void buildSubsystemGraph(doc, { maxNodeWidth, showEdgeLabels })
|
|
411
425
|
.then(({ nodes, edges: e }) => {
|
|
412
426
|
if (!alive) return;
|
|
@@ -455,7 +469,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
455
469
|
const measuredHeights = new Map(leafNodes.map((n) => [n.id, dims.get(n.id)!.height]));
|
|
456
470
|
const gen = ++pass2GenRef.current;
|
|
457
471
|
void buildSubsystemGraph(
|
|
458
|
-
{ components,
|
|
472
|
+
{ components, relations, walkthroughs },
|
|
459
473
|
{ maxNodeWidth, showEdgeLabels, measuredWidths, measuredHeights },
|
|
460
474
|
)
|
|
461
475
|
.then(({ nodes, edges: e }) => {
|
|
@@ -469,7 +483,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
469
483
|
// Reveal Pass 1 layout rather than leaving the cover up forever.
|
|
470
484
|
setLayoutReady(true);
|
|
471
485
|
});
|
|
472
|
-
}, [built.nodes, components,
|
|
486
|
+
}, [built.nodes, components, relations, walkthroughs, maxNodeWidth, showEdgeLabels]);
|
|
473
487
|
|
|
474
488
|
// After Pass 1 commits, try Pass 2 immediately with retained measurements.
|
|
475
489
|
// Same-id live updates often get no new React Flow `dimensions` events, so
|
|
@@ -498,7 +512,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
498
512
|
// Node components call SUBSYSTEM_CALLBACKS.onSelect on click (their inner
|
|
499
513
|
// onClick stops React Flow propagation), so wire selection + width through it.
|
|
500
514
|
// Lookup from edge id → the authored SubsystemComponentEdge (for onEdgeSelect).
|
|
501
|
-
const edgeById = useMemo(() => new Map(
|
|
515
|
+
const edgeById = useMemo(() => new Map(graphEdges.map((e) => [e.id, e])), [graphEdges]);
|
|
502
516
|
|
|
503
517
|
// Shared edge-selection logic used by both React Flow's onEdgeClick and the
|
|
504
518
|
// clickable edge label (SUBSYSTEM_CALLBACKS.onEdgeSelect).
|
|
@@ -510,8 +524,8 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
510
524
|
}
|
|
511
525
|
const src = edgeById.get(edgeId);
|
|
512
526
|
setSelectedEdgeId(edgeId);
|
|
513
|
-
// A direct edge selection on the canvas supersedes any
|
|
514
|
-
|
|
527
|
+
// A direct edge selection on the canvas supersedes any walkthrough focus.
|
|
528
|
+
setFocusedWalkthroughId(null);
|
|
515
529
|
setFocusedStepIndex(null);
|
|
516
530
|
if (src) onEdgeSelect?.(src);
|
|
517
531
|
},
|
|
@@ -531,7 +545,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
531
545
|
}
|
|
532
546
|
setSelected(comp);
|
|
533
547
|
setSelectedEdgeId(null);
|
|
534
|
-
|
|
548
|
+
setFocusedWalkthroughId(null);
|
|
535
549
|
setFocusedStepIndex(null);
|
|
536
550
|
onSelect?.(id);
|
|
537
551
|
}
|
|
@@ -557,45 +571,45 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
557
571
|
// without a stale closure value.
|
|
558
572
|
const selectedEdgeIdRef = useRef<string | null>(null);
|
|
559
573
|
selectedEdgeIdRef.current = selectedEdgeId;
|
|
560
|
-
// Edge ids in
|
|
561
|
-
// step's edge). Used to frame the camera. `null` = no
|
|
574
|
+
// Edge ids in walkthrough focus (an active flow's edge set, or a single
|
|
575
|
+
// step's edge). Used to frame the camera. `null` = no walkthrough focus.
|
|
562
576
|
const focusEdgeIds = useMemo(() => {
|
|
563
|
-
if (
|
|
564
|
-
const tl =
|
|
577
|
+
if (focusedWalkthroughId == null || !walkthroughs) return null;
|
|
578
|
+
const tl = walkthroughs.find((t) => t.id === focusedWalkthroughId);
|
|
565
579
|
if (!tl) return null;
|
|
566
580
|
if (focusedStepIndex != null) {
|
|
567
581
|
const step = tl.steps[focusedStepIndex];
|
|
568
|
-
return step ? new Set([step
|
|
582
|
+
return step ? new Set([walkthroughStepGraphEdgeId(step)]) : null;
|
|
569
583
|
}
|
|
570
|
-
return new Set(tl.steps.map((s) => s
|
|
571
|
-
}, [
|
|
584
|
+
return new Set(tl.steps.map((s) => walkthroughStepGraphEdgeId(s)));
|
|
585
|
+
}, [walkthroughs, focusedWalkthroughId, focusedStepIndex]);
|
|
572
586
|
|
|
573
587
|
// 1-based step numbers per edge of the active flow (focused or
|
|
574
588
|
// hover/autoplay-highlighted). An edge can appear in more than one step.
|
|
575
589
|
const selectedFlowStepNos = useMemo(() => {
|
|
576
|
-
const activeId =
|
|
577
|
-
if (activeId == null || !
|
|
578
|
-
const tl =
|
|
590
|
+
const activeId = focusedWalkthroughId ?? hoveredWalkthroughStep?.walkthroughId;
|
|
591
|
+
if (activeId == null || !walkthroughs) return null;
|
|
592
|
+
const tl = walkthroughs.find((t) => t.id === activeId);
|
|
579
593
|
if (!tl) return null;
|
|
580
594
|
const map = new Map<string, number[]>();
|
|
581
595
|
tl.steps.forEach((s, i) => {
|
|
582
|
-
const list = map.get(s
|
|
596
|
+
const list = map.get(walkthroughStepGraphEdgeId(s)) ?? [];
|
|
583
597
|
list.push(i + 1);
|
|
584
|
-
map.set(s
|
|
598
|
+
map.set(walkthroughStepGraphEdgeId(s), list);
|
|
585
599
|
});
|
|
586
600
|
return map;
|
|
587
|
-
}, [
|
|
601
|
+
}, [walkthroughs, focusedWalkthroughId, hoveredWalkthroughStep]);
|
|
588
602
|
|
|
589
|
-
// Union of every expanded (opened)
|
|
603
|
+
// Union of every expanded (opened) walkthrough's edges — the visible set.
|
|
590
604
|
const openedEdgeIds = useMemo(() => {
|
|
591
|
-
if (!
|
|
605
|
+
if (!walkthroughs || expandedWalkthroughs.size === 0) return null;
|
|
592
606
|
const ids = new Set<string>();
|
|
593
|
-
for (const tl of
|
|
594
|
-
if (!
|
|
595
|
-
for (const s of tl.steps) ids.add(s
|
|
607
|
+
for (const tl of walkthroughs) {
|
|
608
|
+
if (!expandedWalkthroughs.has(tl.id)) continue;
|
|
609
|
+
for (const s of tl.steps) ids.add(walkthroughStepGraphEdgeId(s));
|
|
596
610
|
}
|
|
597
611
|
return ids.size > 0 ? ids : null;
|
|
598
|
-
}, [
|
|
612
|
+
}, [walkthroughs, expandedWalkthroughs]);
|
|
599
613
|
|
|
600
614
|
const endpointsOf = (edgeIds: ReadonlySet<string> | null): Set<string> | null => {
|
|
601
615
|
if (!edgeIds) return null;
|
|
@@ -609,15 +623,15 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
609
623
|
};
|
|
610
624
|
// Edge/nodes involved in the hovered step or whole flow.
|
|
611
625
|
const hoverEdgeIds = useMemo(() => {
|
|
612
|
-
if (!
|
|
613
|
-
const tl =
|
|
626
|
+
if (!hoveredWalkthroughStep || !walkthroughs) return null;
|
|
627
|
+
const tl = walkthroughs.find((t) => t.id === hoveredWalkthroughStep.walkthroughId);
|
|
614
628
|
if (!tl) return null;
|
|
615
|
-
if (
|
|
616
|
-
return new Set(tl.steps.map((s) => s
|
|
629
|
+
if (hoveredWalkthroughStep.stepIndex == null) {
|
|
630
|
+
return new Set(tl.steps.map((s) => walkthroughStepGraphEdgeId(s)));
|
|
617
631
|
}
|
|
618
|
-
const step = tl.steps[
|
|
619
|
-
return step ? new Set([step
|
|
620
|
-
}, [
|
|
632
|
+
const step = tl.steps[hoveredWalkthroughStep.stepIndex];
|
|
633
|
+
return step ? new Set([walkthroughStepGraphEdgeId(step)]) : null;
|
|
634
|
+
}, [walkthroughs, hoveredWalkthroughStep]);
|
|
621
635
|
// While hovering with a *step* already selected, brighten the union of
|
|
622
636
|
// selected + hovered participants. Whole-flow focus (or no focus) keeps
|
|
623
637
|
// the old hover-replace preview so a step hover still dims the rest.
|
|
@@ -667,8 +681,8 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
667
681
|
// React Flow's own selection state from updating.
|
|
668
682
|
const dispNodes = useMemo(() => {
|
|
669
683
|
// While a step/flow is hovered, selected + hovered participants stay
|
|
670
|
-
// bright; everything else among the currently-visible (opened-
|
|
671
|
-
// nodes is dimmed. Nodes outside the opened-
|
|
684
|
+
// bright; everything else among the currently-visible (opened-walkthrough)
|
|
685
|
+
// nodes is dimmed. Nodes outside the opened-walkthrough set stay hidden.
|
|
672
686
|
return xyflowNodesBase.map((n) => {
|
|
673
687
|
// Boundary frames follow their members: hidden when no member is
|
|
674
688
|
// visible, dimmed when members are dimmed. Never selectable.
|
|
@@ -800,7 +814,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
800
814
|
(_e, node: Node) => {
|
|
801
815
|
const comp = (node.data as { component?: SubsystemComponent } | undefined)?.component;
|
|
802
816
|
setSelectedEdgeId(null);
|
|
803
|
-
|
|
817
|
+
setFocusedWalkthroughId(null);
|
|
804
818
|
setFocusedStepIndex(null);
|
|
805
819
|
if (node.type === 'subsystem-component' && comp) {
|
|
806
820
|
// Clicking the already-selected node unselects it (toggle off).
|
|
@@ -827,14 +841,14 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
827
841
|
const onPaneClick = useCallback(() => {
|
|
828
842
|
setSelected(null);
|
|
829
843
|
setSelectedEdgeId(null);
|
|
830
|
-
|
|
844
|
+
setFocusedWalkthroughId(null);
|
|
831
845
|
setFocusedStepIndex(null);
|
|
832
846
|
}, []);
|
|
833
847
|
|
|
834
848
|
// Sidebar file trees — one per repo on multi-repo graphs, each under its
|
|
835
849
|
// own owner-avatar header. Clicking a header collapses that repo's tree.
|
|
836
850
|
const repoGroups = useMemo(() => buildRepoGroups(components), [components]);
|
|
837
|
-
const
|
|
851
|
+
const hasWalkthroughs = useMemo(() => (walkthroughs?.length ?? 0) > 0, [walkthroughs]);
|
|
838
852
|
const [collapsedRepos, setCollapsedRepos] = useState<Set<string>>(new Set());
|
|
839
853
|
const toggleRepoCollapsed = useCallback((key: string) => {
|
|
840
854
|
setCollapsedRepos((prev) => {
|
|
@@ -876,13 +890,13 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
876
890
|
[onFileSelect],
|
|
877
891
|
);
|
|
878
892
|
|
|
879
|
-
// Camera helper shared by the
|
|
893
|
+
// Camera helper shared by the walkthrough interactions: frames the focused
|
|
880
894
|
// edges' endpoint nodes via `fitView({ nodes })`, which uses the store's
|
|
881
895
|
// live positions + measured dims — so the frame always includes BOTH
|
|
882
896
|
// components the edge attaches to (and therefore the edge line between them).
|
|
883
897
|
const fitFocusBounds = useCallback(
|
|
884
898
|
(ids: ReadonlySet<string>) => {
|
|
885
|
-
if (!
|
|
899
|
+
if (!zoomOnWalkthroughFocus) return;
|
|
886
900
|
const nodeIds = new Set<string>();
|
|
887
901
|
for (const e of baseEdges) {
|
|
888
902
|
if (!ids.has(e.id)) continue;
|
|
@@ -896,13 +910,13 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
896
910
|
duration: 300,
|
|
897
911
|
});
|
|
898
912
|
},
|
|
899
|
-
[baseEdges, fitView,
|
|
913
|
+
[baseEdges, fitView, zoomOnWalkthroughFocus],
|
|
900
914
|
);
|
|
901
915
|
|
|
902
|
-
// Zoom back out to the full diagram after the last expanded
|
|
916
|
+
// Zoom back out to the full diagram after the last expanded walkthrough
|
|
903
917
|
// closes (visibility restores non-flow nodes that were hidden).
|
|
904
918
|
const fitOverview = useCallback(() => {
|
|
905
|
-
if (!
|
|
919
|
+
if (!zoomOnWalkthroughFocus) return;
|
|
906
920
|
fitView({
|
|
907
921
|
padding: 0.1,
|
|
908
922
|
includeHiddenNodes: false,
|
|
@@ -910,68 +924,83 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
910
924
|
maxZoom: 2,
|
|
911
925
|
duration: 300,
|
|
912
926
|
});
|
|
913
|
-
}, [fitView,
|
|
927
|
+
}, [fitView, zoomOnWalkthroughFocus]);
|
|
914
928
|
|
|
915
929
|
// Focus an entire flow: hide everything but the flow's nodes and edges, and
|
|
916
930
|
// frame the flow on the canvas. Selection state is cleared — the graph now
|
|
917
931
|
// reads as the narrative. No drawer: the code view only opens on a step
|
|
918
|
-
// click. A stale
|
|
932
|
+
// click. A stale walkthrough drawer (from a previously focused flow's step)
|
|
919
933
|
// closes; an explicitly opened file drawer stays.
|
|
920
934
|
// In `dim` mode: clear step highlight and leave the full graph visible
|
|
921
935
|
// (no zoom / hide) — matching "step away from a hovered step".
|
|
922
|
-
const
|
|
923
|
-
(tl:
|
|
936
|
+
const focusWalkthroughEdges = useCallback(
|
|
937
|
+
(tl: SubsystemWalkthrough) => {
|
|
924
938
|
setSelected(null);
|
|
925
939
|
setSelectedEdgeId(null);
|
|
926
|
-
|
|
927
|
-
if (
|
|
940
|
+
setHoveredWalkthroughStep(null);
|
|
941
|
+
if (walkthroughStepMode === 'dim') {
|
|
928
942
|
setFocusedStepIndex(null);
|
|
929
|
-
|
|
930
|
-
setDrawerTarget((prev) => (prev?.kind === '
|
|
943
|
+
setFocusedWalkthroughId(null);
|
|
944
|
+
setDrawerTarget((prev) => (prev?.kind === 'walkthrough' ? null : prev));
|
|
931
945
|
return;
|
|
932
946
|
}
|
|
933
947
|
setFocusedStepIndex(null);
|
|
934
|
-
|
|
935
|
-
fitFocusBounds(new Set(tl.steps.map((s) => s
|
|
936
|
-
setDrawerTarget((prev) => (prev?.kind === '
|
|
948
|
+
setFocusedWalkthroughId(tl.id);
|
|
949
|
+
fitFocusBounds(new Set(tl.steps.map((s) => walkthroughStepGraphEdgeId(s))));
|
|
950
|
+
setDrawerTarget((prev) => (prev?.kind === 'walkthrough' ? null : prev));
|
|
937
951
|
},
|
|
938
|
-
[fitFocusBounds,
|
|
952
|
+
[fitFocusBounds, walkthroughStepMode],
|
|
939
953
|
);
|
|
940
954
|
|
|
941
|
-
const
|
|
942
|
-
|
|
955
|
+
const clearWalkthroughFocus = useCallback(() => {
|
|
956
|
+
setFocusedWalkthroughId(null);
|
|
943
957
|
setFocusedStepIndex(null);
|
|
944
|
-
|
|
945
|
-
setDrawerTarget((prev) => (prev?.kind === '
|
|
958
|
+
setHoveredWalkthroughStep(null);
|
|
959
|
+
setDrawerTarget((prev) => (prev?.kind === 'walkthrough' ? null : prev));
|
|
946
960
|
}, []);
|
|
947
961
|
|
|
948
|
-
// Focus a single step's edge on the canvas and open/scroll the
|
|
949
|
-
// drawer to that step's snippet.
|
|
962
|
+
// Focus a single step's edge on the canvas and open/scroll the walkthrough
|
|
963
|
+
// drawer to that step's snippet. Clicking the already-focused step clears
|
|
964
|
+
// step focus and returns to whole-flow (high-level) framing.
|
|
950
965
|
// In `dim` mode: only dim non-participants (same as hovering a step) —
|
|
951
966
|
// no camera move, no drawer, no hide.
|
|
952
|
-
const
|
|
953
|
-
(tl:
|
|
967
|
+
const focusWalkthroughStep = useCallback(
|
|
968
|
+
(tl: SubsystemWalkthrough, stepIndex: number) => {
|
|
954
969
|
const step = tl.steps[stepIndex];
|
|
955
970
|
if (!step) return;
|
|
956
971
|
setSelected(null);
|
|
957
972
|
setSelectedEdgeId(null);
|
|
958
|
-
if (
|
|
973
|
+
if (walkthroughStepMode === 'dim') {
|
|
959
974
|
setFocusedStepIndex(null);
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
setDrawerTarget((prev) => (prev?.kind === '
|
|
975
|
+
setFocusedWalkthroughId(null);
|
|
976
|
+
setHoveredWalkthroughStep({ walkthroughId: tl.id, stepIndex });
|
|
977
|
+
setDrawerTarget((prev) => (prev?.kind === 'walkthrough' ? null : prev));
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
// Toggle: clicking the selected step unselects it (back to whole flow).
|
|
981
|
+
if (focusedWalkthroughId === tl.id && focusedStepIndex === stepIndex) {
|
|
982
|
+
setFocusedStepIndex(null);
|
|
983
|
+
// Pointer is still over the row — keep hover preview so dimming doesn't
|
|
984
|
+
// flash off until mouseleave.
|
|
985
|
+
setHoveredWalkthroughStep({ walkthroughId: tl.id, stepIndex });
|
|
986
|
+
setDrawerTarget((prev) => (prev?.kind === 'walkthrough' ? null : prev));
|
|
987
|
+
if (pendingFocusFitRef.current != null) {
|
|
988
|
+
window.clearTimeout(pendingFocusFitRef.current);
|
|
989
|
+
pendingFocusFitRef.current = null;
|
|
990
|
+
}
|
|
991
|
+
fitFocusBounds(new Set(tl.steps.map((s) => walkthroughStepGraphEdgeId(s))));
|
|
963
992
|
return;
|
|
964
993
|
}
|
|
965
994
|
setFocusedStepIndex(stepIndex);
|
|
966
|
-
|
|
967
|
-
|
|
995
|
+
setFocusedWalkthroughId(tl.id);
|
|
996
|
+
setHoveredWalkthroughStep(null);
|
|
968
997
|
// Open the drawer before fitting. If it was closed, wait for its height
|
|
969
998
|
// transition so fitView uses the reduced canvas — not full height.
|
|
970
999
|
const drawerWasOpen = drawerOpenRef.current;
|
|
971
|
-
if (
|
|
1000
|
+
if (renderWalkthroughViewer) {
|
|
972
1001
|
setDrawerTarget({
|
|
973
|
-
kind: '
|
|
974
|
-
|
|
1002
|
+
kind: 'walkthrough',
|
|
1003
|
+
walkthroughId: tl.id,
|
|
975
1004
|
stepIndex,
|
|
976
1005
|
});
|
|
977
1006
|
} else {
|
|
@@ -981,7 +1010,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
981
1010
|
startLine: step.line,
|
|
982
1011
|
});
|
|
983
1012
|
}
|
|
984
|
-
const edgeIds = new Set([step
|
|
1013
|
+
const edgeIds = new Set([walkthroughStepGraphEdgeId(step)]);
|
|
985
1014
|
if (pendingFocusFitRef.current != null) {
|
|
986
1015
|
window.clearTimeout(pendingFocusFitRef.current);
|
|
987
1016
|
pendingFocusFitRef.current = null;
|
|
@@ -995,7 +1024,13 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
995
1024
|
}, FILE_DRAWER_HEIGHT_MS + 20);
|
|
996
1025
|
}
|
|
997
1026
|
},
|
|
998
|
-
[
|
|
1027
|
+
[
|
|
1028
|
+
fitFocusBounds,
|
|
1029
|
+
focusedStepIndex,
|
|
1030
|
+
focusedWalkthroughId,
|
|
1031
|
+
renderWalkthroughViewer,
|
|
1032
|
+
walkthroughStepMode,
|
|
1033
|
+
],
|
|
999
1034
|
);
|
|
1000
1035
|
|
|
1001
1036
|
useEffect(() => {
|
|
@@ -1016,9 +1051,9 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
1016
1051
|
const prev = prevPreviewEdgeIdsRef.current;
|
|
1017
1052
|
prevPreviewEdgeIdsRef.current = previewEdgeIds;
|
|
1018
1053
|
|
|
1019
|
-
if (!
|
|
1054
|
+
if (!zoomOnWalkthroughFocus || walkthroughStepMode === 'dim') return;
|
|
1020
1055
|
// Camera follows hover only when a specific step is already focused.
|
|
1021
|
-
if (
|
|
1056
|
+
if (focusedWalkthroughId == null || focusedStepIndex == null) return;
|
|
1022
1057
|
|
|
1023
1058
|
if (previewEdgeIds) {
|
|
1024
1059
|
fitFocusBounds(previewEdgeIds);
|
|
@@ -1030,33 +1065,33 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
1030
1065
|
}, [
|
|
1031
1066
|
previewEdgeIds,
|
|
1032
1067
|
focusEdgeIds,
|
|
1033
|
-
|
|
1068
|
+
focusedWalkthroughId,
|
|
1034
1069
|
focusedStepIndex,
|
|
1035
1070
|
fitFocusBounds,
|
|
1036
|
-
|
|
1037
|
-
|
|
1071
|
+
zoomOnWalkthroughFocus,
|
|
1072
|
+
walkthroughStepMode,
|
|
1038
1073
|
]);
|
|
1039
1074
|
|
|
1040
|
-
// Graph-only embeds: cycle
|
|
1075
|
+
// Graph-only embeds: cycle walkthrough steps with hover-style dimming.
|
|
1041
1076
|
useEffect(() => {
|
|
1042
|
-
if (!
|
|
1043
|
-
const playable =
|
|
1077
|
+
if (!autoPlayWalkthroughs || !walkthroughs?.length || !layoutReady) return;
|
|
1078
|
+
const playable = walkthroughs.filter((tl) => tl.steps.length > 0);
|
|
1044
1079
|
if (playable.length === 0) return;
|
|
1045
1080
|
|
|
1046
1081
|
let cancelled = false;
|
|
1047
1082
|
let tlIdx = 0;
|
|
1048
1083
|
let stepIdx = 0;
|
|
1049
1084
|
let timer: number | null = null;
|
|
1050
|
-
const interval = Math.max(400,
|
|
1085
|
+
const interval = Math.max(400, walkthroughAutoPlayIntervalMs);
|
|
1051
1086
|
|
|
1052
1087
|
const tick = () => {
|
|
1053
1088
|
if (cancelled) return;
|
|
1054
1089
|
const tl = playable[tlIdx]!;
|
|
1055
1090
|
setSelected(null);
|
|
1056
1091
|
setSelectedEdgeId(null);
|
|
1057
|
-
|
|
1092
|
+
setFocusedWalkthroughId(null);
|
|
1058
1093
|
setFocusedStepIndex(null);
|
|
1059
|
-
|
|
1094
|
+
setHoveredWalkthroughStep({ walkthroughId: tl.id, stepIndex: stepIdx });
|
|
1060
1095
|
stepIdx += 1;
|
|
1061
1096
|
if (stepIdx >= tl.steps.length) {
|
|
1062
1097
|
stepIdx = 0;
|
|
@@ -1069,17 +1104,17 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
1069
1104
|
return () => {
|
|
1070
1105
|
cancelled = true;
|
|
1071
1106
|
if (timer != null) window.clearTimeout(timer);
|
|
1072
|
-
|
|
1107
|
+
setHoveredWalkthroughStep(null);
|
|
1073
1108
|
};
|
|
1074
|
-
}, [
|
|
1109
|
+
}, [autoPlayWalkthroughs, walkthroughs, walkthroughAutoPlayIntervalMs, layoutReady]);
|
|
1075
1110
|
|
|
1076
|
-
// Arrow keys step through the focused
|
|
1111
|
+
// Arrow keys step through the focused walkthrough once a step is active
|
|
1077
1112
|
// (sidebar click or drawer open). Ignores typing targets and chords.
|
|
1078
1113
|
useEffect(() => {
|
|
1079
|
-
if (
|
|
1114
|
+
if (focusedWalkthroughId == null || focusedStepIndex == null || !walkthroughs) {
|
|
1080
1115
|
return;
|
|
1081
1116
|
}
|
|
1082
|
-
const tl =
|
|
1117
|
+
const tl = walkthroughs.find((t) => t.id === focusedWalkthroughId);
|
|
1083
1118
|
if (!tl || tl.steps.length === 0) return;
|
|
1084
1119
|
|
|
1085
1120
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
@@ -1105,31 +1140,31 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
1105
1140
|
}
|
|
1106
1141
|
if (next === focusedStepIndex) return;
|
|
1107
1142
|
e.preventDefault();
|
|
1108
|
-
|
|
1143
|
+
focusWalkthroughStep(tl, next);
|
|
1109
1144
|
};
|
|
1110
1145
|
|
|
1111
1146
|
window.addEventListener('keydown', onKeyDown);
|
|
1112
1147
|
return () => window.removeEventListener('keydown', onKeyDown);
|
|
1113
1148
|
}, [
|
|
1114
|
-
|
|
1149
|
+
focusedWalkthroughId,
|
|
1115
1150
|
focusedStepIndex,
|
|
1116
|
-
|
|
1117
|
-
|
|
1151
|
+
walkthroughs,
|
|
1152
|
+
focusWalkthroughStep,
|
|
1118
1153
|
]);
|
|
1119
1154
|
|
|
1120
|
-
const
|
|
1155
|
+
const toggleWalkthroughCollapsed = useCallback(
|
|
1121
1156
|
(tlId: string) => {
|
|
1122
1157
|
const collapsingLast =
|
|
1123
|
-
|
|
1158
|
+
expandedWalkthroughs.has(tlId) && expandedWalkthroughs.size === 1;
|
|
1124
1159
|
|
|
1125
|
-
|
|
1160
|
+
setExpandedWalkthroughs((prev) => {
|
|
1126
1161
|
const next = new Set(prev);
|
|
1127
1162
|
if (next.has(tlId)) next.delete(tlId);
|
|
1128
1163
|
else next.add(tlId);
|
|
1129
1164
|
return next;
|
|
1130
1165
|
});
|
|
1131
1166
|
|
|
1132
|
-
if (!collapsingLast || !
|
|
1167
|
+
if (!collapsingLast || !zoomOnWalkthroughFocus) return;
|
|
1133
1168
|
|
|
1134
1169
|
// Cancel any pending focus fit so it doesn't fight the overview zoom.
|
|
1135
1170
|
if (pendingFocusFitRef.current != null) {
|
|
@@ -1149,7 +1184,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
1149
1184
|
drawerWasOpen ? FILE_DRAWER_HEIGHT_MS + 20 : 20,
|
|
1150
1185
|
);
|
|
1151
1186
|
},
|
|
1152
|
-
[
|
|
1187
|
+
[expandedWalkthroughs, fitOverview, zoomOnWalkthroughFocus],
|
|
1153
1188
|
);
|
|
1154
1189
|
|
|
1155
1190
|
// Filename-badge clicks on nodes open the drawer through the same path as
|
|
@@ -1179,7 +1214,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
1179
1214
|
if (!comp || comp.id === selectedRef.current?.id) return;
|
|
1180
1215
|
setSelected(comp);
|
|
1181
1216
|
setSelectedEdgeId(null);
|
|
1182
|
-
|
|
1217
|
+
setFocusedWalkthroughId(null);
|
|
1183
1218
|
setFocusedStepIndex(null);
|
|
1184
1219
|
onSelect?.(comp.id);
|
|
1185
1220
|
},
|
|
@@ -1245,11 +1280,11 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
1245
1280
|
[],
|
|
1246
1281
|
);
|
|
1247
1282
|
|
|
1248
|
-
const
|
|
1249
|
-
|
|
1250
|
-
const
|
|
1251
|
-
(ctx:
|
|
1252
|
-
|
|
1283
|
+
const walkthroughViewerRef = useRef(renderWalkthroughViewer);
|
|
1284
|
+
walkthroughViewerRef.current = renderWalkthroughViewer;
|
|
1285
|
+
const renderWalkthroughDrawerContent = useCallback(
|
|
1286
|
+
(ctx: WalkthroughViewerContext) =>
|
|
1287
|
+
walkthroughViewerRef.current?.(ctx) ?? null,
|
|
1253
1288
|
[],
|
|
1254
1289
|
);
|
|
1255
1290
|
|
|
@@ -1258,7 +1293,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
1258
1293
|
{/* Sidebar: scrollable title/description on top, files or flows pinned below.
|
|
1259
1294
|
The description hides by default so files/flows get the room; the
|
|
1260
1295
|
title-row toggle reveals it, and the lower panel yields back to 50%. */}
|
|
1261
|
-
{!hideSidebar && (title || description || sidebarExtra || sidebarAfterDescription || treeFilePaths.length > 0 ||
|
|
1296
|
+
{!hideSidebar && (title || description || sidebarExtra || sidebarAfterDescription || treeFilePaths.length > 0 || hasWalkthroughs) && (
|
|
1262
1297
|
<div
|
|
1263
1298
|
style={{
|
|
1264
1299
|
width: sidebarWidth,
|
|
@@ -1347,7 +1382,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
1347
1382
|
)}
|
|
1348
1383
|
{sidebarAfterDescription}
|
|
1349
1384
|
</div>
|
|
1350
|
-
{(treeFilePaths.length > 0 ||
|
|
1385
|
+
{(treeFilePaths.length > 0 || hasWalkthroughs) && (
|
|
1351
1386
|
<div
|
|
1352
1387
|
style={{
|
|
1353
1388
|
...(showDesc ? { height: '50%' as const } : { flex: 1, minHeight: 0 }),
|
|
@@ -1359,7 +1394,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
1359
1394
|
overflow: 'hidden',
|
|
1360
1395
|
}}
|
|
1361
1396
|
>
|
|
1362
|
-
{
|
|
1397
|
+
{hasWalkthroughs && (
|
|
1363
1398
|
<div
|
|
1364
1399
|
role="tablist"
|
|
1365
1400
|
aria-label="Sidebar view"
|
|
@@ -1371,7 +1406,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
1371
1406
|
background: theme.colors.backgroundSecondary ?? theme.colors.background,
|
|
1372
1407
|
}}
|
|
1373
1408
|
>
|
|
1374
|
-
{(['
|
|
1409
|
+
{(['walkthroughs', 'files'] as const).map((view) => (
|
|
1375
1410
|
<button
|
|
1376
1411
|
key={view}
|
|
1377
1412
|
type="button"
|
|
@@ -1395,12 +1430,12 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
1395
1430
|
cursor: 'pointer',
|
|
1396
1431
|
}}
|
|
1397
1432
|
>
|
|
1398
|
-
{view}
|
|
1433
|
+
{view === 'walkthroughs' ? 'Walkthroughs' : 'Files'}
|
|
1399
1434
|
</button>
|
|
1400
1435
|
))}
|
|
1401
1436
|
</div>
|
|
1402
1437
|
)}
|
|
1403
|
-
{sidebarView === '
|
|
1438
|
+
{sidebarView === 'walkthroughs' && walkthroughs && walkthroughs.length > 0 ? (
|
|
1404
1439
|
<div
|
|
1405
1440
|
style={{
|
|
1406
1441
|
flex: 1,
|
|
@@ -1409,30 +1444,29 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
1409
1444
|
padding: '0 0 12px',
|
|
1410
1445
|
}}
|
|
1411
1446
|
>
|
|
1412
|
-
{
|
|
1413
|
-
<
|
|
1447
|
+
{walkthroughs.map((tl) => (
|
|
1448
|
+
<WalkthroughFlow
|
|
1414
1449
|
key={tl.id}
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
collapsed={!expandedThroughlines.has(tl.id)}
|
|
1450
|
+
walkthrough={tl}
|
|
1451
|
+
collapsed={!expandedWalkthroughs.has(tl.id)}
|
|
1418
1452
|
active={
|
|
1419
|
-
|
|
1453
|
+
focusedWalkthroughId === tl.id
|
|
1420
1454
|
? { stepIndex: focusedStepIndex }
|
|
1421
|
-
:
|
|
1422
|
-
? { stepIndex:
|
|
1455
|
+
: hoveredWalkthroughStep?.walkthroughId === tl.id
|
|
1456
|
+
? { stepIndex: hoveredWalkthroughStep.stepIndex }
|
|
1423
1457
|
: null
|
|
1424
1458
|
}
|
|
1425
|
-
onToggleCollapsed={
|
|
1426
|
-
onFocusFlow={
|
|
1427
|
-
onClearFocus={
|
|
1428
|
-
onFocusStep={
|
|
1459
|
+
onToggleCollapsed={toggleWalkthroughCollapsed}
|
|
1460
|
+
onFocusFlow={focusWalkthroughEdges}
|
|
1461
|
+
onClearFocus={clearWalkthroughFocus}
|
|
1462
|
+
onFocusStep={focusWalkthroughStep}
|
|
1429
1463
|
onHoverStep={(tl, i) =>
|
|
1430
|
-
|
|
1464
|
+
setHoveredWalkthroughStep({ walkthroughId: tl.id, stepIndex: i })
|
|
1431
1465
|
}
|
|
1432
1466
|
onHoverFlow={(tl) =>
|
|
1433
|
-
|
|
1467
|
+
setHoveredWalkthroughStep({ walkthroughId: tl.id, stepIndex: null })
|
|
1434
1468
|
}
|
|
1435
|
-
onLeaveStep={() =>
|
|
1469
|
+
onLeaveStep={() => setHoveredWalkthroughStep(null)}
|
|
1436
1470
|
/>
|
|
1437
1471
|
))}
|
|
1438
1472
|
</div>
|
|
@@ -1479,7 +1513,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
1479
1513
|
)}
|
|
1480
1514
|
|
|
1481
1515
|
{/* Drag handle between sidebar and canvas — resize the left panel. */}
|
|
1482
|
-
{!hideSidebar && (title || description || sidebarExtra || sidebarAfterDescription || treeFilePaths.length > 0 ||
|
|
1516
|
+
{!hideSidebar && (title || description || sidebarExtra || sidebarAfterDescription || treeFilePaths.length > 0 || hasWalkthroughs) && (
|
|
1483
1517
|
<div
|
|
1484
1518
|
onMouseDown={onSidebarResizeStart}
|
|
1485
1519
|
aria-label="Resize sidebar"
|
|
@@ -1604,9 +1638,9 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
1604
1638
|
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
|
|
1605
1639
|
<Controls showZoom showFitView showInteractive />
|
|
1606
1640
|
</ReactFlow>
|
|
1607
|
-
{/* Graph /
|
|
1641
|
+
{/* Graph / walkthrough / step titles — non-interactive chips at the top
|
|
1608
1642
|
of the canvas. Graph-only embeds use these without opening the sidebar. */}
|
|
1609
|
-
{(graphTitle ||
|
|
1643
|
+
{(graphTitle || overlayWalkthroughTitle || overlayWalkthroughStep) && (
|
|
1610
1644
|
<div
|
|
1611
1645
|
style={{
|
|
1612
1646
|
position: 'absolute',
|
|
@@ -1643,11 +1677,11 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
1643
1677
|
{graphTitle}
|
|
1644
1678
|
</div>
|
|
1645
1679
|
)}
|
|
1646
|
-
{
|
|
1680
|
+
{overlayWalkthroughTitle && (
|
|
1647
1681
|
<div
|
|
1648
1682
|
style={{
|
|
1649
1683
|
maxWidth: '100%',
|
|
1650
|
-
minWidth:
|
|
1684
|
+
minWidth: overlayWalkthroughStep ? 160 : undefined,
|
|
1651
1685
|
display: 'flex',
|
|
1652
1686
|
flexDirection: 'column',
|
|
1653
1687
|
background: theme.colors.backgroundSecondary ?? theme.colors.background,
|
|
@@ -1658,9 +1692,9 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
1658
1692
|
opacity: 0.95,
|
|
1659
1693
|
}}
|
|
1660
1694
|
aria-label={
|
|
1661
|
-
|
|
1662
|
-
? `${
|
|
1663
|
-
:
|
|
1695
|
+
overlayWalkthroughStep
|
|
1696
|
+
? `${overlayWalkthroughTitle}, step ${overlayWalkthroughStep.index} of ${overlayWalkthroughStep.total}`
|
|
1697
|
+
: overlayWalkthroughTitle
|
|
1664
1698
|
}
|
|
1665
1699
|
>
|
|
1666
1700
|
<div
|
|
@@ -1676,9 +1710,9 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
1676
1710
|
textAlign: 'center',
|
|
1677
1711
|
}}
|
|
1678
1712
|
>
|
|
1679
|
-
{
|
|
1713
|
+
{overlayWalkthroughTitle}
|
|
1680
1714
|
</div>
|
|
1681
|
-
{
|
|
1715
|
+
{overlayWalkthroughStep && overlayWalkthroughStep.total > 0 && (
|
|
1682
1716
|
<div
|
|
1683
1717
|
style={{
|
|
1684
1718
|
display: 'flex',
|
|
@@ -1687,10 +1721,10 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
1687
1721
|
}}
|
|
1688
1722
|
aria-hidden="true"
|
|
1689
1723
|
>
|
|
1690
|
-
{Array.from({ length:
|
|
1724
|
+
{Array.from({ length: overlayWalkthroughStep.total }, (_, i) => {
|
|
1691
1725
|
const n = i + 1;
|
|
1692
|
-
const active = n ===
|
|
1693
|
-
const done = n <
|
|
1726
|
+
const active = n === overlayWalkthroughStep.index;
|
|
1727
|
+
const done = n < overlayWalkthroughStep.index;
|
|
1694
1728
|
return (
|
|
1695
1729
|
<span
|
|
1696
1730
|
key={n}
|
|
@@ -1713,7 +1747,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
1713
1747
|
)}
|
|
1714
1748
|
</div>
|
|
1715
1749
|
)}
|
|
1716
|
-
{
|
|
1750
|
+
{overlayWalkthroughStep?.annotation && (
|
|
1717
1751
|
<div
|
|
1718
1752
|
style={{
|
|
1719
1753
|
maxWidth: '100%',
|
|
@@ -1729,7 +1763,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
1729
1763
|
lineHeight: 1.35,
|
|
1730
1764
|
}}
|
|
1731
1765
|
>
|
|
1732
|
-
{
|
|
1766
|
+
{overlayWalkthroughStep.annotation}
|
|
1733
1767
|
</div>
|
|
1734
1768
|
)}
|
|
1735
1769
|
</div>
|
|
@@ -1763,12 +1797,12 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
|
|
|
1763
1797
|
)}
|
|
1764
1798
|
</div>
|
|
1765
1799
|
<FileDrawer title={drawerTitle} onClose={() => setDrawerTarget(null)}>
|
|
1766
|
-
{drawerTarget?.kind === '
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
<
|
|
1770
|
-
render={
|
|
1771
|
-
|
|
1800
|
+
{drawerTarget?.kind === 'walkthrough' &&
|
|
1801
|
+
focusedWalkthrough &&
|
|
1802
|
+
renderWalkthroughViewer ? (
|
|
1803
|
+
<WalkthroughDrawerContent
|
|
1804
|
+
render={renderWalkthroughDrawerContent}
|
|
1805
|
+
walkthrough={focusedWalkthrough}
|
|
1772
1806
|
stepIndex={drawerTarget.stepIndex}
|
|
1773
1807
|
/>
|
|
1774
1808
|
) : drawerTarget?.kind === 'file' ? (
|
|
@@ -1866,13 +1900,12 @@ function RepoGroupHeader({
|
|
|
1866
1900
|
);
|
|
1867
1901
|
}
|
|
1868
1902
|
|
|
1869
|
-
/** One collapsible
|
|
1903
|
+
/** One collapsible walkthrough in the sidebar's flows panel. Clicking the
|
|
1870
1904
|
* title: closed → open + select; open and unselected → select; open and
|
|
1871
1905
|
* selected → close + clear focus. The right-aligned close button collapses
|
|
1872
1906
|
* without selecting. A step row focuses that step's edge. */
|
|
1873
|
-
function
|
|
1874
|
-
|
|
1875
|
-
edges,
|
|
1907
|
+
function WalkthroughFlow({
|
|
1908
|
+
walkthrough,
|
|
1876
1909
|
collapsed,
|
|
1877
1910
|
active,
|
|
1878
1911
|
onToggleCollapsed,
|
|
@@ -1883,24 +1916,22 @@ function ThroughlineFlow({
|
|
|
1883
1916
|
onHoverFlow,
|
|
1884
1917
|
onLeaveStep,
|
|
1885
1918
|
}: {
|
|
1886
|
-
|
|
1887
|
-
edges: SubsystemComponentEdge[];
|
|
1919
|
+
walkthrough: SubsystemWalkthrough;
|
|
1888
1920
|
collapsed: boolean;
|
|
1889
1921
|
/** `{ stepIndex: null }` = whole flow focused; `{ stepIndex }` = one step. */
|
|
1890
1922
|
active: { stepIndex: number | null } | null;
|
|
1891
1923
|
onToggleCollapsed: (tlId: string) => void;
|
|
1892
|
-
onFocusFlow: (tl:
|
|
1924
|
+
onFocusFlow: (tl: SubsystemWalkthrough) => void;
|
|
1893
1925
|
onClearFocus: () => void;
|
|
1894
|
-
onFocusStep: (tl:
|
|
1895
|
-
onHoverStep: (tl:
|
|
1926
|
+
onFocusStep: (tl: SubsystemWalkthrough, stepIndex: number) => void;
|
|
1927
|
+
onHoverStep: (tl: SubsystemWalkthrough, stepIndex: number) => void;
|
|
1896
1928
|
/** Preview the whole flow on the canvas (used while the row is collapsed). */
|
|
1897
|
-
onHoverFlow: (tl:
|
|
1929
|
+
onHoverFlow: (tl: SubsystemWalkthrough) => void;
|
|
1898
1930
|
onLeaveStep: () => void;
|
|
1899
1931
|
}) {
|
|
1900
1932
|
const { theme } = useTheme();
|
|
1901
1933
|
const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
|
|
1902
1934
|
const hoverBg = theme.colors.background;
|
|
1903
|
-
const edgeById = useMemo(() => new Map(edges.map((e) => [e.id, e])), [edges]);
|
|
1904
1935
|
const wholeFlowActive = active !== null && active.stepIndex === null;
|
|
1905
1936
|
const [headerHover, setHeaderHover] = useState(false);
|
|
1906
1937
|
const [closeHover, setCloseHover] = useState(false);
|
|
@@ -1924,9 +1955,9 @@ function ThroughlineFlow({
|
|
|
1924
1955
|
useEffect(() => () => stopPlaying(), []);
|
|
1925
1956
|
|
|
1926
1957
|
const startPlaying = useCallback(() => {
|
|
1927
|
-
if (collapsed) onToggleCollapsed(
|
|
1928
|
-
if (active === null || active.stepIndex !== null) onFocusFlow(
|
|
1929
|
-
const stepCount =
|
|
1958
|
+
if (collapsed) onToggleCollapsed(walkthrough.id);
|
|
1959
|
+
if (active === null || active.stepIndex !== null) onFocusFlow(walkthrough);
|
|
1960
|
+
const stepCount = walkthrough.steps.length;
|
|
1930
1961
|
if (stepCount === 0) return;
|
|
1931
1962
|
setPlaying(true);
|
|
1932
1963
|
let i = 0;
|
|
@@ -1936,12 +1967,12 @@ function ThroughlineFlow({
|
|
|
1936
1967
|
setPlaying(false);
|
|
1937
1968
|
return;
|
|
1938
1969
|
}
|
|
1939
|
-
onFocusStep(
|
|
1970
|
+
onFocusStep(walkthrough, i);
|
|
1940
1971
|
i += 1;
|
|
1941
|
-
playTimerRef.current = window.setTimeout(tick,
|
|
1972
|
+
playTimerRef.current = window.setTimeout(tick, WALKTHROUGH_PLAY_PAUSE_MS);
|
|
1942
1973
|
};
|
|
1943
1974
|
tick();
|
|
1944
|
-
}, [collapsed, active, onToggleCollapsed, onFocusFlow,
|
|
1975
|
+
}, [collapsed, active, onToggleCollapsed, onFocusFlow, walkthrough, onFocusStep]);
|
|
1945
1976
|
|
|
1946
1977
|
const togglePlay = useCallback(() => {
|
|
1947
1978
|
if (playing) {
|
|
@@ -1964,7 +1995,7 @@ function ThroughlineFlow({
|
|
|
1964
1995
|
<div
|
|
1965
1996
|
onMouseEnter={() => {
|
|
1966
1997
|
setHeaderHover(true);
|
|
1967
|
-
if (collapsed) onHoverFlow(
|
|
1998
|
+
if (collapsed) onHoverFlow(walkthrough);
|
|
1968
1999
|
}}
|
|
1969
2000
|
onMouseLeave={() => {
|
|
1970
2001
|
setHeaderHover(false);
|
|
@@ -1983,12 +2014,12 @@ function ThroughlineFlow({
|
|
|
1983
2014
|
type="button"
|
|
1984
2015
|
onClick={() => {
|
|
1985
2016
|
if (collapsed) {
|
|
1986
|
-
onToggleCollapsed(
|
|
1987
|
-
onFocusFlow(
|
|
2017
|
+
onToggleCollapsed(walkthrough.id);
|
|
2018
|
+
onFocusFlow(walkthrough);
|
|
1988
2019
|
} else if (active === null) {
|
|
1989
|
-
onFocusFlow(
|
|
2020
|
+
onFocusFlow(walkthrough);
|
|
1990
2021
|
} else {
|
|
1991
|
-
onToggleCollapsed(
|
|
2022
|
+
onToggleCollapsed(walkthrough.id);
|
|
1992
2023
|
onClearFocus();
|
|
1993
2024
|
}
|
|
1994
2025
|
}}
|
|
@@ -2015,14 +2046,14 @@ function ThroughlineFlow({
|
|
|
2015
2046
|
color: theme.colors.text,
|
|
2016
2047
|
}}
|
|
2017
2048
|
>
|
|
2018
|
-
{
|
|
2049
|
+
{walkthrough.title}
|
|
2019
2050
|
</span>
|
|
2020
2051
|
</button>
|
|
2021
2052
|
{!collapsed && (
|
|
2022
2053
|
<>
|
|
2023
2054
|
<button
|
|
2024
2055
|
type="button"
|
|
2025
|
-
aria-label={playing ? `Pause ${
|
|
2056
|
+
aria-label={playing ? `Pause ${walkthrough.title} autoplay` : `Play ${walkthrough.title}`}
|
|
2026
2057
|
title={playing ? 'Pause' : 'Play through steps'}
|
|
2027
2058
|
onMouseEnter={() => setPlayHover(true)}
|
|
2028
2059
|
onMouseLeave={() => setPlayHover(false)}
|
|
@@ -2050,12 +2081,12 @@ function ThroughlineFlow({
|
|
|
2050
2081
|
</button>
|
|
2051
2082
|
<button
|
|
2052
2083
|
type="button"
|
|
2053
|
-
aria-label={`Close ${
|
|
2084
|
+
aria-label={`Close ${walkthrough.title}`}
|
|
2054
2085
|
onMouseEnter={() => setCloseHover(true)}
|
|
2055
2086
|
onMouseLeave={() => setCloseHover(false)}
|
|
2056
2087
|
onClick={(e) => {
|
|
2057
2088
|
e.stopPropagation();
|
|
2058
|
-
onToggleCollapsed(
|
|
2089
|
+
onToggleCollapsed(walkthrough.id);
|
|
2059
2090
|
if (active !== null) onClearFocus();
|
|
2060
2091
|
}}
|
|
2061
2092
|
style={{
|
|
@@ -2087,23 +2118,22 @@ function ThroughlineFlow({
|
|
|
2087
2118
|
onLeaveStep();
|
|
2088
2119
|
}}
|
|
2089
2120
|
>
|
|
2090
|
-
{
|
|
2091
|
-
const
|
|
2092
|
-
const mech = edge?.mechanism;
|
|
2121
|
+
{walkthrough.steps.map((step, i) => {
|
|
2122
|
+
const mech = step.mechanism;
|
|
2093
2123
|
const color = mech ? MECHANISM_COLOR[mech] : muted;
|
|
2094
2124
|
const stepActive = active !== null && active.stepIndex === i;
|
|
2095
2125
|
return (
|
|
2096
2126
|
<button
|
|
2097
|
-
key={`${step
|
|
2127
|
+
key={`${walkthroughStepGraphEdgeId(step)}-${i}`}
|
|
2098
2128
|
ref={(el) => {
|
|
2099
2129
|
stepButtonRefs.current[i] = el;
|
|
2100
2130
|
}}
|
|
2101
2131
|
type="button"
|
|
2102
2132
|
onMouseEnter={() => {
|
|
2103
2133
|
setHoveredStep(i);
|
|
2104
|
-
onHoverStep(
|
|
2134
|
+
onHoverStep(walkthrough, i);
|
|
2105
2135
|
}}
|
|
2106
|
-
onClick={() => onFocusStep(
|
|
2136
|
+
onClick={() => onFocusStep(walkthrough, i)}
|
|
2107
2137
|
style={{
|
|
2108
2138
|
display: 'flex',
|
|
2109
2139
|
alignItems: 'center',
|
|
@@ -2159,7 +2189,7 @@ function ThroughlineFlow({
|
|
|
2159
2189
|
color,
|
|
2160
2190
|
}}
|
|
2161
2191
|
>
|
|
2162
|
-
{mech ?? step.
|
|
2192
|
+
{mech ?? step.mechanism}
|
|
2163
2193
|
</span>
|
|
2164
2194
|
<span
|
|
2165
2195
|
style={{
|