Initial POC for compressing horizontal offset for wide/deep trees
Brian Vaughn committed
May 31, 2019 at 11:24 UTC
5a269e7ced1011885e3622c2bde8d30fcd8ad3ab
10 files changed
+102
-197
src/devtools/views/Components/Element.css
+7
-18
@@ -9,6 +9,7 @@
9
align-items: center;
10
cursor: default;
11
user-select: none;
12
+ color: var(--color-component-name);
13
}
14
.HoveredElement {
15
background-color: var(--color-background-hover);
@@ -17,6 +18,10 @@
18
background-color: var(--color-background-inactive);
19
}
20
21
+.Bracket {
22
+ color: var(--color-jsx-arrow-brackets);
23
+}
24
+
25
.ScrollAnchor {
26
height: 100%;
27
width: 0;
@@ -37,24 +42,6 @@
42
--color-expand-collapse-toggle: var(--color-component-name-inverted);
43
}
44
40
-.DollarR {
41
- color: var(--color-jsx-arrow-brackets);
42
-}
43
-
44
-.Component {
45
- color: var(--color-component-name);
46
-}
47
-.Component:before {
48
- white-space: nowrap;
49
- content: '<';
50
- color: var(--color-jsx-arrow-brackets);
51
-}
52
-.Component:after {
53
- white-space: nowrap;
54
- content: '>';
55
- color: var(--color-jsx-arrow-brackets);
56
-}
57
-
45
.AttributeName {
46
color: var(--color-attribute-name);
47
}
@@ -74,10 +61,12 @@
61
display: inline-flex;
62
width: 1rem;
63
height: 1rem;
64
+ flex: 0 0 1rem;
65
color: var(--color-expand-collapse-toggle);
66
}
67
68
.Badge {
69
+ color: var(--color-text);
70
background-color: var(--color-component-badge-background);
71
padding: 0.125rem 0.25rem;
72
line-height: normal;
src/devtools/views/Components/Element.js
+18
-95
@@ -4,20 +4,13 @@ import React, {
4
Fragment,
5
useCallback,
6
useContext,
7
- useLayoutEffect,
7
useMemo,
9
- useRef,
8
useState,
9
} from 'react';
12
-import {
13
- ElementTypeClass,
14
- ElementTypeFunction,
15
- ElementTypeMemo,
16
- ElementTypeForwardRef,
17
-} from 'src/types';
10
+import { ElementTypeMemo, ElementTypeForwardRef } from 'src/types';
11
import Store from 'src/devtools/store';
12
import ButtonIcon from '../ButtonIcon';
20
-import { createRegExp } from '../utils';
13
+import { createRegExp, truncateText } from '../utils';
14
import { TreeDispatcherContext, TreeStateContext } from './TreeContext';
15
import { StoreContext } from '../context';
16
@@ -46,13 +39,7 @@ export default function ElementView({ data, index, style }: Props) {
39
40
const [isHovered, setIsHovered] = useState(false);
41
49
- const {
50
- lastScrolledIDRef,
51
- isNavigatingWithKeyboard,
52
- onElementMouseEnter,
53
- showIndentLines,
54
- treeFocused,
55
- } = data;
42
+ const { isNavigatingWithKeyboard, onElementMouseEnter, treeFocused } = data;
43
const id = element === null ? null : element.id;
44
const isSelected = selectedElementID === id;
45
@@ -62,49 +49,6 @@ export default function ElementView({ data, index, style }: Props) {
49
}
50
}, [dispatch, id]);
51
65
- const scrollAnchorStartRef = useRef<HTMLSpanElement | null>(null);
66
- const scrollAnchorEndRef = useRef<HTMLSpanElement | null>(null);
67
-
68
- // The tree above has its own autoscrolling, but it only works for rows.
69
- // However, even when the row gets into the viewport, the component name
70
- // might be too far left or right on the screen. Adjust it in this case.
71
- useLayoutEffect(() => {
72
- if (isSelected) {
73
- // Don't select the same item twice.
74
- // A row may appear and disappear just by scrolling:
75
- // https://github.com/bvaughn/react-devtools-experimental/issues/67
76
- // It doesn't necessarily indicate a user action.
77
- // TODO: we might want to revamp the autoscroll logic
78
- // to only happen explicitly for user-initiated events.
79
- if (lastScrolledIDRef.current === id) {
80
- return;
81
- }
82
- lastScrolledIDRef.current = id;
83
-
84
- // We want to bring the whole <Component> name into view,
85
- // including the expansion toggle and the "=== $r" hint.
86
- // However, even calling scrollIntoView() on a wrapper parent node (e.g. <span>)
87
- // wouldn't guarantee that it will be *fully* brought into view.
88
- // As a workaround, we'll have two anchor spans, and scroll each into view.
89
- if (scrollAnchorEndRef.current !== null) {
90
- scrollAnchorEndRef.current.scrollIntoView({
91
- behavior: 'auto',
92
- block: 'nearest',
93
- inline: 'nearest',
94
- });
95
- }
96
- if (scrollAnchorStartRef.current !== null) {
97
- // We scroll the start anchor last because it's
98
- // more important for it to be in the view.
99
- scrollAnchorStartRef.current.scrollIntoView({
100
- behavior: 'auto',
101
- block: 'nearest',
102
- inline: 'nearest',
103
- });
104
- }
105
- }
106
- }, [id, isSelected, lastScrolledIDRef]);
107
-
52
const handleMouseDown = useCallback(
53
({ metaKey }) => {
54
if (id !== null) {
@@ -138,8 +82,6 @@ export default function ElementView({ data, index, style }: Props) {
82
83
const { depth, displayName, key, type } = ((element: any): Element);
84
141
- const showDollarR =
142
- isSelected && (type === ElementTypeClass || type === ElementTypeFunction);
85
const showBadge = type === ElementTypeMemo || type === ElementTypeForwardRef;
86
87
let className = styles.Element;
@@ -162,45 +104,26 @@ export default function ElementView({ data, index, style }: Props) {
104
...style, // "style" comes from react-window
105
106
// Left padding presents the appearance of a nested tree structure.
165
- paddingLeft: '0.25rem',
166
-
167
- // These style overrides enable the background color to fill the full visible width,
168
- // when combined with the CSS tweaks in Tree.
169
- // A lot of options were considered; this seemed the one that requires the least code.
170
- // See https://github.com/bvaughn/react-devtools-experimental/issues/9
171
- width: undefined,
172
- minWidth: '100%',
173
- position: 'relative',
174
- marginBottom: `-${style.height}px`,
107
+ // We must use padding rather than margin/left because of the selected background color.
108
+ paddingLeft: `calc(${depth} * var(--indentation-size))`,
109
}}
110
>
177
- <div
178
- style={{
179
- width: `${depth * 0.75}rem`,
180
- height: '100%',
181
- backgroundSize: '0.75rem 1rem',
182
- backgroundColor: 'transparent',
183
- backgroundImage: showIndentLines
184
- ? 'linear-gradient(to right, transparent 8px, var(--color-guideline) 8px, transparent 9px)'
185
- : '',
186
- backgroundRepeat: 'repeat',
187
- }}
188
- />
189
- <span className={styles.ScrollAnchor} ref={scrollAnchorStartRef} />
111
{ownerID === null ? (
112
<ExpandCollapseToggle element={element} store={store} />
113
) : null}
193
- <span className={styles.Component}>
194
- <DisplayName displayName={displayName} id={((id: any): number)} />
195
- {key && (
196
- <Fragment>
197
- <span className={styles.AttributeName}>key</span>=
198
- <span className={styles.AttributeValue}>"{key}"</span>
199
- </Fragment>
200
- )}
201
- </span>
202
- {showDollarR && <span className={styles.DollarR}> == $r</span>}
203
- <span className={styles.ScrollAnchor} ref={scrollAnchorEndRef} />
114
+
115
+ <span className={styles.Bracket}><</span>
116
+ <DisplayName displayName={displayName} id={((id: any): number)} />
117
+ {key && (
118
+ <Fragment>
119
+ <span className={styles.AttributeName}>key</span>=
120
+ <span className={styles.AttributeValue} title={key}>
121
+ "{truncateText(`${key}`, 10)}"
122
+ </span>
123
+ </Fragment>
124
+ )}
125
+ <span className={styles.Bracket}>></span>
126
+
127
{showBadge && (
128
<span className={styles.Badge}>
129
{type === ElementTypeMemo ? 'Memo' : 'ForwardRef'}
src/devtools/views/Components/Guideline.css
renamed
src/devtools/views/Components/Guideline.js
renamed
+13
-30
@@ -1,6 +1,6 @@
1
// @flow
2
3
-import React, { Fragment, useContext, useMemo } from 'react';
3
+import React, { useContext, useMemo } from 'react';
4
import { TreeStateContext } from './TreeContext';
5
import TreeFocusedContext from './TreeFocusedContext';
6
import { SettingsContext } from '../Settings/SettingsContext';
@@ -8,23 +8,7 @@ import { StoreContext } from '../context';
8
import { useSubscription } from '../hooks';
9
import Store from '../../store';
10
11
-import styles from './Guidelines.css';
12
-
13
-export default function Guidelines(_: {||}) {
14
- const { selectedElementID } = useContext(TreeStateContext);
15
- const treeFocused = useContext(TreeFocusedContext);
16
-
17
- return (
18
- <Fragment>
19
- <Guideline
20
- className={
21
- treeFocused ? styles.GuidelineActive : styles.GuidelineInactive
22
- }
23
- elementID={selectedElementID}
24
- />
25
- </Fragment>
26
- );
27
-}
11
+import styles from './Guideline.css';
12
13
type Data = {|
14
depth: number,
@@ -32,23 +16,20 @@ type Data = {|
16
stopIndex: number,
17
|};
18
35
-type Props = {|
36
- className: string,
37
- elementID: number | null,
38
-|};
39
-
40
-function Guideline({ className, elementID }: Props) {
41
- const store = useContext(StoreContext);
19
+export default function Guideline(_: {||}) {
20
const { lineHeight } = useContext(SettingsContext);
21
+ const store = useContext(StoreContext);
22
+ const { selectedElementID } = useContext(TreeStateContext);
23
+ const treeFocused = useContext(TreeFocusedContext);
24
25
const subscription = useMemo(
26
() => ({
27
getCurrentValue: () => {
47
- if (elementID === null) {
28
+ if (selectedElementID === null) {
29
return null;
30
}
31
51
- const element = store.getElementByID(elementID);
32
+ const element = store.getElementByID(selectedElementID);
33
if (
34
element === null ||
35
element.isCollapsed ||
@@ -92,7 +73,7 @@ function Guideline({ className, elementID }: Props) {
73
};
74
},
75
}),
95
- [elementID, store]
76
+ [selectedElementID, store]
77
);
78
const data = useSubscription<Data | null, Store>(subscription);
79
@@ -104,11 +85,13 @@ function Guideline({ className, elementID }: Props) {
85
86
return (
87
<div
107
- className={className}
88
+ className={
89
+ treeFocused ? styles.GuidelineActive : styles.GuidelineInactive
90
+ }
91
style={{
92
position: 'absolute',
93
top: `${startIndex * lineHeight}px`,
111
- left: `${depth * 0.75 + 0.75}rem`,
94
+ left: `calc(${depth} * var(--indentation-size) + 0.5rem)`,
95
height: `${(stopIndex + 1 - startIndex) * lineHeight}px`,
96
}}
97
/>
src/devtools/views/Components/SelectedElement.css
+4
-2
@@ -33,11 +33,13 @@
33
34
.Component,
35
.Owner {
36
- overflow: hidden;
37
- text-overflow: ellipsis;
36
color: var(--color-component-name);
37
font-family: var(--font-family-monospace);
38
font-size: var(--font-size-monospace-normal);
39
+ white-space: nowrap;
40
+ overflow: hidden;
41
+ text-overflow: ellipsis;
42
+ max-width: 100%;
43
}
44
.Component:before,
45
.Owner:before {
src/devtools/views/Components/Tree.css
+8
@@ -7,6 +7,14 @@
7
border-top: 1px solid var(--color-border);
8
}
9
10
+.List {
11
+ overflow-x: hidden !important;
12
+}
13
+
14
+.InnerElementType {
15
+ overflow-x: hidden;
16
+}
17
+
18
.SearchInput {
19
flex: 0 0 42px;
20
display: flex;
src/devtools/views/Components/Tree.js
+36
-18
@@ -22,7 +22,7 @@ import SearchInput from './SearchInput';
22
import { ComponentFiltersModalContextController } from './ComponentFiltersModalContext';
23
import ToggleComponentFiltersModalButton from './ToggleComponentFiltersModalButton';
24
import ComponentFiltersModal from './ComponentFiltersModal';
25
-import Guidelines from './Guidelines';
25
+import Guideline from './Guideline';
26
import TreeFocusedContext from './TreeFocusedContext';
27
28
import styles from './Tree.css';
@@ -32,7 +32,6 @@ export type ItemData = {|
32
isNavigatingWithKeyboard: boolean,
33
lastScrolledIDRef: { current: number | null },
34
onElementMouseEnter: (id: number) => void,
35
- showIndentLines: boolean,
35
treeFocused: boolean,
36
|};
37
@@ -60,7 +59,7 @@ export default function Tree(props: Props) {
59
60
const [treeFocused, setTreeFocused] = useState<boolean>(false);
61
63
- const { lineHeight, showIndentLines } = useContext(SettingsContext);
62
+ const { lineHeight } = useContext(SettingsContext);
63
64
// Make sure a newly selected element is visible in the list.
65
// This is helpful for things like the owners list and search.
@@ -264,7 +263,6 @@ export default function Tree(props: Props) {
263
isNavigatingWithKeyboard,
264
onElementMouseEnter: handleElementMouseEnter,
265
lastScrolledIDRef,
267
- showIndentLines,
266
treeFocused,
267
}),
268
[
@@ -272,7 +270,6 @@ export default function Tree(props: Props) {
270
isNavigatingWithKeyboard,
271
handleElementMouseEnter,
272
lastScrolledIDRef,
275
- showIndentLines,
273
treeFocused,
274
]
275
);
@@ -310,7 +307,6 @@ export default function Tree(props: Props) {
307
itemCount={numElements}
308
itemData={itemData}
309
itemSize={lineHeight}
313
- overscanCount={3}
310
ref={listRef}
311
width={width}
312
>
@@ -326,6 +322,31 @@ export default function Tree(props: Props) {
322
);
323
}
324
325
+function updateIndentationSizeVar(innerDiv: HTMLDivElement): void {
326
+ const list = ((innerDiv.parentElement: any): HTMLDivElement);
327
+
328
+ let indentationSize =
329
+ parseFloat(getComputedStyle(list).getPropertyValue('--indentation-size')) ||
330
+ 12;
331
+
332
+ let maxChildWidth = 0;
333
+ for (let child of innerDiv.children) {
334
+ const { lastElementChild } = child;
335
+ // Skip over e.g. the guideline element
336
+ if (lastElementChild != null) {
337
+ const bounds = ((lastElementChild.getBoundingClientRect(): any): DOMRect);
338
+ maxChildWidth = Math.max(maxChildWidth, bounds.x + bounds.width);
339
+ }
340
+ }
341
+
342
+ indentationSize = Math.min(
343
+ 12,
344
+ (list.clientWidth / maxChildWidth) * indentationSize
345
+ );
346
+
347
+ list.style.setProperty('--indentation-size', `${indentationSize}px`);
348
+}
349
+
350
function InnerElementType({ children, style, ...rest }) {
351
const { ownerID } = useContext(TreeStateContext);
352
@@ -335,13 +356,12 @@ function InnerElementType({ children, style, ...rest }) {
356
// and ensure that once we've grown to a new max size, we don't shrink below it.
357
// This improves the user experience when scrolling between wide and narrow rows.
358
const divRef = useRef<HTMLDivElement | null>(null);
338
- const [minWidth, setMinWidth] = useState(null);
359
+
360
// TODO This is a valid warning, but we're ignoring it for the time being.
361
// eslint-disable-next-line react-hooks/exhaustive-deps
362
useEffect(() => {
363
if (divRef.current !== null) {
343
- const measuredWidth = divRef.current.offsetWidth;
344
- setMinWidth(w => Math.max(w || 0, measuredWidth));
364
+ updateIndentationSizeVar(divRef.current);
365
}
366
});
367
@@ -349,7 +369,11 @@ function InnerElementType({ children, style, ...rest }) {
369
// This will cause a render with 100% min-width, a measurement
370
// in an effect, and a second render where we know the width.
371
useEffect(() => {
352
- const invalidateMinWidth = () => setMinWidth(null);
372
+ const invalidateMinWidth = () => {
373
+ if (divRef.current !== null) {
374
+ updateIndentationSizeVar(divRef.current);
375
+ }
376
+ };
377
window.addEventListener('resize', invalidateMinWidth);
378
return () => window.removeEventListener('resize', invalidateMinWidth);
379
}, []);
@@ -359,7 +383,6 @@ function InnerElementType({ children, style, ...rest }) {
383
const [prevOwnerID, setPrevOwnerID] = useState(ownerID);
384
if (ownerID !== prevOwnerID) {
385
setPrevOwnerID(ownerID);
362
- setMinWidth(null);
386
}
387
388
// This style override enables the background color to fill the full visible width,
@@ -369,16 +392,11 @@ function InnerElementType({ children, style, ...rest }) {
392
return (
393
<div
394
className={styles.InnerElementType}
372
- style={{
373
- ...style,
374
- display: 'inline-block',
375
- minWidth: minWidth || '100%',
376
- width: undefined,
377
- }}
395
+ style={style}
396
ref={divRef}
397
{...rest}
398
>
381
- <Guidelines />
399
+ <Guideline />
400
{children}
401
</div>
402
);
src/devtools/views/Settings/Settings.js
+3
-23
@@ -11,14 +11,9 @@ import styles from './Settings.css';
11
12
function Settings(_: {||}) {
13
const store = useContext(StoreContext);
14
- const {
15
- displayDensity,
16
- setDisplayDensity,
17
- showIndentLines,
18
- setShowIndentLines,
19
- theme,
20
- setTheme,
21
- } = useContext(SettingsContext);
14
+ const { displayDensity, setDisplayDensity, theme, setTheme } = useContext(
15
+ SettingsContext
16
+ );
17
18
const captureScreenshotsSubscription = useMemo(
19
() => ({
@@ -62,13 +57,6 @@ function Settings(_: {||}) {
57
[setTheme]
58
);
59
65
- const updateShowIndentLines = useCallback(
66
- ({ currentTarget }) => {
67
- setShowIndentLines(currentTarget.checked);
68
- },
69
- [setShowIndentLines]
70
- );
71
-
60
const updateCaptureScreenshotsWhileProfiling = useCallback(
61
({ currentTarget }) => {
62
store.captureScreenshots = currentTarget.checked;
@@ -155,14 +143,6 @@ function Settings(_: {||}) {
143
/>{' '}
144
Collapse newly added components by default
145
</label>
158
- <label className={styles.CheckboxOption}>
159
- <input
160
- type="checkbox"
161
- checked={showIndentLines}
162
- onChange={updateShowIndentLines}
163
- />{' '}
164
- Show indent lines
165
- </label>
146
</div>
147
148
{store.supportsCaptureScreenshots && (
src/devtools/views/Settings/SettingsContext.js
-11
@@ -16,9 +16,6 @@ type Context = {|
16
// Specified as a separate prop so it can trigger a re-render of FixedSizeList.
17
lineHeight: number,
18
19
- showIndentLines: boolean,
20
- setShowIndentLines: (value: boolean) => void,
21
-
19
theme: Theme,
20
setTheme(value: Theme): void,
21
|};
@@ -47,10 +44,6 @@ function SettingsContextController({
44
'React::DevTools::displayDensity',
45
'compact'
46
);
50
- const [showIndentLines, setShowIndentLines] = useLocalStorage<boolean>(
51
- 'React::DevTools::showIndentLines',
52
- true
53
- );
47
const [theme, setTheme] = useLocalStorage<Theme>(
48
'React::DevTools::theme',
49
'auto'
@@ -133,8 +126,6 @@ function SettingsContextController({
126
setDisplayDensity,
127
theme,
128
setTheme,
136
- showIndentLines,
137
- setShowIndentLines,
129
lineHeight:
130
displayDensity === 'compact'
131
? compactLineHeight
@@ -144,9 +135,7 @@ function SettingsContextController({
135
comfortableLineHeight,
136
compactLineHeight,
137
displayDensity,
147
- showIndentLines,
138
setDisplayDensity,
149
- setShowIndentLines,
139
setTheme,
140
theme,
141
]
src/devtools/views/utils.js
+13
@@ -170,3 +170,16 @@ export function downloadFile(filename: string, text: string): void {
170
171
((document.body: any): HTMLBodyElement).removeChild(element);
172
}
173
+
174
+export function truncateText(text: string, maxLength: number): string {
175
+ const { length } = text;
176
+ if (length > maxLength) {
177
+ return (
178
+ text.substr(0, Math.floor(maxLength / 2)) +
179
+ '…' +
180
+ text.substr(length - Math.ceil(maxLength / 2) + 1)
181
+ );
182
+ } else {
183
+ return text;
184
+ }
185
+}