Skip to content

Commit 7c0fff6

Browse files
authored
[DevTools] Add Play/Pause and Skip Controls to the Timeline (facebook#34620)
Stacked on facebook#34625. This is a nice way to step through the timeline and simulate the visuals on screen as you do it. It's also convenient to step through one at a time, especially with the forwards button. However, the secondary purpose of this is that it helps anchor the UI visually as something like a timeline like in a video so that the timeline itself becomes more identifiable. https://github.com/user-attachments/assets/cb367c8e-9efb-4a00-a58e-4579be20beb8
1 parent e2d19bf commit 7c0fff6

File tree

6 files changed

+249
-31
lines changed

6 files changed

+249
-31
lines changed

packages/react-devtools-shared/src/devtools/views/ButtonIcon.js

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ export type IconType =
4242
| 'panel-bottom-close'
4343
| 'filter-on'
4444
| 'filter-off'
45+
| 'play'
46+
| 'pause'
47+
| 'skip-previous'
48+
| 'skip-next'
4549
| 'error'
4650
| 'suspend'
4751
| 'undo'
@@ -163,6 +167,22 @@ export default function ButtonIcon({className = '', type}: Props): React.Node {
163167
pathData = PATH_MATERIAL_FILTER_ALT_OFF;
164168
viewBox = panelIcons;
165169
break;
170+
case 'play':
171+
pathData = PATH_MATERIAL_PLAY_ARROW;
172+
viewBox = panelIcons;
173+
break;
174+
case 'pause':
175+
pathData = PATH_MATERIAL_PAUSE;
176+
viewBox = panelIcons;
177+
break;
178+
case 'skip-previous':
179+
pathData = PATH_MATERIAL_SKIP_PREVIOUS_ARROW;
180+
viewBox = panelIcons;
181+
break;
182+
case 'skip-next':
183+
pathData = PATH_MATERIAL_SKIP_NEXT_ARROW;
184+
viewBox = panelIcons;
185+
break;
166186
case 'suspend':
167187
pathData = PATH_SUSPEND;
168188
break;
@@ -358,3 +378,23 @@ const PATH_MATERIAL_FILTER_ALT = `
358378
const PATH_MATERIAL_FILTER_ALT_OFF = `
359379
m592-481-57-57 143-182H353l-80-80h487q25 0 36 22t-4 42L592-481ZM791-56 560-287v87q0 17-11.5 28.5T520-160h-80q-17 0-28.5-11.5T400-200v-247L56-791l56-57 736 736-57 56ZM535-538Z
360380
`;
381+
382+
// Source: Material Design Icons play_arrow
383+
const PATH_MATERIAL_PLAY_ARROW = `
384+
M320-200v-560l440 280-440 280Zm80-280Zm0 134 210-134-210-134v268Z
385+
`;
386+
387+
// Source: Material Design Icons pause
388+
const PATH_MATERIAL_PAUSE = `
389+
M520-200v-560h240v560H520Zm-320 0v-560h240v560H200Zm400-80h80v-400h-80v400Zm-320 0h80v-400h-80v400Zm0-400v400-400Zm320 0v400-400Z
390+
`;
391+
392+
// Source: Material Design Icons skip_previous
393+
const PATH_MATERIAL_SKIP_PREVIOUS_ARROW = `
394+
M220-240v-480h80v480h-80Zm520 0L380-480l360-240v480Zm-80-240Zm0 90v-180l-136 90 136 90Z
395+
`;
396+
397+
// Source: Material Design Icons skip_next
398+
const PATH_MATERIAL_SKIP_NEXT_ARROW = `
399+
M660-240v-480h80v480h-80Zm-440 0v-480l360 240-360 240Zm80-240Zm0 90 136-90-136-90v180Z
400+
`;

packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,3 +139,7 @@
139139
grid-template-columns: 1fr auto;
140140
align-items: center;
141141
}
142+
143+
.SuspenseTreeViewFooterButtons {
144+
padding: 0.25rem;
145+
}

packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -441,14 +441,14 @@ function SuspenseTab(_: {}) {
441441
<SuspenseRects />
442442
</div>
443443
<footer className={styles.SuspenseTreeViewFooter}>
444-
<div className={styles.SuspenseTimeline}>
445-
<SuspenseTimeline />
444+
<SuspenseTimeline />
445+
<div className={styles.SuspenseTreeViewFooterButtons}>
446+
<ToggleInspectedElement
447+
dispatch={dispatch}
448+
state={state}
449+
orientation="vertical"
450+
/>
446451
</div>
447-
<ToggleInspectedElement
448-
dispatch={dispatch}
449-
state={state}
450-
orientation="vertical"
451-
/>
452452
</footer>
453453
</div>
454454
</div>

packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.css

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
.SuspenseTimelineContainer {
2-
width: 100%;
32
display: flex;
43
flex-direction: row;
54
padding: 0.25rem;

packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js

Lines changed: 104 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
*/
99

1010
import * as React from 'react';
11-
import {useContext, useLayoutEffect, useRef} from 'react';
11+
import {useContext, useLayoutEffect, useEffect, useRef} from 'react';
1212
import {BridgeContext, StoreContext} from '../context';
1313
import {TreeDispatcherContext} from '../Components/TreeContext';
1414
import {useHighlightHostInstance} from '../hooks';
@@ -21,6 +21,8 @@ import typeof {
2121
SyntheticEvent,
2222
SyntheticPointerEvent,
2323
} from 'react-dom-bindings/src/events/SyntheticEvent';
24+
import Button from '../Button';
25+
import ButtonIcon from '../ButtonIcon';
2426

2527
function SuspenseTimelineInput() {
2628
const bridge = useContext(BridgeContext);
@@ -34,6 +36,7 @@ function SuspenseTimelineInput() {
3436
selectedRootID: rootID,
3537
timeline,
3638
timelineIndex,
39+
playing,
3740
} = useContext(SuspenseTreeStateContext);
3841

3942
const inputRef = useRef<HTMLElement | null>(null);
@@ -98,26 +101,7 @@ function SuspenseTimelineInput() {
98101
}
99102

100103
function handleChange(event: SyntheticEvent) {
101-
if (rootID === null) {
102-
return;
103-
}
104-
const rendererID = store.getRendererIDForElement(rootID);
105-
if (rendererID === null) {
106-
console.error(
107-
`No renderer ID found for root element ${rootID} in suspense timeline.`,
108-
);
109-
return;
110-
}
111-
112104
const pendingTimelineIndex = +event.currentTarget.value;
113-
const suspendedSet = timeline.slice(pendingTimelineIndex);
114-
115-
bridge.send('overrideSuspenseMilestone', {
116-
rendererID,
117-
rootID,
118-
suspendedSet,
119-
});
120-
121105
switchSuspenseNode(pendingTimelineIndex);
122106
}
123107

@@ -153,10 +137,108 @@ function SuspenseTimelineInput() {
153137
highlightHostInstance(suspenseID);
154138
}
155139

140+
function skipPrevious() {
141+
const nextSelectedSuspenseID = timeline[timelineIndex - 1];
142+
highlightHostInstance(nextSelectedSuspenseID);
143+
treeDispatch({
144+
type: 'SELECT_ELEMENT_BY_ID',
145+
payload: nextSelectedSuspenseID,
146+
});
147+
suspenseTreeDispatch({
148+
type: 'SUSPENSE_SKIP_TIMELINE_INDEX',
149+
payload: false,
150+
});
151+
}
152+
153+
function skipForward() {
154+
const nextSelectedSuspenseID = timeline[timelineIndex + 1];
155+
highlightHostInstance(nextSelectedSuspenseID);
156+
treeDispatch({
157+
type: 'SELECT_ELEMENT_BY_ID',
158+
payload: nextSelectedSuspenseID,
159+
});
160+
suspenseTreeDispatch({
161+
type: 'SUSPENSE_SKIP_TIMELINE_INDEX',
162+
payload: true,
163+
});
164+
}
165+
166+
function togglePlaying() {
167+
suspenseTreeDispatch({
168+
type: 'SUSPENSE_PLAY_PAUSE',
169+
payload: 'toggle',
170+
});
171+
}
172+
173+
// TODO: useEffectEvent here once it's supported in all versions DevTools supports.
174+
// For now we just exclude it from deps since we don't lint those anyway.
175+
function changeTimelineIndex(newIndex: number) {
176+
// Synchronize timeline index with what is resuspended.
177+
if (rootID === null) {
178+
return;
179+
}
180+
const rendererID = store.getRendererIDForElement(rootID);
181+
if (rendererID === null) {
182+
console.error(
183+
`No renderer ID found for root element ${rootID} in suspense timeline.`,
184+
);
185+
return;
186+
}
187+
// We suspend everything after the current selection. The root isn't showing
188+
// anything suspended in the root. The step after that should have one less
189+
// thing suspended. I.e. the first suspense boundary should be unsuspended
190+
// when it's selected. This also lets you show everything in the last step.
191+
const suspendedSet = timeline.slice(timelineIndex + 1);
192+
bridge.send('overrideSuspenseMilestone', {
193+
rendererID,
194+
rootID,
195+
suspendedSet,
196+
});
197+
}
198+
199+
useEffect(() => {
200+
changeTimelineIndex(timelineIndex);
201+
}, [timelineIndex]);
202+
203+
useEffect(() => {
204+
if (!playing) {
205+
return undefined;
206+
}
207+
// While playing, advance one step every second.
208+
const PLAY_SPEED_INTERVAL = 1000;
209+
const timer = setInterval(() => {
210+
suspenseTreeDispatch({
211+
type: 'SUSPENSE_PLAY_TICK',
212+
});
213+
}, PLAY_SPEED_INTERVAL);
214+
return () => {
215+
clearInterval(timer);
216+
};
217+
}, [playing]);
218+
156219
return (
157220
<>
158-
{timelineIndex}/{max}
159-
<div className={styles.SuspenseTimelineInput}>
221+
<Button
222+
disabled={timelineIndex === 0}
223+
title={'Previous'}
224+
onClick={skipPrevious}>
225+
<ButtonIcon type={'skip-previous'} />
226+
</Button>
227+
<Button
228+
disabled={max === 0 && !playing}
229+
title={playing ? 'Pause' : 'Play'}
230+
onClick={togglePlaying}>
231+
<ButtonIcon type={playing ? 'pause' : 'play'} />
232+
</Button>
233+
<Button
234+
disabled={timelineIndex === max}
235+
title={'Next'}
236+
onClick={skipForward}>
237+
<ButtonIcon type={'skip-next'} />
238+
</Button>
239+
<div
240+
className={styles.SuspenseTimelineInput}
241+
title={timelineIndex + '/' + max}>
160242
<input
161243
className={styles.SuspenseTimelineSlider}
162244
type="range"

0 commit comments

Comments
 (0)