react/packages/react-devtools-shared/src/devtools/views/Components/Components.js
Ruslan Lesiutin d45db667d4
feat: static Components panel layout (#33696)
## Summary

Follow-up to https://github.com/facebook/react/pull/33517.

With https://github.com/facebook/react/pull/33517, we now preserve at
least some minimal indent. This actually doesn't work with the current
setup, because we don't allow the container to overflow, so basically
deeply nested elements will go off the screen.

With these changes, we completely change the approach:
- The layout will be static and it will have a constant indentation that
will always be preserved.
- The container will allow overflows, so users will be able to scroll
horizontally and vertically.
- We will implement automatic horizontal and vertical scrolls, if
selected element is not in a viewport.
- New: added vertical delimiter that can be used for simpler visual
navigation.

## Demo
### Current public release

https://github.com/user-attachments/assets/58645d42-c6b8-408b-b76f-95fb272f2e1e

### With https://github.com/facebook/react/pull/33517 

https://github.com/user-attachments/assets/845285c8-5a01-4739-bcd7-ffc089e771bf

### This PR

https://github.com/user-attachments/assets/72086b84-8d84-4626-94b3-e22e114e028e
2025-07-04 12:29:19 +01:00

247 lines
6.8 KiB
JavaScript

/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import * as React from 'react';
import {Fragment, useEffect, useLayoutEffect, useReducer, useRef} from 'react';
import Tree from './Tree';
import {OwnersListContextController} from './OwnersListContext';
import portaledContent from '../portaledContent';
import {SettingsModalContextController} from 'react-devtools-shared/src/devtools/views/Settings/SettingsModalContext';
import {
localStorageGetItem,
localStorageSetItem,
} from 'react-devtools-shared/src/storage';
import InspectedElementErrorBoundary from './InspectedElementErrorBoundary';
import InspectedElement from './InspectedElement';
import {ModalDialog} from '../ModalDialog';
import SettingsModal from 'react-devtools-shared/src/devtools/views/Settings/SettingsModal';
import {NativeStyleContextController} from './NativeStyleEditor/context';
import styles from './Components.css';
type Orientation = 'horizontal' | 'vertical';
type ResizeActionType =
| 'ACTION_SET_DID_MOUNT'
| 'ACTION_SET_IS_RESIZING'
| 'ACTION_SET_HORIZONTAL_PERCENTAGE'
| 'ACTION_SET_VERTICAL_PERCENTAGE';
type ResizeAction = {
type: ResizeActionType,
payload: any,
};
type ResizeState = {
horizontalPercentage: number,
isResizing: boolean,
verticalPercentage: number,
};
function Components(_: {}) {
const wrapperElementRef = useRef<null | HTMLElement>(null);
const resizeElementRef = useRef<null | HTMLElement>(null);
const [state, dispatch] = useReducer<ResizeState, any, ResizeAction>(
resizeReducer,
null,
initResizeState,
);
const {horizontalPercentage, verticalPercentage} = state;
useLayoutEffect(() => {
const resizeElement = resizeElementRef.current;
setResizeCSSVariable(
resizeElement,
'horizontal',
horizontalPercentage * 100,
);
setResizeCSSVariable(resizeElement, 'vertical', verticalPercentage * 100);
}, []);
useEffect(() => {
const timeoutID = setTimeout(() => {
localStorageSetItem(
LOCAL_STORAGE_KEY,
JSON.stringify({
horizontalPercentage,
verticalPercentage,
}),
);
}, 500);
return () => clearTimeout(timeoutID);
}, [horizontalPercentage, verticalPercentage]);
const {isResizing} = state;
const onResizeStart = () =>
dispatch({type: 'ACTION_SET_IS_RESIZING', payload: true});
let onResize;
let onResizeEnd;
if (isResizing) {
onResizeEnd = () =>
dispatch({type: 'ACTION_SET_IS_RESIZING', payload: false});
// $FlowFixMe[missing-local-annot]
onResize = event => {
const resizeElement = resizeElementRef.current;
const wrapperElement = wrapperElementRef.current;
if (!isResizing || wrapperElement === null || resizeElement === null) {
return;
}
event.preventDefault();
const orientation = getOrientation(wrapperElement);
const {height, width, left, top} = wrapperElement.getBoundingClientRect();
const currentMousePosition =
orientation === 'horizontal'
? event.clientX - left
: event.clientY - top;
const boundaryMin = MINIMUM_SIZE;
const boundaryMax =
orientation === 'horizontal'
? width - MINIMUM_SIZE
: height - MINIMUM_SIZE;
const isMousePositionInBounds =
currentMousePosition > boundaryMin &&
currentMousePosition < boundaryMax;
if (isMousePositionInBounds) {
const resizedElementDimension =
orientation === 'horizontal' ? width : height;
const actionType =
orientation === 'horizontal'
? 'ACTION_SET_HORIZONTAL_PERCENTAGE'
: 'ACTION_SET_VERTICAL_PERCENTAGE';
const percentage =
(currentMousePosition / resizedElementDimension) * 100;
setResizeCSSVariable(resizeElement, orientation, percentage);
dispatch({
type: actionType,
payload: currentMousePosition / resizedElementDimension,
});
}
};
}
return (
<SettingsModalContextController>
<OwnersListContextController>
<div
ref={wrapperElementRef}
className={styles.Components}
onMouseMove={onResize}
onMouseLeave={onResizeEnd}
onMouseUp={onResizeEnd}>
<Fragment>
<div ref={resizeElementRef} className={styles.TreeWrapper}>
<Tree />
</div>
<div className={styles.ResizeBarWrapper}>
<div onMouseDown={onResizeStart} className={styles.ResizeBar} />
</div>
<div className={styles.InspectedElementWrapper}>
<NativeStyleContextController>
<InspectedElementErrorBoundary>
<InspectedElement />
</InspectedElementErrorBoundary>
</NativeStyleContextController>
</div>
<ModalDialog />
<SettingsModal />
</Fragment>
</div>
</OwnersListContextController>
</SettingsModalContextController>
);
}
const LOCAL_STORAGE_KEY = 'React::DevTools::createResizeReducer';
const VERTICAL_MODE_MAX_WIDTH = 600;
const MINIMUM_SIZE = 100;
function initResizeState(): ResizeState {
let horizontalPercentage = 0.65;
let verticalPercentage = 0.5;
try {
let data = localStorageGetItem(LOCAL_STORAGE_KEY);
if (data != null) {
data = JSON.parse(data);
horizontalPercentage = data.horizontalPercentage;
verticalPercentage = data.verticalPercentage;
}
} catch (error) {}
return {
horizontalPercentage,
isResizing: false,
verticalPercentage,
};
}
function resizeReducer(state: ResizeState, action: ResizeAction): ResizeState {
switch (action.type) {
case 'ACTION_SET_IS_RESIZING':
return {
...state,
isResizing: action.payload,
};
case 'ACTION_SET_HORIZONTAL_PERCENTAGE':
return {
...state,
horizontalPercentage: action.payload,
};
case 'ACTION_SET_VERTICAL_PERCENTAGE':
return {
...state,
verticalPercentage: action.payload,
};
default:
return state;
}
}
function getOrientation(
wrapperElement: null | HTMLElement,
): null | Orientation {
if (wrapperElement != null) {
const {width} = wrapperElement.getBoundingClientRect();
return width > VERTICAL_MODE_MAX_WIDTH ? 'horizontal' : 'vertical';
}
return null;
}
function setResizeCSSVariable(
resizeElement: null | HTMLElement,
orientation: null | Orientation,
percentage: number,
): void {
if (resizeElement !== null && orientation !== null) {
resizeElement.style.setProperty(
`--${orientation}-resize-percentage`,
`${percentage}%`,
);
}
}
export default (portaledContent(Components): React$ComponentType<{}>);