Files
flipper/desktop/flipper-plugin/src/ui/elements-inspector/elements.tsx
Michel Weststrate 5242a81e94 Fix elements jumping around when making a selection
Summary:
Minor usability improvements, addresses: https://fb.workplace.com/groups/flippersupport/permalink/1133169680497022/.

Handcrafted scroll detection, to make sure elements aren't scroll unnecessarily

Reviewed By: nikoant

Differential Revision: D28438078

fbshipit-source-id: 037f1456a5b6f37a0ea1b9e8318e54b3fad382ec
2021-05-17 03:17:00 -07:00

784 lines
21 KiB
TypeScript

/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {Dropdown, Menu, Typography} from 'antd';
import {ElementID, Element, ElementSearchResultSet} from './ElementsInspector';
import {createRef, PureComponent, ReactElement} from 'react';
import styled from '@emotion/styled';
import React, {MouseEvent, KeyboardEvent} from 'react';
import {theme} from '../theme';
import {Layout} from '../Layout';
import {tryGetFlipperLibImplementation} from 'flipper-plugin/src/plugin/FlipperLib';
import {DownOutlined, RightOutlined} from '@ant-design/icons';
const {Text} = Typography;
const contextMenuTrigger = ['contextMenu' as const];
export const ElementsConstants = {
rowHeight: 23,
};
const backgroundColor = (props: {
selected: boolean;
focused: boolean;
isQueryMatch: boolean;
even: boolean;
}) => {
if (props.selected) {
return '#4d84f5';
} else if (props.isQueryMatch) {
return '#4d84f5';
} else if (props.focused) {
return '#00CF52';
} else if (props.even) {
return '#f6f7f9';
} else {
return '';
}
};
const backgroundColorHover = (props: {selected: boolean; focused: boolean}) => {
if (props.selected) {
return '#4d84f5';
} else if (props.focused) {
return '#00CF52';
} else {
return '#EBF1FB';
}
};
const ElementsRowContainer = styled(Layout.Horizontal)<any>((props) => ({
flexDirection: 'row',
alignItems: 'center',
backgroundColor: backgroundColor(props),
color: props.selected || props.focused ? theme.backgroundDefault : '#58409b',
flexShrink: 0,
flexWrap: 'nowrap',
height: ElementsConstants.rowHeight,
paddingLeft: (props.level - 1) * 12,
paddingRight: 20,
position: 'relative',
'& *': {
color:
props.selected || props.focused
? `${theme.backgroundDefault} !important`
: '',
},
'&:hover': {
backgroundColor: backgroundColorHover(props),
},
}));
ElementsRowContainer.displayName = 'Elements:ElementsRowContainer';
const ElementsRowDecoration = styled(Layout.Horizontal)({
flexShrink: 0,
justifyContent: 'flex-end',
alignItems: 'center',
marginRight: 4,
position: 'relative',
width: 16,
top: -1,
});
ElementsRowDecoration.displayName = 'Elements:ElementsRowDecoration';
const ElementsLine = styled.div<{childrenCount: number}>((props) => ({
backgroundColor: '#bec2c9',
height: props.childrenCount * ElementsConstants.rowHeight - 4,
position: 'absolute',
right: 3,
top: ElementsConstants.rowHeight - 3,
zIndex: 2,
width: 2,
borderRadius: '999em',
}));
ElementsLine.displayName = 'Elements:ElementsLine';
const DecorationImage = styled.img({
height: 12,
marginRight: 5,
width: 12,
});
DecorationImage.displayName = 'Elements:DecorationImage';
const NoShrinkText = styled(Text)({
flexShrink: 0,
flexWrap: 'nowrap',
overflow: 'hidden',
fontWeight: 400,
font: theme.monospace.fontFamily,
fontSize: theme.monospace.fontSize,
});
NoShrinkText.displayName = 'Elements:NoShrinkText';
const ElementsRowAttributeContainer = styled(NoShrinkText)({
color: '#333333',
fontWeight: 300,
marginLeft: 5,
});
ElementsRowAttributeContainer.displayName =
'Elements:ElementsRowAttributeContainer';
const ElementsRowAttributeKey = styled.span({
color: '#fb724b',
});
ElementsRowAttributeKey.displayName = 'Elements:ElementsRowAttributeKey';
const ElementsRowAttributeValue = styled.span({
color: '#688694',
});
ElementsRowAttributeValue.displayName = 'Elements:ElementsRowAttributeValue';
// Merge this functionality with components/Highlight
class PartialHighlight extends PureComponent<{
selected: boolean;
highlighted: string | undefined | null;
content: string;
}> {
static HighlightedText = styled.span<{selected: boolean}>((props) => ({
backgroundColor: '#fcd872',
color: props.selected ? `${'#58409b'} !important` : 'auto',
}));
render() {
const {highlighted, content, selected} = this.props;
if (
content &&
highlighted != null &&
highlighted != '' &&
content.toLowerCase().includes(highlighted.toLowerCase())
) {
const highlightStart = content
.toLowerCase()
.indexOf(highlighted.toLowerCase());
const highlightEnd = highlightStart + highlighted.length;
const before = content.substring(0, highlightStart);
const match = content.substring(highlightStart, highlightEnd);
const after = content.substring(highlightEnd);
return (
<span>
{before}
<PartialHighlight.HighlightedText selected={selected}>
{match}
</PartialHighlight.HighlightedText>
{after}
</span>
);
} else {
return <span>{content}</span>;
}
}
}
class ElementsRowAttribute extends PureComponent<{
name: string;
value: string;
matchingSearchQuery: string | undefined | null;
selected: boolean;
}> {
render() {
const {name, value, matchingSearchQuery, selected} = this.props;
return (
<ElementsRowAttributeContainer>
<ElementsRowAttributeKey>{name}</ElementsRowAttributeKey>=
<ElementsRowAttributeValue>
<PartialHighlight
content={value}
highlighted={
name === 'id' || name === 'addr' ? matchingSearchQuery : ''
}
selected={selected}
/>
</ElementsRowAttributeValue>
</ElementsRowAttributeContainer>
);
}
}
type FlatElement = {
key: ElementID;
element: Element;
level: number;
};
type FlatElements = Array<FlatElement>;
type ElementsRowProps = {
id: ElementID;
level: number;
selected: boolean;
focused: boolean;
matchingSearchQuery: string | undefined | null;
isQueryMatch: boolean;
element: Element;
even: boolean;
onElementSelected: (key: ElementID) => void;
onElementExpanded: (key: ElementID, deep: boolean) => void;
childrenCount: number;
onElementHovered:
| ((key: ElementID | undefined | null) => void)
| undefined
| null;
onCopyExpandedTree: (key: Element, maxDepth: number) => string;
style?: Object;
contextMenuExtensions?: () => Array<ContextMenuExtension>;
decorateRow?: DecorateRow;
forwardedRef?: React.Ref<HTMLDivElement> | null;
};
type ElementsRowState = {
hovered: boolean;
};
class ElementsRow extends PureComponent<ElementsRowProps, ElementsRowState> {
constructor(props: ElementsRowProps, context: Object) {
super(props, context);
this.state = {hovered: false};
}
getContextMenu = () => {
const {props} = this;
let items = [
{
label: 'Copy',
click: () => {
tryGetFlipperLibImplementation()?.writeTextToClipboard(
props.onCopyExpandedTree(props.element, 0),
);
},
},
{
label: 'Copy expanded child elements',
click: () =>
tryGetFlipperLibImplementation()?.writeTextToClipboard(
props.onCopyExpandedTree(props.element, 255),
),
},
{
label: props.element.expanded ? 'Collapse' : 'Expand',
click: () => {
this.props.onElementExpanded(this.props.id, false);
},
},
{
label: props.element.expanded
? 'Collapse all child elements'
: 'Expand all child elements',
click: () => {
this.props.onElementExpanded(this.props.id, true);
},
},
];
items = items.concat(
props.element.attributes.map((o) => {
return {
label: `Copy ${o.name}`,
click: () => {
tryGetFlipperLibImplementation()?.writeTextToClipboard(o.value);
},
};
}),
);
// Array.isArray check for backward compatibility
const extensions: ContextMenuExtension[] | undefined = Array.isArray(
props.contextMenuExtensions,
)
? props.contextMenuExtensions
: props.contextMenuExtensions?.();
extensions?.forEach((extension) => {
items.push({
label: extension.label,
click: () => extension.click(this.props.id),
});
});
return (
<Menu>
{items.map(({label, click}) => (
<Menu.Item key={label} onClick={click}>
{label}
</Menu.Item>
))}
</Menu>
);
};
onClick = () => {
this.props.onElementSelected(this.props.id);
};
onDoubleClick = (event: MouseEvent<any>) => {
this.props.onElementSelected(this.props.id);
this.props.onElementExpanded(this.props.id, event.altKey);
};
onMouseEnter = () => {
this.setState({hovered: true});
if (this.props.onElementHovered) {
this.props.onElementHovered(this.props.id);
}
};
onMouseLeave = () => {
this.setState({hovered: false});
if (this.props.onElementHovered) {
this.props.onElementHovered(null);
}
};
render() {
const {
element,
id,
level,
selected,
focused,
style,
even,
matchingSearchQuery,
decorateRow,
forwardedRef,
} = this.props;
const hasChildren = element.children && element.children.length > 0;
let arrow;
if (hasChildren) {
arrow = (
<span
onClick={this.onDoubleClick}
role="button"
tabIndex={-1}
style={{
color: selected || focused ? 'white' : '#1d2129',
fontSize: '8px',
}}>
{element.expanded ? <DownOutlined /> : <RightOutlined />}
</span>
);
}
const attributes = element.attributes
? element.attributes.map((attr) => (
<ElementsRowAttribute
key={attr.name}
name={attr.name}
value={attr.value}
matchingSearchQuery={matchingSearchQuery}
selected={selected}
/>
))
: [];
const decoration = decorateRow
? decorateRow(element)
: defaultDecorateRow(element);
// when we hover over or select an expanded element with children, we show a line from the
// bottom of the element to the next sibling
let line;
const shouldShowLine =
(selected || this.state.hovered) && hasChildren && element.expanded;
if (shouldShowLine) {
line = <ElementsLine childrenCount={this.props.childrenCount} />;
}
return (
<Dropdown
key={id}
overlay={this.getContextMenu}
trigger={contextMenuTrigger}>
<ElementsRowContainer
level={level}
ref={forwardedRef}
selected={selected}
focused={focused}
matchingSearchQuery={matchingSearchQuery}
even={even}
onClick={this.onClick}
onDoubleClick={this.onDoubleClick}
onMouseEnter={this.onMouseEnter}
onMouseLeave={this.onMouseLeave}
isQueryMatch={this.props.isQueryMatch}
style={style}>
<ElementsRowDecoration>
{line}
{arrow}
</ElementsRowDecoration>
<NoShrinkText>
{decoration}
<PartialHighlight
content={element.name}
highlighted={matchingSearchQuery}
selected={selected}
/>
</NoShrinkText>
{attributes}
</ElementsRowContainer>
</Dropdown>
);
}
}
function defaultDecorateRow(element: Element) {
switch (element.decoration) {
case 'litho':
return <DecorationImage src="icons/litho-logo.png" />;
case 'componentkit':
return <DecorationImage src="icons/componentkit-logo.png" />;
case 'accessibility':
return <DecorationImage src="icons/accessibility.png" />;
default:
return null;
}
}
function containsKeyInSearchResults(
searchResults: ElementSearchResultSet | undefined | null,
key: ElementID,
) {
return searchResults != undefined && searchResults.matches.has(key);
}
const ElementsContainer = styled('div')({
display: 'table',
backgroundColor: theme.backgroundDefault,
});
ElementsContainer.displayName = 'Elements:ElementsContainer';
export type DecorateRow = (e: Element) => ReactElement<any> | undefined | null;
type ElementsProps = {
root: ElementID | undefined | null;
selected: ElementID | undefined | null;
focused?: ElementID | undefined | null;
searchResults: ElementSearchResultSet | undefined | null;
elements: {[key: string]: Element};
onElementSelected: (key: ElementID) => void;
onElementExpanded: (key: ElementID, deep: boolean) => void;
onElementHovered:
| ((key: ElementID | undefined | null) => void)
| undefined
| null;
alternateRowColor?: boolean;
contextMenuExtensions?: () => Array<ContextMenuExtension>;
decorateRow?: DecorateRow;
};
type ElementsState = {
flatKeys: Array<ElementID>;
flatElements: FlatElements;
maxDepth: number;
scrolledElement: string | null | undefined;
};
export type ContextMenuExtension = {
label: string;
click: (element: ElementID) => any;
};
export class Elements extends PureComponent<ElementsProps, ElementsState> {
static defaultProps = {
alternateRowColor: true,
};
constructor(props: ElementsProps, context: Object) {
super(props, context);
this.state = {
flatElements: [],
flatKeys: [],
maxDepth: 0,
scrolledElement: null,
};
}
static getDerivedStateFromProps(props: ElementsProps) {
const flatElements: FlatElements = [];
const flatKeys: Array<ElementID> = [];
let maxDepth = 0;
function seed(key: ElementID, level: number) {
const element = props.elements[key];
if (!element) {
return;
}
maxDepth = Math.max(maxDepth, level);
flatElements.push({
element,
key,
level,
});
flatKeys.push(key);
if (
element.children != null &&
element.children.length > 0 &&
element.expanded
) {
for (const key of element.children) {
seed(key, level + 1);
}
}
}
if (props.root != null) {
seed(props.root, 1);
} else {
const virtualRoots: Set<string> = new Set();
Object.keys(props.elements).forEach((id) => virtualRoots.add(id));
for (const [currentId, element] of Object.entries(props.elements)) {
if (!element) {
virtualRoots.delete(currentId);
} else {
element.children.forEach((id) => virtualRoots.delete(id));
}
}
virtualRoots.forEach((id) => seed(id, 1));
}
return {flatElements, flatKeys, maxDepth};
}
selectElement = (key: ElementID) => {
this.props.onElementSelected(key);
};
onKeyDown = (e: KeyboardEvent<any>) => {
const {selected} = this.props;
if (selected == null) {
return;
}
const {props} = this;
const {flatElements, flatKeys} = this.state;
const selectedIndex = flatKeys.indexOf(selected);
if (selectedIndex < 0) {
return;
}
const selectedElement = props.elements[selected];
if (!selectedElement) {
return;
}
if (
e.key === 'c' &&
((e.metaKey && process.platform === 'darwin') ||
(e.ctrlKey && process.platform !== 'darwin'))
) {
e.stopPropagation();
e.preventDefault();
tryGetFlipperLibImplementation()?.writeTextToClipboard(
selectedElement.name,
);
return;
}
if (e.key === 'ArrowUp') {
e.stopPropagation();
if (selectedIndex === 0 || flatKeys.length === 1) {
return;
}
e.preventDefault();
this.selectElement(flatKeys[selectedIndex - 1]);
}
if (e.key === 'ArrowDown') {
e.stopPropagation();
if (selectedIndex === flatKeys.length - 1) {
return;
}
e.preventDefault();
this.selectElement(flatKeys[selectedIndex + 1]);
}
if (e.key === 'ArrowLeft') {
e.stopPropagation();
e.preventDefault();
if (selectedElement.expanded) {
// unexpand
props.onElementExpanded(selected, false);
} else {
// jump to parent
let parentKey;
const targetLevel = flatElements[selectedIndex].level - 1;
for (let i = selectedIndex; i >= 0; i--) {
const {level} = flatElements[i];
if (level === targetLevel) {
parentKey = flatKeys[i];
break;
}
}
if (parentKey) {
this.selectElement(parentKey);
}
}
}
if (e.key === 'ArrowRight' && selectedElement.children.length > 0) {
e.stopPropagation();
e.preventDefault();
if (selectedElement.expanded) {
// go to first child
this.selectElement(selectedElement.children[0]);
} else {
// expand
props.onElementExpanded(selected, false);
}
}
};
onElementHoveredHandler = (key: string | null | undefined) => {
this.props.onElementHovered?.(key);
};
onCopyExpandedTree = (
element: Element,
maxDepth: number,
depth: number = 0,
): string => {
const shouldIncludeChildren = element.expanded && depth < maxDepth;
const children = shouldIncludeChildren
? element.children.map((childId) => {
const childElement = this.props.elements[childId];
return childElement == null
? ''
: this.onCopyExpandedTree(childElement, maxDepth, depth + 1);
})
: [];
const childrenValue = children.toString().replace(',', '');
const indentation = depth === 0 ? '' : '\n'.padEnd(depth * 2 + 1, ' ');
const attrs = element.attributes.reduce(
(acc, val) => acc + ` ${val.name}=${val.value}`,
'',
);
return `${indentation}${element.name}${attrs}${childrenValue}`;
};
parentRef = createRef<HTMLDivElement>();
scrollToSelectionRefHandler = (selectedRow: HTMLDivElement | null) => {
if (selectedRow && this.state.scrolledElement !== this.props.selected) {
if (
this.parentRef.current &&
!isInView(this.parentRef.current, selectedRow as HTMLElement)
) {
// second child is the element containing the element name
// by scrolling to the second element, we make sure padding is addressed and we scroll horizontally as well
selectedRow.children[1]?.scrollIntoView?.({
block: 'center',
inline: 'center',
});
}
this.setState({scrolledElement: this.props.selected});
}
};
buildRow = (row: FlatElement, index: number) => {
const {
onElementExpanded,
onElementSelected,
selected,
focused,
searchResults,
contextMenuExtensions,
decorateRow,
} = this.props;
const {flatElements} = this.state;
let childrenCount = 0;
for (let i = index + 1; i < flatElements.length; i++) {
const child = flatElements[i];
if (child.level <= row.level) {
break;
} else {
childrenCount++;
}
}
let isEven = false;
if (this.props.alternateRowColor) {
isEven = index % 2 === 0;
}
return (
<ElementsRow
level={row.level}
id={row.key}
key={row.key}
even={isEven}
onElementExpanded={onElementExpanded}
onElementHovered={this.onElementHoveredHandler}
onElementSelected={onElementSelected}
onCopyExpandedTree={this.onCopyExpandedTree}
selected={selected === row.key}
focused={focused === row.key}
matchingSearchQuery={
searchResults && containsKeyInSearchResults(searchResults, row.key)
? searchResults.query
: null
}
isQueryMatch={containsKeyInSearchResults(searchResults, row.key)}
element={row.element}
childrenCount={childrenCount}
contextMenuExtensions={contextMenuExtensions}
decorateRow={decorateRow}
forwardedRef={
selected == row.key // && this.state.scrolledElement !== selected
? this.scrollToSelectionRefHandler
: null
}
/>
);
};
render() {
return (
<ElementsContainer
onKeyDown={this.onKeyDown}
tabIndex={0}
ref={this.parentRef}>
{this.state.flatElements.map(this.buildRow)}
</ElementsContainer>
);
}
}
function isInView(parent: HTMLElement, el: HTMLElement) {
// find the scroll container. (This is fragile)
const scrollContainer = parent.parentElement!.parentElement!;
// check vertical scroll
if (
el.offsetTop > scrollContainer.scrollTop &&
el.offsetTop < scrollContainer.scrollTop + scrollContainer.clientHeight
) {
// check if horizontal scroll is needed,
// do this by checking the indented node, not the row (which is always visible)
const child = el.childNodes[0] as HTMLElement;
if (
child.offsetLeft > scrollContainer.scrollLeft &&
child.offsetLeft <
scrollContainer.scrollLeft + scrollContainer.clientWidth
) {
return true;
}
}
return false;
}