diff --git a/packages/react-devtools-shared/src/devtools/views/ButtonIcon.js b/packages/react-devtools-shared/src/devtools/views/ButtonIcon.js index a91c7e04b5545..454497e6b02da 100644 --- a/packages/react-devtools-shared/src/devtools/views/ButtonIcon.js +++ b/packages/react-devtools-shared/src/devtools/views/ButtonIcon.js @@ -34,6 +34,12 @@ export type IconType = | 'save' | 'search' | 'settings' + | 'panel-left-close' + | 'panel-left-open' + | 'panel-right-close' + | 'panel-right-open' + | 'panel-bottom-open' + | 'panel-bottom-close' | 'error' | 'suspend' | 'undo' @@ -46,8 +52,10 @@ type Props = { type: IconType, }; +const materialIconsViewBox = '0 -960 960 960'; export default function ButtonIcon({className = '', type}: Props): React.Node { let pathData = null; + let viewBox = '0 0 24 24'; switch (type) { case 'add': pathData = PATH_ADD; @@ -121,6 +129,30 @@ export default function ButtonIcon({className = '', type}: Props): React.Node { case 'error': pathData = PATH_ERROR; break; + case 'panel-left-close': + pathData = PATH_MATERIAL_PANEL_LEFT_CLOSE; + viewBox = materialIconsViewBox; + break; + case 'panel-left-open': + pathData = PATH_MATERIAL_PANEL_LEFT_OPEN; + viewBox = materialIconsViewBox; + break; + case 'panel-right-close': + pathData = PATH_MATERIAL_PANEL_RIGHT_CLOSE; + viewBox = materialIconsViewBox; + break; + case 'panel-right-open': + pathData = PATH_MATERIAL_PANEL_RIGHT_OPEN; + viewBox = materialIconsViewBox; + break; + case 'panel-bottom-open': + pathData = PATH_MATERIAL_PANEL_BOTTOM_OPEN; + viewBox = materialIconsViewBox; + break; + case 'panel-bottom-close': + pathData = PATH_MATERIAL_PANEL_BOTTOM_CLOSE; + viewBox = materialIconsViewBox; + break; case 'suspend': pathData = PATH_SUSPEND; break; @@ -147,7 +179,7 @@ export default function ButtonIcon({className = '', type}: Props): React.Node { className={`${styles.ButtonIcon} ${className}`} width="24" height="24" - viewBox="0 0 24 24"> + viewBox={viewBox}> {typeof pathData === 'string' ? ( @@ -276,3 +308,33 @@ const PATH_VIEW_SOURCE = ` const PATH_EDITOR = ` M7 5h10v2h2V3c0-1.1-.9-1.99-2-1.99L7 1c-1.1 0-2 .9-2 2v4h2V5zm8.41 11.59L20 12l-4.59-4.59L14 8.83 17.17 12 14 15.17l1.41 1.42zM10 15.17L6.83 12 10 8.83 8.59 7.41 4 12l4.59 4.59L10 15.17zM17 19H7v-2H5v4c0 1.1.9 2 2 2h10c1.1 0 2-.9 2-2v-4h-2v2z `; + +// Source: Material Design Icons left_panel_close +const PATH_MATERIAL_PANEL_LEFT_CLOSE = ` + M648-324v-312L480-480l168 156ZM211-144q-27.64 0-47.32-19.68T144-211v-538q0-27.64 19.68-47.32T211-816h538q27.64 0 47.32 19.68T816-749v538q0 27.64-19.68 47.32T749-144H211Zm125-72v-528H216v528h120Zm72 0h336v-528H408v528Zm-72 0H216h120Z +`; + +// Source: Material Design Icons left_panel_open +const PATH_MATERIAL_PANEL_LEFT_OPEN = ` + M504-595v230q0 12.25 10.5 16.62Q525-344 534-352l110-102q11-11.18 11-26.09T644-506L534-608q-8.82-8-19.41-3.5T504-595ZM211-144q-27.64 0-47.32-19.68T144-211v-538q0-27.64 19.68-47.32T211-816h538q27.64 0 47.32 19.68T816-749v538q0 27.64-19.68 47.32T749-144H211Zm125-72v-528H216v528h120Zm72 0h336v-528H408v528Zm-72 0H216h120Z +`; + +// Source: Material Design Icons right_panel_close +const PATH_MATERIAL_PANEL_RIGHT_CLOSE = ` + M312-365q0 12.25 10.5 16.62Q333-344 342-352l110-102q11-11.18 11-26.09T452-506L342-608q-8.82-8-19.41-3.5T312-595v230ZM211-144q-27.64 0-47.32-19.68T144-211v-538q0-27.64 19.68-47.32T211-816h538q27.64 0 47.32 19.68T816-749v538q0 27.64-19.68 47.32T749-144H211Zm413-72h120v-528H624v528Zm-72 0v-528H216v528h336Zm72 0h120-120Z +`; + +// Source: Material Design Icons right_panel_open +const PATH_MATERIAL_PANEL_RIGHT_OPEN = ` + M456-365v-230q0-12.25-10.5-16.63Q435-616 426-608L316-506q-11 11.18-11 26.09T316-454l110 102q8.82 8 19.41 3.5T456-365ZM211-144q-27.64 0-47.32-19.68T144-211v-538q0-27.64 19.68-47.32T211-816h538q27.64 0 47.32 19.68T816-749v538q0 27.64-19.68 47.32T749-144H211Zm413-72h120v-528H624v528Zm-72 0v-528H216v528h336Zm72 0h120-120Z +`; + +// Source: Material Design Icons bottom_panel_open +const PATH_MATERIAL_PANEL_BOTTOM_OPEN = ` + M365-504h230q12.25 0 16.63-10.5Q616-525 608-534L506-644q-11.18-11-26.09-11T454-644L352-534q-8 8.82-3.5 19.41T365-504ZM211-144q-27.64 0-47.32-19.68T144-211v-538q0-27.64 19.68-47.32T211-816h538q27.64 0 47.32 19.68T816-749v538q0 27.64-19.68 47.32T749-144H211Zm5-192v120h528v-120H216Zm0-72h528v-336H216v336Zm0 72v120-120Z +`; + +// Source: Material Design Icons bottom_panel_close +const PATH_MATERIAL_PANEL_BOTTOM_CLOSE = ` + m506-508 102-110q8-8.82 3.5-19.41T595-648H365q-12.25 0-16.62 10.5Q344-627 352-618l102 110q11.18 11 26.09 11T506-508Zm243-308q27.64 0 47.32 19.68T816-749v538q0 27.64-19.68 47.32T749-144H211q-27.64 0-47.32-19.68T144-211v-538q0-27.64 19.68-47.32T211-816h538ZM216-336v120h528v-120H216Zm528-72v-336H216v336h528Zm-528 72v120-120Z +`; diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.css b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.css new file mode 100644 index 0000000000000..5a153545fecf6 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.css @@ -0,0 +1,117 @@ +.SuspenseTab { + position: relative; + width: 100%; + height: 100%; + display: flex; + flex-direction: row; + background-color: var(--color-background); + color: var(--color-text); + font-family: var(--font-family-sans); +} + +.SuspenseTab, .SuspenseTab * { + box-sizing: border-box; + -webkit-font-smoothing: var(--font-smoothing); +} + +.TreeWrapper { + flex: 1 1 var(--horizontal-resize-tree-percentage); + display: flex; + flex-direction: row; + overflow: auto; + border-top: 1px solid var(--color-border); +} + +.InspectedElementWrapper { + flex: 1 1 calc(100% - var(--horizontal-resize-tree-percentage)); + overflow-x: hidden; + overflow-y: auto; +} + +.ResizeBarWrapper { + flex: 0 0 0px; + position: relative; +} + +.ResizeBar { + position: absolute; + /* + * moving the bar out of its bounding box might cause its hitbox to overlap + * with another scrollbar creating disorienting UX where you both resize and scroll + * at the same time. + * If you adjust this value, double check that starting resize right on this edge + * doesn't also cause scroll + */ + left: 1px; + width: 5px; + height: 100%; + cursor: ew-resize; +} + +.TreeView footer { + display: none; +} + + + +@container devtools (width < 600px) { + .SuspenseTab { + flex-direction: column; + } + + .TreeWrapper { + border-top: 1px solid var(--color-border); + flex: 1 0 var(--vertical-resize-tree-percentage); + } + + .InspectedElementWrapper { + flex: 1 1 50%; + } + + .TreeWrapper + .ResizeBarWrapper .ResizeBar { + top: 1px; + left: 0; + width: 100%; + height: 5px; + cursor: ns-resize; + } + + .TreeView footer { + display: flex; + justify-content: end; + } + + .ToggleInspectedElement[data-orientation="horizontal"] { + display: none; + } +} + +.TreeList { + flex: 0 0 var(--horizontal-resize-tree-list-percentage); + border-right: 1px solid var(--color-border); + padding: 0.25rem +} + +.TreeView { + flex: 1 1 35%; + display: flex; + flex-direction: column; +} + + + +.Rects { + border-top: 1px solid var(--color-border); + padding: 0.25rem; + flex-grow: 1; +} + +.TimelineWrapper { + padding: 0.25rem; + display: flex; + flex-direction: row; +} + +.Timeline { + flex-grow: 1; +} diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js index 01d11b3d7e130..a920b6dabd5f1 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js @@ -1,8 +1,445 @@ +/** + * 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 {useEffect, useLayoutEffect, useReducer, useRef} from 'react'; + +import { + localStorageGetItem, + localStorageSetItem, +} from 'react-devtools-shared/src/storage'; +import ButtonIcon, {type IconType} from '../ButtonIcon'; +import InspectedElementErrorBoundary from '../Components/InspectedElementErrorBoundary'; +import InspectedElement from '../Components/InspectedElement'; import portaledContent from '../portaledContent'; +import styles from './SuspenseTab.css'; +import Button from '../Button'; + +type Orientation = 'horizontal' | 'vertical'; + +type LayoutActionType = + | 'ACTION_SET_TREE_LIST_TOGGLE' + | 'ACTION_SET_TREE_LIST_HORIZONTAL_FRACTION' + | 'ACTION_SET_INSPECTED_ELEMENT_TOGGLE' + | 'ACTION_SET_INSPECTED_ELEMENT_HORIZONTAL_FRACTION' + | 'ACTION_SET_INSPECTED_ELEMENT_VERTICAL_FRACTION'; +type LayoutAction = { + type: LayoutActionType, + payload: any, +}; + +type LayoutState = { + treeListHidden: boolean, + treeListHorizontalFraction: number, + inspectedElementHidden: boolean, + inspectedElementHorizontalFraction: number, + inspectedElementVerticalFraction: number, +}; +type LayoutDispatch = (action: LayoutAction) => void; + +function SuspenseTreeList() { + return
tree list
; +} + +function SuspenseTimeline() { + return
timeline
; +} + +function SuspenseRects() { + return
rects
; +} + +function ToggleTreeList({ + dispatch, + state, +}: { + dispatch: LayoutDispatch, + state: LayoutState, +}) { + return ( + + ); +} + +function ToggleInspectedElement({ + dispatch, + state, + orientation, +}: { + dispatch: LayoutDispatch, + state: LayoutState, + orientation: 'horizontal' | 'vertical', +}) { + let iconType: IconType; + if (orientation === 'horizontal') { + iconType = state.inspectedElementHidden + ? 'panel-right-open' + : 'panel-right-close'; + } else { + iconType = state.inspectedElementHidden + ? 'panel-bottom-open' + : 'panel-bottom-close'; + } + return ( + + ); +} + +function SuspenseTab(_: {}) { + const [state, dispatch] = useReducer( + layoutReducer, + null, + initLayoutState, + ); + + const wrapperTreeRef = useRef(null); + const resizeTreeRef = useRef(null); + const resizeTreeListRef = useRef(null); + + // TODO: We'll show the recently inspected element in this tab when it should probably + // switch to the nearest Suspense boundary when we switch into this tab. + + const { + inspectedElementHidden, + inspectedElementHorizontalFraction, + inspectedElementVerticalFraction, + treeListHidden, + treeListHorizontalFraction, + } = state; + + useLayoutEffect(() => { + const wrapperElement = wrapperTreeRef.current; + + setResizeCSSVariable( + wrapperElement, + 'tree', + 'horizontal', + inspectedElementHorizontalFraction * 100, + ); + setResizeCSSVariable( + wrapperElement, + 'tree', + 'vertical', + inspectedElementVerticalFraction * 100, + ); + + const resizeTreeListElement = resizeTreeListRef.current; + setResizeCSSVariable( + resizeTreeListElement, + 'tree-list', + 'horizontal', + treeListHorizontalFraction * 100, + ); + }, []); + useEffect(() => { + const timeoutID = setTimeout(() => { + localStorageSetItem( + LOCAL_STORAGE_KEY, + JSON.stringify({ + inspectedElementHidden, + inspectedElementHorizontalFraction, + inspectedElementVerticalFraction, + treeListHidden, + treeListHorizontalFraction, + }), + ); + }, 500); + + return () => clearTimeout(timeoutID); + }, [ + inspectedElementHidden, + inspectedElementHorizontalFraction, + inspectedElementVerticalFraction, + treeListHidden, + treeListHorizontalFraction, + ]); + + const onResizeStart = (event: SyntheticPointerEvent) => { + const element = event.currentTarget; + element.setPointerCapture(event.pointerId); + }; + + const onResizeEnd = (event: SyntheticPointerEvent) => { + const element = event.currentTarget; + element.releasePointerCapture(event.pointerId); + }; + + const onResizeTree = (event: SyntheticPointerEvent) => { + const element = event.currentTarget; + const isResizing = element.hasPointerCapture(event.pointerId); + if (!isResizing) { + return; + } + + const resizeElement = resizeTreeRef.current; + const wrapperElement = wrapperTreeRef.current; + + if (wrapperElement === null || resizeElement === null) { + return; + } + + event.preventDefault(); + + const orientation = getTreeOrientation(wrapperElement); + + const {height, width, left, top} = wrapperElement.getBoundingClientRect(); + + const currentMousePosition = + orientation === 'horizontal' ? event.clientX - left : event.clientY - top; + + const boundaryMin = MINIMUM_TREE_SIZE; + const boundaryMax = + orientation === 'horizontal' + ? width - MINIMUM_TREE_SIZE + : height - MINIMUM_TREE_SIZE; + + const isMousePositionInBounds = + currentMousePosition > boundaryMin && currentMousePosition < boundaryMax; + + if (isMousePositionInBounds) { + const resizedElementDimension = + orientation === 'horizontal' ? width : height; + const actionType = + orientation === 'horizontal' + ? 'ACTION_SET_INSPECTED_ELEMENT_HORIZONTAL_FRACTION' + : 'ACTION_SET_INSPECTED_ELEMENT_VERTICAL_FRACTION'; + const fraction = currentMousePosition / resizedElementDimension; + const percentage = fraction * 100; + + setResizeCSSVariable(wrapperElement, 'tree', orientation, percentage); + + dispatch({ + type: actionType, + payload: fraction, + }); + } + }; + + const onResizeTreeList = (event: SyntheticPointerEvent) => { + const element = event.currentTarget; + const isResizing = element.hasPointerCapture(event.pointerId); + if (!isResizing) { + return; + } + + const resizeElement = resizeTreeListRef.current; + const wrapperElement = resizeTreeRef.current; + + if (wrapperElement === null || resizeElement === null) { + return; + } + + event.preventDefault(); + + const orientation = 'horizontal'; + + const {height, width, left, top} = wrapperElement.getBoundingClientRect(); + + const currentMousePosition = + orientation === 'horizontal' ? event.clientX - left : event.clientY - top; + + const boundaryMin = MINIMUM_TREE_LIST_SIZE; + const boundaryMax = + orientation === 'horizontal' + ? width - MINIMUM_TREE_LIST_SIZE + : height - MINIMUM_TREE_LIST_SIZE; + + const isMousePositionInBounds = + currentMousePosition > boundaryMin && currentMousePosition < boundaryMax; + + if (isMousePositionInBounds) { + const resizedElementDimension = + orientation === 'horizontal' ? width : height; + const actionType = 'ACTION_SET_TREE_LIST_HORIZONTAL_FRACTION'; + const percentage = (currentMousePosition / resizedElementDimension) * 100; + + setResizeCSSVariable(resizeElement, 'tree-list', orientation, percentage); + + dispatch({ + type: actionType, + payload: currentMousePosition / resizedElementDimension, + }); + } + }; + + return ( +
+
+ +
+
+
+
+
+ + + +
+
+ +
+
+ +
+
+
+
+
+
+ +
+ ); +} + +const LOCAL_STORAGE_KEY = 'React::DevTools::SuspenseTab::layout'; +const VERTICAL_TREE_MODE_MAX_WIDTH = 600; +const MINIMUM_TREE_SIZE = 100; +const MINIMUM_TREE_LIST_SIZE = 100; + +function layoutReducer(state: LayoutState, action: LayoutAction): LayoutState { + switch (action.type) { + case 'ACTION_SET_TREE_LIST_TOGGLE': + return { + ...state, + treeListHidden: !state.treeListHidden, + }; + case 'ACTION_SET_TREE_LIST_HORIZONTAL_FRACTION': + return { + ...state, + treeListHorizontalFraction: action.payload, + }; + case 'ACTION_SET_INSPECTED_ELEMENT_TOGGLE': + return { + ...state, + inspectedElementHidden: !state.inspectedElementHidden, + }; + case 'ACTION_SET_INSPECTED_ELEMENT_HORIZONTAL_FRACTION': + return { + ...state, + inspectedElementHorizontalFraction: action.payload, + }; + case 'ACTION_SET_INSPECTED_ELEMENT_VERTICAL_FRACTION': + return { + ...state, + inspectedElementVerticalFraction: action.payload, + }; + default: + return state; + } +} + +function initLayoutState(): LayoutState { + let inspectedElementHidden = false; + let inspectedElementHorizontalFraction = 0.65; + let inspectedElementVerticalFraction = 0.5; + let treeListHidden = false; + let treeListHorizontalFraction = 0.35; + + try { + let data = localStorageGetItem(LOCAL_STORAGE_KEY); + if (data != null) { + data = JSON.parse(data); + inspectedElementHidden = data.inspectedElementHidden; + inspectedElementHorizontalFraction = + data.inspectedElementHorizontalFraction; + inspectedElementVerticalFraction = data.inspectedElementVerticalFraction; + treeListHidden = data.treeListHidden; + treeListHorizontalFraction = data.treeListHorizontalFraction; + } + } catch (error) {} + + return { + inspectedElementHidden, + inspectedElementHorizontalFraction, + inspectedElementVerticalFraction, + treeListHidden, + treeListHorizontalFraction, + }; +} + +function getTreeOrientation( + wrapperElement: null | HTMLElement, +): null | Orientation { + if (wrapperElement != null) { + const {width} = wrapperElement.getBoundingClientRect(); + return width > VERTICAL_TREE_MODE_MAX_WIDTH ? 'horizontal' : 'vertical'; + } + return null; +} -function SuspenseTab() { - return 'Under construction'; +function setResizeCSSVariable( + resizeElement: null | HTMLElement, + name: 'tree' | 'tree-list', + orientation: null | Orientation, + percentage: number, +): void { + if (resizeElement !== null && orientation !== null) { + resizeElement.style.setProperty( + `--${orientation}-resize-${name}-percentage`, + `${percentage}%`, + ); + } } -export default (portaledContent(SuspenseTab): React.ComponentType<{}>); +export default (portaledContent(SuspenseTab): React$ComponentType<{}>);