@principal-ai/subsystems-react 0.28.1 → 0.28.3
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/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.d.ts +7 -0
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +7 -201
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/WalkthroughsPanel.d.ts +53 -0
- package/dist/subsystem/WalkthroughsPanel.d.ts.map +1 -0
- package/dist/subsystem/WalkthroughsPanel.js +371 -0
- package/dist/subsystem/WalkthroughsPanel.js.map +1 -0
- package/dist/subsystem/model.d.ts +15 -0
- package/dist/subsystem/model.d.ts.map +1 -1
- package/dist/subsystem/model.js +28 -0
- package/dist/subsystem/model.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +4 -0
- package/src/stories/Subsystem/ComponentGraph/ProposedWalkthroughs.stories.tsx +165 -0
- package/src/stories/Subsystem/ComponentGraph/Reorder.stories.tsx +204 -0
- package/src/subsystem/SubsystemComponentGraph.tsx +36 -319
- package/src/subsystem/WalkthroughsPanel.tsx +628 -0
- package/src/subsystem/model.test.ts +76 -1
- package/src/subsystem/model.ts +31 -0
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WalkthroughsPanel — the sidebar's flows panel: the ordered list of a
|
|
3
|
+
* subsystem's walkthroughs, each rendered as a collapsible `WalkthroughFlow`
|
|
4
|
+
* row. Extracted from `SubsystemComponentGraph`'s `Inner` so the panel (and the
|
|
5
|
+
* drag-to-reorder controller it hosts) is a component boundary of its own.
|
|
6
|
+
*
|
|
7
|
+
* Ordering is authored order: the list renders `walkthroughs` in array order.
|
|
8
|
+
* When `onReorder` is supplied, each row grows a grip in its header and dropping
|
|
9
|
+
* a row onto another emits the reordered array (see `reorderWalkthroughs`); the
|
|
10
|
+
* host owns persistence.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
14
|
+
import type { DragEvent as ReactDragEvent } from 'react';
|
|
15
|
+
import { useTheme } from '@principal-ade/industry-theme';
|
|
16
|
+
import { ChevronDown, GripVertical, Pause, Play } from 'lucide-react';
|
|
17
|
+
import {
|
|
18
|
+
PROPOSED_COLOR,
|
|
19
|
+
reorderTargetIndex,
|
|
20
|
+
reorderWalkthroughs,
|
|
21
|
+
walkthroughStepGraphEdgeId,
|
|
22
|
+
type SubsystemWalkthrough,
|
|
23
|
+
} from './model';
|
|
24
|
+
|
|
25
|
+
/** Pause (ms) between steps when a walkthrough autoplays. */
|
|
26
|
+
export const WALKTHROUGH_PLAY_PAUSE_MS = 2500;
|
|
27
|
+
|
|
28
|
+
/** Drag state handed to a row's grip while a reorder is in flight. */
|
|
29
|
+
interface WalkthroughReorderHandle {
|
|
30
|
+
index: number;
|
|
31
|
+
isDragging: boolean;
|
|
32
|
+
onDragStart: (index: number, e: ReactDragEvent) => void;
|
|
33
|
+
onDragEnd: () => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** One collapsible walkthrough in the sidebar's flows panel. Clicking the
|
|
37
|
+
* title: closed → open + select; open with a step selected → select the whole
|
|
38
|
+
* flow (deselect the step, stay open); open with the whole flow selected →
|
|
39
|
+
* close + clear focus. The right-aligned close button collapses without
|
|
40
|
+
* selecting. A step row focuses that step's edge. */
|
|
41
|
+
function WalkthroughFlow({
|
|
42
|
+
walkthrough,
|
|
43
|
+
collapsed,
|
|
44
|
+
active,
|
|
45
|
+
onToggleCollapsed,
|
|
46
|
+
onFocusFlow,
|
|
47
|
+
onClearFocus,
|
|
48
|
+
onFocusStep,
|
|
49
|
+
onHoverStep,
|
|
50
|
+
onHoverFlow,
|
|
51
|
+
onLeaveStep,
|
|
52
|
+
reorder,
|
|
53
|
+
dragActive,
|
|
54
|
+
isHoverSuppressed,
|
|
55
|
+
proposedAliases,
|
|
56
|
+
}: {
|
|
57
|
+
walkthrough: SubsystemWalkthrough;
|
|
58
|
+
collapsed: boolean;
|
|
59
|
+
/** `{ stepIndex: null }` = whole flow focused; `{ stepIndex }` = one step. */
|
|
60
|
+
active: { stepIndex: number | null } | null;
|
|
61
|
+
onToggleCollapsed: (tlId: string) => void;
|
|
62
|
+
onFocusFlow: (tl: SubsystemWalkthrough) => void;
|
|
63
|
+
onClearFocus: () => void;
|
|
64
|
+
onFocusStep: (tl: SubsystemWalkthrough, stepIndex: number) => void;
|
|
65
|
+
onHoverStep: (tl: SubsystemWalkthrough, stepIndex: number) => void;
|
|
66
|
+
/** Preview the whole flow on the canvas (used while the row is collapsed). */
|
|
67
|
+
onHoverFlow: (tl: SubsystemWalkthrough) => void;
|
|
68
|
+
onLeaveStep: () => void;
|
|
69
|
+
/** When set, renders a grip in the header that starts a reorder drag. */
|
|
70
|
+
reorder?: WalkthroughReorderHandle;
|
|
71
|
+
/** True while any row is mid-drag; used to reset stale hover styling. */
|
|
72
|
+
dragActive?: boolean;
|
|
73
|
+
/** True just after a drop; ignores the browser's synthetic hover until the pointer moves. */
|
|
74
|
+
isHoverSuppressed?: () => boolean;
|
|
75
|
+
/**
|
|
76
|
+
* Aliases of components marked `proposed`. A row whose steps touch one gets
|
|
77
|
+
* its title tinted, and each such step (index and title) is tinted too
|
|
78
|
+
* (darkgoldenrod, same as the proposed node treatment).
|
|
79
|
+
*/
|
|
80
|
+
proposedAliases?: ReadonlySet<string>;
|
|
81
|
+
}) {
|
|
82
|
+
const { theme } = useTheme();
|
|
83
|
+
const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
|
|
84
|
+
const hoverBg = theme.colors.background;
|
|
85
|
+
const wholeFlowActive = active !== null && active.stepIndex === null;
|
|
86
|
+
// A step is "proposed" when either endpoint is a proposed component.
|
|
87
|
+
const stepProposed = (from: string, to: string) =>
|
|
88
|
+
proposedAliases != null && (proposedAliases.has(from) || proposedAliases.has(to));
|
|
89
|
+
const touchesProposed = walkthrough.steps.some((s) => stepProposed(s.from, s.to));
|
|
90
|
+
const [headerHover, setHeaderHover] = useState(false);
|
|
91
|
+
const [collapseHover, setCollapseHover] = useState(false);
|
|
92
|
+
const [playHover, setPlayHover] = useState(false);
|
|
93
|
+
const [gripHover, setGripHover] = useState(false);
|
|
94
|
+
const [hoveredStep, setHoveredStep] = useState<number | null>(null);
|
|
95
|
+
const stepButtonRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
|
96
|
+
// Autoplay: stepping through the flow's steps with a pause between each.
|
|
97
|
+
const [playing, setPlaying] = useState(false);
|
|
98
|
+
const playTimerRef = useRef<number | null>(null);
|
|
99
|
+
|
|
100
|
+
const stopPlaying = useCallback(() => {
|
|
101
|
+
if (playTimerRef.current != null) {
|
|
102
|
+
window.clearTimeout(playTimerRef.current);
|
|
103
|
+
playTimerRef.current = null;
|
|
104
|
+
}
|
|
105
|
+
setPlaying(false);
|
|
106
|
+
}, []);
|
|
107
|
+
|
|
108
|
+
// Clear any pending timer on unmount.
|
|
109
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
110
|
+
useEffect(() => () => stopPlaying(), []);
|
|
111
|
+
|
|
112
|
+
// Native HTML5 drag suppresses mouse events, so a row hovered when the drag
|
|
113
|
+
// began never gets its mouseleave — its hover styling would stick after the
|
|
114
|
+
// drop. Reset every row's hover whenever a drag starts or ends.
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
setHeaderHover(false);
|
|
117
|
+
setCollapseHover(false);
|
|
118
|
+
setPlayHover(false);
|
|
119
|
+
setGripHover(false);
|
|
120
|
+
setHoveredStep(null);
|
|
121
|
+
}, [dragActive]);
|
|
122
|
+
|
|
123
|
+
const startPlaying = useCallback(() => {
|
|
124
|
+
if (collapsed) onToggleCollapsed(walkthrough.id);
|
|
125
|
+
if (active === null || active.stepIndex !== null) onFocusFlow(walkthrough);
|
|
126
|
+
const stepCount = walkthrough.steps.length;
|
|
127
|
+
if (stepCount === 0) return;
|
|
128
|
+
setPlaying(true);
|
|
129
|
+
let i = 0;
|
|
130
|
+
const tick = () => {
|
|
131
|
+
if (i >= stepCount) {
|
|
132
|
+
playTimerRef.current = null;
|
|
133
|
+
setPlaying(false);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
onFocusStep(walkthrough, i);
|
|
137
|
+
i += 1;
|
|
138
|
+
playTimerRef.current = window.setTimeout(tick, WALKTHROUGH_PLAY_PAUSE_MS);
|
|
139
|
+
};
|
|
140
|
+
tick();
|
|
141
|
+
}, [collapsed, active, onToggleCollapsed, onFocusFlow, walkthrough, onFocusStep]);
|
|
142
|
+
|
|
143
|
+
const togglePlay = useCallback(() => {
|
|
144
|
+
if (playing) {
|
|
145
|
+
stopPlaying();
|
|
146
|
+
} else {
|
|
147
|
+
startPlaying();
|
|
148
|
+
}
|
|
149
|
+
}, [playing, stopPlaying, startPlaying]);
|
|
150
|
+
|
|
151
|
+
// Keep DOM focus on the active step so the browser focus ring (and
|
|
152
|
+
// subsequent arrow keys) follow arrow navigation, not the originally
|
|
153
|
+
// clicked button.
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
if (active?.stepIndex == null) return;
|
|
156
|
+
stepButtonRefs.current[active.stepIndex]?.focus({ preventScroll: true });
|
|
157
|
+
}, [active?.stepIndex]);
|
|
158
|
+
|
|
159
|
+
return (
|
|
160
|
+
<div>
|
|
161
|
+
<div
|
|
162
|
+
onMouseEnter={() => {
|
|
163
|
+
if (isHoverSuppressed?.()) return;
|
|
164
|
+
setHeaderHover(true);
|
|
165
|
+
if (collapsed) onHoverFlow(walkthrough);
|
|
166
|
+
}}
|
|
167
|
+
onMouseLeave={() => {
|
|
168
|
+
setHeaderHover(false);
|
|
169
|
+
if (collapsed) onLeaveStep();
|
|
170
|
+
}}
|
|
171
|
+
style={{
|
|
172
|
+
display: 'flex',
|
|
173
|
+
alignItems: 'center',
|
|
174
|
+
gap: 4,
|
|
175
|
+
padding: '0 16px',
|
|
176
|
+
background: wholeFlowActive || headerHover ? hoverBg : 'transparent',
|
|
177
|
+
transition: 'background 120ms ease',
|
|
178
|
+
}}
|
|
179
|
+
>
|
|
180
|
+
<button
|
|
181
|
+
type="button"
|
|
182
|
+
onClick={() => {
|
|
183
|
+
if (collapsed) {
|
|
184
|
+
onToggleCollapsed(walkthrough.id);
|
|
185
|
+
onFocusFlow(walkthrough);
|
|
186
|
+
} else if (active !== null && active.stepIndex === null) {
|
|
187
|
+
// Whole flow already selected → collapse + clear.
|
|
188
|
+
onToggleCollapsed(walkthrough.id);
|
|
189
|
+
onClearFocus();
|
|
190
|
+
} else {
|
|
191
|
+
// Expanded with nothing (or a step) selected → select the whole
|
|
192
|
+
// flow, which deselects any focused step without collapsing.
|
|
193
|
+
onFocusFlow(walkthrough);
|
|
194
|
+
}
|
|
195
|
+
}}
|
|
196
|
+
style={{
|
|
197
|
+
flex: 1,
|
|
198
|
+
display: 'flex',
|
|
199
|
+
alignItems: 'center',
|
|
200
|
+
minWidth: 0,
|
|
201
|
+
padding: '10px 0',
|
|
202
|
+
border: 'none',
|
|
203
|
+
background: 'transparent',
|
|
204
|
+
textAlign: 'left',
|
|
205
|
+
cursor: 'pointer',
|
|
206
|
+
}}
|
|
207
|
+
>
|
|
208
|
+
<span
|
|
209
|
+
style={{
|
|
210
|
+
overflow: 'hidden',
|
|
211
|
+
textOverflow: 'ellipsis',
|
|
212
|
+
whiteSpace: 'nowrap',
|
|
213
|
+
fontSize: theme.fontSizes[1],
|
|
214
|
+
fontFamily: theme.fonts.monospace,
|
|
215
|
+
fontWeight: 600,
|
|
216
|
+
color: touchesProposed ? PROPOSED_COLOR : theme.colors.text,
|
|
217
|
+
}}
|
|
218
|
+
>
|
|
219
|
+
{walkthrough.title}
|
|
220
|
+
</span>
|
|
221
|
+
</button>
|
|
222
|
+
{!collapsed && (
|
|
223
|
+
<>
|
|
224
|
+
<button
|
|
225
|
+
type="button"
|
|
226
|
+
aria-label={playing ? `Pause ${walkthrough.title} autoplay` : `Play ${walkthrough.title}`}
|
|
227
|
+
title={playing ? 'Pause' : 'Play through steps'}
|
|
228
|
+
onMouseEnter={() => setPlayHover(true)}
|
|
229
|
+
onMouseLeave={() => setPlayHover(false)}
|
|
230
|
+
onClick={(e) => {
|
|
231
|
+
e.stopPropagation();
|
|
232
|
+
togglePlay();
|
|
233
|
+
}}
|
|
234
|
+
style={{
|
|
235
|
+
display: 'inline-flex',
|
|
236
|
+
alignItems: 'center',
|
|
237
|
+
justifyContent: 'center',
|
|
238
|
+
flexShrink: 0,
|
|
239
|
+
width: 22,
|
|
240
|
+
height: 22,
|
|
241
|
+
padding: 0,
|
|
242
|
+
border: 'none',
|
|
243
|
+
borderRadius: 4,
|
|
244
|
+
background: playing || playHover ? theme.colors.border : 'transparent',
|
|
245
|
+
color: playing || playHover ? theme.colors.text : muted,
|
|
246
|
+
cursor: 'pointer',
|
|
247
|
+
transition: 'background 120ms ease, color 120ms ease',
|
|
248
|
+
}}
|
|
249
|
+
>
|
|
250
|
+
{playing ? <Pause size={12} strokeWidth={2} /> : <Play size={12} strokeWidth={2} />}
|
|
251
|
+
</button>
|
|
252
|
+
<button
|
|
253
|
+
type="button"
|
|
254
|
+
aria-label={`Collapse ${walkthrough.title}`}
|
|
255
|
+
title="Collapse"
|
|
256
|
+
onMouseEnter={() => setCollapseHover(true)}
|
|
257
|
+
onMouseLeave={() => setCollapseHover(false)}
|
|
258
|
+
onClick={(e) => {
|
|
259
|
+
e.stopPropagation();
|
|
260
|
+
onToggleCollapsed(walkthrough.id);
|
|
261
|
+
if (active !== null) onClearFocus();
|
|
262
|
+
}}
|
|
263
|
+
style={{
|
|
264
|
+
display: 'inline-flex',
|
|
265
|
+
alignItems: 'center',
|
|
266
|
+
justifyContent: 'center',
|
|
267
|
+
flexShrink: 0,
|
|
268
|
+
width: 22,
|
|
269
|
+
height: 22,
|
|
270
|
+
padding: 0,
|
|
271
|
+
border: 'none',
|
|
272
|
+
borderRadius: 4,
|
|
273
|
+
background: collapseHover ? theme.colors.border : 'transparent',
|
|
274
|
+
color: collapseHover ? theme.colors.text : muted,
|
|
275
|
+
cursor: 'pointer',
|
|
276
|
+
transition: 'background 120ms ease, color 120ms ease',
|
|
277
|
+
}}
|
|
278
|
+
>
|
|
279
|
+
<ChevronDown size={14} strokeWidth={2} />
|
|
280
|
+
</button>
|
|
281
|
+
</>
|
|
282
|
+
)}
|
|
283
|
+
{reorder && (
|
|
284
|
+
<span
|
|
285
|
+
role="button"
|
|
286
|
+
tabIndex={-1}
|
|
287
|
+
draggable
|
|
288
|
+
aria-label={`Reorder ${walkthrough.title}`}
|
|
289
|
+
title="Drag to reorder"
|
|
290
|
+
onMouseEnter={() => {
|
|
291
|
+
if (!isHoverSuppressed?.()) setGripHover(true);
|
|
292
|
+
}}
|
|
293
|
+
onMouseLeave={() => setGripHover(false)}
|
|
294
|
+
onDragStart={(e) => reorder.onDragStart(reorder.index, e)}
|
|
295
|
+
onDragEnd={reorder.onDragEnd}
|
|
296
|
+
onClick={(e) => e.stopPropagation()}
|
|
297
|
+
style={{
|
|
298
|
+
display: 'inline-flex',
|
|
299
|
+
alignItems: 'center',
|
|
300
|
+
flexShrink: 0,
|
|
301
|
+
marginRight: -8,
|
|
302
|
+
padding: '2px 0',
|
|
303
|
+
color: gripHover || reorder.isDragging ? theme.colors.text : muted,
|
|
304
|
+
cursor: reorder.isDragging ? 'grabbing' : 'grab',
|
|
305
|
+
transition: 'color 120ms ease',
|
|
306
|
+
}}
|
|
307
|
+
>
|
|
308
|
+
<GripVertical size={14} strokeWidth={2} />
|
|
309
|
+
</span>
|
|
310
|
+
)}
|
|
311
|
+
</div>
|
|
312
|
+
{!collapsed && (
|
|
313
|
+
<div
|
|
314
|
+
style={{ display: 'flex', flexDirection: 'column' }}
|
|
315
|
+
onMouseLeave={() => {
|
|
316
|
+
setHoveredStep(null);
|
|
317
|
+
onLeaveStep();
|
|
318
|
+
}}
|
|
319
|
+
>
|
|
320
|
+
{walkthrough.steps.map((step, i) => {
|
|
321
|
+
const stepActive = active !== null && active.stepIndex === i;
|
|
322
|
+
const proposed = stepProposed(step.from, step.to);
|
|
323
|
+
return (
|
|
324
|
+
<button
|
|
325
|
+
key={`${walkthroughStepGraphEdgeId(step)}-${i}`}
|
|
326
|
+
ref={(el) => {
|
|
327
|
+
stepButtonRefs.current[i] = el;
|
|
328
|
+
}}
|
|
329
|
+
type="button"
|
|
330
|
+
title={proposed ? 'Step touches a proposed component' : undefined}
|
|
331
|
+
onMouseEnter={() => {
|
|
332
|
+
setHoveredStep(i);
|
|
333
|
+
onHoverStep(walkthrough, i);
|
|
334
|
+
}}
|
|
335
|
+
onClick={() => onFocusStep(walkthrough, i)}
|
|
336
|
+
style={{
|
|
337
|
+
display: 'flex',
|
|
338
|
+
alignItems: 'center',
|
|
339
|
+
gap: 8,
|
|
340
|
+
minWidth: 0,
|
|
341
|
+
padding: '8px 8px 8px 17px',
|
|
342
|
+
textAlign: 'left',
|
|
343
|
+
borderRadius: 0,
|
|
344
|
+
border: 'none',
|
|
345
|
+
outline: 'none',
|
|
346
|
+
background: stepActive || hoveredStep === i ? hoverBg : 'transparent',
|
|
347
|
+
cursor: 'pointer',
|
|
348
|
+
transition: 'background 120ms ease',
|
|
349
|
+
}}
|
|
350
|
+
>
|
|
351
|
+
<span
|
|
352
|
+
style={{
|
|
353
|
+
flexShrink: 0,
|
|
354
|
+
width: 14,
|
|
355
|
+
fontSize: theme.fontSizes[0],
|
|
356
|
+
fontFamily: theme.fonts.monospace,
|
|
357
|
+
fontWeight: proposed ? 700 : undefined,
|
|
358
|
+
color: proposed
|
|
359
|
+
? PROPOSED_COLOR
|
|
360
|
+
: stepActive
|
|
361
|
+
? theme.colors.text
|
|
362
|
+
: muted,
|
|
363
|
+
}}
|
|
364
|
+
>
|
|
365
|
+
{i + 1}
|
|
366
|
+
</span>
|
|
367
|
+
<span
|
|
368
|
+
style={{
|
|
369
|
+
flex: 1,
|
|
370
|
+
minWidth: 0,
|
|
371
|
+
overflow: 'hidden',
|
|
372
|
+
textOverflow: 'ellipsis',
|
|
373
|
+
whiteSpace: 'nowrap',
|
|
374
|
+
fontSize: theme.fontSizes[1],
|
|
375
|
+
fontFamily: theme.fonts.monospace,
|
|
376
|
+
color: proposed ? PROPOSED_COLOR : theme.colors.text,
|
|
377
|
+
}}
|
|
378
|
+
>
|
|
379
|
+
{step.symbol}
|
|
380
|
+
</span>
|
|
381
|
+
</button>
|
|
382
|
+
);
|
|
383
|
+
})}
|
|
384
|
+
</div>
|
|
385
|
+
)}
|
|
386
|
+
</div>
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export interface WalkthroughsPanelProps {
|
|
391
|
+
/** Ordered walkthroughs — the list renders them in array order. */
|
|
392
|
+
walkthroughs: SubsystemWalkthrough[];
|
|
393
|
+
/** Ids of the rows whose step lists are expanded. */
|
|
394
|
+
expandedWalkthroughs: Set<string>;
|
|
395
|
+
focusedWalkthroughId: string | null;
|
|
396
|
+
focusedStepIndex: number | null;
|
|
397
|
+
hoveredWalkthroughStep: {
|
|
398
|
+
walkthroughId: string;
|
|
399
|
+
stepIndex: number | null;
|
|
400
|
+
} | null;
|
|
401
|
+
onToggleCollapsed: (tlId: string) => void;
|
|
402
|
+
onFocusFlow: (tl: SubsystemWalkthrough) => void;
|
|
403
|
+
onClearFocus: () => void;
|
|
404
|
+
onFocusStep: (tl: SubsystemWalkthrough, stepIndex: number) => void;
|
|
405
|
+
onHoverStep: (tl: SubsystemWalkthrough, stepIndex: number) => void;
|
|
406
|
+
onHoverFlow: (tl: SubsystemWalkthrough) => void;
|
|
407
|
+
onLeaveStep: () => void;
|
|
408
|
+
/**
|
|
409
|
+
* When set, rows grow a drag grip (and become drop targets) so a drop emits
|
|
410
|
+
* the reordered array. Grips show only while every row is collapsed. Omit
|
|
411
|
+
* for a read-only panel.
|
|
412
|
+
*/
|
|
413
|
+
onReorder?: (next: SubsystemWalkthrough[]) => void;
|
|
414
|
+
/**
|
|
415
|
+
* Aliases of components marked `proposed`. A row whose steps touch one gets
|
|
416
|
+
* its title tinted, and each such step (index and title) is tinted too
|
|
417
|
+
* (darkgoldenrod, same as the proposed node treatment).
|
|
418
|
+
*/
|
|
419
|
+
proposedAliases?: ReadonlySet<string>;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* The flows panel: a scroll container of `WalkthroughFlow` rows, optionally
|
|
424
|
+
* drag-reorderable. The host owns the array; a drop calls `onReorder` with the
|
|
425
|
+
* next order and the host re-renders with it.
|
|
426
|
+
*/
|
|
427
|
+
export function WalkthroughsPanel({
|
|
428
|
+
walkthroughs,
|
|
429
|
+
expandedWalkthroughs,
|
|
430
|
+
focusedWalkthroughId,
|
|
431
|
+
focusedStepIndex,
|
|
432
|
+
hoveredWalkthroughStep,
|
|
433
|
+
onToggleCollapsed,
|
|
434
|
+
onFocusFlow,
|
|
435
|
+
onClearFocus,
|
|
436
|
+
onFocusStep,
|
|
437
|
+
onHoverStep,
|
|
438
|
+
onHoverFlow,
|
|
439
|
+
onLeaveStep,
|
|
440
|
+
onReorder,
|
|
441
|
+
proposedAliases,
|
|
442
|
+
}: WalkthroughsPanelProps) {
|
|
443
|
+
const { theme } = useTheme();
|
|
444
|
+
// Reordering is offered only while every row is collapsed: the compact list
|
|
445
|
+
// is what you drag, and expanded step lists would make drop targets tall and
|
|
446
|
+
// the landing boundary ambiguous.
|
|
447
|
+
const allCollapsed = walkthroughs.every((w) => !expandedWalkthroughs.has(w.id));
|
|
448
|
+
const reorderable = onReorder != null && allCollapsed;
|
|
449
|
+
// Row being dragged, and the gap the pointer is over (boundaries 0..n
|
|
450
|
+
// between rows). The boundary is where the dragged row will be inserted.
|
|
451
|
+
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
|
452
|
+
const [dropBoundary, setDropBoundary] = useState<number | null>(null);
|
|
453
|
+
// After a native drag ends the browser fires a mouseenter on whatever sits
|
|
454
|
+
// under the cursor, which would light up a row the user never moved onto.
|
|
455
|
+
// Ignore hover until the pointer actually moves. A ref (not state) so the
|
|
456
|
+
// guard is synchronous — the synthetic event can beat a re-render.
|
|
457
|
+
const suppressHoverRef = useRef(false);
|
|
458
|
+
const hoverClearRef = useRef<(() => void) | null>(null);
|
|
459
|
+
|
|
460
|
+
const suppressHoverUntilMove = useCallback(() => {
|
|
461
|
+
suppressHoverRef.current = true;
|
|
462
|
+
if (hoverClearRef.current) return;
|
|
463
|
+
const clear = () => {
|
|
464
|
+
suppressHoverRef.current = false;
|
|
465
|
+
window.removeEventListener('mousemove', clear);
|
|
466
|
+
hoverClearRef.current = null;
|
|
467
|
+
};
|
|
468
|
+
hoverClearRef.current = clear;
|
|
469
|
+
window.addEventListener('mousemove', clear);
|
|
470
|
+
}, []);
|
|
471
|
+
|
|
472
|
+
useEffect(
|
|
473
|
+
() => () => {
|
|
474
|
+
if (hoverClearRef.current) {
|
|
475
|
+
window.removeEventListener('mousemove', hoverClearRef.current);
|
|
476
|
+
hoverClearRef.current = null;
|
|
477
|
+
}
|
|
478
|
+
},
|
|
479
|
+
[],
|
|
480
|
+
);
|
|
481
|
+
|
|
482
|
+
const resetDrag = useCallback(() => {
|
|
483
|
+
setDragIndex(null);
|
|
484
|
+
setDropBoundary(null);
|
|
485
|
+
suppressHoverUntilMove();
|
|
486
|
+
}, [suppressHoverUntilMove]);
|
|
487
|
+
|
|
488
|
+
const handleDragStart = useCallback((index: number, e: ReactDragEvent) => {
|
|
489
|
+
suppressHoverRef.current = false;
|
|
490
|
+
setDragIndex(index);
|
|
491
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
492
|
+
// Firefox requires data to be set for a drag to begin.
|
|
493
|
+
e.dataTransfer.setData('text/plain', String(index));
|
|
494
|
+
}, []);
|
|
495
|
+
|
|
496
|
+
// Pick the boundary nearest the pointer: above the row when over its top
|
|
497
|
+
// half, below it when over the bottom half. The line marks that gap, and the
|
|
498
|
+
// drop inserts there — so the indicator always matches the result.
|
|
499
|
+
const handleDragOver = useCallback(
|
|
500
|
+
(e: ReactDragEvent, index: number) => {
|
|
501
|
+
if (dragIndex == null) return;
|
|
502
|
+
e.preventDefault();
|
|
503
|
+
e.dataTransfer.dropEffect = 'move';
|
|
504
|
+
const rect = e.currentTarget.getBoundingClientRect();
|
|
505
|
+
const below = e.clientY - rect.top > rect.height / 2;
|
|
506
|
+
setDropBoundary(index + (below ? 1 : 0));
|
|
507
|
+
},
|
|
508
|
+
[dragIndex],
|
|
509
|
+
);
|
|
510
|
+
|
|
511
|
+
const handleDrop = useCallback(
|
|
512
|
+
(e: ReactDragEvent) => {
|
|
513
|
+
e.preventDefault();
|
|
514
|
+
const boundary = dropBoundary;
|
|
515
|
+
const from = dragIndex ?? Number(e.dataTransfer.getData('text/plain'));
|
|
516
|
+
resetDrag();
|
|
517
|
+
if (!onReorder || boundary == null || !Number.isInteger(from)) return;
|
|
518
|
+
// Removing the dragged row shifts every boundary after it down one.
|
|
519
|
+
const target = reorderTargetIndex(boundary, from);
|
|
520
|
+
if (target === from) return;
|
|
521
|
+
onReorder(reorderWalkthroughs(walkthroughs, from, target));
|
|
522
|
+
},
|
|
523
|
+
[dragIndex, dropBoundary, onReorder, resetDrag, walkthroughs],
|
|
524
|
+
);
|
|
525
|
+
|
|
526
|
+
const accent = theme.colors.accent ?? theme.colors.primary ?? theme.colors.text;
|
|
527
|
+
const isHoverSuppressed = useCallback(() => suppressHoverRef.current, []);
|
|
528
|
+
|
|
529
|
+
return (
|
|
530
|
+
<div
|
|
531
|
+
style={{
|
|
532
|
+
flex: 1,
|
|
533
|
+
minHeight: 0,
|
|
534
|
+
overflowY: 'auto',
|
|
535
|
+
padding: '0 0 12px',
|
|
536
|
+
}}
|
|
537
|
+
onDragEnd={reorderable ? resetDrag : undefined}
|
|
538
|
+
>
|
|
539
|
+
{walkthroughs.map((tl, i) => {
|
|
540
|
+
const isDragging = dragIndex === i;
|
|
541
|
+
// One indicator per boundary: a row's top edge for boundaries above it,
|
|
542
|
+
// the last row's bottom edge for the boundary at the very end.
|
|
543
|
+
const showTop = reorderable && dropBoundary === i;
|
|
544
|
+
const showBottom =
|
|
545
|
+
reorderable &&
|
|
546
|
+
i === walkthroughs.length - 1 &&
|
|
547
|
+
dropBoundary === walkthroughs.length;
|
|
548
|
+
return (
|
|
549
|
+
<div
|
|
550
|
+
key={tl.id}
|
|
551
|
+
data-walkthrough-row={tl.id}
|
|
552
|
+
data-reorder-index={i}
|
|
553
|
+
onDragOver={reorderable ? (e) => handleDragOver(e, i) : undefined}
|
|
554
|
+
onDrop={reorderable ? handleDrop : undefined}
|
|
555
|
+
style={{
|
|
556
|
+
position: 'relative',
|
|
557
|
+
opacity: isDragging ? 0.45 : 1,
|
|
558
|
+
transition: 'opacity 120ms ease',
|
|
559
|
+
}}
|
|
560
|
+
>
|
|
561
|
+
{/* Overlays so the divider/drop line stay visible above the row's
|
|
562
|
+
own (opaque) hover background. Accent paints over the divider. */}
|
|
563
|
+
{i > 0 && (
|
|
564
|
+
<div
|
|
565
|
+
aria-hidden
|
|
566
|
+
style={{
|
|
567
|
+
position: 'absolute',
|
|
568
|
+
top: 0,
|
|
569
|
+
left: 0,
|
|
570
|
+
right: 0,
|
|
571
|
+
height: 1,
|
|
572
|
+
background: theme.colors.border,
|
|
573
|
+
pointerEvents: 'none',
|
|
574
|
+
}}
|
|
575
|
+
/>
|
|
576
|
+
)}
|
|
577
|
+
{(showTop || showBottom) && (
|
|
578
|
+
<div
|
|
579
|
+
aria-hidden
|
|
580
|
+
style={{
|
|
581
|
+
position: 'absolute',
|
|
582
|
+
left: 0,
|
|
583
|
+
right: 0,
|
|
584
|
+
height: 2,
|
|
585
|
+
zIndex: 2,
|
|
586
|
+
background: accent,
|
|
587
|
+
pointerEvents: 'none',
|
|
588
|
+
...(showTop ? { top: 0 } : { bottom: 0 }),
|
|
589
|
+
}}
|
|
590
|
+
/>
|
|
591
|
+
)}
|
|
592
|
+
<WalkthroughFlow
|
|
593
|
+
walkthrough={tl}
|
|
594
|
+
collapsed={!expandedWalkthroughs.has(tl.id)}
|
|
595
|
+
active={
|
|
596
|
+
focusedWalkthroughId === tl.id
|
|
597
|
+
? { stepIndex: focusedStepIndex }
|
|
598
|
+
: hoveredWalkthroughStep?.walkthroughId === tl.id
|
|
599
|
+
? { stepIndex: hoveredWalkthroughStep.stepIndex }
|
|
600
|
+
: null
|
|
601
|
+
}
|
|
602
|
+
onToggleCollapsed={onToggleCollapsed}
|
|
603
|
+
onFocusFlow={onFocusFlow}
|
|
604
|
+
onClearFocus={onClearFocus}
|
|
605
|
+
onFocusStep={onFocusStep}
|
|
606
|
+
onHoverStep={onHoverStep}
|
|
607
|
+
onHoverFlow={onHoverFlow}
|
|
608
|
+
onLeaveStep={onLeaveStep}
|
|
609
|
+
reorder={
|
|
610
|
+
reorderable
|
|
611
|
+
? {
|
|
612
|
+
index: i,
|
|
613
|
+
isDragging,
|
|
614
|
+
onDragStart: handleDragStart,
|
|
615
|
+
onDragEnd: resetDrag,
|
|
616
|
+
}
|
|
617
|
+
: undefined
|
|
618
|
+
}
|
|
619
|
+
dragActive={dragIndex != null}
|
|
620
|
+
isHoverSuppressed={isHoverSuppressed}
|
|
621
|
+
proposedAliases={proposedAliases}
|
|
622
|
+
/>
|
|
623
|
+
</div>
|
|
624
|
+
);
|
|
625
|
+
})}
|
|
626
|
+
</div>
|
|
627
|
+
);
|
|
628
|
+
}
|