/**
* Copyright 2018-present Facebook.
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
* @format
*/
import type {
ElementID,
Element,
ElementSearchResultSet,
} from './ElementsInspector.js';
import {reportInteraction} from '../../../utils/InteractionTracker.tsx';
import ContextMenu from '../ContextMenu.js';
import {PureComponent, type Element as ReactElement} from 'react';
import FlexRow from '../FlexRow.js';
import FlexColumn from '../FlexColumn.js';
import Glyph from '../Glyph.js';
import {colors} from '../colors.js';
import Text from '../Text.js';
import styled from '../../styled/index.js';
import {clipboard} from 'electron';
const ROW_HEIGHT = 23;
const backgroundColor = props => {
if (props.selected) {
return colors.macOSTitleBarIconSelected;
} else if (props.isQueryMatch) {
return colors.purpleLight;
} else if (props.focused) {
return '#00CF52';
} else if (props.even) {
return colors.light02;
} else {
return '';
}
};
const backgroundColorHover = props => {
if (props.selected) {
return colors.macOSTitleBarIconSelected;
} else if (props.focused) {
return '#00CF52';
} else {
return '#EBF1FB';
}
};
const ElementsRowContainer = styled(ContextMenu)(props => ({
flexDirection: 'row',
alignItems: 'center',
backgroundColor: backgroundColor(props),
color: props.selected || props.focused ? colors.white : colors.grapeDark3,
flexShrink: 0,
flexWrap: 'nowrap',
height: ROW_HEIGHT,
minWidth: '100%',
paddingLeft: (props.level - 1) * 12,
paddingRight: 20,
position: 'relative',
'& *': {
color: props.selected || props.focused ? `${colors.white} !important` : '',
},
'&:hover': {
backgroundColor: backgroundColorHover(props),
},
}));
const ElementsRowDecoration = styled(FlexRow)({
flexShrink: 0,
justifyContent: 'flex-end',
alignItems: 'center',
marginRight: 4,
position: 'relative',
width: 16,
top: -1,
});
const ElementsLine = styled('div')(props => ({
backgroundColor: colors.light20,
height: props.childrenCount * ROW_HEIGHT - 4,
position: 'absolute',
right: 3,
top: ROW_HEIGHT - 3,
zIndex: 2,
width: 2,
borderRadius: '999em',
}));
const DecorationImage = styled('img')({
height: 12,
marginRight: 5,
width: 12,
});
const NoShrinkText = styled(Text)({
flexShrink: 0,
flexWrap: 'nowrap',
overflow: 'hidden',
userSelect: 'none',
fontWeight: 400,
});
const ElementsRowAttributeContainer = styled(NoShrinkText)({
color: colors.dark80,
fontWeight: 300,
marginLeft: 5,
});
const ElementsRowAttributeKey = styled('span')({
color: colors.tomato,
});
const ElementsRowAttributeValue = styled('span')({
color: colors.slateDark3,
});
class PartialHighlight extends PureComponent<{
selected: boolean,
highlighted: ?string,
content: string,
}> {
static HighlightedText = styled('span')(({selected}) => ({
backgroundColor: colors.lemon,
color: selected ? `${colors.grapeDark3} !important` : 'auto',
}));
render() {
const {highlighted, content, selected} = this.props;
let renderedValue;
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);
renderedValue = [
{before}
{match}
{after}
,
];
} else {
renderedValue = {content};
}
return renderedValue;
}
}
class ElementsRowAttribute extends PureComponent<{
name: string,
value: string,
matchingSearchQuery: ?string,
selected: boolean,
}> {
render() {
const {name, value, matchingSearchQuery, selected} = this.props;
return (
{name}=
);
}
}
type FlatElement = {|
key: ElementID,
element: Element,
level: number,
|};
type FlatElements = Array;
type ElementsRowProps = {
id: ElementID,
level: number,
selected: boolean,
focused: boolean,
matchingSearchQuery: ?string,
isQueryMatch: boolean,
element: Element,
even: boolean,
onElementSelected: (key: ElementID) => void,
onElementExpanded: (key: ElementID, deep: boolean) => void,
childrenCount: number,
onElementHovered: ?(key: ?ElementID) => void,
style?: Object,
contextMenuExtensions: Array,
decorateRow?: DecorateRow,
};
type ElementsRowState = {|
hovered: boolean,
|};
class ElementsRow extends PureComponent {
constructor(props: ElementsRowProps, context: Object) {
super(props, context);
this.state = {hovered: false};
this.interaction = reportInteraction('ElementsRow', props.element.name);
}
interaction: (name: string, data: any) => void;
getContextMenu = (): Array => {
const {props} = this;
const items = [
{
type: 'separator',
},
{
label: 'Copy',
click: () => {
clipboard.writeText(props.element.name + ' id=' + props.element.id);
},
},
{
label: props.element.expanded ? 'Collapse' : 'Expand',
click: () => {
this.props.onElementExpanded(this.props.id, false);
},
},
];
for (const extension of props.contextMenuExtensions) {
items.push({
label: extension.label,
click: () => extension.click(this.props.id),
});
}
return items;
};
onClick = () => {
this.props.onElementSelected(this.props.id);
this.interaction('selected', {level: this.props.level});
};
onDoubleClick = (event: SyntheticMouseEvent<*>) => {
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,
} = this.props;
const hasChildren = element.children && element.children.length > 0;
let arrow;
if (hasChildren) {
arrow = (
);
}
const attributes = element.attributes
? element.attributes.map(attr => (
))
: [];
const decoration = decorateRow
? decorateRow(element)
: (() => {
switch (element.decoration) {
case 'litho':
return ;
case 'componentkit':
return ;
case 'componentscript':
return ;
case 'accessibility':
return ;
default:
return null;
}
})();
// 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 = ;
}
return (
{line}
{arrow}
{decoration}
{attributes}
);
}
}
function containsKeyInSearchResults(
searchResults: ?ElementSearchResultSet,
key: ElementID,
) {
return searchResults != undefined && searchResults.matches.has(key);
}
const ElementsContainer = styled(FlexColumn)({
backgroundColor: colors.white,
minHeight: '100%',
minWidth: '100%',
overflow: 'auto',
});
const ElementsBox = styled(FlexColumn)({
alignItems: 'flex-start',
flex: 1,
overflow: 'auto',
});
export type DecorateRow = Element => ?ReactElement;
type ElementsProps = {|
root: ?ElementID,
selected: ?ElementID,
focused?: ?ElementID,
searchResults: ?ElementSearchResultSet,
elements: {[key: ElementID]: Element},
onElementSelected: (key: ElementID) => void,
onElementExpanded: (key: ElementID, deep: boolean) => void,
onElementHovered: ?(key: ?ElementID) => void,
alternateRowColor?: boolean,
contextMenuExtensions?: Array,
decorateRow?: DecorateRow,
|};
type ElementsState = {|
flatKeys: Array,
flatElements: FlatElements,
maxDepth: number,
|};
export type ContextMenuExtension = {|
label: string,
click: ElementID => void,
|};
export class Elements extends PureComponent {
static defaultProps = {
alternateRowColor: true,
};
constructor(props: ElementsProps, context: Object) {
super(props, context);
this.state = {
flatElements: [],
flatKeys: [],
maxDepth: 0,
};
}
componentDidMount() {
this.setProps(this.props, true);
}
componentWillReceiveProps(nextProps: ElementsProps) {
this.setProps(nextProps);
}
setProps(props: ElementsProps, force?: boolean) {
const flatElements: FlatElements = [];
const flatKeys = [];
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);
}
this.setState({flatElements, flatKeys, maxDepth});
}
selectElement = (key: ElementID) => {
this.props.onElementSelected(key);
};
onKeyDown = (e: SyntheticKeyboardEvent<*>) => {
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.preventDefault();
clipboard.writeText(selectedElement.name);
return;
}
if (e.key === 'ArrowUp') {
if (selectedIndex === 0 || flatKeys.length === 1) {
return;
}
e.preventDefault();
this.selectElement(flatKeys[selectedIndex - 1]);
}
if (e.key === 'ArrowDown') {
if (selectedIndex === flatKeys.length - 1) {
return;
}
e.preventDefault();
this.selectElement(flatKeys[selectedIndex + 1]);
}
if (e.key === 'ArrowLeft') {
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.preventDefault();
if (selectedElement.expanded) {
// go to first child
this.selectElement(selectedElement.children[0]);
} else {
// expand
props.onElementExpanded(selected, false);
}
}
};
buildRow = (row: FlatElement, index: number) => {
const {
elements,
onElementExpanded,
onElementHovered,
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 (
);
};
render() {
return (
{this.state.flatElements.map(this.buildRow)}
);
}
}