@principal-ai/subsystems-react 0.28.0 → 0.28.2
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 +4 -209
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/WalkthroughsPanel.d.ts +47 -0
- package/dist/subsystem/WalkthroughsPanel.d.ts.map +1 -0
- package/dist/subsystem/WalkthroughsPanel.js +345 -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/Reorder.stories.tsx +204 -0
- package/src/subsystem/SubsystemComponentGraph.tsx +29 -332
- package/src/subsystem/WalkthroughsPanel.tsx +569 -0
- package/src/subsystem/model.test.ts +76 -1
- package/src/subsystem/model.ts +31 -0
|
@@ -0,0 +1,569 @@
|
|
|
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 { GripVertical, Pause, Play, X } from 'lucide-react';
|
|
17
|
+
import {
|
|
18
|
+
reorderTargetIndex,
|
|
19
|
+
reorderWalkthroughs,
|
|
20
|
+
walkthroughStepGraphEdgeId,
|
|
21
|
+
type SubsystemWalkthrough,
|
|
22
|
+
} from './model';
|
|
23
|
+
|
|
24
|
+
/** Pause (ms) between steps when a walkthrough autoplays. */
|
|
25
|
+
export const WALKTHROUGH_PLAY_PAUSE_MS = 2500;
|
|
26
|
+
|
|
27
|
+
/** Drag state handed to a row's grip while a reorder is in flight. */
|
|
28
|
+
interface WalkthroughReorderHandle {
|
|
29
|
+
index: number;
|
|
30
|
+
isDragging: boolean;
|
|
31
|
+
onDragStart: (index: number, e: ReactDragEvent) => void;
|
|
32
|
+
onDragEnd: () => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** One collapsible walkthrough in the sidebar's flows panel. Clicking the
|
|
36
|
+
* title: closed → open + select; open and unselected → select; open and
|
|
37
|
+
* selected → close + clear focus. The right-aligned close button collapses
|
|
38
|
+
* without selecting. A step row focuses that step's edge. */
|
|
39
|
+
function WalkthroughFlow({
|
|
40
|
+
walkthrough,
|
|
41
|
+
collapsed,
|
|
42
|
+
active,
|
|
43
|
+
onToggleCollapsed,
|
|
44
|
+
onFocusFlow,
|
|
45
|
+
onClearFocus,
|
|
46
|
+
onFocusStep,
|
|
47
|
+
onHoverStep,
|
|
48
|
+
onHoverFlow,
|
|
49
|
+
onLeaveStep,
|
|
50
|
+
reorder,
|
|
51
|
+
dragActive,
|
|
52
|
+
isHoverSuppressed,
|
|
53
|
+
}: {
|
|
54
|
+
walkthrough: SubsystemWalkthrough;
|
|
55
|
+
collapsed: boolean;
|
|
56
|
+
/** `{ stepIndex: null }` = whole flow focused; `{ stepIndex }` = one step. */
|
|
57
|
+
active: { stepIndex: number | null } | null;
|
|
58
|
+
onToggleCollapsed: (tlId: string) => void;
|
|
59
|
+
onFocusFlow: (tl: SubsystemWalkthrough) => void;
|
|
60
|
+
onClearFocus: () => void;
|
|
61
|
+
onFocusStep: (tl: SubsystemWalkthrough, stepIndex: number) => void;
|
|
62
|
+
onHoverStep: (tl: SubsystemWalkthrough, stepIndex: number) => void;
|
|
63
|
+
/** Preview the whole flow on the canvas (used while the row is collapsed). */
|
|
64
|
+
onHoverFlow: (tl: SubsystemWalkthrough) => void;
|
|
65
|
+
onLeaveStep: () => void;
|
|
66
|
+
/** When set, renders a grip in the header that starts a reorder drag. */
|
|
67
|
+
reorder?: WalkthroughReorderHandle;
|
|
68
|
+
/** True while any row is mid-drag; used to reset stale hover styling. */
|
|
69
|
+
dragActive?: boolean;
|
|
70
|
+
/** True just after a drop; ignores the browser's synthetic hover until the pointer moves. */
|
|
71
|
+
isHoverSuppressed?: () => boolean;
|
|
72
|
+
}) {
|
|
73
|
+
const { theme } = useTheme();
|
|
74
|
+
const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
|
|
75
|
+
const hoverBg = theme.colors.background;
|
|
76
|
+
const wholeFlowActive = active !== null && active.stepIndex === null;
|
|
77
|
+
const [headerHover, setHeaderHover] = useState(false);
|
|
78
|
+
const [closeHover, setCloseHover] = useState(false);
|
|
79
|
+
const [playHover, setPlayHover] = useState(false);
|
|
80
|
+
const [gripHover, setGripHover] = useState(false);
|
|
81
|
+
const [hoveredStep, setHoveredStep] = useState<number | null>(null);
|
|
82
|
+
const stepButtonRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
|
83
|
+
// Autoplay: stepping through the flow's steps with a pause between each.
|
|
84
|
+
const [playing, setPlaying] = useState(false);
|
|
85
|
+
const playTimerRef = useRef<number | null>(null);
|
|
86
|
+
|
|
87
|
+
const stopPlaying = useCallback(() => {
|
|
88
|
+
if (playTimerRef.current != null) {
|
|
89
|
+
window.clearTimeout(playTimerRef.current);
|
|
90
|
+
playTimerRef.current = null;
|
|
91
|
+
}
|
|
92
|
+
setPlaying(false);
|
|
93
|
+
}, []);
|
|
94
|
+
|
|
95
|
+
// Clear any pending timer on unmount.
|
|
96
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
97
|
+
useEffect(() => () => stopPlaying(), []);
|
|
98
|
+
|
|
99
|
+
// Native HTML5 drag suppresses mouse events, so a row hovered when the drag
|
|
100
|
+
// began never gets its mouseleave — its hover styling would stick after the
|
|
101
|
+
// drop. Reset every row's hover whenever a drag starts or ends.
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
setHeaderHover(false);
|
|
104
|
+
setCloseHover(false);
|
|
105
|
+
setPlayHover(false);
|
|
106
|
+
setGripHover(false);
|
|
107
|
+
setHoveredStep(null);
|
|
108
|
+
}, [dragActive]);
|
|
109
|
+
|
|
110
|
+
const startPlaying = useCallback(() => {
|
|
111
|
+
if (collapsed) onToggleCollapsed(walkthrough.id);
|
|
112
|
+
if (active === null || active.stepIndex !== null) onFocusFlow(walkthrough);
|
|
113
|
+
const stepCount = walkthrough.steps.length;
|
|
114
|
+
if (stepCount === 0) return;
|
|
115
|
+
setPlaying(true);
|
|
116
|
+
let i = 0;
|
|
117
|
+
const tick = () => {
|
|
118
|
+
if (i >= stepCount) {
|
|
119
|
+
playTimerRef.current = null;
|
|
120
|
+
setPlaying(false);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
onFocusStep(walkthrough, i);
|
|
124
|
+
i += 1;
|
|
125
|
+
playTimerRef.current = window.setTimeout(tick, WALKTHROUGH_PLAY_PAUSE_MS);
|
|
126
|
+
};
|
|
127
|
+
tick();
|
|
128
|
+
}, [collapsed, active, onToggleCollapsed, onFocusFlow, walkthrough, onFocusStep]);
|
|
129
|
+
|
|
130
|
+
const togglePlay = useCallback(() => {
|
|
131
|
+
if (playing) {
|
|
132
|
+
stopPlaying();
|
|
133
|
+
} else {
|
|
134
|
+
startPlaying();
|
|
135
|
+
}
|
|
136
|
+
}, [playing, stopPlaying, startPlaying]);
|
|
137
|
+
|
|
138
|
+
// Keep DOM focus on the active step so the browser focus ring (and
|
|
139
|
+
// subsequent arrow keys) follow arrow navigation, not the originally
|
|
140
|
+
// clicked button.
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
if (active?.stepIndex == null) return;
|
|
143
|
+
stepButtonRefs.current[active.stepIndex]?.focus({ preventScroll: true });
|
|
144
|
+
}, [active?.stepIndex]);
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<div>
|
|
148
|
+
<div
|
|
149
|
+
onMouseEnter={() => {
|
|
150
|
+
if (isHoverSuppressed?.()) return;
|
|
151
|
+
setHeaderHover(true);
|
|
152
|
+
if (collapsed) onHoverFlow(walkthrough);
|
|
153
|
+
}}
|
|
154
|
+
onMouseLeave={() => {
|
|
155
|
+
setHeaderHover(false);
|
|
156
|
+
if (collapsed) onLeaveStep();
|
|
157
|
+
}}
|
|
158
|
+
style={{
|
|
159
|
+
display: 'flex',
|
|
160
|
+
alignItems: 'center',
|
|
161
|
+
gap: 4,
|
|
162
|
+
padding: '0 16px',
|
|
163
|
+
background: wholeFlowActive || headerHover ? hoverBg : 'transparent',
|
|
164
|
+
transition: 'background 120ms ease',
|
|
165
|
+
}}
|
|
166
|
+
>
|
|
167
|
+
<button
|
|
168
|
+
type="button"
|
|
169
|
+
onClick={() => {
|
|
170
|
+
if (collapsed) {
|
|
171
|
+
onToggleCollapsed(walkthrough.id);
|
|
172
|
+
onFocusFlow(walkthrough);
|
|
173
|
+
} else if (active === null) {
|
|
174
|
+
onFocusFlow(walkthrough);
|
|
175
|
+
} else {
|
|
176
|
+
onToggleCollapsed(walkthrough.id);
|
|
177
|
+
onClearFocus();
|
|
178
|
+
}
|
|
179
|
+
}}
|
|
180
|
+
style={{
|
|
181
|
+
flex: 1,
|
|
182
|
+
display: 'flex',
|
|
183
|
+
alignItems: 'center',
|
|
184
|
+
minWidth: 0,
|
|
185
|
+
padding: '10px 0',
|
|
186
|
+
border: 'none',
|
|
187
|
+
background: 'transparent',
|
|
188
|
+
textAlign: 'left',
|
|
189
|
+
cursor: 'pointer',
|
|
190
|
+
}}
|
|
191
|
+
>
|
|
192
|
+
<span
|
|
193
|
+
style={{
|
|
194
|
+
overflow: 'hidden',
|
|
195
|
+
textOverflow: 'ellipsis',
|
|
196
|
+
whiteSpace: 'nowrap',
|
|
197
|
+
fontSize: theme.fontSizes[1],
|
|
198
|
+
fontFamily: theme.fonts.monospace,
|
|
199
|
+
fontWeight: 600,
|
|
200
|
+
color: theme.colors.text,
|
|
201
|
+
}}
|
|
202
|
+
>
|
|
203
|
+
{walkthrough.title}
|
|
204
|
+
</span>
|
|
205
|
+
</button>
|
|
206
|
+
{!collapsed && (
|
|
207
|
+
<>
|
|
208
|
+
<button
|
|
209
|
+
type="button"
|
|
210
|
+
aria-label={playing ? `Pause ${walkthrough.title} autoplay` : `Play ${walkthrough.title}`}
|
|
211
|
+
title={playing ? 'Pause' : 'Play through steps'}
|
|
212
|
+
onMouseEnter={() => setPlayHover(true)}
|
|
213
|
+
onMouseLeave={() => setPlayHover(false)}
|
|
214
|
+
onClick={(e) => {
|
|
215
|
+
e.stopPropagation();
|
|
216
|
+
togglePlay();
|
|
217
|
+
}}
|
|
218
|
+
style={{
|
|
219
|
+
display: 'inline-flex',
|
|
220
|
+
alignItems: 'center',
|
|
221
|
+
justifyContent: 'center',
|
|
222
|
+
flexShrink: 0,
|
|
223
|
+
width: 22,
|
|
224
|
+
height: 22,
|
|
225
|
+
padding: 0,
|
|
226
|
+
border: 'none',
|
|
227
|
+
borderRadius: 4,
|
|
228
|
+
background: playing || playHover ? theme.colors.border : 'transparent',
|
|
229
|
+
color: playing || playHover ? theme.colors.text : muted,
|
|
230
|
+
cursor: 'pointer',
|
|
231
|
+
transition: 'background 120ms ease, color 120ms ease',
|
|
232
|
+
}}
|
|
233
|
+
>
|
|
234
|
+
{playing ? <Pause size={12} strokeWidth={2} /> : <Play size={12} strokeWidth={2} />}
|
|
235
|
+
</button>
|
|
236
|
+
<button
|
|
237
|
+
type="button"
|
|
238
|
+
aria-label={`Close ${walkthrough.title}`}
|
|
239
|
+
onMouseEnter={() => setCloseHover(true)}
|
|
240
|
+
onMouseLeave={() => setCloseHover(false)}
|
|
241
|
+
onClick={(e) => {
|
|
242
|
+
e.stopPropagation();
|
|
243
|
+
onToggleCollapsed(walkthrough.id);
|
|
244
|
+
if (active !== null) onClearFocus();
|
|
245
|
+
}}
|
|
246
|
+
style={{
|
|
247
|
+
display: 'inline-flex',
|
|
248
|
+
alignItems: 'center',
|
|
249
|
+
justifyContent: 'center',
|
|
250
|
+
flexShrink: 0,
|
|
251
|
+
width: 22,
|
|
252
|
+
height: 22,
|
|
253
|
+
padding: 0,
|
|
254
|
+
border: 'none',
|
|
255
|
+
borderRadius: 4,
|
|
256
|
+
background: closeHover ? theme.colors.border : 'transparent',
|
|
257
|
+
color: closeHover ? theme.colors.text : muted,
|
|
258
|
+
cursor: 'pointer',
|
|
259
|
+
transition: 'background 120ms ease, color 120ms ease',
|
|
260
|
+
}}
|
|
261
|
+
>
|
|
262
|
+
<X size={12} strokeWidth={2} />
|
|
263
|
+
</button>
|
|
264
|
+
</>
|
|
265
|
+
)}
|
|
266
|
+
{reorder && (
|
|
267
|
+
<span
|
|
268
|
+
role="button"
|
|
269
|
+
tabIndex={-1}
|
|
270
|
+
draggable
|
|
271
|
+
aria-label={`Reorder ${walkthrough.title}`}
|
|
272
|
+
title="Drag to reorder"
|
|
273
|
+
onMouseEnter={() => {
|
|
274
|
+
if (!isHoverSuppressed?.()) setGripHover(true);
|
|
275
|
+
}}
|
|
276
|
+
onMouseLeave={() => setGripHover(false)}
|
|
277
|
+
onDragStart={(e) => reorder.onDragStart(reorder.index, e)}
|
|
278
|
+
onDragEnd={reorder.onDragEnd}
|
|
279
|
+
onClick={(e) => e.stopPropagation()}
|
|
280
|
+
style={{
|
|
281
|
+
display: 'inline-flex',
|
|
282
|
+
alignItems: 'center',
|
|
283
|
+
flexShrink: 0,
|
|
284
|
+
marginRight: -8,
|
|
285
|
+
padding: '2px 0',
|
|
286
|
+
color: gripHover || reorder.isDragging ? theme.colors.text : muted,
|
|
287
|
+
cursor: reorder.isDragging ? 'grabbing' : 'grab',
|
|
288
|
+
transition: 'color 120ms ease',
|
|
289
|
+
}}
|
|
290
|
+
>
|
|
291
|
+
<GripVertical size={14} strokeWidth={2} />
|
|
292
|
+
</span>
|
|
293
|
+
)}
|
|
294
|
+
</div>
|
|
295
|
+
{!collapsed && (
|
|
296
|
+
<div
|
|
297
|
+
style={{ display: 'flex', flexDirection: 'column' }}
|
|
298
|
+
onMouseLeave={() => {
|
|
299
|
+
setHoveredStep(null);
|
|
300
|
+
onLeaveStep();
|
|
301
|
+
}}
|
|
302
|
+
>
|
|
303
|
+
{walkthrough.steps.map((step, i) => {
|
|
304
|
+
const stepActive = active !== null && active.stepIndex === i;
|
|
305
|
+
return (
|
|
306
|
+
<button
|
|
307
|
+
key={`${walkthroughStepGraphEdgeId(step)}-${i}`}
|
|
308
|
+
ref={(el) => {
|
|
309
|
+
stepButtonRefs.current[i] = el;
|
|
310
|
+
}}
|
|
311
|
+
type="button"
|
|
312
|
+
onMouseEnter={() => {
|
|
313
|
+
setHoveredStep(i);
|
|
314
|
+
onHoverStep(walkthrough, i);
|
|
315
|
+
}}
|
|
316
|
+
onClick={() => onFocusStep(walkthrough, i)}
|
|
317
|
+
style={{
|
|
318
|
+
display: 'flex',
|
|
319
|
+
alignItems: 'center',
|
|
320
|
+
gap: 8,
|
|
321
|
+
minWidth: 0,
|
|
322
|
+
padding: '8px 8px 8px 12px',
|
|
323
|
+
textAlign: 'left',
|
|
324
|
+
borderRadius: 6,
|
|
325
|
+
border: 'none',
|
|
326
|
+
outline: 'none',
|
|
327
|
+
background: stepActive || hoveredStep === i ? hoverBg : 'transparent',
|
|
328
|
+
cursor: 'pointer',
|
|
329
|
+
transition: 'background 120ms ease',
|
|
330
|
+
}}
|
|
331
|
+
>
|
|
332
|
+
<span
|
|
333
|
+
style={{
|
|
334
|
+
flexShrink: 0,
|
|
335
|
+
width: 14,
|
|
336
|
+
fontSize: theme.fontSizes[0],
|
|
337
|
+
fontFamily: theme.fonts.monospace,
|
|
338
|
+
color: stepActive ? theme.colors.text : muted,
|
|
339
|
+
}}
|
|
340
|
+
>
|
|
341
|
+
{i + 1}
|
|
342
|
+
</span>
|
|
343
|
+
<span
|
|
344
|
+
style={{
|
|
345
|
+
flex: 1,
|
|
346
|
+
minWidth: 0,
|
|
347
|
+
overflow: 'hidden',
|
|
348
|
+
textOverflow: 'ellipsis',
|
|
349
|
+
whiteSpace: 'nowrap',
|
|
350
|
+
fontSize: theme.fontSizes[1],
|
|
351
|
+
fontFamily: theme.fonts.monospace,
|
|
352
|
+
color: theme.colors.text,
|
|
353
|
+
}}
|
|
354
|
+
>
|
|
355
|
+
{step.symbol}
|
|
356
|
+
</span>
|
|
357
|
+
</button>
|
|
358
|
+
);
|
|
359
|
+
})}
|
|
360
|
+
</div>
|
|
361
|
+
)}
|
|
362
|
+
</div>
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export interface WalkthroughsPanelProps {
|
|
367
|
+
/** Ordered walkthroughs — the list renders them in array order. */
|
|
368
|
+
walkthroughs: SubsystemWalkthrough[];
|
|
369
|
+
/** Ids of the rows whose step lists are expanded. */
|
|
370
|
+
expandedWalkthroughs: Set<string>;
|
|
371
|
+
focusedWalkthroughId: string | null;
|
|
372
|
+
focusedStepIndex: number | null;
|
|
373
|
+
hoveredWalkthroughStep: {
|
|
374
|
+
walkthroughId: string;
|
|
375
|
+
stepIndex: number | null;
|
|
376
|
+
} | null;
|
|
377
|
+
onToggleCollapsed: (tlId: string) => void;
|
|
378
|
+
onFocusFlow: (tl: SubsystemWalkthrough) => void;
|
|
379
|
+
onClearFocus: () => void;
|
|
380
|
+
onFocusStep: (tl: SubsystemWalkthrough, stepIndex: number) => void;
|
|
381
|
+
onHoverStep: (tl: SubsystemWalkthrough, stepIndex: number) => void;
|
|
382
|
+
onHoverFlow: (tl: SubsystemWalkthrough) => void;
|
|
383
|
+
onLeaveStep: () => void;
|
|
384
|
+
/**
|
|
385
|
+
* When set, rows grow a drag grip (and become drop targets) so a drop emits
|
|
386
|
+
* the reordered array. Grips show only while every row is collapsed. Omit
|
|
387
|
+
* for a read-only panel.
|
|
388
|
+
*/
|
|
389
|
+
onReorder?: (next: SubsystemWalkthrough[]) => void;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* The flows panel: a scroll container of `WalkthroughFlow` rows, optionally
|
|
394
|
+
* drag-reorderable. The host owns the array; a drop calls `onReorder` with the
|
|
395
|
+
* next order and the host re-renders with it.
|
|
396
|
+
*/
|
|
397
|
+
export function WalkthroughsPanel({
|
|
398
|
+
walkthroughs,
|
|
399
|
+
expandedWalkthroughs,
|
|
400
|
+
focusedWalkthroughId,
|
|
401
|
+
focusedStepIndex,
|
|
402
|
+
hoveredWalkthroughStep,
|
|
403
|
+
onToggleCollapsed,
|
|
404
|
+
onFocusFlow,
|
|
405
|
+
onClearFocus,
|
|
406
|
+
onFocusStep,
|
|
407
|
+
onHoverStep,
|
|
408
|
+
onHoverFlow,
|
|
409
|
+
onLeaveStep,
|
|
410
|
+
onReorder,
|
|
411
|
+
}: WalkthroughsPanelProps) {
|
|
412
|
+
const { theme } = useTheme();
|
|
413
|
+
// Reordering is offered only while every row is collapsed: the compact list
|
|
414
|
+
// is what you drag, and expanded step lists would make drop targets tall and
|
|
415
|
+
// the landing boundary ambiguous.
|
|
416
|
+
const allCollapsed = walkthroughs.every((w) => !expandedWalkthroughs.has(w.id));
|
|
417
|
+
const reorderable = onReorder != null && allCollapsed;
|
|
418
|
+
// Row being dragged, and the gap the pointer is over (boundaries 0..n
|
|
419
|
+
// between rows). The boundary is where the dragged row will be inserted.
|
|
420
|
+
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
|
421
|
+
const [dropBoundary, setDropBoundary] = useState<number | null>(null);
|
|
422
|
+
// After a native drag ends the browser fires a mouseenter on whatever sits
|
|
423
|
+
// under the cursor, which would light up a row the user never moved onto.
|
|
424
|
+
// Ignore hover until the pointer actually moves. A ref (not state) so the
|
|
425
|
+
// guard is synchronous — the synthetic event can beat a re-render.
|
|
426
|
+
const suppressHoverRef = useRef(false);
|
|
427
|
+
const hoverClearRef = useRef<(() => void) | null>(null);
|
|
428
|
+
|
|
429
|
+
const suppressHoverUntilMove = useCallback(() => {
|
|
430
|
+
suppressHoverRef.current = true;
|
|
431
|
+
if (hoverClearRef.current) return;
|
|
432
|
+
const clear = () => {
|
|
433
|
+
suppressHoverRef.current = false;
|
|
434
|
+
window.removeEventListener('mousemove', clear);
|
|
435
|
+
hoverClearRef.current = null;
|
|
436
|
+
};
|
|
437
|
+
hoverClearRef.current = clear;
|
|
438
|
+
window.addEventListener('mousemove', clear);
|
|
439
|
+
}, []);
|
|
440
|
+
|
|
441
|
+
useEffect(
|
|
442
|
+
() => () => {
|
|
443
|
+
if (hoverClearRef.current) {
|
|
444
|
+
window.removeEventListener('mousemove', hoverClearRef.current);
|
|
445
|
+
hoverClearRef.current = null;
|
|
446
|
+
}
|
|
447
|
+
},
|
|
448
|
+
[],
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
const resetDrag = useCallback(() => {
|
|
452
|
+
setDragIndex(null);
|
|
453
|
+
setDropBoundary(null);
|
|
454
|
+
suppressHoverUntilMove();
|
|
455
|
+
}, [suppressHoverUntilMove]);
|
|
456
|
+
|
|
457
|
+
const handleDragStart = useCallback((index: number, e: ReactDragEvent) => {
|
|
458
|
+
suppressHoverRef.current = false;
|
|
459
|
+
setDragIndex(index);
|
|
460
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
461
|
+
// Firefox requires data to be set for a drag to begin.
|
|
462
|
+
e.dataTransfer.setData('text/plain', String(index));
|
|
463
|
+
}, []);
|
|
464
|
+
|
|
465
|
+
// Pick the boundary nearest the pointer: above the row when over its top
|
|
466
|
+
// half, below it when over the bottom half. The line marks that gap, and the
|
|
467
|
+
// drop inserts there — so the indicator always matches the result.
|
|
468
|
+
const handleDragOver = useCallback(
|
|
469
|
+
(e: ReactDragEvent, index: number) => {
|
|
470
|
+
if (dragIndex == null) return;
|
|
471
|
+
e.preventDefault();
|
|
472
|
+
e.dataTransfer.dropEffect = 'move';
|
|
473
|
+
const rect = e.currentTarget.getBoundingClientRect();
|
|
474
|
+
const below = e.clientY - rect.top > rect.height / 2;
|
|
475
|
+
setDropBoundary(index + (below ? 1 : 0));
|
|
476
|
+
},
|
|
477
|
+
[dragIndex],
|
|
478
|
+
);
|
|
479
|
+
|
|
480
|
+
const handleDrop = useCallback(
|
|
481
|
+
(e: ReactDragEvent) => {
|
|
482
|
+
e.preventDefault();
|
|
483
|
+
const boundary = dropBoundary;
|
|
484
|
+
const from = dragIndex ?? Number(e.dataTransfer.getData('text/plain'));
|
|
485
|
+
resetDrag();
|
|
486
|
+
if (!onReorder || boundary == null || !Number.isInteger(from)) return;
|
|
487
|
+
// Removing the dragged row shifts every boundary after it down one.
|
|
488
|
+
const target = reorderTargetIndex(boundary, from);
|
|
489
|
+
if (target === from) return;
|
|
490
|
+
onReorder(reorderWalkthroughs(walkthroughs, from, target));
|
|
491
|
+
},
|
|
492
|
+
[dragIndex, dropBoundary, onReorder, resetDrag, walkthroughs],
|
|
493
|
+
);
|
|
494
|
+
|
|
495
|
+
const accent = theme.colors.accent ?? theme.colors.primary ?? theme.colors.text;
|
|
496
|
+
const isHoverSuppressed = useCallback(() => suppressHoverRef.current, []);
|
|
497
|
+
|
|
498
|
+
return (
|
|
499
|
+
<div
|
|
500
|
+
style={{
|
|
501
|
+
flex: 1,
|
|
502
|
+
minHeight: 0,
|
|
503
|
+
overflowY: 'auto',
|
|
504
|
+
padding: '0 0 12px',
|
|
505
|
+
}}
|
|
506
|
+
onDragEnd={reorderable ? resetDrag : undefined}
|
|
507
|
+
>
|
|
508
|
+
{walkthroughs.map((tl, i) => {
|
|
509
|
+
const isDragging = dragIndex === i;
|
|
510
|
+
// One indicator per boundary: a row's top edge for boundaries above it,
|
|
511
|
+
// the last row's bottom edge for the boundary at the very end.
|
|
512
|
+
const showTop = reorderable && dropBoundary === i;
|
|
513
|
+
const showBottom =
|
|
514
|
+
reorderable &&
|
|
515
|
+
i === walkthroughs.length - 1 &&
|
|
516
|
+
dropBoundary === walkthroughs.length;
|
|
517
|
+
return (
|
|
518
|
+
<div
|
|
519
|
+
key={tl.id}
|
|
520
|
+
data-walkthrough-row={tl.id}
|
|
521
|
+
data-reorder-index={i}
|
|
522
|
+
onDragOver={reorderable ? (e) => handleDragOver(e, i) : undefined}
|
|
523
|
+
onDrop={reorderable ? handleDrop : undefined}
|
|
524
|
+
style={{
|
|
525
|
+
opacity: isDragging ? 0.45 : 1,
|
|
526
|
+
boxShadow: showTop
|
|
527
|
+
? `inset 0 2px 0 ${accent}`
|
|
528
|
+
: showBottom
|
|
529
|
+
? `inset 0 -2px 0 ${accent}`
|
|
530
|
+
: undefined,
|
|
531
|
+
transition: 'opacity 120ms ease, box-shadow 120ms ease',
|
|
532
|
+
}}
|
|
533
|
+
>
|
|
534
|
+
<WalkthroughFlow
|
|
535
|
+
walkthrough={tl}
|
|
536
|
+
collapsed={!expandedWalkthroughs.has(tl.id)}
|
|
537
|
+
active={
|
|
538
|
+
focusedWalkthroughId === tl.id
|
|
539
|
+
? { stepIndex: focusedStepIndex }
|
|
540
|
+
: hoveredWalkthroughStep?.walkthroughId === tl.id
|
|
541
|
+
? { stepIndex: hoveredWalkthroughStep.stepIndex }
|
|
542
|
+
: null
|
|
543
|
+
}
|
|
544
|
+
onToggleCollapsed={onToggleCollapsed}
|
|
545
|
+
onFocusFlow={onFocusFlow}
|
|
546
|
+
onClearFocus={onClearFocus}
|
|
547
|
+
onFocusStep={onFocusStep}
|
|
548
|
+
onHoverStep={onHoverStep}
|
|
549
|
+
onHoverFlow={onHoverFlow}
|
|
550
|
+
onLeaveStep={onLeaveStep}
|
|
551
|
+
reorder={
|
|
552
|
+
reorderable
|
|
553
|
+
? {
|
|
554
|
+
index: i,
|
|
555
|
+
isDragging,
|
|
556
|
+
onDragStart: handleDragStart,
|
|
557
|
+
onDragEnd: resetDrag,
|
|
558
|
+
}
|
|
559
|
+
: undefined
|
|
560
|
+
}
|
|
561
|
+
dragActive={dragIndex != null}
|
|
562
|
+
isHoverSuppressed={isHoverSuppressed}
|
|
563
|
+
/>
|
|
564
|
+
</div>
|
|
565
|
+
);
|
|
566
|
+
})}
|
|
567
|
+
</div>
|
|
568
|
+
);
|
|
569
|
+
}
|
|
@@ -29,8 +29,10 @@ import {
|
|
|
29
29
|
packageColor,
|
|
30
30
|
subsystemGraphLayoutKey,
|
|
31
31
|
describeConstructBreakdown,
|
|
32
|
+
reorderWalkthroughs,
|
|
33
|
+
reorderTargetIndex,
|
|
32
34
|
} from './model';
|
|
33
|
-
import type { SubsystemComponent, SubsystemComponentEdge } from './model';
|
|
35
|
+
import type { SubsystemComponent, SubsystemComponentEdge, SubsystemWalkthrough } from './model';
|
|
34
36
|
|
|
35
37
|
const comps: SubsystemComponent[] = [
|
|
36
38
|
{ alias: 'reader', name: 'SessionReader', construct: 'class', file: 'SessionReader.ts', purl: 'pkg:github/principal-ai/agent-monitoring' },
|
|
@@ -793,3 +795,76 @@ describe('module badge labels', () => {
|
|
|
793
795
|
expect(moduleBadgeWidth('src/…/nodes.tsx')).toBeLessThan(moduleBadgeWidth('packages/…/nodes.tsx'));
|
|
794
796
|
});
|
|
795
797
|
});
|
|
798
|
+
|
|
799
|
+
describe('reorderWalkthroughs', () => {
|
|
800
|
+
const wts: SubsystemWalkthrough[] = [
|
|
801
|
+
{ id: 'a', title: 'A', steps: [] },
|
|
802
|
+
{ id: 'b', title: 'B', steps: [] },
|
|
803
|
+
{ id: 'c', title: 'C', steps: [] },
|
|
804
|
+
];
|
|
805
|
+
const ids = (list: SubsystemWalkthrough[]) => list.map((w) => w.id);
|
|
806
|
+
|
|
807
|
+
test('moves an item forward to the target index', () => {
|
|
808
|
+
expect(ids(reorderWalkthroughs(wts, 0, 2))).toEqual(['b', 'c', 'a']);
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
test('moves an item backward to the target index', () => {
|
|
812
|
+
expect(ids(reorderWalkthroughs(wts, 2, 0))).toEqual(['c', 'a', 'b']);
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
test('returns a new array and leaves the input untouched', () => {
|
|
816
|
+
const next = reorderWalkthroughs(wts, 1, 2);
|
|
817
|
+
expect(next).not.toBe(wts);
|
|
818
|
+
expect(ids(wts)).toEqual(['a', 'b', 'c']);
|
|
819
|
+
expect(ids(next)).toEqual(['a', 'c', 'b']);
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
test('no-op when source and target match', () => {
|
|
823
|
+
expect(ids(reorderWalkthroughs(wts, 1, 1))).toEqual(['a', 'b', 'c']);
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
test('out-of-range source returns a shallow copy unchanged', () => {
|
|
827
|
+
const next = reorderWalkthroughs(wts, 5, 0);
|
|
828
|
+
expect(next).not.toBe(wts);
|
|
829
|
+
expect(ids(next)).toEqual(['a', 'b', 'c']);
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
test('clamps a target beyond the end to the last slot', () => {
|
|
833
|
+
expect(ids(reorderWalkthroughs(wts, 0, 99))).toEqual(['b', 'c', 'a']);
|
|
834
|
+
});
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
describe('reorderTargetIndex', () => {
|
|
838
|
+
// Boundaries are the n+1 gaps between rows: 0 is above row 0, n is below
|
|
839
|
+
// the last row. Dragging row `from` to boundary `b` lands it at b, minus one
|
|
840
|
+
// when b is past from (removing the row shifts later gaps down).
|
|
841
|
+
test('dragging down past a row lands after it', () => {
|
|
842
|
+
expect(reorderTargetIndex(2, 0)).toBe(1);
|
|
843
|
+
expect(reorderTargetIndex(3, 0)).toBe(2);
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
test('dragging up lands at the boundary', () => {
|
|
847
|
+
expect(reorderTargetIndex(0, 2)).toBe(0);
|
|
848
|
+
expect(reorderTargetIndex(1, 2)).toBe(1);
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
test('the boundaries around the dragged row are no-ops', () => {
|
|
852
|
+
expect(reorderTargetIndex(1, 1)).toBe(1);
|
|
853
|
+
expect(reorderTargetIndex(2, 1)).toBe(1);
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
test('dropping the first row above itself is a no-op', () => {
|
|
857
|
+
expect(reorderTargetIndex(0, 0)).toBe(0);
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
test('composes with reorderWalkthroughs to match the indicator', () => {
|
|
861
|
+
const wts: SubsystemWalkthrough[] = [
|
|
862
|
+
{ id: 'a', title: 'A', steps: [] },
|
|
863
|
+
{ id: 'b', title: 'B', steps: [] },
|
|
864
|
+
{ id: 'c', title: 'C', steps: [] },
|
|
865
|
+
];
|
|
866
|
+
// Drag A onto the gap below B (boundary 2) → A lands between B and C.
|
|
867
|
+
const target = reorderTargetIndex(2, 0);
|
|
868
|
+
expect(reorderWalkthroughs(wts, 0, target).map((w) => w.id)).toEqual(['b', 'a', 'c']);
|
|
869
|
+
});
|
|
870
|
+
});
|
package/src/subsystem/model.ts
CHANGED
|
@@ -349,6 +349,37 @@ export function walkthroughStepGraphEdgeId(
|
|
|
349
349
|
return derivedGraphEdgeId(step.from, step.to, step.mechanism);
|
|
350
350
|
}
|
|
351
351
|
|
|
352
|
+
/**
|
|
353
|
+
* Move one walkthrough from `from` to `to`, returning a new array. Used by the
|
|
354
|
+
* flows panel's drag-to-reorder: the array order is the walkthroughs' display
|
|
355
|
+
* order, so a reorder is just an array splice. Out-of-range `from` returns a
|
|
356
|
+
* shallow copy unchanged; `to` is clamped into range.
|
|
357
|
+
*/
|
|
358
|
+
export function reorderWalkthroughs(
|
|
359
|
+
walkthroughs: readonly SubsystemWalkthrough[],
|
|
360
|
+
from: number,
|
|
361
|
+
to: number,
|
|
362
|
+
): SubsystemWalkthrough[] {
|
|
363
|
+
const next = [...walkthroughs];
|
|
364
|
+
if (!Number.isInteger(from) || from < 0 || from >= next.length) return next;
|
|
365
|
+
const target = Math.max(0, Math.min(to, next.length - 1));
|
|
366
|
+
if (target === from) return next;
|
|
367
|
+
const [moved] = next.splice(from, 1);
|
|
368
|
+
if (moved) next.splice(target, 0, moved);
|
|
369
|
+
return next;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Final index for a drag-reorder given the insertion `boundary` (one of the
|
|
374
|
+
* `n + 1` gaps between rows) and the dragged row's `from` index. Removing the
|
|
375
|
+
* dragged row shifts every boundary after it down one, so a boundary past
|
|
376
|
+
* `from` maps to `boundary - 1`. Feed the result to `reorderWalkthroughs` as
|
|
377
|
+
* `to`; a result equal to `from` is a no-op.
|
|
378
|
+
*/
|
|
379
|
+
export function reorderTargetIndex(boundary: number, from: number): number {
|
|
380
|
+
return boundary - (boundary > from ? 1 : 0);
|
|
381
|
+
}
|
|
382
|
+
|
|
352
383
|
export function deriveGraphEdges(doc: {
|
|
353
384
|
relations?: readonly SubsystemRelation[];
|
|
354
385
|
walkthroughs?: readonly SubsystemWalkthrough[];
|