Initial commit 🎉
fbshipit-source-id: b6fc29740c6875d2e78953b8a7123890a67930f2 Co-authored-by: Sebastian McKenzie <sebmck@fb.com> Co-authored-by: John Knox <jknox@fb.com> Co-authored-by: Emil Sjölander <emilsj@fb.com> Co-authored-by: Pritesh Nandgaonkar <prit91@fb.com>
This commit is contained in:
12
src/ui/components/Block.js
Normal file
12
src/ui/components/Block.js
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 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 styled from '../styled/index.js';
|
||||
|
||||
export default styled.view({
|
||||
display: 'block',
|
||||
});
|
||||
15
src/ui/components/Box.js
Normal file
15
src/ui/components/Box.js
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* 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 FlexBox from './FlexBox.js';
|
||||
|
||||
export default FlexBox.extends({
|
||||
height: '100%',
|
||||
overflow: 'auto',
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
});
|
||||
363
src/ui/components/Button.js
Normal file
363
src/ui/components/Button.js
Normal file
@@ -0,0 +1,363 @@
|
||||
/**
|
||||
* 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 Glyph from './Glyph.js';
|
||||
import styled from '../styled/index.js';
|
||||
import type {StyledComponent} from '../styled/index.js';
|
||||
import {findDOMNode} from 'react-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
import {colors} from './colors.js';
|
||||
import {connect} from 'react-redux';
|
||||
import electron from 'electron';
|
||||
|
||||
const borderColor = props => {
|
||||
if (!props.windowIsFocused) {
|
||||
return colors.macOSTitleBarButtonBorderBlur;
|
||||
} else if (props.type === 'danger') {
|
||||
return colors.red;
|
||||
} else {
|
||||
return colors.macOSTitleBarButtonBorder;
|
||||
}
|
||||
};
|
||||
const borderBottomColor = props => {
|
||||
if (!props.windowIsFocused) {
|
||||
return colors.macOSTitleBarButtonBorderBlur;
|
||||
} else if (props.type === 'danger') {
|
||||
return colors.red;
|
||||
} else {
|
||||
return colors.macOSTitleBarButtonBorderBottom;
|
||||
}
|
||||
};
|
||||
|
||||
const StyledButton = styled.view(
|
||||
{
|
||||
backgroundColor: props => {
|
||||
if (!props.windowIsFocused) {
|
||||
return colors.macOSTitleBarButtonBackgroundBlur;
|
||||
} else {
|
||||
return colors.white;
|
||||
}
|
||||
},
|
||||
backgroundImage: props =>
|
||||
props.windowIsFocused
|
||||
? `linear-gradient(to bottom, transparent 0%,${
|
||||
colors.macOSTitleBarButtonBackground
|
||||
} 100%)`
|
||||
: 'none',
|
||||
borderStyle: 'solid',
|
||||
borderWidth: 1,
|
||||
borderColor,
|
||||
borderBottomColor,
|
||||
fontSize: props => (props.compact === true ? 11 : '1em'),
|
||||
color: props => {
|
||||
if (props.type === 'danger' && props.windowIsFocused) {
|
||||
return colors.red;
|
||||
} else if (props.disabled) {
|
||||
return colors.macOSTitleBarIconBlur;
|
||||
} else {
|
||||
return colors.light50;
|
||||
}
|
||||
},
|
||||
borderRadius: 4,
|
||||
position: 'relative',
|
||||
padding: '0 6px',
|
||||
height: props => (props.compact === true ? 24 : 28),
|
||||
margin: 0,
|
||||
marginLeft: props => (props.inButtonGroup === true ? 0 : 10),
|
||||
minWidth: 34,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
|
||||
boxShadow: props =>
|
||||
props.pulse && props.windowIsFocused
|
||||
? `0 0 0 ${colors.macOSTitleBarIconSelected}`
|
||||
: '',
|
||||
animation: props =>
|
||||
props.pulse && props.windowIsFocused ? 'pulse 1s infinite' : '',
|
||||
|
||||
'&:not(:first-child)': {
|
||||
borderTopLeftRadius: props => (props.inButtonGroup === true ? 0 : 4),
|
||||
borderBottomLeftRadius: props => (props.inButtonGroup === true ? 0 : 4),
|
||||
},
|
||||
|
||||
'&:not(:last-child)': {
|
||||
borderTopRightRadius: props => (props.inButtonGroup === true ? 0 : 4),
|
||||
borderBottomRightRadius: props => (props.inButtonGroup === true ? 0 : 4),
|
||||
borderRight: props => (props.inButtonGroup === true ? 0 : ''),
|
||||
},
|
||||
|
||||
'&:first-of-type': {
|
||||
marginLeft: 0,
|
||||
},
|
||||
|
||||
'&:active': {
|
||||
borderColor: colors.macOSTitleBarButtonBorder,
|
||||
borderBottomColor: colors.macOSTitleBarButtonBorderBottom,
|
||||
background: `linear-gradient(to bottom, ${
|
||||
colors.macOSTitleBarButtonBackgroundActiveHighlight
|
||||
} 1px, ${colors.macOSTitleBarButtonBackgroundActive} 0%, ${
|
||||
colors.macOSTitleBarButtonBorderBlur
|
||||
} 100%)`,
|
||||
},
|
||||
|
||||
'&:disabled': {
|
||||
borderColor,
|
||||
borderBottomColor,
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
|
||||
'&:hover::before': {
|
||||
content: props => (props.dropdown ? "''" : ''),
|
||||
position: 'absolute',
|
||||
bottom: 1,
|
||||
right: 2,
|
||||
borderStyle: 'solid',
|
||||
borderWidth: '4px 3px 0 3px',
|
||||
borderColor: props =>
|
||||
`${colors.macOSTitleBarIcon} transparent transparent transparent`,
|
||||
},
|
||||
},
|
||||
{
|
||||
ignoreAttributes: [
|
||||
'dispatch',
|
||||
'compact',
|
||||
'large',
|
||||
'windowIsFocused',
|
||||
'inButtonGroup',
|
||||
'danger',
|
||||
'pulse',
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
const Icon = Glyph.extends(
|
||||
{
|
||||
marginRight: props => (props.hasText ? 3 : 0),
|
||||
},
|
||||
{
|
||||
ignoreAttributes: ['hasText', 'type'],
|
||||
},
|
||||
);
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* onClick handler.
|
||||
*/
|
||||
onClick?: (event: SyntheticMouseEvent<>) => void,
|
||||
/**
|
||||
* Whether this button is disabled.
|
||||
*/
|
||||
disabled?: boolean,
|
||||
/**
|
||||
* Whether this button is large. Increases padding and line-height.
|
||||
*/
|
||||
large?: boolean,
|
||||
/**
|
||||
* Whether this button is compact. Decreases padding and line-height.
|
||||
*/
|
||||
compact?: boolean,
|
||||
/**
|
||||
* Type of button.
|
||||
*/
|
||||
type?: 'primary' | 'success' | 'warning' | 'danger',
|
||||
/**
|
||||
* Children.
|
||||
*/
|
||||
children?: React$Node,
|
||||
/**
|
||||
* Dropdown menu template shown on click.
|
||||
*/
|
||||
dropdown?: Array<Electron$MenuItemOptions>,
|
||||
/**
|
||||
* Name of the icon dispalyed next to the text
|
||||
*/
|
||||
icon?: string,
|
||||
iconSize?: number,
|
||||
/**
|
||||
* For toggle buttons, if the button is selected
|
||||
*/
|
||||
selected?: boolean,
|
||||
/**
|
||||
* Button is pulsing
|
||||
*/
|
||||
pulse?: boolean,
|
||||
/**
|
||||
* URL to open in the browser on click
|
||||
*/
|
||||
href?: string,
|
||||
};
|
||||
|
||||
type State = {
|
||||
active: boolean,
|
||||
};
|
||||
|
||||
/**
|
||||
* Simple button.
|
||||
*
|
||||
* **Usage**
|
||||
*
|
||||
* ```jsx
|
||||
* import {Button} from 'sonar';
|
||||
* <Button onClick={handler}>Click me</Button>
|
||||
* ```
|
||||
*
|
||||
* @example Default button
|
||||
* <Button>Click me</Button>
|
||||
* @example Primary button
|
||||
* <Button type="primary">Click me</Button>
|
||||
* @example Success button
|
||||
* <Button type="success">Click me</Button>
|
||||
* @example Warning button
|
||||
* <Button type="warning">Click me</Button>
|
||||
* @example Danger button
|
||||
* <Button type="danger">Click me</Button>
|
||||
* @example Default solid button
|
||||
* <Button solid={true}>Click me</Button>
|
||||
* @example Primary solid button
|
||||
* <Button type="primary" solid={true}>Click me</Button>
|
||||
* @example Success solid button
|
||||
* <Button type="success" solid={true}>Click me</Button>
|
||||
* @example Warning solid button
|
||||
* <Button type="warning" solid={true}>Click me</Button>
|
||||
* @example Danger solid button
|
||||
* <Button type="danger" solid={true}>Click me</Button>
|
||||
* @example Compact button
|
||||
* <Button compact={true}>Click me</Button>
|
||||
* @example Large button
|
||||
* <Button large={true}>Click me</Button>
|
||||
* @example Disabled button
|
||||
* <Button disabled={true}>Click me</Button>
|
||||
*/
|
||||
class Button extends styled.StylableComponent<
|
||||
Props & {windowIsFocused: boolean},
|
||||
State,
|
||||
> {
|
||||
static contextTypes = {
|
||||
inButtonGroup: PropTypes.bool,
|
||||
};
|
||||
|
||||
state = {
|
||||
active: false,
|
||||
};
|
||||
|
||||
_ref: ?Element | ?Text;
|
||||
|
||||
onMouseDown = () => this.setState({active: true});
|
||||
onMouseUp = () => this.setState({active: false});
|
||||
|
||||
onClick = (e: SyntheticMouseEvent<>) => {
|
||||
if (this.props.disabled === true) {
|
||||
return;
|
||||
}
|
||||
if (this.props.dropdown) {
|
||||
const menu = electron.remote.Menu.buildFromTemplate(this.props.dropdown);
|
||||
const position = {};
|
||||
if (this._ref != null && this._ref instanceof Element) {
|
||||
const {left, bottom} = this._ref.getBoundingClientRect();
|
||||
position.x = parseInt(left, 10);
|
||||
position.y = parseInt(bottom + 6, 10);
|
||||
}
|
||||
menu.popup(electron.remote.getCurrentWindow(), {
|
||||
async: true,
|
||||
...position,
|
||||
});
|
||||
}
|
||||
if (this.props.onClick) {
|
||||
this.props.onClick(e);
|
||||
}
|
||||
if (this.props.href != null) {
|
||||
electron.shell.openExternal(this.props.href);
|
||||
}
|
||||
};
|
||||
|
||||
setRef = (ref: ?React.ElementRef<any>) => {
|
||||
this._ref = findDOMNode(ref);
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
icon,
|
||||
children,
|
||||
selected,
|
||||
iconSize,
|
||||
windowIsFocused,
|
||||
...props
|
||||
} = this.props;
|
||||
const {active} = this.state;
|
||||
|
||||
let color = colors.macOSTitleBarIcon;
|
||||
if (props.disabled === true) {
|
||||
color = colors.macOSTitleBarIconBlur;
|
||||
} else if (windowIsFocused && selected === true) {
|
||||
color = colors.macOSTitleBarIconSelected;
|
||||
} else if (!windowIsFocused && (selected == null || selected === false)) {
|
||||
color = colors.macOSTitleBarIconBlur;
|
||||
} else if (!windowIsFocused && selected === true) {
|
||||
color = colors.macOSTitleBarIconSelectedBlur;
|
||||
} else if (selected == null && active) {
|
||||
color = colors.macOSTitleBarIconActive;
|
||||
} else if (props.type === 'danger') {
|
||||
color = colors.red;
|
||||
}
|
||||
|
||||
let iconComponent;
|
||||
if (icon != null) {
|
||||
iconComponent = (
|
||||
<Icon
|
||||
name={icon}
|
||||
size={
|
||||
iconSize != null ? iconSize : this.props.compact === true ? 12 : 16
|
||||
}
|
||||
color={color}
|
||||
hasText={Boolean(children)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledButton
|
||||
{...props}
|
||||
ref={this.setRef}
|
||||
windowIsFocused={windowIsFocused}
|
||||
onClick={this.onClick}
|
||||
onMouseDown={this.onMouseDown}
|
||||
onMouseUp={this.onMouseUp}
|
||||
inButtonGroup={this.context.inButtonGroup}>
|
||||
{iconComponent}
|
||||
{children}
|
||||
{this.props.pulse === true && (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 4px 0 ${colors.macOSTitleBarIconSelected};
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 4px 6px transparent;
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 4px 0 transparent;
|
||||
}
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</StyledButton>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const ConnectedButton = connect(({application: {windowIsFocused}}) => ({
|
||||
windowIsFocused,
|
||||
}))(Button);
|
||||
|
||||
// $FlowFixMe
|
||||
export default (ConnectedButton: StyledComponent<Props>);
|
||||
45
src/ui/components/ButtonGroup.js
Normal file
45
src/ui/components/ButtonGroup.js
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* 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 styled from '../styled/index.js';
|
||||
import {Component} from 'react';
|
||||
|
||||
const PropTypes = require('prop-types');
|
||||
|
||||
const ButtonGroupContainer = styled.view({
|
||||
display: 'inline-flex',
|
||||
marginLeft: 10,
|
||||
'&:first-child': {
|
||||
marginLeft: 0,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Group a series of buttons together.
|
||||
*
|
||||
* @example List of buttons
|
||||
* <ButtonGroup>
|
||||
* <Button>One</Button>
|
||||
* <Button>Two</Button>
|
||||
* <Button>Three</Button>
|
||||
* </ButtonGroup>
|
||||
*/
|
||||
export default class ButtonGroup extends Component<{
|
||||
children: React$Node,
|
||||
}> {
|
||||
getChildContext() {
|
||||
return {inButtonGroup: true};
|
||||
}
|
||||
|
||||
render() {
|
||||
return <ButtonGroupContainer>{this.props.children}</ButtonGroupContainer>;
|
||||
}
|
||||
}
|
||||
|
||||
ButtonGroup.childContextTypes = {
|
||||
inButtonGroup: PropTypes.bool,
|
||||
};
|
||||
28
src/ui/components/ButtonNavigationGroup.js
Normal file
28
src/ui/components/ButtonNavigationGroup.js
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 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 ButtonGroup from './ButtonGroup.js';
|
||||
import Button from './Button.js';
|
||||
|
||||
export default function ButtonNavigationGroup(props: {|
|
||||
canGoBack: boolean,
|
||||
canGoForward: boolean,
|
||||
onBack: () => void,
|
||||
onForward: () => void,
|
||||
|}) {
|
||||
return (
|
||||
<ButtonGroup>
|
||||
<Button disabled={!props.canGoBack} onClick={props.onBack}>
|
||||
{'<'}
|
||||
</Button>
|
||||
|
||||
<Button disabled={!props.canGoForward} onClick={props.onForward}>
|
||||
{'<'}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
);
|
||||
}
|
||||
36
src/ui/components/Checkbox.js
Normal file
36
src/ui/components/Checkbox.js
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 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 {PureComponent} from 'react';
|
||||
import styled from '../styled/index.js';
|
||||
|
||||
type CheckboxProps = {
|
||||
checked: boolean,
|
||||
onChange: (checked: boolean) => void,
|
||||
};
|
||||
|
||||
const CheckboxContainer = styled.textInput({
|
||||
display: 'inline-block',
|
||||
marginRight: 5,
|
||||
verticalAlign: 'middle',
|
||||
});
|
||||
|
||||
export default class Checkbox extends PureComponent<CheckboxProps> {
|
||||
onChange = (e: SyntheticInputEvent<HTMLInputElement>) => {
|
||||
this.props.onChange(e.target.checked);
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<CheckboxContainer
|
||||
type="checkbox"
|
||||
checked={this.props.checked}
|
||||
onChange={this.onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
12
src/ui/components/ClickableList.js
Normal file
12
src/ui/components/ClickableList.js
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 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 styled from '../styled/index.js';
|
||||
|
||||
export default styled.view({
|
||||
marginBottom: 10,
|
||||
});
|
||||
33
src/ui/components/ClickableListItem.js
Normal file
33
src/ui/components/ClickableListItem.js
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* 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 styled from '../styled/index.js';
|
||||
import {colors} from './colors.js';
|
||||
|
||||
export default styled.view(
|
||||
{
|
||||
backgroundColor: ({active, windowFocused}) => {
|
||||
if (active && windowFocused) {
|
||||
return colors.macOSTitleBarIconSelected;
|
||||
} else if (active && !windowFocused) {
|
||||
return colors.macOSTitleBarBorderBlur;
|
||||
} else {
|
||||
return 'none';
|
||||
}
|
||||
},
|
||||
color: ({active, windowFocused}) =>
|
||||
active && windowFocused ? colors.white : colors.macOSSidebarSectionItem,
|
||||
lineHeight: '25px',
|
||||
padding: '0 10px',
|
||||
'&[disabled]': {
|
||||
color: 'rgba(0, 0, 0, 0.5)',
|
||||
},
|
||||
},
|
||||
{
|
||||
ignoreAttributes: ['active', 'windowFocused'],
|
||||
},
|
||||
);
|
||||
12
src/ui/components/CodeBlock.js
Normal file
12
src/ui/components/CodeBlock.js
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 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 styled from '../styled/index.js';
|
||||
|
||||
export default styled.view({
|
||||
fontFamily: 'monospace',
|
||||
});
|
||||
56
src/ui/components/ContextMenu.js
Normal file
56
src/ui/components/ContextMenu.js
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 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 FlexColumn from './FlexColumn.js';
|
||||
import styled from '../styled/index.js';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
type MenuTemplate = Array<Electron$MenuItemOptions>;
|
||||
|
||||
type Props = {
|
||||
items?: MenuTemplate,
|
||||
buildItems?: () => MenuTemplate,
|
||||
children: React$Node,
|
||||
component: React.ComponentType<any> | string,
|
||||
};
|
||||
|
||||
export default class ContextMenu extends styled.StylablePureComponent<Props> {
|
||||
static defaultProps = {
|
||||
component: FlexColumn,
|
||||
};
|
||||
|
||||
static contextTypes = {
|
||||
appendToContextMenu: PropTypes.func,
|
||||
};
|
||||
|
||||
onContextMenu = (e: SyntheticMouseEvent<>) => {
|
||||
if (typeof this.context.appendToContextMenu === 'function') {
|
||||
if (this.props.items != null) {
|
||||
this.context.appendToContextMenu(this.props.items);
|
||||
} else if (this.props.buildItems != null) {
|
||||
this.context.appendToContextMenu(this.props.buildItems());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
items: _itesm,
|
||||
buildItems: _buildItems,
|
||||
component,
|
||||
...props
|
||||
} = this.props;
|
||||
return React.createElement(
|
||||
component,
|
||||
{
|
||||
onContextMenu: this.onContextMenu,
|
||||
...props,
|
||||
},
|
||||
this.props.children,
|
||||
);
|
||||
}
|
||||
}
|
||||
50
src/ui/components/ContextMenuProvider.js
Normal file
50
src/ui/components/ContextMenuProvider.js
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 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 {Component} from 'react';
|
||||
import styled from '../styled/index.js';
|
||||
import electron from 'electron';
|
||||
|
||||
const PropTypes = require('prop-types');
|
||||
|
||||
type MenuTemplate = Array<Electron$MenuItemOptions>;
|
||||
|
||||
const Container = styled.view({
|
||||
display: 'contents',
|
||||
});
|
||||
|
||||
export default class ContextMenuProvider extends Component<{|
|
||||
children: React$Node,
|
||||
|}> {
|
||||
static childContextTypes = {
|
||||
appendToContextMenu: PropTypes.func,
|
||||
};
|
||||
|
||||
getChildContext() {
|
||||
return {appendToContextMenu: this.appendToContextMenu};
|
||||
}
|
||||
|
||||
_menuTemplate: MenuTemplate = [];
|
||||
|
||||
appendToContextMenu = (items: MenuTemplate) => {
|
||||
this._menuTemplate = this._menuTemplate.concat(items);
|
||||
};
|
||||
|
||||
onContextMenu = () => {
|
||||
const menu = electron.remote.Menu.buildFromTemplate(this._menuTemplate);
|
||||
this._menuTemplate = [];
|
||||
menu.popup(electron.remote.getCurrentWindow(), {async: true});
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Container onContextMenu={this.onContextMenu}>
|
||||
{this.props.children}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
35
src/ui/components/Dropdown.js
Normal file
35
src/ui/components/Dropdown.js
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 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 ContextMenu from './ContextMenu.js';
|
||||
|
||||
const invariant = require('invariant');
|
||||
|
||||
export default class Dropdown extends ContextMenu {
|
||||
trigger: string = 'onClick';
|
||||
|
||||
ref: ?HTMLElement;
|
||||
|
||||
getCoordinates(): {top: number, left: number} {
|
||||
const {ref} = this;
|
||||
invariant(ref, 'expected ref');
|
||||
|
||||
const rect = ref.getBoundingClientRect();
|
||||
return {
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
};
|
||||
}
|
||||
|
||||
setRef = (ref: ?HTMLElement) => {
|
||||
this.ref = ref;
|
||||
};
|
||||
|
||||
render() {
|
||||
return <span ref={this.setRef}>{this.props.children}</span>;
|
||||
}
|
||||
}
|
||||
38
src/ui/components/ErrorBlock.js
Normal file
38
src/ui/components/ErrorBlock.js
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 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 styled from '../styled/index.js';
|
||||
import CodeBlock from './CodeBlock.js';
|
||||
|
||||
const ErrorBlockContainer = CodeBlock.extends({
|
||||
backgroundColor: '#f2dede',
|
||||
border: '1px solid #ebccd1',
|
||||
borderRadius: 4,
|
||||
color: '#a94442',
|
||||
overflow: 'auto',
|
||||
padding: 10,
|
||||
});
|
||||
|
||||
export default class ErrorBlock extends styled.StylableComponent<{
|
||||
error: Error | string | void,
|
||||
className?: string,
|
||||
}> {
|
||||
render() {
|
||||
const {className, error} = this.props;
|
||||
|
||||
let stack = 'Unknown Error';
|
||||
if (typeof error === 'string') {
|
||||
stack = error;
|
||||
} else if (error && typeof error === 'object') {
|
||||
stack = error.stack || error.message || stack;
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBlockContainer className={className}>{stack}</ErrorBlockContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
80
src/ui/components/ErrorBoundary.js
Normal file
80
src/ui/components/ErrorBoundary.js
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 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 ErrorBlock from './ErrorBlock.js';
|
||||
import {Component} from 'react';
|
||||
import Heading from './Heading.js';
|
||||
import Button from './Button.js';
|
||||
import View from './View.js';
|
||||
import LogManager from '../../fb-stubs/Logger.js';
|
||||
|
||||
const ErrorBoundaryContainer = View.extends({
|
||||
overflow: 'auto',
|
||||
padding: 10,
|
||||
});
|
||||
|
||||
const ErrorBoundaryStack = ErrorBlock.extends({
|
||||
marginBottom: 10,
|
||||
});
|
||||
|
||||
type ErrorBoundaryProps = {
|
||||
buildHeading?: (err: Error) => string,
|
||||
heading?: string,
|
||||
logger?: LogManager,
|
||||
showStack?: boolean,
|
||||
children?: React$Node,
|
||||
};
|
||||
|
||||
type ErrorBoundaryState = {|
|
||||
error: ?Error,
|
||||
|};
|
||||
|
||||
export default class ErrorBoundary extends Component<
|
||||
ErrorBoundaryProps,
|
||||
ErrorBoundaryState,
|
||||
> {
|
||||
constructor(props: ErrorBoundaryProps, context: Object) {
|
||||
super(props, context);
|
||||
this.state = {error: null};
|
||||
}
|
||||
|
||||
componentDidCatch(err: Error) {
|
||||
this.props.logger &&
|
||||
this.props.logger.error(err.toString(), 'ErrorBoundary');
|
||||
this.setState({error: err});
|
||||
}
|
||||
|
||||
clearError = () => {
|
||||
this.setState({error: null});
|
||||
};
|
||||
|
||||
render() {
|
||||
const {error} = this.state;
|
||||
if (error) {
|
||||
const {buildHeading} = this.props;
|
||||
let {heading} = this.props;
|
||||
if (buildHeading) {
|
||||
heading = buildHeading(error);
|
||||
}
|
||||
if (heading == null) {
|
||||
heading = 'An error has occured';
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBoundaryContainer fill={true}>
|
||||
<Heading>{heading}</Heading>
|
||||
{this.props.showStack !== false && (
|
||||
<ErrorBoundaryStack error={error} />
|
||||
)}
|
||||
<Button onClick={this.clearError}>Clear error and try again</Button>
|
||||
</ErrorBoundaryContainer>
|
||||
);
|
||||
} else {
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
}
|
||||
82
src/ui/components/File.js
Normal file
82
src/ui/components/File.js
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* 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 {Component} from 'react';
|
||||
|
||||
const React = require('react');
|
||||
const fs = require('fs');
|
||||
|
||||
type FileProps = {|
|
||||
src: string,
|
||||
buffer?: ?string,
|
||||
encoding: string,
|
||||
onError?: (err: Error) => React.Element<*>,
|
||||
onLoading?: () => React.Element<*>,
|
||||
onData?: (content: string) => void,
|
||||
onLoad: (content: string) => React.Element<*>,
|
||||
|};
|
||||
|
||||
type FileState = {|
|
||||
error: ?Error,
|
||||
loaded: boolean,
|
||||
content: string,
|
||||
|};
|
||||
|
||||
export default class File extends Component<FileProps, FileState> {
|
||||
constructor(props: FileProps, context: Object) {
|
||||
super(props, context);
|
||||
this.state = {
|
||||
content: props.buffer || '',
|
||||
error: null,
|
||||
loaded: props.buffer != null,
|
||||
};
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
encoding: 'utf8',
|
||||
};
|
||||
|
||||
componentWillReceiveProps(nextProps: FileProps) {
|
||||
if (nextProps.buffer != null) {
|
||||
this.setState({content: nextProps.buffer, loaded: true});
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.state.loaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
fs.readFile(this.props.src, this.props.encoding, (err, content) => {
|
||||
if (err) {
|
||||
this.setState({error: err});
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({content, loaded: true});
|
||||
|
||||
if (this.props.onData) {
|
||||
this.props.onData(content);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const {onError, onLoad, onLoading} = this.props;
|
||||
const {content, error, loaded} = this.state;
|
||||
|
||||
if (error && onError) {
|
||||
return onError(error);
|
||||
} else if (loaded) {
|
||||
return onLoad(content);
|
||||
} else if (onLoading) {
|
||||
return onLoading();
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
173
src/ui/components/FileList.js
Normal file
173
src/ui/components/FileList.js
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* 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 {Component} from 'react';
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const EMPTY_MAP = new Map();
|
||||
const EMPTY_FILE_LIST_STATE = {error: null, files: EMPTY_MAP};
|
||||
|
||||
export type FileListFileType = 'file' | 'folder';
|
||||
|
||||
export type FileListFile = {|
|
||||
name: string,
|
||||
src: string,
|
||||
type: FileListFileType,
|
||||
size: number,
|
||||
mtime: number,
|
||||
atime: number,
|
||||
ctime: number,
|
||||
birthtime: number,
|
||||
|};
|
||||
|
||||
export type FileListFiles = Array<FileListFile>;
|
||||
|
||||
type FileListProps = {
|
||||
src: string,
|
||||
onError?: ?(err: Error) => React$Node,
|
||||
onLoad?: () => void,
|
||||
onFiles: (files: FileListFiles) => React$Node,
|
||||
};
|
||||
|
||||
type FileListState = {|
|
||||
files: Map<string, FileListFile>,
|
||||
error: ?Error,
|
||||
|};
|
||||
|
||||
export default class FileList extends Component<FileListProps, FileListState> {
|
||||
constructor(props: FileListProps, context: Object) {
|
||||
super(props, context);
|
||||
this.state = EMPTY_FILE_LIST_STATE;
|
||||
}
|
||||
|
||||
watcher: ?fs.FSWatcher;
|
||||
|
||||
fetchFile(name: string): Promise<FileListFile> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const loc = path.join(this.props.src, name);
|
||||
|
||||
fs.lstat(loc, (err, stat) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
const details: FileListFile = {
|
||||
atime: Number(stat.atime),
|
||||
birthtime:
|
||||
typeof stat.birthtime === 'object' ? Number(stat.birthtime) : 0,
|
||||
ctime: Number(stat.ctime),
|
||||
mtime: Number(stat.mtime),
|
||||
name,
|
||||
size: stat.size,
|
||||
src: loc,
|
||||
type: stat.isDirectory() ? 'folder' : 'file',
|
||||
};
|
||||
resolve(details);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fetchFiles(callback?: Function) {
|
||||
const {src} = this.props;
|
||||
|
||||
const setState = data => {
|
||||
if (!hasChangedDir()) {
|
||||
this.setState(data);
|
||||
}
|
||||
};
|
||||
|
||||
const hasChangedDir = () => this.props.src !== src;
|
||||
|
||||
fs.readdir(src, (err, files) => {
|
||||
if (err) {
|
||||
setState({error: err, files: EMPTY_MAP});
|
||||
return;
|
||||
}
|
||||
|
||||
const filesSet: Map<string, FileListFile> = new Map();
|
||||
const next = () => {
|
||||
if (hasChangedDir()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!files.length) {
|
||||
setState({error: null, files: filesSet});
|
||||
if (callback) {
|
||||
callback();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const name = files.shift();
|
||||
this.fetchFile(name)
|
||||
.then(data => {
|
||||
filesSet.set(name, data);
|
||||
next();
|
||||
})
|
||||
.catch(err => {
|
||||
setState({error: err, files: EMPTY_MAP});
|
||||
});
|
||||
};
|
||||
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps: FileListProps) {
|
||||
if (nextProps.src !== this.props.src) {
|
||||
this.initialFetch(nextProps);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.initialFetch(this.props);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.removeWatcher();
|
||||
}
|
||||
|
||||
initialFetch(props: FileListProps) {
|
||||
this.removeWatcher();
|
||||
|
||||
fs.access(props.src, fs.constants.R_OK, err => {
|
||||
if (err) {
|
||||
this.setState({error: err, files: EMPTY_MAP});
|
||||
return;
|
||||
}
|
||||
|
||||
this.fetchFiles(props.onLoad);
|
||||
|
||||
this.watcher = fs.watch(props.src, () => {
|
||||
this.fetchFiles();
|
||||
});
|
||||
|
||||
this.watcher.on('error', err => {
|
||||
this.setState({error: err, files: EMPTY_MAP});
|
||||
this.removeWatcher();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
removeWatcher() {
|
||||
if (this.watcher) {
|
||||
this.watcher.close();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {error, files} = this.state;
|
||||
const {onError, onFiles} = this.props;
|
||||
if (error && onError) {
|
||||
return onError(error);
|
||||
} else {
|
||||
return onFiles(Array.from(files.values()));
|
||||
}
|
||||
}
|
||||
}
|
||||
18
src/ui/components/FlexBox.js
Normal file
18
src/ui/components/FlexBox.js
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* 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 View from './View.js';
|
||||
|
||||
export default View.extends(
|
||||
{
|
||||
display: 'flex',
|
||||
flexShrink: props => (props.shrink == null || props.shrink ? 1 : 0),
|
||||
},
|
||||
{
|
||||
ignoreAttributes: ['shrink'],
|
||||
},
|
||||
);
|
||||
14
src/ui/components/FlexCenter.js
Normal file
14
src/ui/components/FlexCenter.js
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 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 View from './View.js';
|
||||
|
||||
export default View.extends({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
});
|
||||
12
src/ui/components/FlexColumn.js
Normal file
12
src/ui/components/FlexColumn.js
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 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 FlexBox from './FlexBox.js';
|
||||
|
||||
export default FlexBox.extends({
|
||||
flexDirection: 'column',
|
||||
});
|
||||
12
src/ui/components/FlexRow.js
Normal file
12
src/ui/components/FlexRow.js
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 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 FlexBox from './FlexBox.js';
|
||||
|
||||
export default FlexBox.extends({
|
||||
flexDirection: 'row',
|
||||
});
|
||||
72
src/ui/components/FocusableBox.js
Normal file
72
src/ui/components/FocusableBox.js
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* 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 {Component} from 'react';
|
||||
import Box from './Box.js';
|
||||
import {colors} from './colors';
|
||||
|
||||
const FocusableBoxBorder = Box.extends(
|
||||
{
|
||||
border: `1px solid ${colors.highlight}`,
|
||||
bottom: '0',
|
||||
left: '0',
|
||||
pointerEvents: 'none',
|
||||
position: 'absolute',
|
||||
right: '0',
|
||||
top: '0',
|
||||
},
|
||||
{
|
||||
ignoreAttributes: [],
|
||||
},
|
||||
);
|
||||
|
||||
export default class FocusableBox extends Component<
|
||||
Object,
|
||||
{|
|
||||
focused: boolean,
|
||||
|},
|
||||
> {
|
||||
constructor(props: Object, context: Object) {
|
||||
super(props, context);
|
||||
this.state = {focused: false};
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
focusable: true,
|
||||
};
|
||||
|
||||
onBlur = (e: SyntheticFocusEvent<>) => {
|
||||
const {onBlur} = this.props;
|
||||
if (onBlur) {
|
||||
onBlur(e);
|
||||
}
|
||||
if (this.state.focused) {
|
||||
this.setState({focused: false});
|
||||
}
|
||||
};
|
||||
|
||||
onFocus = (e: SyntheticFocusEvent<>) => {
|
||||
const {onFocus} = this.props;
|
||||
if (onFocus) {
|
||||
onFocus(e);
|
||||
}
|
||||
if (this.props.focusable) {
|
||||
this.setState({focused: true});
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {props} = this;
|
||||
|
||||
return (
|
||||
<Box {...props} onFocus={this.onFocus} onBlur={this.onBlur} tabIndex="0">
|
||||
{props.children}
|
||||
{this.state.focused && <FocusableBoxBorder />}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
107
src/ui/components/Glyph.js
Normal file
107
src/ui/components/Glyph.js
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* 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 styled from '../styled/index.js';
|
||||
const PropTypes = require('prop-types');
|
||||
import {getIconUrl} from '../../utils/icons.js';
|
||||
|
||||
const ColoredIconBlack = styled.image(
|
||||
{
|
||||
height: props => props.size,
|
||||
verticalAlign: 'middle',
|
||||
width: props => props.size,
|
||||
},
|
||||
{
|
||||
ignoreAttributes: ['size'],
|
||||
},
|
||||
);
|
||||
|
||||
const ColoredIconCustom = styled.view(
|
||||
{
|
||||
height: props => props.size,
|
||||
verticalAlign: 'middle',
|
||||
width: props => props.size,
|
||||
backgroundColor: props => props.color,
|
||||
display: 'inline-block',
|
||||
maskImage: props => `url('${props.src}')`,
|
||||
maskSize: '100% 100%',
|
||||
// $FlowFixMe: prefixed property
|
||||
WebkitMaskImage: props => `url('${props.src}')`,
|
||||
// $FlowFixMe: prefixed property
|
||||
WebkitMaskSize: '100% 100%',
|
||||
},
|
||||
{
|
||||
ignoreAttributes: ['color', 'size', 'src'],
|
||||
},
|
||||
);
|
||||
|
||||
export function ColoredIcon(
|
||||
props: {|
|
||||
name: string,
|
||||
src: string,
|
||||
size?: number,
|
||||
className?: string,
|
||||
color?: string,
|
||||
|},
|
||||
context: {|
|
||||
glyphColor?: string,
|
||||
|},
|
||||
) {
|
||||
const {color = context.glyphColor, name, size = 16, src} = props;
|
||||
|
||||
const isBlack =
|
||||
color == null ||
|
||||
color === '#000' ||
|
||||
color === 'black' ||
|
||||
color === '#000000';
|
||||
|
||||
if (isBlack) {
|
||||
return (
|
||||
<ColoredIconBlack
|
||||
alt={name}
|
||||
src={src}
|
||||
size={size}
|
||||
className={props.className}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<ColoredIconCustom
|
||||
color={color}
|
||||
size={size}
|
||||
src={src}
|
||||
className={props.className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ColoredIcon.contextTypes = {
|
||||
glyphColor: PropTypes.string,
|
||||
};
|
||||
|
||||
export default class Glyph extends styled.StylablePureComponent<{
|
||||
name: string,
|
||||
size?: 8 | 10 | 12 | 16 | 18 | 20 | 24 | 32,
|
||||
variant?: 'filled' | 'outline',
|
||||
className?: string,
|
||||
color?: string,
|
||||
}> {
|
||||
render() {
|
||||
const {name, size, variant, color, className} = this.props;
|
||||
|
||||
return (
|
||||
<ColoredIcon
|
||||
name={name}
|
||||
className={className}
|
||||
color={color}
|
||||
size={size}
|
||||
src={getIconUrl(name, size, variant)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
57
src/ui/components/Heading.js
Normal file
57
src/ui/components/Heading.js
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* 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 styled from '../styled/index.js';
|
||||
|
||||
const LargeHeading = styled.view({
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
lineHeight: '20px',
|
||||
borderBottom: '1px solid #ddd',
|
||||
marginBottom: 10,
|
||||
});
|
||||
|
||||
const SmallHeading = styled.view({
|
||||
fontSize: 12,
|
||||
color: '#90949c',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 10,
|
||||
textTransform: 'uppercase',
|
||||
});
|
||||
|
||||
/**
|
||||
* A heading component.
|
||||
*
|
||||
* @example Heading 1
|
||||
* <Heading level={1}>I'm a heading</Heading>
|
||||
* @example Heading 2
|
||||
* <Heading level={2}>I'm a heading</Heading>
|
||||
* @example Heading 3
|
||||
* <Heading level={3}>I'm a heading</Heading>
|
||||
* @example Heading 4
|
||||
* <Heading level={4}>I'm a heading</Heading>
|
||||
* @example Heading 5
|
||||
* <Heading level={5}>I'm a heading</Heading>
|
||||
* @example Heading 6
|
||||
* <Heading level={6}>I'm a heading</Heading>
|
||||
*/
|
||||
export default function Heading(props: {
|
||||
/**
|
||||
* Level of the heading. A number from 1-6. Where 1 is the largest heading.
|
||||
*/
|
||||
level?: number,
|
||||
/**
|
||||
* Children.
|
||||
*/
|
||||
children?: React$Node,
|
||||
}) {
|
||||
if (props.level === 1) {
|
||||
return <LargeHeading>{props.children}</LargeHeading>;
|
||||
} else {
|
||||
return <SmallHeading>{props.children}</SmallHeading>;
|
||||
}
|
||||
}
|
||||
14
src/ui/components/HorizontalRule.js
Normal file
14
src/ui/components/HorizontalRule.js
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 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 styled from '../styled/index.js';
|
||||
|
||||
export default styled.view({
|
||||
backgroundColor: '#c9ced4',
|
||||
height: 1,
|
||||
margin: '5px 0',
|
||||
});
|
||||
14
src/ui/components/InlineContextMenu.js
Normal file
14
src/ui/components/InlineContextMenu.js
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 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 ContextMenu from './ContextMenu.js';
|
||||
|
||||
export default class InlineContextMenu extends ContextMenu {
|
||||
render() {
|
||||
return <span>{this.props.children}</span>;
|
||||
}
|
||||
}
|
||||
41
src/ui/components/Input.js
Normal file
41
src/ui/components/Input.js
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 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 styled from '../styled/index.js';
|
||||
import {colors} from './colors.js';
|
||||
|
||||
export const inputStyle = {
|
||||
border: `1px solid ${colors.light15}`,
|
||||
borderRadius: 4,
|
||||
font: 'inherit',
|
||||
fontSize: '1em',
|
||||
height: (props: Object) => (props.compact ? '17px' : '28px'),
|
||||
lineHeight: (props: Object) => (props.compact ? '17px' : '28px'),
|
||||
marginRight: 5,
|
||||
|
||||
'&:disabled': {
|
||||
backgroundColor: '#ddd',
|
||||
borderColor: '#ccc',
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
};
|
||||
|
||||
const Input = styled.textInput(
|
||||
{
|
||||
...inputStyle,
|
||||
padding: props => (props.compact ? '0 5px' : '0 10px'),
|
||||
},
|
||||
{
|
||||
ignoreAttributes: ['compact'],
|
||||
},
|
||||
);
|
||||
|
||||
Input.defaultProps = {
|
||||
type: 'text',
|
||||
};
|
||||
|
||||
export default Input;
|
||||
696
src/ui/components/Interactive.js
Normal file
696
src/ui/components/Interactive.js
Normal file
@@ -0,0 +1,696 @@
|
||||
/**
|
||||
* 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 {Rect} from '../../utils/geometry.js';
|
||||
import LowPassFilter from '../../utils/LowPassFilter.js';
|
||||
import {
|
||||
getDistanceTo,
|
||||
maybeSnapLeft,
|
||||
maybeSnapTop,
|
||||
SNAP_SIZE,
|
||||
} from '../../utils/snap.js';
|
||||
import {styled} from 'sonar';
|
||||
|
||||
const invariant = require('invariant');
|
||||
const React = require('react');
|
||||
|
||||
const WINDOW_CURSOR_BOUNDARY = 5;
|
||||
|
||||
type CursorState = {|
|
||||
top: number,
|
||||
left: number,
|
||||
|};
|
||||
|
||||
type ResizingSides = ?{|
|
||||
left?: boolean,
|
||||
top?: boolean,
|
||||
bottom?: boolean,
|
||||
right?: boolean,
|
||||
|};
|
||||
|
||||
const ALL_RESIZABLE: ResizingSides = {
|
||||
bottom: true,
|
||||
left: true,
|
||||
right: true,
|
||||
top: true,
|
||||
};
|
||||
|
||||
type InteractiveProps = {|
|
||||
isMovableAnchor?: (event: SyntheticMouseEvent<>) => boolean,
|
||||
onMoveStart?: () => void,
|
||||
onMoveEnd?: () => void,
|
||||
onMove?: (top: number, left: number, event: SyntheticMouseEvent<>) => void,
|
||||
id?: string,
|
||||
movable?: boolean,
|
||||
hidden?: boolean,
|
||||
moving?: boolean,
|
||||
fill?: boolean,
|
||||
siblings?: $Shape<{[key: string]: $Shape<Rect>}>,
|
||||
updateCursor?: (cursor: ?string) => void,
|
||||
zIndex?: number,
|
||||
top?: number,
|
||||
left?: number,
|
||||
minTop: number,
|
||||
minLeft: number,
|
||||
width?: number | string,
|
||||
height?: number | string,
|
||||
minWidth: number,
|
||||
minHeight: number,
|
||||
maxWidth?: number,
|
||||
maxHeight?: number,
|
||||
onCanResize?: (sides: ResizingSides) => void,
|
||||
onResizeStart?: () => void,
|
||||
onResizeEnd?: () => void,
|
||||
onResize?: (width: number, height: number) => void,
|
||||
resizing?: boolean,
|
||||
resizable?: boolean | ResizingSides,
|
||||
innerRef?: (elem: HTMLElement) => void,
|
||||
style?: Object,
|
||||
className?: string,
|
||||
children?: React.Element<*>,
|
||||
|};
|
||||
|
||||
type InteractiveState = {|
|
||||
moving: boolean,
|
||||
movingInitialProps: ?InteractiveProps,
|
||||
movingInitialCursor: ?CursorState,
|
||||
cursor: ?string,
|
||||
resizingSides: ResizingSides,
|
||||
couldResize: boolean,
|
||||
resizing: boolean,
|
||||
resizingInitialRect: ?Rect,
|
||||
resizingInitialCursor: ?CursorState,
|
||||
|};
|
||||
|
||||
const InteractiveContainer = styled.view({
|
||||
willChange: 'transform, height, width, z-index',
|
||||
});
|
||||
|
||||
export default class Interactive extends styled.StylableComponent<
|
||||
InteractiveProps,
|
||||
InteractiveState,
|
||||
> {
|
||||
constructor(props: InteractiveProps, context: Object) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
couldResize: false,
|
||||
cursor: null,
|
||||
moving: false,
|
||||
movingInitialCursor: null,
|
||||
movingInitialProps: null,
|
||||
resizing: false,
|
||||
resizingInitialCursor: null,
|
||||
resizingInitialRect: null,
|
||||
resizingSides: null,
|
||||
};
|
||||
|
||||
this.globalMouse = false;
|
||||
}
|
||||
|
||||
globalMouse: boolean;
|
||||
ref: HTMLElement;
|
||||
|
||||
nextTop: ?number;
|
||||
nextLeft: ?number;
|
||||
nextEvent: ?SyntheticMouseEvent<>;
|
||||
|
||||
static defaultProps = {
|
||||
minHeight: 0,
|
||||
minLeft: 0,
|
||||
minTop: 0,
|
||||
minWidth: 0,
|
||||
};
|
||||
|
||||
onMouseMove = (event: SyntheticMouseEvent<>) => {
|
||||
if (this.state.moving) {
|
||||
this.calculateMove(event);
|
||||
} else if (this.state.resizing) {
|
||||
this.calculateResize(event);
|
||||
} else {
|
||||
this.calculateResizable(event);
|
||||
}
|
||||
};
|
||||
|
||||
startAction = (event: SyntheticMouseEvent<>) => {
|
||||
this.globalMouse = true;
|
||||
window.addEventListener('pointerup', this.endAction, {passive: true});
|
||||
window.addEventListener('pointermove', this.onMouseMove, {passive: true});
|
||||
|
||||
const {isMovableAnchor} = this.props;
|
||||
if (isMovableAnchor && isMovableAnchor(event)) {
|
||||
this.startTitleAction(event);
|
||||
} else {
|
||||
this.startWindowAction(event);
|
||||
}
|
||||
};
|
||||
|
||||
startTitleAction(event: SyntheticMouseEvent<>) {
|
||||
if (this.state.couldResize) {
|
||||
this.startResizeAction(event);
|
||||
} else if (this.props.movable === true) {
|
||||
this.startMoving(event);
|
||||
}
|
||||
}
|
||||
|
||||
startMoving(event: SyntheticMouseEvent<>) {
|
||||
const {onMoveStart} = this.props;
|
||||
if (onMoveStart) {
|
||||
onMoveStart();
|
||||
}
|
||||
|
||||
if (this.context.os) {
|
||||
// pause OS timers to avoid lag when dragging
|
||||
this.context.os.timers.pause();
|
||||
}
|
||||
|
||||
const topLpf = new LowPassFilter();
|
||||
const leftLpf = new LowPassFilter();
|
||||
|
||||
this.nextTop = null;
|
||||
this.nextLeft = null;
|
||||
this.nextEvent = null;
|
||||
|
||||
const onFrame = () => {
|
||||
if (!this.state.moving) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {nextEvent, nextTop, nextLeft} = this;
|
||||
if (nextEvent && nextTop != null && nextLeft != null) {
|
||||
if (topLpf.hasFullBuffer()) {
|
||||
const newTop = topLpf.next(nextTop);
|
||||
const newLeft = leftLpf.next(nextLeft);
|
||||
this.move(newTop, newLeft, nextEvent);
|
||||
} else {
|
||||
this.move(nextTop, nextLeft, nextEvent);
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(onFrame);
|
||||
};
|
||||
|
||||
this.setState(
|
||||
{
|
||||
cursor: 'move',
|
||||
moving: true,
|
||||
movingInitialCursor: {
|
||||
left: event.clientX,
|
||||
top: event.clientY,
|
||||
},
|
||||
movingInitialProps: this.props,
|
||||
},
|
||||
onFrame,
|
||||
);
|
||||
}
|
||||
|
||||
getPossibleTargetWindows(rect: Rect) {
|
||||
const closeWindows = [];
|
||||
|
||||
const {siblings} = this.props;
|
||||
if (siblings) {
|
||||
for (const key in siblings) {
|
||||
if (key === this.props.id) {
|
||||
// don't target ourselves
|
||||
continue;
|
||||
}
|
||||
|
||||
const win = siblings[key];
|
||||
const distance = getDistanceTo(rect, win);
|
||||
if (distance <= SNAP_SIZE) {
|
||||
closeWindows.push(win);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return closeWindows;
|
||||
}
|
||||
|
||||
startWindowAction(event: SyntheticMouseEvent<>) {
|
||||
if (this.state.couldResize) {
|
||||
this.startResizeAction(event);
|
||||
}
|
||||
}
|
||||
|
||||
startResizeAction(event: SyntheticMouseEvent<>) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
const {onResizeStart} = this.props;
|
||||
if (onResizeStart) {
|
||||
onResizeStart();
|
||||
}
|
||||
|
||||
this.setState({
|
||||
resizing: true,
|
||||
resizingInitialCursor: {
|
||||
left: event.clientX,
|
||||
top: event.clientY,
|
||||
},
|
||||
resizingInitialRect: this.getRect(),
|
||||
});
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: InteractiveProps, prevState: InteractiveState) {
|
||||
if (prevState.cursor !== this.state.cursor) {
|
||||
const {updateCursor} = this.props;
|
||||
if (updateCursor) {
|
||||
updateCursor(this.state.cursor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resetMoving() {
|
||||
const {onMoveEnd} = this.props;
|
||||
if (onMoveEnd) {
|
||||
onMoveEnd();
|
||||
}
|
||||
|
||||
if (this.context.os) {
|
||||
// resume os timers
|
||||
this.context.os.timers.resume();
|
||||
}
|
||||
|
||||
this.setState({
|
||||
cursor: undefined,
|
||||
moving: false,
|
||||
movingInitialProps: undefined,
|
||||
resizingInitialCursor: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
resetResizing() {
|
||||
const {onResizeEnd} = this.props;
|
||||
if (onResizeEnd) {
|
||||
onResizeEnd();
|
||||
}
|
||||
|
||||
this.setState({
|
||||
resizing: false,
|
||||
resizingInitialCursor: undefined,
|
||||
resizingInitialRect: undefined,
|
||||
resizingSides: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.endAction();
|
||||
}
|
||||
|
||||
endAction = () => {
|
||||
this.globalMouse = false;
|
||||
|
||||
window.removeEventListener('pointermove', this.onMouseMove);
|
||||
window.removeEventListener('pointerup', this.endAction);
|
||||
|
||||
if (this.state.moving) {
|
||||
this.resetMoving();
|
||||
}
|
||||
|
||||
if (this.state.resizing) {
|
||||
this.resetResizing();
|
||||
}
|
||||
};
|
||||
|
||||
onMouseLeave = () => {
|
||||
if (!this.state.resizing && !this.state.moving) {
|
||||
this.setState({
|
||||
cursor: undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
calculateMove(event: SyntheticMouseEvent<>) {
|
||||
const {movingInitialCursor, movingInitialProps} = this.state;
|
||||
|
||||
invariant(movingInitialProps, 'TODO');
|
||||
invariant(movingInitialCursor, 'TODO');
|
||||
|
||||
const {clientX: cursorLeft, clientY: cursorTop} = event;
|
||||
|
||||
const movedLeft = movingInitialCursor.left - cursorLeft;
|
||||
const movedTop = movingInitialCursor.top - cursorTop;
|
||||
|
||||
let newLeft = (movingInitialProps.left || 0) - movedLeft;
|
||||
let newTop = (movingInitialProps.top || 0) - movedTop;
|
||||
|
||||
if (event.altKey) {
|
||||
const snapProps = this.getRect();
|
||||
const windows = this.getPossibleTargetWindows(snapProps);
|
||||
newLeft = maybeSnapLeft(snapProps, windows, newLeft);
|
||||
newTop = maybeSnapTop(snapProps, windows, newTop);
|
||||
}
|
||||
|
||||
this.nextTop = newTop;
|
||||
this.nextLeft = newLeft;
|
||||
this.nextEvent = event;
|
||||
}
|
||||
|
||||
resize(width: number, height: number) {
|
||||
if (width === this.props.width && height === this.props.height) {
|
||||
// noop
|
||||
return;
|
||||
}
|
||||
|
||||
const {onResize} = this.props;
|
||||
if (!onResize) {
|
||||
return;
|
||||
}
|
||||
|
||||
width = Math.max(this.props.minWidth, width);
|
||||
height = Math.max(this.props.minHeight, height);
|
||||
|
||||
const {maxHeight, maxWidth} = this.props;
|
||||
if (maxWidth != null) {
|
||||
width = Math.min(maxWidth, width);
|
||||
}
|
||||
if (maxHeight != null) {
|
||||
height = Math.min(maxHeight, height);
|
||||
}
|
||||
|
||||
onResize(width, height);
|
||||
}
|
||||
|
||||
move(top: number, left: number, event: SyntheticMouseEvent<>) {
|
||||
top = Math.max(this.props.minTop, top);
|
||||
left = Math.max(this.props.minLeft, left);
|
||||
|
||||
if (top === this.props.top && left === this.props.left) {
|
||||
// noop
|
||||
return;
|
||||
}
|
||||
|
||||
const {onMove} = this.props;
|
||||
if (onMove) {
|
||||
onMove(top, left, event);
|
||||
}
|
||||
}
|
||||
|
||||
calculateResize(event: SyntheticMouseEvent<>) {
|
||||
const {
|
||||
resizingInitialCursor,
|
||||
resizingInitialRect,
|
||||
resizingSides,
|
||||
} = this.state;
|
||||
|
||||
invariant(resizingInitialRect, 'TODO');
|
||||
invariant(resizingInitialCursor, 'TODO');
|
||||
invariant(resizingSides, 'TODO');
|
||||
|
||||
const deltaLeft = resizingInitialCursor.left - event.clientX;
|
||||
const deltaTop = resizingInitialCursor.top - event.clientY;
|
||||
|
||||
let newLeft = resizingInitialRect.left;
|
||||
let newTop = resizingInitialRect.top;
|
||||
|
||||
let newWidth = resizingInitialRect.width;
|
||||
let newHeight = resizingInitialRect.height;
|
||||
|
||||
// right
|
||||
if (resizingSides.right === true) {
|
||||
newWidth -= deltaLeft;
|
||||
}
|
||||
|
||||
// bottom
|
||||
if (resizingSides.bottom === true) {
|
||||
newHeight -= deltaTop;
|
||||
}
|
||||
|
||||
const rect = this.getRect();
|
||||
|
||||
// left
|
||||
if (resizingSides.left === true) {
|
||||
newLeft -= deltaLeft;
|
||||
newWidth += deltaLeft;
|
||||
|
||||
if (this.props.movable === true) {
|
||||
// prevent from being shrunk past the minimum width
|
||||
const right = rect.left + rect.width;
|
||||
const maxLeft = right - this.props.minWidth;
|
||||
|
||||
let cleanLeft = Math.max(0, newLeft);
|
||||
cleanLeft = Math.min(cleanLeft, maxLeft);
|
||||
newWidth -= Math.abs(newLeft - cleanLeft);
|
||||
newLeft = cleanLeft;
|
||||
}
|
||||
}
|
||||
|
||||
// top
|
||||
if (resizingSides.top === true) {
|
||||
newTop -= deltaTop;
|
||||
newHeight += deltaTop;
|
||||
|
||||
if (this.props.movable === true) {
|
||||
// prevent from being shrunk past the minimum height
|
||||
const bottom = rect.top + rect.height;
|
||||
const maxTop = bottom - this.props.minHeight;
|
||||
|
||||
let cleanTop = Math.max(0, newTop);
|
||||
cleanTop = Math.min(cleanTop, maxTop);
|
||||
newHeight += newTop - cleanTop;
|
||||
newTop = cleanTop;
|
||||
}
|
||||
}
|
||||
|
||||
if (event.altKey) {
|
||||
const windows = this.getPossibleTargetWindows(rect);
|
||||
|
||||
if (resizingSides.left === true) {
|
||||
const newLeft2 = maybeSnapLeft(rect, windows, newLeft);
|
||||
newWidth += newLeft - newLeft2;
|
||||
newLeft = newLeft2;
|
||||
}
|
||||
|
||||
if (resizingSides.top === true) {
|
||||
const newTop2 = maybeSnapTop(rect, windows, newTop);
|
||||
newHeight += newTop - newTop2;
|
||||
newTop = newTop2;
|
||||
}
|
||||
|
||||
if (resizingSides.bottom === true) {
|
||||
newHeight = maybeSnapTop(rect, windows, newTop + newHeight) - newTop;
|
||||
}
|
||||
|
||||
if (resizingSides.right === true) {
|
||||
newWidth = maybeSnapLeft(rect, windows, newLeft + newWidth) - newLeft;
|
||||
}
|
||||
}
|
||||
|
||||
this.move(newTop, newLeft, event);
|
||||
this.resize(newWidth, newHeight);
|
||||
}
|
||||
|
||||
getRect(): Rect {
|
||||
const {props, ref} = this;
|
||||
invariant(ref, 'expected ref');
|
||||
|
||||
return {
|
||||
height: ref.offsetHeight || 0,
|
||||
left: props.left || 0,
|
||||
top: props.top || 0,
|
||||
width: ref.offsetWidth || 0,
|
||||
};
|
||||
}
|
||||
|
||||
getResizable(): ResizingSides {
|
||||
const {resizable} = this.props;
|
||||
|
||||
if (resizable === true) {
|
||||
return ALL_RESIZABLE;
|
||||
} else if (resizable == null || resizable === false) {
|
||||
return;
|
||||
} else {
|
||||
return resizable;
|
||||
}
|
||||
}
|
||||
|
||||
checkIfResizable(
|
||||
event: SyntheticMouseEvent<>,
|
||||
): ?{|
|
||||
left: boolean,
|
||||
right: boolean,
|
||||
top: boolean,
|
||||
bottom: boolean,
|
||||
|} {
|
||||
const canResize = this.getResizable();
|
||||
if (!canResize) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {left: offsetLeft, top: offsetTop} = this.ref.getBoundingClientRect();
|
||||
const {height, width} = this.getRect();
|
||||
|
||||
const x = event.clientX - offsetLeft;
|
||||
const y = event.clientY - offsetTop;
|
||||
|
||||
const atTop: boolean = y <= WINDOW_CURSOR_BOUNDARY;
|
||||
const atBottom: boolean = y >= height - WINDOW_CURSOR_BOUNDARY;
|
||||
|
||||
const atLeft: boolean = x <= WINDOW_CURSOR_BOUNDARY;
|
||||
const atRight: boolean = x >= width - WINDOW_CURSOR_BOUNDARY;
|
||||
|
||||
return {
|
||||
bottom: canResize.bottom === true && atBottom,
|
||||
left: canResize.left === true && atLeft,
|
||||
right: canResize.right === true && atRight,
|
||||
top: canResize.top === true && atTop,
|
||||
};
|
||||
}
|
||||
|
||||
calculateResizable(event: SyntheticMouseEvent<>) {
|
||||
const resizing = this.checkIfResizable(event);
|
||||
if (!resizing) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canResize = this.getResizable();
|
||||
if (!canResize) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {bottom, left, right, top} = resizing;
|
||||
let newCursor;
|
||||
|
||||
const movingHorizontal = left || right;
|
||||
const movingVertical = top || left;
|
||||
|
||||
// left
|
||||
if (left) {
|
||||
newCursor = 'ew-resize';
|
||||
}
|
||||
|
||||
// right
|
||||
if (right) {
|
||||
newCursor = 'ew-resize';
|
||||
}
|
||||
|
||||
// if resizing vertically and one side can't be resized then use different cursor
|
||||
if (
|
||||
movingHorizontal &&
|
||||
(canResize.left !== true || canResize.right !== true)
|
||||
) {
|
||||
newCursor = 'col-resize';
|
||||
}
|
||||
|
||||
// top
|
||||
if (top) {
|
||||
newCursor = 'ns-resize';
|
||||
|
||||
// top left
|
||||
if (left) {
|
||||
newCursor = 'nwse-resize';
|
||||
}
|
||||
|
||||
// top right
|
||||
if (right) {
|
||||
newCursor = 'nesw-resize';
|
||||
}
|
||||
}
|
||||
|
||||
// bottom
|
||||
if (bottom) {
|
||||
newCursor = 'ns-resize';
|
||||
|
||||
// bottom left
|
||||
if (left) {
|
||||
newCursor = 'nesw-resize';
|
||||
}
|
||||
|
||||
// bottom right
|
||||
if (right) {
|
||||
newCursor = 'nwse-resize';
|
||||
}
|
||||
}
|
||||
|
||||
// if resizing horziontally and one side can't be resized then use different cursor
|
||||
if (
|
||||
movingVertical &&
|
||||
!movingHorizontal &&
|
||||
(canResize.top !== true || canResize.bottom !== true)
|
||||
) {
|
||||
newCursor = 'row-resize';
|
||||
}
|
||||
|
||||
const resizingSides = {
|
||||
bottom,
|
||||
left,
|
||||
right,
|
||||
top,
|
||||
};
|
||||
|
||||
const {onCanResize} = this.props;
|
||||
if (onCanResize) {
|
||||
onCanResize();
|
||||
}
|
||||
|
||||
this.setState({
|
||||
couldResize: Boolean(newCursor),
|
||||
cursor: newCursor,
|
||||
resizingSides,
|
||||
});
|
||||
}
|
||||
|
||||
setRef = (ref: HTMLElement) => {
|
||||
this.ref = ref;
|
||||
|
||||
const {innerRef} = this.props;
|
||||
if (innerRef) {
|
||||
innerRef(ref);
|
||||
}
|
||||
};
|
||||
|
||||
onLocalMouseMove = (event: SyntheticMouseEvent<>) => {
|
||||
if (!this.globalMouse) {
|
||||
this.onMouseMove(event);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {fill, height, left, movable, top, width, zIndex} = this.props;
|
||||
|
||||
const style: Object = {
|
||||
cursor: this.state.cursor,
|
||||
zIndex: zIndex == null ? 'auto' : zIndex,
|
||||
};
|
||||
|
||||
if (movable === true || top != null || left != null) {
|
||||
if (fill === true) {
|
||||
style.left = left || 0;
|
||||
style.top = top || 0;
|
||||
} else {
|
||||
style.transform = `translate3d(${left || 0}px, ${top || 0}px, 0)`;
|
||||
}
|
||||
}
|
||||
|
||||
if (fill === true) {
|
||||
style.right = 0;
|
||||
style.bottom = 0;
|
||||
style.width = '100%';
|
||||
style.height = '100%';
|
||||
} else {
|
||||
style.width = width == null ? 'auto' : width;
|
||||
style.height = height == null ? 'auto' : height;
|
||||
}
|
||||
|
||||
if (this.props.style) {
|
||||
Object.assign(style, this.props.style);
|
||||
}
|
||||
|
||||
return (
|
||||
<InteractiveContainer
|
||||
className={this.props.className}
|
||||
hidden={this.props.hidden}
|
||||
innerRef={this.setRef}
|
||||
onMouseDown={this.startAction}
|
||||
onMouseMove={this.onLocalMouseMove}
|
||||
onMouseLeave={this.onMouseLeave} // eslint-disable-next-line
|
||||
style={style}>
|
||||
{this.props.children}
|
||||
</InteractiveContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
13
src/ui/components/Label.js
Normal file
13
src/ui/components/Label.js
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* 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 styled from '../styled/index.js';
|
||||
|
||||
export default styled.view({
|
||||
fontSize: 12,
|
||||
fontWeight: 'bold',
|
||||
});
|
||||
39
src/ui/components/Link.js
Normal file
39
src/ui/components/Link.js
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 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 styled from '../styled/index.js';
|
||||
import {colors} from './colors.js';
|
||||
import {Component} from 'react';
|
||||
import {shell} from 'electron';
|
||||
|
||||
const StyledLink = styled.text(
|
||||
{
|
||||
color: colors.highlight,
|
||||
'&:hover': {
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
},
|
||||
{
|
||||
ignoreAttributes: [],
|
||||
},
|
||||
);
|
||||
|
||||
export default class Link extends Component<{
|
||||
href: string,
|
||||
children?: React$Node,
|
||||
}> {
|
||||
onClick = () => {
|
||||
shell.openExternal(this.props.href);
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<StyledLink onClick={this.onClick}>{this.props.children}</StyledLink>
|
||||
);
|
||||
}
|
||||
}
|
||||
42
src/ui/components/LoadingIndicator.js
Normal file
42
src/ui/components/LoadingIndicator.js
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 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 {StyledComponent} from '../styled/index.js';
|
||||
import styled from '../styled/index.js';
|
||||
|
||||
const animation = styled.keyframes({
|
||||
'0%': {
|
||||
transform: 'rotate(0deg)',
|
||||
},
|
||||
'100%': {
|
||||
transform: 'rotate(360deg)',
|
||||
},
|
||||
});
|
||||
|
||||
const LoadingIndicator: StyledComponent<{
|
||||
size?: number,
|
||||
}> = styled.view(
|
||||
{
|
||||
animation: `${animation} 1s infinite linear`,
|
||||
width: props => props.size,
|
||||
height: props => props.size,
|
||||
minWidth: props => props.size,
|
||||
minHeight: props => props.size,
|
||||
borderRadius: '50%',
|
||||
border: props => `${props.size / 6}px solid rgba(0, 0, 0, 0.2)`,
|
||||
borderLeftColor: 'rgba(0, 0, 0, 0.4)',
|
||||
},
|
||||
{
|
||||
ignoreAttributes: ['size'],
|
||||
},
|
||||
);
|
||||
|
||||
LoadingIndicator.defaultProps = {
|
||||
size: 50,
|
||||
};
|
||||
|
||||
export default LoadingIndicator;
|
||||
49
src/ui/components/ModalOverlay.js
Normal file
49
src/ui/components/ModalOverlay.js
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 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 styled from '../styled/index.js';
|
||||
import {Component} from 'react';
|
||||
|
||||
const Overlay = styled.view({
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
||||
bottom: 0,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
left: 0,
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
zIndex: 99999,
|
||||
});
|
||||
|
||||
export default class ModalOverlay extends Component<{
|
||||
onClose: () => void,
|
||||
children?: React$Node,
|
||||
}> {
|
||||
ref: HTMLElement;
|
||||
|
||||
setRef = (ref: HTMLElement) => {
|
||||
this.ref = ref;
|
||||
};
|
||||
|
||||
onClick = (e: SyntheticMouseEvent<>) => {
|
||||
if (e.target === this.ref) {
|
||||
this.props.onClose();
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {props} = this;
|
||||
|
||||
return (
|
||||
<Overlay innerRef={this.setRef} onClick={this.onClick}>
|
||||
{props.children}
|
||||
</Overlay>
|
||||
);
|
||||
}
|
||||
}
|
||||
428
src/ui/components/Orderable.js
Normal file
428
src/ui/components/Orderable.js
Normal file
@@ -0,0 +1,428 @@
|
||||
/**
|
||||
* 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 {Rect} from '../../utils/geometry.js';
|
||||
import styled from '../styled/index.js';
|
||||
import {Component} from 'react';
|
||||
|
||||
const React = require('react');
|
||||
|
||||
export type OrderableOrder = Array<string>;
|
||||
|
||||
type OrderableOrientation = 'horizontal' | 'vertical';
|
||||
|
||||
type OrderableProps = {
|
||||
items: {[key: string]: React.Element<*>},
|
||||
orientation: OrderableOrientation,
|
||||
onChange?: (order: OrderableOrder, key: string) => void,
|
||||
order?: ?OrderableOrder,
|
||||
className?: string,
|
||||
reverse?: boolean,
|
||||
altKey?: boolean,
|
||||
moveDelay?: number,
|
||||
dragOpacity?: number,
|
||||
ignoreChildEvents?: boolean,
|
||||
};
|
||||
|
||||
type OrderableState = {|
|
||||
order?: ?OrderableOrder,
|
||||
movingOrder?: ?OrderableOrder,
|
||||
|};
|
||||
|
||||
type TabSizes = {
|
||||
[key: string]: Rect,
|
||||
};
|
||||
|
||||
const OrderableContainer = styled.view({
|
||||
position: 'relative',
|
||||
});
|
||||
|
||||
const OrderableItemContainer = styled.view(
|
||||
{
|
||||
display: props =>
|
||||
props.orientation === 'vertical' ? 'block' : 'inline-block',
|
||||
},
|
||||
{
|
||||
ignoreAttributes: ['orientation'],
|
||||
},
|
||||
);
|
||||
|
||||
class OrderableItem extends Component<{
|
||||
orientation: OrderableOrientation,
|
||||
id: string,
|
||||
children?: React$Node,
|
||||
addRef: (key: string, ref: HTMLElement) => void,
|
||||
startMove: (KEY: string, event: SyntheticMouseEvent<>) => void,
|
||||
}> {
|
||||
addRef = (ref: HTMLElement) => {
|
||||
this.props.addRef(this.props.id, ref);
|
||||
};
|
||||
|
||||
startMove = (event: SyntheticMouseEvent<>) => {
|
||||
this.props.startMove(this.props.id, event);
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<OrderableItemContainer
|
||||
orientation={this.props.orientation}
|
||||
key={this.props.id}
|
||||
innerRef={this.addRef}
|
||||
onMouseDown={this.startMove}>
|
||||
{this.props.children}
|
||||
</OrderableItemContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default class Orderable extends styled.StylableComponent<
|
||||
OrderableProps,
|
||||
OrderableState,
|
||||
> {
|
||||
constructor(props: OrderableProps, context: Object) {
|
||||
super(props, context);
|
||||
this.tabRefs = {};
|
||||
this.state = {order: props.order};
|
||||
this.setProps(props);
|
||||
}
|
||||
|
||||
_mousemove: ?Function;
|
||||
_mouseup: ?Function;
|
||||
timer: any;
|
||||
|
||||
sizeKey: 'width' | 'height';
|
||||
offsetKey: 'left' | 'top';
|
||||
mouseKey: 'offsetX' | 'offsetY';
|
||||
screenKey: 'screenX' | 'screenY';
|
||||
|
||||
containerRef: ?HTMLElement;
|
||||
tabRefs: {
|
||||
[key: string]: ?HTMLElement,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
dragOpacity: 1,
|
||||
moveDelay: 50,
|
||||
};
|
||||
|
||||
setProps(props: OrderableProps) {
|
||||
const {orientation} = props;
|
||||
this.sizeKey = orientation === 'horizontal' ? 'width' : 'height';
|
||||
this.offsetKey = orientation === 'horizontal' ? 'left' : 'top';
|
||||
this.mouseKey = orientation === 'horizontal' ? 'offsetX' : 'offsetY';
|
||||
this.screenKey = orientation === 'horizontal' ? 'screenX' : 'screenY';
|
||||
}
|
||||
|
||||
shouldComponentUpdate() {
|
||||
return !this.state.movingOrder;
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps: OrderableProps) {
|
||||
this.setState({
|
||||
order: nextProps.order,
|
||||
});
|
||||
this.setProps(nextProps);
|
||||
}
|
||||
|
||||
startMove = (key: string, event: SyntheticMouseEvent<*>) => {
|
||||
if (this.props.altKey === true && event.altKey === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.props.ignoreChildEvents === true) {
|
||||
const tabRef = this.tabRefs[key];
|
||||
// $FlowFixMe parentNode not implemented
|
||||
if (event.target !== tabRef && event.target.parentNode !== tabRef) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.reset();
|
||||
event.persist();
|
||||
|
||||
const {moveDelay} = this.props;
|
||||
if (moveDelay == null) {
|
||||
this._startMove(key, event);
|
||||
} else {
|
||||
const cancel = () => {
|
||||
clearTimeout(this.timer);
|
||||
document.removeEventListener('mouseup', cancel);
|
||||
};
|
||||
document.addEventListener('mouseup', cancel);
|
||||
|
||||
this.timer = setTimeout(() => {
|
||||
cancel();
|
||||
this._startMove(key, event);
|
||||
}, moveDelay);
|
||||
}
|
||||
};
|
||||
|
||||
_startMove(activeKey: string, event: SyntheticMouseEvent<>) {
|
||||
// $FlowFixMe
|
||||
const clickOffset = event.nativeEvent[this.mouseKey];
|
||||
|
||||
// calculate offsets before we start moving element
|
||||
const sizes: TabSizes = {};
|
||||
for (const key in this.tabRefs) {
|
||||
const elem = this.tabRefs[key];
|
||||
if (elem) {
|
||||
const rect: Rect = elem.getBoundingClientRect();
|
||||
sizes[key] = {
|
||||
height: rect.height,
|
||||
left: elem.offsetLeft,
|
||||
top: elem.offsetTop,
|
||||
width: rect.width,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const {containerRef} = this;
|
||||
if (containerRef) {
|
||||
containerRef.style.height = `${containerRef.offsetHeight}px`;
|
||||
containerRef.style.width = `${containerRef.offsetWidth}px`;
|
||||
}
|
||||
|
||||
for (const key in this.tabRefs) {
|
||||
const elem = this.tabRefs[key];
|
||||
if (elem) {
|
||||
const size = sizes[key];
|
||||
elem.style.position = 'absolute';
|
||||
elem.style.top = `${size.top}px`;
|
||||
elem.style.left = `${size.left}px`;
|
||||
elem.style.height = `${size.height}px`;
|
||||
elem.style.width = `${size.width}px`;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener(
|
||||
'mouseup',
|
||||
(this._mouseup = () => {
|
||||
this.stopMove(activeKey, sizes);
|
||||
}),
|
||||
{passive: true},
|
||||
);
|
||||
|
||||
// $FlowFixMe
|
||||
const screenClickPos = event.nativeEvent[this.screenKey];
|
||||
|
||||
document.addEventListener(
|
||||
'mousemove',
|
||||
(this._mousemove = (event: MouseEvent) => {
|
||||
// $FlowFixMe
|
||||
const goingOpposite = event[this.screenKey] < screenClickPos;
|
||||
this.possibleMove(activeKey, goingOpposite, event, clickOffset, sizes);
|
||||
}),
|
||||
{passive: true},
|
||||
);
|
||||
}
|
||||
|
||||
possibleMove(
|
||||
activeKey: string,
|
||||
goingOpposite: boolean,
|
||||
event: MouseEvent,
|
||||
cursorOffset: number,
|
||||
sizes: TabSizes,
|
||||
) {
|
||||
// update moving tab position
|
||||
const {containerRef} = this;
|
||||
const movingSize = sizes[activeKey];
|
||||
const activeTab = this.tabRefs[activeKey];
|
||||
if (containerRef) {
|
||||
const containerRect: Rect = containerRef.getBoundingClientRect();
|
||||
|
||||
let newActivePos = // $FlowFixMe
|
||||
event[this.screenKey] - containerRect[this.offsetKey] - cursorOffset;
|
||||
newActivePos = Math.max(-1, newActivePos);
|
||||
newActivePos = Math.min(
|
||||
newActivePos,
|
||||
containerRect[this.sizeKey] - movingSize[this.sizeKey],
|
||||
);
|
||||
|
||||
movingSize[this.offsetKey] = newActivePos;
|
||||
|
||||
if (activeTab) {
|
||||
activeTab.style.setProperty(this.offsetKey, `${newActivePos}px`);
|
||||
|
||||
const {dragOpacity} = this.props;
|
||||
if (dragOpacity != null && dragOpacity !== 1) {
|
||||
activeTab.style.opacity = `${dragOpacity}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// figure out new order
|
||||
const zipped: Array<[string, number]> = [];
|
||||
for (const key in sizes) {
|
||||
const rect = sizes[key];
|
||||
let offset = rect[this.offsetKey];
|
||||
let size = rect[this.sizeKey];
|
||||
|
||||
if (goingOpposite) {
|
||||
// when dragging opposite add the size to the offset
|
||||
if (key === activeKey) {
|
||||
// calculate the active tab to be a quarter of the actual size so when dragging in the opposite
|
||||
// direction, you need to cover 75% of the previous tab to trigger a movement
|
||||
size *= 0.25;
|
||||
}
|
||||
offset += size;
|
||||
} else if (key === activeKey) {
|
||||
// if not dragging in the opposite direction and we're the active tab, require covering 25% of the
|
||||
// next tab in roder to trigger a movement
|
||||
offset += size * 0.75;
|
||||
}
|
||||
|
||||
zipped.push([key, offset]);
|
||||
}
|
||||
|
||||
// calculate ordering
|
||||
const order = zipped
|
||||
.sort(([, a], [, b]) => {
|
||||
return Number(a > b);
|
||||
})
|
||||
.map(([key]) => key);
|
||||
|
||||
this.moveTabs(order, activeKey, sizes);
|
||||
this.setState({movingOrder: order});
|
||||
}
|
||||
|
||||
moveTabs(order: OrderableOrder, activeKey: ?string, sizes: TabSizes) {
|
||||
let offset = 0;
|
||||
for (const key of order) {
|
||||
const size = sizes[key];
|
||||
const tab = this.tabRefs[key];
|
||||
if (tab) {
|
||||
let newZIndex = key === activeKey ? 2 : 1;
|
||||
const prevZIndex = tab.style.zIndex;
|
||||
if (prevZIndex) {
|
||||
newZIndex += Number(prevZIndex);
|
||||
}
|
||||
tab.style.zIndex = String(newZIndex);
|
||||
|
||||
if (key === activeKey) {
|
||||
tab.style.transition = 'opacity 100ms ease-in-out';
|
||||
} else {
|
||||
tab.style.transition = `${this.offsetKey} 300ms ease-in-out`;
|
||||
tab.style.setProperty(this.offsetKey, `${offset}px`);
|
||||
}
|
||||
offset += size[this.sizeKey];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getMidpoint(rect: Rect) {
|
||||
return rect[this.offsetKey] + rect[this.sizeKey] / 2;
|
||||
}
|
||||
|
||||
stopMove(activeKey: string, sizes: TabSizes) {
|
||||
const {movingOrder} = this.state;
|
||||
|
||||
const {onChange} = this.props;
|
||||
if (onChange && movingOrder) {
|
||||
const activeTab = this.tabRefs[activeKey];
|
||||
if (activeTab) {
|
||||
activeTab.style.opacity = '';
|
||||
|
||||
const transitionend = () => {
|
||||
activeTab.removeEventListener('transitionend', transitionend);
|
||||
this.reset();
|
||||
};
|
||||
activeTab.addEventListener('transitionend', transitionend);
|
||||
}
|
||||
|
||||
this.resetListeners();
|
||||
this.moveTabs(movingOrder, null, sizes);
|
||||
onChange(movingOrder, activeKey);
|
||||
} else {
|
||||
this.reset();
|
||||
}
|
||||
|
||||
this.setState({movingOrder: null});
|
||||
}
|
||||
|
||||
resetListeners() {
|
||||
clearTimeout(this.timer);
|
||||
|
||||
const {_mousemove, _mouseup} = this;
|
||||
if (_mouseup) {
|
||||
document.removeEventListener('mouseup', _mouseup);
|
||||
}
|
||||
if (_mousemove) {
|
||||
document.removeEventListener('mousemove', _mousemove);
|
||||
}
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.resetListeners();
|
||||
|
||||
const {containerRef} = this;
|
||||
if (containerRef) {
|
||||
containerRef.removeAttribute('style');
|
||||
}
|
||||
|
||||
for (const key in this.tabRefs) {
|
||||
const elem = this.tabRefs[key];
|
||||
if (elem) {
|
||||
elem.removeAttribute('style');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.reset();
|
||||
}
|
||||
|
||||
addRef = (key: string, elem: ?HTMLElement) => {
|
||||
this.tabRefs[key] = elem;
|
||||
};
|
||||
|
||||
setContainerRef = (ref: HTMLElement) => {
|
||||
this.containerRef = ref;
|
||||
};
|
||||
|
||||
render() {
|
||||
const {items} = this.props;
|
||||
|
||||
// calculate order of elements
|
||||
let {order} = this.state;
|
||||
if (!order) {
|
||||
order = Object.keys(items);
|
||||
}
|
||||
for (const key in items) {
|
||||
if (order.indexOf(key) < 0) {
|
||||
if (this.props.reverse === true) {
|
||||
order.unshift(key);
|
||||
} else {
|
||||
order.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<OrderableContainer
|
||||
className={this.props.className}
|
||||
innerRef={this.setContainerRef}>
|
||||
{order.map(key => {
|
||||
const item = items[key];
|
||||
if (item) {
|
||||
return (
|
||||
<OrderableItem
|
||||
orientation={this.props.orientation}
|
||||
key={key}
|
||||
id={key}
|
||||
addRef={this.addRef}
|
||||
startMove={this.startMove}>
|
||||
{item}
|
||||
</OrderableItem>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</OrderableContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
179
src/ui/components/Panel.js
Normal file
179
src/ui/components/Panel.js
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* 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 FlexColumn from './FlexColumn.js';
|
||||
|
||||
import styled from '../styled/index.js';
|
||||
import FlexBox from './FlexBox.js';
|
||||
|
||||
import {colors} from './colors.js';
|
||||
import Glyph from './Glyph.js';
|
||||
|
||||
const BORDER = '1px solid #dddfe2';
|
||||
const ignoreAttributes = ['floating', 'padded'];
|
||||
|
||||
const Chevron = Glyph.extends({
|
||||
marginRight: 4,
|
||||
marginLeft: -2,
|
||||
marginBottom: 1,
|
||||
});
|
||||
|
||||
/**
|
||||
* A Panel component.
|
||||
*/
|
||||
export default class Panel extends styled.StylableComponent<
|
||||
{|
|
||||
/**
|
||||
* Class name to customise styling.
|
||||
*/
|
||||
className?: string,
|
||||
/**
|
||||
* Whether this panel is floating from the rest of the UI. ie. if it has
|
||||
* margin and a border.
|
||||
*/
|
||||
floating?: boolean,
|
||||
/**
|
||||
* Whether the panel takes up all the space it can. Equivalent to the following CSS:
|
||||
*
|
||||
* height: 100%;
|
||||
* width: 100%;
|
||||
*/
|
||||
fill?: boolean,
|
||||
/**
|
||||
* Heading for this panel. If this is anything other than a string then no
|
||||
* padding is applied to the heading.
|
||||
*/
|
||||
heading: React$Node,
|
||||
/**
|
||||
* Contents of the panel.
|
||||
*/
|
||||
children?: React$Node,
|
||||
/**
|
||||
* Whether the panel header and body have padding.
|
||||
*/
|
||||
padded?: boolean,
|
||||
/**
|
||||
* Whether the panel can be collapsed. Defaults to true
|
||||
*/
|
||||
collapsable: boolean,
|
||||
/**
|
||||
* Initial state for panel if it is collapsable
|
||||
*/
|
||||
collapsed?: boolean,
|
||||
/**
|
||||
* Heading for this panel. If this is anything other than a string then no
|
||||
* padding is applied to the heading.
|
||||
*/
|
||||
accessory?: React$Node,
|
||||
|},
|
||||
{
|
||||
collapsed: boolean,
|
||||
},
|
||||
> {
|
||||
static defaultProps: {|
|
||||
floating: boolean,
|
||||
fill: boolean,
|
||||
collapsable: boolean,
|
||||
|} = {
|
||||
fill: false,
|
||||
floating: true,
|
||||
collapsable: true,
|
||||
};
|
||||
|
||||
static PanelContainer = FlexColumn.extends(
|
||||
{
|
||||
flexShrink: 0,
|
||||
padding: props => (props.floating ? 10 : 0),
|
||||
borderBottom: props => (props.collapsed ? 'none' : BORDER),
|
||||
},
|
||||
{ignoreAttributes: ['collapsed', ...ignoreAttributes]},
|
||||
);
|
||||
|
||||
static PanelHeader = FlexBox.extends(
|
||||
{
|
||||
backgroundColor: '#f6f7f9',
|
||||
border: props => (props.floating ? BORDER : 'none'),
|
||||
borderBottom: BORDER,
|
||||
borderTopLeftRadius: 2,
|
||||
borderTopRightRadius: 2,
|
||||
justifyContent: 'space-between',
|
||||
lineHeight: '27px',
|
||||
fontWeight: 500,
|
||||
flexShrink: 0,
|
||||
padding: props => (props.padded ? '0 10px' : 0),
|
||||
'&:not(:first-child)': {
|
||||
borderTop: BORDER,
|
||||
},
|
||||
},
|
||||
{ignoreAttributes},
|
||||
);
|
||||
|
||||
static PanelBody = FlexColumn.extends(
|
||||
{
|
||||
backgroundColor: '#fff',
|
||||
border: props => (props.floating ? BORDER : 'none'),
|
||||
borderBottomLeftRadius: 2,
|
||||
borderBottomRightRadius: 2,
|
||||
borderTop: 'none',
|
||||
flexGrow: 1,
|
||||
padding: props => (props.padded ? 10 : 0),
|
||||
},
|
||||
{ignoreAttributes},
|
||||
);
|
||||
state = {
|
||||
collapsed: this.props.collapsed == null ? false : this.props.collapsed,
|
||||
};
|
||||
|
||||
onClick = () => this.setState({collapsed: !this.state.collapsed});
|
||||
|
||||
render() {
|
||||
const {
|
||||
padded,
|
||||
children,
|
||||
className,
|
||||
fill,
|
||||
floating,
|
||||
heading,
|
||||
collapsable,
|
||||
accessory,
|
||||
} = this.props;
|
||||
const {collapsed} = this.state;
|
||||
return (
|
||||
<Panel.PanelContainer
|
||||
className={className}
|
||||
floating={floating}
|
||||
fill={fill}
|
||||
collapsed={collapsed}>
|
||||
<Panel.PanelHeader
|
||||
floating={floating}
|
||||
padded={typeof heading === 'string'}
|
||||
onClick={this.onClick}>
|
||||
<span>
|
||||
{collapsable && (
|
||||
<Chevron
|
||||
color={colors.macOSTitleBarIcon}
|
||||
name={collapsed ? 'triangle-right' : 'triangle-down'}
|
||||
size={12}
|
||||
/>
|
||||
)}
|
||||
{heading}
|
||||
</span>
|
||||
{accessory}
|
||||
</Panel.PanelHeader>
|
||||
|
||||
{children == null || (collapsable && collapsed) ? null : (
|
||||
<Panel.PanelBody
|
||||
fill={fill}
|
||||
padded={padded == null ? true : padded}
|
||||
floating={floating}>
|
||||
{children}
|
||||
</Panel.PanelBody>
|
||||
)}
|
||||
</Panel.PanelContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
53
src/ui/components/PathBreadcrumbs.js
Normal file
53
src/ui/components/PathBreadcrumbs.js
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 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 ButtonGroup from './ButtonGroup.js';
|
||||
import {Component} from 'react';
|
||||
import Button from './Button.js';
|
||||
|
||||
const path = require('path');
|
||||
|
||||
class PathBreadcrumbsItem extends Component<{
|
||||
name: string,
|
||||
path: string,
|
||||
isFolder: boolean,
|
||||
onClick: (path: string) => void,
|
||||
}> {
|
||||
onClick = () => {
|
||||
this.props.onClick(this.props.path);
|
||||
};
|
||||
|
||||
render() {
|
||||
return <Button onClick={this.onClick}>{this.props.name}</Button>;
|
||||
}
|
||||
}
|
||||
|
||||
export default function PathBreadcrumbs(props: {|
|
||||
path: string,
|
||||
isFile?: boolean,
|
||||
onClick: (path: string) => void,
|
||||
|}) {
|
||||
const parts = props.path === path.sep ? [''] : props.path.split(path.sep);
|
||||
const {onClick} = props;
|
||||
|
||||
return (
|
||||
<ButtonGroup>
|
||||
{parts.map((part, i) => {
|
||||
const fullPath = parts.slice(0, i + 1).join(path.sep) || path.sep;
|
||||
return (
|
||||
<PathBreadcrumbsItem
|
||||
key={`${i}:${part}`}
|
||||
name={part || fullPath}
|
||||
path={fullPath}
|
||||
isFolder={!(props.isFile === true && i === parts.length - 1)}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ButtonGroup>
|
||||
);
|
||||
}
|
||||
89
src/ui/components/Popover.js
Normal file
89
src/ui/components/Popover.js
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* 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 {PureComponent} from 'react';
|
||||
import FlexColumn from './FlexColumn.js';
|
||||
import styled from '../styled/index.js';
|
||||
import {colors} from './colors.js';
|
||||
|
||||
const Anchor = styled.image({
|
||||
zIndex: 6,
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, calc(100% + 2px))',
|
||||
});
|
||||
|
||||
const PopoverContainer = FlexColumn.extends({
|
||||
backgroundColor: colors.white,
|
||||
borderRadius: 7,
|
||||
border: '1px solid rgba(0,0,0,0.3)',
|
||||
boxShadow: '0 2px 10px 0 rgba(0,0,0,0.3)',
|
||||
position: 'absolute',
|
||||
zIndex: 5,
|
||||
bottom: 0,
|
||||
marginTop: 15,
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, calc(100% + 15px))',
|
||||
overflow: 'hidden',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
display: 'block',
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
height: 13,
|
||||
top: -13,
|
||||
width: 26,
|
||||
backgroundColor: colors.white,
|
||||
},
|
||||
});
|
||||
|
||||
type Props = {|
|
||||
children: React.Node,
|
||||
onDismiss: Function,
|
||||
|};
|
||||
|
||||
export default class Popover extends PureComponent<Props> {
|
||||
_ref: ?Element;
|
||||
|
||||
componentDidMount() {
|
||||
window.document.addEventListener('click', this.handleClick);
|
||||
window.document.addEventListener('keydown', this.handleKeydown);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.document.addEventListener('click', this.handleClick);
|
||||
window.document.addEventListener('keydown', this.handleKeydown);
|
||||
}
|
||||
|
||||
handleClick = (e: SyntheticMouseEvent<>) => {
|
||||
// $FlowFixMe
|
||||
if (this._ref && !this._ref.contains(e.target)) {
|
||||
this.props.onDismiss();
|
||||
}
|
||||
};
|
||||
|
||||
handleKeydown = (e: SyntheticKeyboardEvent<>) => {
|
||||
if (e.key === 'Escape') {
|
||||
this.props.onDismiss();
|
||||
}
|
||||
};
|
||||
|
||||
_setRef = (ref: ?Element) => {
|
||||
this._ref = ref;
|
||||
};
|
||||
|
||||
render() {
|
||||
return [
|
||||
<Anchor src="./anchor.svg" key="anchor" />,
|
||||
<PopoverContainer innerRef={this._setRef} key="popup">
|
||||
{this.props.children}
|
||||
</PopoverContainer>,
|
||||
];
|
||||
}
|
||||
}
|
||||
52
src/ui/components/ResizeSensor.js
Normal file
52
src/ui/components/ResizeSensor.js
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* 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 styled from '../styled/index.js';
|
||||
import {Component} from 'react';
|
||||
|
||||
const IFrame = styled.customHTMLTag('iframe', {
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
position: 'absolute',
|
||||
zIndex: -1,
|
||||
top: 0,
|
||||
left: 0,
|
||||
});
|
||||
|
||||
export default class ResizeSensor extends Component<{
|
||||
onResize: (e: UIEvent) => void,
|
||||
}> {
|
||||
iframe: ?HTMLIFrameElement;
|
||||
|
||||
setRef = (ref: ?HTMLIFrameElement) => {
|
||||
this.iframe = ref;
|
||||
};
|
||||
|
||||
render() {
|
||||
return <IFrame innerRef={this.setRef} />;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const {iframe} = this;
|
||||
if (iframe != null) {
|
||||
iframe.contentWindow.addEventListener('resize', this.handleResize);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
const {iframe} = this;
|
||||
if (iframe != null) {
|
||||
iframe.contentWindow.removeEventListener('resize', this.handleResize);
|
||||
}
|
||||
}
|
||||
|
||||
handleResize = () => {
|
||||
window.requestAnimationFrame(this.props.onResize);
|
||||
};
|
||||
}
|
||||
29
src/ui/components/Select.js
Normal file
29
src/ui/components/Select.js
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* 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 {Component} from 'react';
|
||||
|
||||
export default class Select extends Component<{
|
||||
className?: string,
|
||||
options: {
|
||||
[key: string]: string,
|
||||
},
|
||||
onChange: (key: string) => void,
|
||||
selected?: ?string,
|
||||
}> {
|
||||
render() {
|
||||
const {className, options, selected} = this.props;
|
||||
|
||||
return (
|
||||
<select onChange={this.props.onChange} className={className}>
|
||||
{Object.keys(options).map(key => (
|
||||
<option selected={key === selected}>{options[key]}</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
}
|
||||
178
src/ui/components/Sidebar.js
Normal file
178
src/ui/components/Sidebar.js
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* 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 {StyledComponent} from '../styled/index.js';
|
||||
import Interactive from './Interactive.js';
|
||||
import FlexColumn from './FlexColumn.js';
|
||||
import {Component} from 'react';
|
||||
|
||||
const SidebarInteractiveContainer = Interactive.extends({
|
||||
flex: 'none',
|
||||
});
|
||||
|
||||
type SidebarPosition = 'left' | 'top' | 'right' | 'bottom';
|
||||
|
||||
const SidebarContainer: StyledComponent<{
|
||||
position: SidebarPosition,
|
||||
overflow?: boolean,
|
||||
}> = FlexColumn.extends(
|
||||
{
|
||||
backgroundColor: props => props.backgroundColor || '#f7f7f7',
|
||||
borderLeft: props =>
|
||||
props.position === 'right' ? '1px solid #b3b3b3' : 'none',
|
||||
borderTop: props =>
|
||||
props.position === 'bottom' ? '1px solid #b3b3b3' : 'none',
|
||||
borderRight: props =>
|
||||
props.position === 'left' ? '1px solid #b3b3b3' : 'none',
|
||||
borderBottom: props =>
|
||||
props.position === 'top' ? '1px solid #b3b3b3' : 'none',
|
||||
height: '100%',
|
||||
overflowX: 'hidden',
|
||||
overflowY: 'auto',
|
||||
textOverflow: props => (props.overflow ? 'ellipsis' : 'auto'),
|
||||
whiteSpace: props => (props.overflow ? 'nowrap' : 'normal'),
|
||||
},
|
||||
{
|
||||
ignoreAttributes: ['backgroundColor', 'position'],
|
||||
},
|
||||
);
|
||||
|
||||
type SidebarProps = {
|
||||
/**
|
||||
* Position of the sidebar.
|
||||
*/
|
||||
position: SidebarPosition,
|
||||
|
||||
/**
|
||||
* Default width of the sidebar. Only used for left/right sidebars.
|
||||
*/
|
||||
width?: number,
|
||||
/**
|
||||
* Minimum sidebar width. Only used for left/right sidebars.
|
||||
*/
|
||||
minWidth?: number,
|
||||
/**
|
||||
* Maximum sidebar width. Only used for left/right sidebars.
|
||||
*/
|
||||
maxWidth?: number,
|
||||
|
||||
/**
|
||||
* Default height of the sidebar.
|
||||
*/
|
||||
height?: number,
|
||||
/**
|
||||
* Minimum sidebar height. Only used for top/bottom sidebars.
|
||||
*/
|
||||
minHeight?: number,
|
||||
/**
|
||||
* Maximum sidebar height. Only used for top/bottom sidebars.
|
||||
*/
|
||||
maxHeight?: number,
|
||||
|
||||
/**
|
||||
* Background color.
|
||||
*/
|
||||
backgroundColor?: string,
|
||||
/**
|
||||
* Callback when the sidebar size ahs changed.
|
||||
*/
|
||||
onResize?: (width: number, height: number) => void,
|
||||
/**
|
||||
* Contents of the sidebar.
|
||||
*/
|
||||
children?: React$Node,
|
||||
/**
|
||||
* Class name to customise styling.
|
||||
*/
|
||||
className?: string,
|
||||
};
|
||||
|
||||
type SidebarState = {|
|
||||
width?: number,
|
||||
height?: number,
|
||||
userChange: boolean,
|
||||
|};
|
||||
|
||||
/**
|
||||
* A resizable sidebar.
|
||||
*/
|
||||
export default class Sidebar extends Component<SidebarProps, SidebarState> {
|
||||
constructor(props: SidebarProps, context: Object) {
|
||||
super(props, context);
|
||||
this.state = {userChange: false, width: props.width, height: props.height};
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
position: 'left',
|
||||
};
|
||||
|
||||
componentWillReceiveProps(nextProps: SidebarProps) {
|
||||
if (!this.state.userChange) {
|
||||
this.setState({width: nextProps.width, height: nextProps.height});
|
||||
}
|
||||
}
|
||||
|
||||
onResize = (width: number, height: number) => {
|
||||
const {onResize} = this.props;
|
||||
if (onResize) {
|
||||
onResize(width, height);
|
||||
} else {
|
||||
this.setState({userChange: true, width, height});
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {backgroundColor, onResize, position, children} = this.props;
|
||||
let height, minHeight, maxHeight, width, minWidth, maxWidth;
|
||||
|
||||
const resizable: {[key: string]: boolean} = {};
|
||||
if (position === 'left') {
|
||||
resizable.right = true;
|
||||
({width, minWidth, maxWidth} = this.props);
|
||||
} else if (position === 'top') {
|
||||
resizable.bottom = true;
|
||||
({height, minHeight, maxHeight} = this.props);
|
||||
} else if (position === 'right') {
|
||||
resizable.left = true;
|
||||
({width, minWidth, maxWidth} = this.props);
|
||||
} else if (position === 'bottom') {
|
||||
resizable.top = true;
|
||||
({height, minHeight, maxHeight} = this.props);
|
||||
}
|
||||
|
||||
const horizontal = position === 'left' || position === 'right';
|
||||
|
||||
if (horizontal) {
|
||||
width = width == null ? 200 : width;
|
||||
minWidth = minWidth == null ? 100 : minWidth;
|
||||
maxWidth = maxWidth == null ? 600 : maxWidth;
|
||||
} else {
|
||||
height = height == null ? 200 : height;
|
||||
minHeight = minHeight == null ? 100 : minHeight;
|
||||
maxHeight = maxHeight == null ? 600 : maxHeight;
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarInteractiveContainer
|
||||
className={this.props.className}
|
||||
minWidth={minWidth}
|
||||
maxWidth={maxWidth}
|
||||
width={horizontal ? (onResize ? width : this.state.width) : undefined}
|
||||
minHeight={minHeight}
|
||||
maxHeight={maxHeight}
|
||||
height={
|
||||
!horizontal ? (onResize ? height : this.state.height) : undefined
|
||||
}
|
||||
resizable={resizable}
|
||||
onResize={this.onResize}>
|
||||
<SidebarContainer position={position} backgroundColor={backgroundColor}>
|
||||
{children}
|
||||
</SidebarContainer>
|
||||
</SidebarInteractiveContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
15
src/ui/components/SidebarLabel.js
Normal file
15
src/ui/components/SidebarLabel.js
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* 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 {colors} from './colors.js';
|
||||
import Label from './Label.js';
|
||||
|
||||
export default Label.extends({
|
||||
color: colors.blackAlpha30,
|
||||
fontSize: 12,
|
||||
padding: 10,
|
||||
});
|
||||
37
src/ui/components/Tab.js
Normal file
37
src/ui/components/Tab.js
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
export default function Tab(props: {|
|
||||
/**
|
||||
* Label of this tab to show in the tab list.
|
||||
*/
|
||||
label: React$Node,
|
||||
/**
|
||||
* Whether this tab is closable.
|
||||
*/
|
||||
closable?: boolean,
|
||||
/**
|
||||
* Whether this tab is hidden. Useful for when you want a tab to be
|
||||
* inaccessible via the user but you want to manually set the `active` props
|
||||
* yourself.
|
||||
*/
|
||||
hidden?: boolean,
|
||||
/**
|
||||
* Whether this tab should always be included in the DOM and have it's
|
||||
* visibility toggled.
|
||||
*/
|
||||
persist?: boolean,
|
||||
/**
|
||||
* Callback for when tab is closed.
|
||||
*/
|
||||
onClose?: () => void,
|
||||
/**
|
||||
* Contents of this tab.
|
||||
*/
|
||||
children?: React$Node,
|
||||
|}) {
|
||||
throw new Error("don't render me");
|
||||
}
|
||||
271
src/ui/components/Tabs.js
Normal file
271
src/ui/components/Tabs.js
Normal file
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* 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 FlexColumn from './FlexColumn.js';
|
||||
import styled from '../styled/index.js';
|
||||
import Orderable from './Orderable.js';
|
||||
import FlexRow from './FlexRow.js';
|
||||
import {colors} from './colors.js';
|
||||
import Tab from './Tab.js';
|
||||
|
||||
const TabList = FlexRow.extends({
|
||||
alignItems: 'stretch',
|
||||
});
|
||||
|
||||
const TabListItem = styled.view(
|
||||
{
|
||||
backgroundColor: props => (props.active ? colors.light15 : colors.light02),
|
||||
borderBottom: '1px solid #dddfe2',
|
||||
boxShadow: props =>
|
||||
props.active ? 'inset 0px 0px 3px rgba(0,0,0,0.25)' : 'none',
|
||||
color: colors.dark80,
|
||||
flex: 1,
|
||||
fontSize: 13,
|
||||
lineHeight: '28px',
|
||||
overflow: 'hidden',
|
||||
padding: '0 10px',
|
||||
position: 'relative',
|
||||
textAlign: 'center',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
|
||||
'&:hover': {
|
||||
backgroundColor: props =>
|
||||
props.active ? colors.light15 : colors.light05,
|
||||
},
|
||||
},
|
||||
{
|
||||
ignoreAttributes: ['active'],
|
||||
},
|
||||
);
|
||||
|
||||
const TabListAddItem = TabListItem.extends({
|
||||
borderRight: 'none',
|
||||
flex: 0,
|
||||
flexGrow: 0,
|
||||
fontWeight: 'bold',
|
||||
});
|
||||
|
||||
const CloseButton = styled.view({
|
||||
color: '#000',
|
||||
float: 'right',
|
||||
fontSize: 10,
|
||||
fontWeight: 'bold',
|
||||
textAlign: 'center',
|
||||
marginLeft: 6,
|
||||
marginTop: 6,
|
||||
width: 16,
|
||||
height: 16,
|
||||
lineHeight: '16px',
|
||||
borderRadius: '50%',
|
||||
|
||||
'&:hover': {
|
||||
backgroundColor: colors.cherry,
|
||||
color: '#fff',
|
||||
},
|
||||
});
|
||||
|
||||
const OrderableContainer = styled.view({
|
||||
display: 'inline-block',
|
||||
});
|
||||
|
||||
const TabContent = styled.view({
|
||||
height: '100%',
|
||||
overflow: 'auto',
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
/**
|
||||
* A Tabs component.
|
||||
*/
|
||||
export default function Tabs(props: {|
|
||||
/**
|
||||
* Callback for when the active tab has changed.
|
||||
*/
|
||||
onActive?: (key: ?string) => void,
|
||||
/**
|
||||
* The key of the default active tab.
|
||||
*/
|
||||
defaultActive?: string,
|
||||
/**
|
||||
* The key of the currently active tab.
|
||||
*/
|
||||
active?: ?string,
|
||||
/**
|
||||
* Tab elements.
|
||||
*/
|
||||
children?: Array<React$Element<any>>,
|
||||
/**
|
||||
* Whether the tabs can be reordered by the user.
|
||||
*/
|
||||
orderable?: boolean,
|
||||
/**
|
||||
* Callback when the tab order changes.
|
||||
*/
|
||||
onOrder?: (order: Array<string>) => void,
|
||||
/**
|
||||
* Order of tabs.
|
||||
*/
|
||||
order?: Array<string>,
|
||||
/**
|
||||
* Whether to include the contents of every tab in the DOM and just toggle
|
||||
* it's visibility.
|
||||
*/
|
||||
persist?: boolean,
|
||||
/**
|
||||
* Whether to include a button to create additional items.
|
||||
*/
|
||||
newable?: boolean,
|
||||
/**
|
||||
* Callback for when the new button is clicked.
|
||||
*/
|
||||
onNew?: () => void,
|
||||
/**
|
||||
* Elements to insert before all tabs in the tab list.
|
||||
*/
|
||||
before?: Array<React$Node>,
|
||||
/**
|
||||
* Elements to insert after all tabs in the tab list.
|
||||
*/
|
||||
after?: Array<React$Node>,
|
||||
|}) {
|
||||
const {onActive} = props;
|
||||
const active: ?string =
|
||||
props.active == null ? props.defaultActive : props.active;
|
||||
|
||||
// array of other components that aren't tabs
|
||||
const before = props.before || [];
|
||||
const after = props.after || [];
|
||||
|
||||
//
|
||||
const tabs = {};
|
||||
|
||||
// a list of keys
|
||||
const keys = props.order ? props.order.slice() : [];
|
||||
|
||||
const tabContents = [];
|
||||
const tabSiblings = [];
|
||||
|
||||
function add(comps) {
|
||||
for (const comp of [].concat(comps || [])) {
|
||||
if (Array.isArray(comp)) {
|
||||
add(comp);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!comp) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (comp.type !== Tab) {
|
||||
// if element isn't a tab then just push it into the tab list
|
||||
tabSiblings.push(comp);
|
||||
continue;
|
||||
}
|
||||
|
||||
const {children, closable, label, onClose, width} = comp.props;
|
||||
|
||||
const key = comp.key == null ? label : comp.key;
|
||||
if (typeof key !== 'string') {
|
||||
throw new Error('tab needs a string key or a label');
|
||||
}
|
||||
if (!keys.includes(key)) {
|
||||
keys.push(key);
|
||||
}
|
||||
|
||||
const isActive: boolean = active === key;
|
||||
if (isActive || props.persist === true || comp.props.persist === true) {
|
||||
tabContents.push(
|
||||
<TabContent key={key} hidden={!isActive}>
|
||||
{children}
|
||||
</TabContent>,
|
||||
);
|
||||
}
|
||||
|
||||
// this tab has been hidden from the tab bar but can still be selected if it's key is active
|
||||
if (comp.props.hidden) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let closeButton;
|
||||
|
||||
tabs[key] = (
|
||||
<TabListItem
|
||||
key={key}
|
||||
width={width}
|
||||
active={isActive}
|
||||
onMouseDown={
|
||||
!isActive &&
|
||||
onActive &&
|
||||
((event: SyntheticMouseEvent<>) => {
|
||||
if (event.target !== closeButton) {
|
||||
onActive(key);
|
||||
}
|
||||
})
|
||||
}>
|
||||
{comp.props.label}
|
||||
{closable && (
|
||||
<CloseButton // eslint-disable-next-line react/jsx-no-bind
|
||||
innerRef={ref => (closeButton = ref)} // eslint-disable-next-line react/jsx-no-bind
|
||||
onMouseDown={() => {
|
||||
if (isActive && onActive) {
|
||||
const index = keys.indexOf(key);
|
||||
const newActive = keys[index + 1] || keys[index - 1] || null;
|
||||
onActive(newActive);
|
||||
}
|
||||
|
||||
onClose();
|
||||
}}>
|
||||
X
|
||||
</CloseButton>
|
||||
)}
|
||||
</TabListItem>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
add(props.children);
|
||||
|
||||
let tabList;
|
||||
if (props.orderable === true) {
|
||||
tabList = (
|
||||
<OrderableContainer key="orderable-list">
|
||||
<Orderable
|
||||
orientation="horizontal"
|
||||
items={tabs}
|
||||
onChange={props.onOrder}
|
||||
order={keys}
|
||||
/>
|
||||
</OrderableContainer>
|
||||
);
|
||||
} else {
|
||||
tabList = [];
|
||||
for (const key in tabs) {
|
||||
tabList.push(tabs[key]);
|
||||
}
|
||||
}
|
||||
|
||||
if (props.newable === true) {
|
||||
after.push(
|
||||
<TabListAddItem key={keys.length} onMouseDown={props.onNew}>
|
||||
+
|
||||
</TabListAddItem>,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FlexColumn fill={true}>
|
||||
<TabList>
|
||||
{before}
|
||||
{tabList}
|
||||
{after}
|
||||
</TabList>
|
||||
{tabContents}
|
||||
{tabSiblings}
|
||||
</FlexColumn>
|
||||
);
|
||||
}
|
||||
159
src/ui/components/Text.js
Normal file
159
src/ui/components/Text.js
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* 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 {StyledComponent} from '../styled/index.js';
|
||||
import styled from '../styled/index.js';
|
||||
|
||||
/**
|
||||
* A Text component.
|
||||
*/
|
||||
const Text: StyledComponent<{
|
||||
/**
|
||||
* Color of text.
|
||||
*/
|
||||
color?: string,
|
||||
/**
|
||||
* Whether this text is bold. Equivalent to the following CSS:
|
||||
*
|
||||
* font-weight: bold;
|
||||
*/
|
||||
bold?: boolean,
|
||||
/**
|
||||
* Whether this text is italic. Equivalent to the following CSS:
|
||||
*
|
||||
* font-style: italic;
|
||||
*/
|
||||
italic?: boolean,
|
||||
/**
|
||||
* Whether to format the text as code. Equivalent to the following CSS:
|
||||
*
|
||||
* font-size: Andale Mono, monospace;
|
||||
* overflow: auto;
|
||||
* user-select: text;
|
||||
* white-space: pre-wrap;
|
||||
* word-wrap: break-word;
|
||||
*/
|
||||
code?: boolean,
|
||||
/**
|
||||
* Whether this text is underlined. Equivalent to the following CSS:
|
||||
*
|
||||
* text-decoration: underline;
|
||||
*/
|
||||
underline?: boolean,
|
||||
/**
|
||||
* Whether this text is striked. Equivalent to the following CSS:
|
||||
*
|
||||
* text-decoration: line-through;
|
||||
*/
|
||||
strike?: boolean,
|
||||
/**
|
||||
* Whether this text is selectable by the cursor. Equivalent to the following CSS:
|
||||
*
|
||||
* user-select: text;
|
||||
*/
|
||||
selectable?: boolean,
|
||||
/**
|
||||
* Alignment of the text. Equivalent to the `text-align` CSS rule.
|
||||
*/
|
||||
align?: 'left' | 'center' | 'right',
|
||||
/**
|
||||
* Font size to use. Equivalent to the `font-size` CSS rule.
|
||||
*/
|
||||
size?: string | number,
|
||||
/**
|
||||
* Font family to use. Equivalent to the `font-family` CSS rule.
|
||||
*/
|
||||
family?: string,
|
||||
/**
|
||||
* Word wrap to use. Equivalent to the `word-wrap` CSS rule.
|
||||
*/
|
||||
wordWrap?: string,
|
||||
/**
|
||||
* White space to use. Equivalent to the `white-space` CSS rule.
|
||||
*/
|
||||
whiteSpace?: string,
|
||||
}> = styled.text(
|
||||
{
|
||||
color: props => (props.color ? props.color : 'inherit'),
|
||||
display: 'inline',
|
||||
fontWeight: props => (props.bold ? 'bold' : 'inherit'),
|
||||
fontStyle: props => (props.italic ? 'italic' : 'normal'),
|
||||
textAlign: props => props.align || 'left',
|
||||
fontSize: props => {
|
||||
if (props.size == null && props.code) {
|
||||
return 12;
|
||||
} else {
|
||||
return props.size;
|
||||
}
|
||||
},
|
||||
fontFamily: props => {
|
||||
if (props.code) {
|
||||
return 'SF Mono, Monaco, Andale Mono, monospace';
|
||||
} else {
|
||||
return props.family;
|
||||
}
|
||||
},
|
||||
overflow: props => {
|
||||
if (props.code) {
|
||||
return 'auto';
|
||||
} else {
|
||||
return 'visible';
|
||||
}
|
||||
},
|
||||
textDecoration: props => {
|
||||
if (props.underline) {
|
||||
return 'underline';
|
||||
} else if (props.strike) {
|
||||
return 'line-through';
|
||||
} else {
|
||||
return 'none';
|
||||
}
|
||||
},
|
||||
userSelect: props => {
|
||||
if (
|
||||
props.selectable ||
|
||||
(props.code && typeof props.selectable === 'undefined')
|
||||
) {
|
||||
return 'text';
|
||||
} else {
|
||||
return 'none';
|
||||
}
|
||||
},
|
||||
wordWrap: props => {
|
||||
if (props.code) {
|
||||
return 'break-word';
|
||||
} else {
|
||||
return props.wordWrap;
|
||||
}
|
||||
},
|
||||
whiteSpace: props => {
|
||||
if (props.code && typeof props.whiteSpace === 'undefined') {
|
||||
return 'pre';
|
||||
} else {
|
||||
return props.whiteSpace;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
ignoreAttributes: [
|
||||
'selectable',
|
||||
'whiteSpace',
|
||||
'wordWrap',
|
||||
'align',
|
||||
'code',
|
||||
'family',
|
||||
'size',
|
||||
'bold',
|
||||
'italic',
|
||||
'strike',
|
||||
'underline',
|
||||
'color',
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
export default Text;
|
||||
21
src/ui/components/TextParagraph.js
Normal file
21
src/ui/components/TextParagraph.js
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 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 styled from '../styled/index.js';
|
||||
|
||||
/**
|
||||
* A TextParagraph component.
|
||||
*/
|
||||
const TextParagraph = styled.view({
|
||||
marginBottom: 10,
|
||||
|
||||
'&:last-child': {
|
||||
marginBottom: 0,
|
||||
},
|
||||
});
|
||||
|
||||
export default TextParagraph;
|
||||
22
src/ui/components/Textarea.js
Normal file
22
src/ui/components/Textarea.js
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* 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 styled from '../styled/index.js';
|
||||
import {inputStyle} from './Input.js';
|
||||
|
||||
export default styled.customHTMLTag(
|
||||
'textarea',
|
||||
{
|
||||
...inputStyle,
|
||||
lineHeight: 'normal',
|
||||
padding: props => (props.compact ? '5px' : '8px'),
|
||||
resize: 'none',
|
||||
},
|
||||
{
|
||||
ignoreAttributes: ['compact'],
|
||||
},
|
||||
);
|
||||
61
src/ui/components/ToggleSwitch.js
Normal file
61
src/ui/components/ToggleSwitch.js
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* 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 React from 'react';
|
||||
import styled from '../styled/index.js';
|
||||
import {colors} from './colors.js';
|
||||
|
||||
export const StyledButton = styled.view({
|
||||
cursor: 'pointer',
|
||||
width: '30px',
|
||||
height: '16px',
|
||||
background: props => (props.toggled ? colors.green : colors.grey),
|
||||
display: 'block',
|
||||
borderRadius: '100px',
|
||||
position: 'relative',
|
||||
marginLeft: '15px',
|
||||
'&::after': {
|
||||
content: `''`,
|
||||
position: 'absolute',
|
||||
top: '3px',
|
||||
left: props => (props.toggled ? '18px' : '3px'),
|
||||
width: '10px',
|
||||
height: '10px',
|
||||
background: 'white',
|
||||
borderRadius: '100px',
|
||||
transition: 'all cubic-bezier(0.3, 1.5, 0.7, 1) 0.3s',
|
||||
},
|
||||
});
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* onClick handler.
|
||||
*/
|
||||
onClick?: (event: SyntheticMouseEvent<>) => void,
|
||||
/**
|
||||
* whether the button is toggled
|
||||
*/
|
||||
toggled?: boolean,
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle Button.
|
||||
*
|
||||
* **Usage**
|
||||
*
|
||||
* ```jsx
|
||||
* import {ToggleButton} from 'sonar';
|
||||
* <ToggleButton onClick={handler} toggled={boolean}/>
|
||||
* ```
|
||||
*/
|
||||
export default class ToggleButton extends styled.StylableComponent<Props> {
|
||||
render() {
|
||||
return (
|
||||
<StyledButton toggled={this.props.toggled} onClick={this.props.onClick} />
|
||||
);
|
||||
}
|
||||
}
|
||||
49
src/ui/components/Toolbar.js
Normal file
49
src/ui/components/Toolbar.js
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 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 {StyledComponent} from '../styled/index.js';
|
||||
import {colors} from './colors.js';
|
||||
import FlexRow from './FlexRow.js';
|
||||
import FlexBox from './FlexBox.js';
|
||||
|
||||
/**
|
||||
* A toolbar.
|
||||
*/
|
||||
const Toolbar: StyledComponent<{
|
||||
/**
|
||||
* Position of the toolbar. Dictates the location of the border.
|
||||
*/
|
||||
position?: 'top' | 'bottom',
|
||||
compact?: boolean,
|
||||
}> = FlexRow.extends(
|
||||
{
|
||||
backgroundColor: colors.light02,
|
||||
borderBottom: props =>
|
||||
props.position === 'bottom'
|
||||
? 'none'
|
||||
: `1px solid ${colors.sectionHeaderBorder}`,
|
||||
borderTop: props =>
|
||||
props.position === 'bottom'
|
||||
? `1px solid ${colors.sectionHeaderBorder}`
|
||||
: 'none',
|
||||
flexShrink: 0,
|
||||
height: props => (props.compact ? 28 : 42),
|
||||
lineHeight: '32px',
|
||||
alignItems: 'center',
|
||||
padding: 6,
|
||||
width: '100%',
|
||||
},
|
||||
{
|
||||
ignoreAttributes: ['position'],
|
||||
},
|
||||
);
|
||||
|
||||
export const Spacer = FlexBox.extends({
|
||||
flexGrow: 1,
|
||||
});
|
||||
|
||||
export default Toolbar;
|
||||
75
src/ui/components/Tooltip.js
Normal file
75
src/ui/components/Tooltip.js
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* 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 TooltipProvider from './TooltipProvider.js';
|
||||
|
||||
import styled from '../styled/index.js';
|
||||
import {Component} from 'react';
|
||||
|
||||
const PropTypes = require('prop-types');
|
||||
|
||||
const TooltipContainer = styled.view({
|
||||
display: 'contents',
|
||||
});
|
||||
|
||||
type TooltipProps = {
|
||||
title: React$Node,
|
||||
children: React$Node,
|
||||
};
|
||||
|
||||
type TooltipState = {
|
||||
open: boolean,
|
||||
};
|
||||
|
||||
export default class Tooltip extends Component<TooltipProps, TooltipState> {
|
||||
static contextTypes = {
|
||||
TOOLTIP_PROVIDER: PropTypes.object,
|
||||
};
|
||||
|
||||
context: {
|
||||
TOOLTIP_PROVIDER: TooltipProvider,
|
||||
};
|
||||
|
||||
ref: ?HTMLDivElement;
|
||||
|
||||
state = {
|
||||
open: false,
|
||||
};
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.state.open === true) {
|
||||
this.context.TOOLTIP_PROVIDER.close();
|
||||
}
|
||||
}
|
||||
|
||||
onMouseEnter = () => {
|
||||
if (this.ref != null) {
|
||||
this.context.TOOLTIP_PROVIDER.open(this.ref, this.props.title);
|
||||
this.setState({open: true});
|
||||
}
|
||||
};
|
||||
|
||||
onMouseLeave = () => {
|
||||
this.context.TOOLTIP_PROVIDER.close();
|
||||
this.setState({open: false});
|
||||
};
|
||||
|
||||
setRef = (ref: ?HTMLDivElement) => {
|
||||
this.ref = ref;
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<TooltipContainer
|
||||
innerRef={this.setRef}
|
||||
onMouseEnter={this.onMouseEnter}
|
||||
onMouseLeave={this.onMouseLeave}>
|
||||
{this.props.children}
|
||||
</TooltipContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
92
src/ui/components/TooltipProvider.js
Normal file
92
src/ui/components/TooltipProvider.js
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 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 styled from '../styled/index.js';
|
||||
import {Component} from 'react';
|
||||
|
||||
const PropTypes = require('prop-types');
|
||||
|
||||
const TooltipBubble = styled.view(
|
||||
{
|
||||
backgroundColor: '#000',
|
||||
lineHeight: '25px',
|
||||
padding: '0 6px',
|
||||
borderRadius: 4,
|
||||
position: 'absolute',
|
||||
width: 'auto',
|
||||
top: props => props.top,
|
||||
left: props => props.left,
|
||||
zIndex: 99999999999,
|
||||
pointerEvents: 'none',
|
||||
color: '#fff',
|
||||
marginTop: '-30px',
|
||||
},
|
||||
{
|
||||
ignoreAttributes: ['top', 'left'],
|
||||
},
|
||||
);
|
||||
|
||||
type TooltipProps = {
|
||||
children: React$Node,
|
||||
};
|
||||
|
||||
type TooltipState = {
|
||||
tooltip: ?{
|
||||
rect: ClientRect,
|
||||
title: React$Node,
|
||||
},
|
||||
};
|
||||
|
||||
export default class TooltipProvider extends Component<
|
||||
TooltipProps,
|
||||
TooltipState,
|
||||
> {
|
||||
static childContextTypes = {
|
||||
TOOLTIP_PROVIDER: PropTypes.object,
|
||||
};
|
||||
|
||||
state = {
|
||||
tooltip: null,
|
||||
};
|
||||
|
||||
getChildContext() {
|
||||
return {TOOLTIP_PROVIDER: this};
|
||||
}
|
||||
|
||||
open(container: HTMLDivElement, title: React$Node) {
|
||||
const node = container.childNodes[0];
|
||||
if (node == null || !(node instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
tooltip: {
|
||||
rect: node.getBoundingClientRect(),
|
||||
title,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
this.setState({tooltip: null});
|
||||
}
|
||||
|
||||
render() {
|
||||
const {tooltip} = this.state;
|
||||
|
||||
let tooltipElem = null;
|
||||
if (tooltip != null) {
|
||||
tooltipElem = (
|
||||
<TooltipBubble top={tooltip.rect.top} left={tooltip.rect.left}>
|
||||
{tooltip.title}
|
||||
</TooltipBubble>
|
||||
);
|
||||
}
|
||||
|
||||
return [tooltipElem, this.props.children];
|
||||
}
|
||||
}
|
||||
22
src/ui/components/View.js
Normal file
22
src/ui/components/View.js
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* 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 styled from '../styled/index.js';
|
||||
|
||||
const View = styled.view(
|
||||
{
|
||||
height: props => (props.fill ? '100%' : 'auto'),
|
||||
overflow: props => (props.scrollable ? 'auto' : 'visible'),
|
||||
position: 'relative',
|
||||
width: props => (props.fill ? '100%' : 'auto'),
|
||||
},
|
||||
{
|
||||
ignoreAttributes: ['fill', 'scrollable'],
|
||||
},
|
||||
);
|
||||
|
||||
export default View;
|
||||
44
src/ui/components/ViewWithSize.js
Normal file
44
src/ui/components/ViewWithSize.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* 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 {Component} from 'react';
|
||||
|
||||
type ViewWithSizeProps = {
|
||||
onSize: (width: number, height: number) => any,
|
||||
};
|
||||
|
||||
type ViewWithSizeState = {|
|
||||
width: number,
|
||||
height: number,
|
||||
|};
|
||||
|
||||
export default class ViewWithSize extends Component<
|
||||
ViewWithSizeProps,
|
||||
ViewWithSizeState,
|
||||
> {
|
||||
constructor(props: ViewWithSizeProps, context: Object) {
|
||||
super(props, context);
|
||||
this.state = {height: window.innerHeight, width: window.innerWidth};
|
||||
}
|
||||
|
||||
_onResize: Function;
|
||||
|
||||
componentDidMount() {
|
||||
this._onResize = () => {
|
||||
this.setState({height: window.innerHeight, width: window.innerWidth});
|
||||
};
|
||||
window.addEventListener('resize', this._onResize);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('resize', this._onResize);
|
||||
}
|
||||
|
||||
render() {
|
||||
return this.props.onSize(this.state.width, this.state.height);
|
||||
}
|
||||
}
|
||||
146
src/ui/components/VirtualList.js
Normal file
146
src/ui/components/VirtualList.js
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* 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 FlexColumn from './FlexColumn.js';
|
||||
import {Component} from 'react';
|
||||
import View from './View.js';
|
||||
|
||||
const Inner = FlexColumn.extends(
|
||||
{
|
||||
alignItems: 'flex-start',
|
||||
height: props => props.height,
|
||||
minHeight: '100%',
|
||||
minWidth: '100%',
|
||||
overflow: 'visible',
|
||||
width: '100%',
|
||||
},
|
||||
{
|
||||
ignoreAttributes: ['height'],
|
||||
},
|
||||
);
|
||||
|
||||
const Content = FlexColumn.extends(
|
||||
{
|
||||
alignItems: 'flex-start',
|
||||
height: '100%',
|
||||
marginTop: props => props.top,
|
||||
minWidth: '100%',
|
||||
overflow: 'visible',
|
||||
},
|
||||
{
|
||||
ignoreAttributes: ['top'],
|
||||
},
|
||||
);
|
||||
|
||||
type VirtualListProps = {|
|
||||
data: Array<any>,
|
||||
renderRow: (data: any, i: number) => any,
|
||||
rowHeight: number,
|
||||
overscanCount: number,
|
||||
sync?: number,
|
||||
wrapInner?: (data: any) => any,
|
||||
|};
|
||||
|
||||
type VirtualListState = {|
|
||||
offset: number,
|
||||
height: number,
|
||||
|};
|
||||
|
||||
export default class VirtualList extends Component<
|
||||
VirtualListProps,
|
||||
VirtualListState,
|
||||
> {
|
||||
constructor(props: VirtualListProps, context: Object) {
|
||||
super(props, context);
|
||||
this.state = {
|
||||
height: 0,
|
||||
offset: 0,
|
||||
};
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
overscanCount: 10,
|
||||
};
|
||||
|
||||
ref: HTMLElement;
|
||||
|
||||
setRef = (ref: HTMLElement) => {
|
||||
this.ref = ref;
|
||||
};
|
||||
|
||||
resize = () => {
|
||||
if (this.ref && this.state.height !== this.ref.offsetHeight) {
|
||||
this.setState({height: this.ref.offsetHeight});
|
||||
}
|
||||
};
|
||||
|
||||
handleScroll = () => {
|
||||
this.setState({offset: this.ref.scrollTop});
|
||||
if (this.props.sync === true) {
|
||||
this.forceUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
componentDidUpdate() {
|
||||
this.resize();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.resize();
|
||||
window.addEventListener('resize', this.resize);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('resize', this.resize);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {data, overscanCount, renderRow, rowHeight, wrapInner} = this.props;
|
||||
const {height, offset} = this.state;
|
||||
|
||||
// first visible row index
|
||||
// eslint-disable-next-line no-bitwise
|
||||
let start = (offset / rowHeight) | 0;
|
||||
|
||||
// actual number of visible rows (without overscan)
|
||||
// eslint-disable-next-line no-bitwise
|
||||
let visibleRowCount = (height / rowHeight) | 0;
|
||||
|
||||
// Overscan: render blocks of rows modulo an overscan row count
|
||||
// This dramatically reduces DOM writes during scrolling
|
||||
if (overscanCount) {
|
||||
start = Math.max(0, start - start % overscanCount);
|
||||
visibleRowCount += overscanCount;
|
||||
}
|
||||
|
||||
// last visible + overscan row index
|
||||
const end = start + 1 + visibleRowCount;
|
||||
|
||||
// data slice currently in viewport plus overscan items
|
||||
const selection = data.slice(start, end);
|
||||
|
||||
let inner = (
|
||||
<Inner height={data.length * rowHeight}>
|
||||
<Content top={start * rowHeight}>{selection.map(renderRow)}</Content>
|
||||
</Inner>
|
||||
);
|
||||
|
||||
if (wrapInner) {
|
||||
inner = wrapInner(inner);
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
fill={true}
|
||||
onScroll={this.handleScroll}
|
||||
innerRef={this.setRef}
|
||||
scrollable={true}>
|
||||
{inner}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
273
src/ui/components/colors.js
Normal file
273
src/ui/components/colors.js
Normal file
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
// Last updated: Jan 30 2016
|
||||
|
||||
export const colors = {
|
||||
// FIG UI Core
|
||||
blue: '#4267b2', // Blue - Active-state nav glyphs, nav bars, links, buttons
|
||||
blueDark3: '#162643', // Blue - Dark 3 (illustrations only)
|
||||
blueDark2: '#20375f', // Blue - Dark 2 (illustrations only)
|
||||
blueDark1: '#29487d', // Blue - Dark 1 (illustrations only)
|
||||
blueDark: '#365899', // Blue - Dark 0 (blue links, blue button hover states)
|
||||
blueTint15: '#577fbc', // Blue - Tint 15 (illustrations only)
|
||||
blueTint30: '#7596c8', // Blue - Tint 30 (illustrations only)
|
||||
blueTint50: '#9cb4d8', // Blue - Tint 50 (illustrations only)
|
||||
blueTint70: '#c4d2e7', // Blue - Tint 70 (illustrations only)
|
||||
blueTint90: '#ecf0f7', // Blue - Tint 90 (illustrations only)
|
||||
highlight: '#4080ff', // Highlight - Unread, badging notifications, NUX *Use sparingly.*
|
||||
highlightDark3: '#1c4f8c', // Highlight - Dark 3 (illustrations only)
|
||||
highlightDark2: '#1d5fbf', // Highlight - Dark 2 (illustrations only)
|
||||
highlightDark1: '#3578e5', // Highlight - Dark 1 (illustrations only)
|
||||
highlightTint15: '#5d93ff', // Highlight - Tint 15 (illustrations only)
|
||||
highlightTint30: '#79a6ff', // Highlight - Tint 30 (illustrations only)
|
||||
highlightTint50: '#9fbfff', // Highlight - Tint 50 (illustrations only)
|
||||
highlightTint70: '#c6d9ff', // Highlight - Tint 70 (illustrations only)
|
||||
highlightTint90: '#ecf2ff', // Highlight - Tint 90 (illustrations only)
|
||||
highlightBackground: '#edf2fa', // Highlight Background - Background fill for unread or highlighted states. Not intended for hover / pressed states
|
||||
green: '#42b72a', // Green - Confirmation, success, commerce and status
|
||||
red: '#FC3A4B', // Red - Badges, error states
|
||||
redTint: '#FEF2F1',
|
||||
white: '#ffffff', // White - Text and glyphs in Dark UI and media views
|
||||
black: '#000000', // Black - Media backgrounds
|
||||
yellow: '#D79651', // Yellow - Warnings
|
||||
yellowTint: '#FEFBF2',
|
||||
purple: '#8C73C8', // Purple - Verbose
|
||||
purpleTint: '#E8E3F4',
|
||||
grey: '#88A2AB', // Grey - Debug
|
||||
greyTint: '#E7ECEE',
|
||||
cyan: '#4FC9EA', // Cyan - Info
|
||||
cyanTint: '#DCF4FB', // Cyan - Info
|
||||
// FIG UI Light
|
||||
light02: '#f6f7f9', // Light 02 – Modal Headers & Nav - Modal headers and navigation elements that sit above primary UI
|
||||
light05: '#e9ebee', // Light 05 – Mobile & Desktop Wash - Background wash color for desktop and mobile
|
||||
light10: '#dddfe2', // Light 10 – Desktop Dividers, Strokes, Borders - Desktop dividers, strokes, borders
|
||||
light15: '#ced0d4', // Light 15 – Mobile Dividers, Strokes, Borders - Mobile dividers, strokes, borders
|
||||
light20: '#bec2c9', // Light 20 – Inactive Nav Glyphs - Inactive-state nav glyphs, tertiary glyphs
|
||||
light30: '#90949c', // Light 30 – Secondary Text & Glyphs - Secondary text and glyphs, meta text and glyphs
|
||||
light50: '#4b4f56', // Light 50 – Medium Text & Primary Glyphs - Medium text and primary glyphs
|
||||
light80: '#1d2129', // Light 80 – Primary Text - Primary text
|
||||
// FIG UI Alpha
|
||||
whiteAlpha10: 'rgba(255, 255, 255, 0.10)', // Alpha 10 - Inset strokes and borders on photos
|
||||
whiteAlpha15: 'rgba(255, 255, 255, 0.15)', // Alpha 15 - Dividers, strokes, borders
|
||||
whiteAlpha30: 'rgba(255, 255, 255, 0.3)', // Alpha 30 - Secondary text and glyphs, meta text and glyphs
|
||||
whiteAlpha40: 'rgba(255, 255, 255, 0.4)', // Alpha 40 - Overlays
|
||||
whiteAlpha50: 'rgba(255, 255, 255, 0.5)', // Alpha 50 - Medium text and primary glyphs
|
||||
whiteAlpha80: 'rgba(255, 255, 255, 0.8)', // Alpha 80 - Primary Text
|
||||
blackAlpha10: 'rgba(0, 0, 0, 0.1)', // Alpha 10 - Inset strokes and borders on photos
|
||||
blackAlpha15: 'rgba(0, 0, 0, 0.15)', // Alpha 15 - Dividers, strokes, borders
|
||||
blackAlpha30: 'rgba(0, 0, 0, 0.3)', // Alpha 30 - Secondary text and glyphs, meta text and glyphs
|
||||
blackAlpha40: 'rgba(0, 0, 0, 0.4)', // Alpha 40 - Overlays
|
||||
blackAlpha50: 'rgba(0, 0, 0, 0.5)', // Alpha 50 - Medium text and primary glyphs
|
||||
blackAlpha80: 'rgba(0, 0, 0, 0.8)', // Alpha 80 - Primary Text
|
||||
light80Alpha4: 'rgba(29, 33, 41, 0.04)', // Light 80 Alpha 4 - Hover state background fill for list views on WWW
|
||||
light80Alpha8: 'rgba(29, 33, 41, 0.08)', // Light 80 Alpha 8 - Pressed state background fill for list views on WWW and Mobile
|
||||
// FIG UI Dark
|
||||
dark20: '#cccccc', // Dark 20 – Primary Text - Primary text
|
||||
dark50: '#7f7f7f', // Dark 50 – Medium Text & Primary Glyphs - Medium text and primary glyphs
|
||||
dark70: '#4c4c4c', // Dark 70 – Secondary Text & Glyphs - Secondary text and glyphs, meta text and glyphs
|
||||
dark80: '#333333', // Dark 80 – Inactive Nav Glyphs - Inactive-state nav glyphs, tertiary glyphs
|
||||
dark85: '#262626', // Dark 85 – Dividers, Strokes, Borders - Dividers, strokes, borders
|
||||
dark90: '#191919', // Dark 90 – Nav Bar, Tab Bar, Cards - Nav bar, tab bar, cards
|
||||
dark95: '#0d0d0d', // Dark 95 – Background Wash - Background Wash
|
||||
// FIG Spectrum
|
||||
blueGrey: '#5f6673', // Blue Grey
|
||||
blueGreyDark3: '#23272f', // Blue Grey - Dark 3
|
||||
blueGreyDark2: '#303846', // Blue Grey - Dark 2
|
||||
blueGreyDark1: '#4f5766', // Blue Grey - Dark 1
|
||||
blueGreyTint15: '#777d88', // Blue Grey - Tint 15
|
||||
blueGreyTint30: '#8f949d', // Blue Grey - Tint 30
|
||||
blueGreyTint50: '#afb3b9', // Blue Grey - Tint 50
|
||||
blueGreyTint70: '#cfd1d5', // Blue Grey - Tint 70
|
||||
blueGreyTint90: '#eff0f1', // Blue Grey - Tint 90
|
||||
slate: '#b9cad2', // Slate
|
||||
slateDark3: '#688694', // Slate - Dark 3
|
||||
slateDark2: '#89a1ac', // Slate - Dark 2
|
||||
slateDark1: '#a8bbc3', // Slate - Dark 1
|
||||
slateTint15: '#c4d2d9', // Slate - Tint 15
|
||||
slateTint30: '#cedae0', // Slate - Tint 30
|
||||
slateTint50: '#dce5e9', // Slate - Tint 50
|
||||
slateTint70: '#eaeff2', // Slate - Tint 70
|
||||
slateTint90: '#f8fafb', // Slate - Tint 90
|
||||
aluminum: '#a3cedf', // Aluminum
|
||||
aluminumDark3: '#4b8096', // Aluminum - Dark 3
|
||||
aluminumDark2: '#6ca0b6', // Aluminum - Dark 2
|
||||
aluminumDark1: '#8ebfd4', // Aluminum - Dark 1
|
||||
aluminumTint15: '#b0d5e5', // Aluminum - Tint 15
|
||||
aluminumTint30: '#bfdde9', // Aluminum - Tint 30
|
||||
aluminumTint50: '#d1e7f0', // Aluminum - Tint 50
|
||||
aluminumTint70: '#e4f0f6', // Aluminum - Tint 70
|
||||
aluminumTint90: '#f6fafc', // Aluminum - Tint 90
|
||||
seaFoam: '#54c7ec', // Sea Foam
|
||||
seaFoamDark3: '#186d90', // Sea Foam - Dark 3
|
||||
seaFoamDark2: '#2088af', // Sea Foam - Dark 2
|
||||
seaFoamDark1: '#39afd5', // Sea Foam - Dark 1
|
||||
seaFoamTint15: '#6bcfef', // Sea Foam - Tint 15
|
||||
seaFoamTint30: '#84d8f2', // Sea Foam - Tint 30
|
||||
seaFoamTint50: '#a7e3f6', // Sea Foam - Tint 50
|
||||
seaFoamTint70: '#caeef9', // Sea Foam - Tint 70
|
||||
seaFoamTint90: '#eefafd', // Sea Foam - Tint 90
|
||||
teal: '#6bcebb', // Teal
|
||||
tealDark3: '#24917d', // Teal - Dark 3
|
||||
tealDark2: '#31a38d', // Teal - Dark 2
|
||||
tealDark1: '#4dbba6', // Teal - Dark 1
|
||||
tealTint15: '#80d4c4', // Teal - Tint 15
|
||||
tealTint30: '#97dccf', // Teal - Tint 30
|
||||
tealTint50: '#b4e6dd', // Teal - Tint 50
|
||||
tealTint70: '#d2f0ea', // Teal - Tint 70
|
||||
tealTint90: '#f0faf8', // Teal - Tint 90
|
||||
lime: '#a3ce71', // Lime
|
||||
limeDark3: '#629824', // Lime - Dark 3
|
||||
limeDark2: '#71a830', // Lime - Dark 2
|
||||
limeDark1: '#89be4c', // Lime - Dark 1
|
||||
limeTint15: '#b1d587', // Lime - Tint 15
|
||||
limeTint30: '#bedd9c', // Lime - Tint 30
|
||||
limeTint50: '#d1e6b9', // Lime - Tint 50
|
||||
limeTint70: '#e4f0d5', // Lime - Tint 70
|
||||
limeTint90: '#f6faf1', // Lime - Tint 90
|
||||
lemon: '#fcd872', // Lemon
|
||||
lemonDark3: '#d18f41', // Lemon - Dark 3
|
||||
lemonDark2: '#e1a43b', // Lemon - Dark 2
|
||||
lemonDark1: '#f5c33b', // Lemon - Dark 1
|
||||
lemonTint15: '#ffe18f', // Lemon - Tint 15
|
||||
lemonTint30: '#ffe8a8', // Lemon - Tint 30
|
||||
lemonTint50: '#ffecb5', // Lemon - Tint 50
|
||||
lemonTint70: '#fef2d1', // Lemon - Tint 70
|
||||
lemonTint90: '#fffbf0', // Lemon - Tint 90
|
||||
orange: '#f7923b', // Orange
|
||||
orangeDark3: '#ac4615', // Orange - Dark 3
|
||||
orangeDark2: '#cc5d22', // Orange - Dark 2
|
||||
orangeDark1: '#e07a2e', // Orange - Dark 1
|
||||
orangeTint15: '#f9a159', // Orange - Tint 15
|
||||
orangeTint30: '#f9b278', // Orange - Tint 30
|
||||
orangeTint50: '#fbc89f', // Orange - Tint 50
|
||||
orangeTint70: '#fcdec5', // Orange - Tint 70
|
||||
orangeTint90: '#fef4ec', // Orange - Tint 90
|
||||
tomato: '#fb724b', // Tomato - Tometo? Tomato.
|
||||
tomatoDark3: '#c32d0e', // Tomato - Dark 3
|
||||
tomatoDark2: '#db4123', // Tomato - Dark 2
|
||||
tomatoDark1: '#ef6632', // Tomato - Dark 1
|
||||
tomatoTint15: '#f1765e', // Tomato - Tint 15
|
||||
tomatoTint30: '#f38e7b', // Tomato - Tint 30
|
||||
tomatoTint50: '#f7afa0', // Tomato - Tint 50
|
||||
tomatoTint70: '#f9cfc7', // Tomato - Tint 70
|
||||
tomatoTint90: '#fdefed', // Tomato - Tint 90
|
||||
cherry: '#f35369', // Cherry
|
||||
cherryDark3: '#9b2b3a', // Cherry - Dark 3
|
||||
cherryDark2: '#b73749', // Cherry - Dark 2
|
||||
cherryDark1: '#e04c60', // Cherry - Dark 1
|
||||
cherryTint15: '#f36b7f', // Cherry - Tint 15
|
||||
cherryTint30: '#f58796', // Cherry - Tint 30
|
||||
cherryTint50: '#f8a9b4', // Cherry - Tint 50
|
||||
cherryTint70: '#fbccd2', // Cherry - Tint 70
|
||||
cherryTint90: '#feeef0', // Cherry - Tint 90
|
||||
pink: '#ec7ebd', // Pink
|
||||
pinkDark3: '#b0377b', // Pink - Dark 3
|
||||
pinkDark2: '#d4539b', // Pink - Dark 2
|
||||
pinkDark1: '#ec6fb5', // Pink - Dark 1
|
||||
pinkTint15: '#ef92c7', // Pink - Tint 15
|
||||
pinkTint30: '#f2a5d1', // Pink - Tint 30
|
||||
pinkTint50: '#f6bfdf', // Pink - Tint 50
|
||||
pinkTint70: '#f9d9eb', // Pink - Tint 70
|
||||
pinkTint90: '#fdf3f8', // Pink - Tint 90
|
||||
grape: '#8c72cb', // Grape
|
||||
grapeDark3: '#58409b', // Grape - Dark 3
|
||||
grapeDark2: '#6a51b2', // Grape - Dark 2
|
||||
grapeDark1: '#7b64c0', // Grape - Dark 1
|
||||
grapeTint15: '#9d87d2', // Grape - Tint 15
|
||||
grapeTint30: '#af9cda', // Grape - Tint 30
|
||||
grapeTint50: '#c6b8e5', // Grape - Tint 50
|
||||
grapeTint70: '#ddd5f0', // Grape - Tint 70
|
||||
grapeTint90: '#f4f1fa', // Grape - Tint 90
|
||||
// FIG Spectrum (Skin)
|
||||
skin1: '#f1d2b6', // Skin 1
|
||||
skin1Dark3: '#d9a170', // Skin 1 - Dark 3
|
||||
skin1Dark2: '#ddac82', // Skin 1 - Dark 2
|
||||
skin1Dark1: '#e2ba96', // Skin 1 - Dark 1
|
||||
skin1Tint15: '#f3d9c1', // Skin 1 - Tint 15
|
||||
skin1Tint30: '#f6e0cc', // Skin 1 - Tint 30
|
||||
skin1Tint50: '#f8e9db', // Skin 1 - Tint 50
|
||||
skin1Tint70: '#faf2ea', // Skin 1 - Tint 70
|
||||
skin1Tint90: '#fefbf8', // Skin 1 - Tint 90
|
||||
skin2: '#d7b195', // Skin 2
|
||||
skin2Dark3: '#af866a', // Skin 2 - Dark 3
|
||||
skin2Dark2: '#c2977a', // Skin 2 - Dark 2
|
||||
skin2Dark1: '#cfa588', // Skin 2 - Dark 1
|
||||
skin2Tint15: '#debda5', // Skin 2 - Tint 15
|
||||
skin2Tint30: '#e5c9b5', // Skin 2 - Tint 30
|
||||
skin2Tint50: '#ecd8cb', // Skin 2 - Tint 50
|
||||
skin2Tint70: '#f3e8e0', // Skin 2 - Tint 70
|
||||
skin2Tint90: '#fbf7f5', // Skin 2 - Tint 90
|
||||
skin3: '#d8a873', // Skin 3
|
||||
skin3Dark3: '#a77a4e', // Skin 3 - Dark 3
|
||||
skin3Dark2: '#ba8653', // Skin 3 - Dark 2
|
||||
skin3Dark1: '#cd9862', // Skin 3 - Dark 1
|
||||
skin3Tint15: '#e0b588', // Skin 3 - Tint 15
|
||||
skin3Tint30: '#e5c29e', // Skin 3 - Tint 30
|
||||
skin3Tint50: '#ecd4b9', // Skin 3 - Tint 50
|
||||
skin3Tint70: '#f4e5d6', // Skin 3 - Tint 70
|
||||
skin3Tint90: '#fcf6f1', // Skin 3 - Tint 90
|
||||
skin4: '#a67b4f', // Skin 4
|
||||
skin4Dark3: '#815830', // Skin 4 - Dark 3
|
||||
skin4Dark2: '#94683d', // Skin 4 - Dark 2
|
||||
skin4Dark1: '#a07243', // Skin 4 - Dark 1
|
||||
skin4Tint15: '#ae8761', // Skin 4 - Tint 15
|
||||
skin4Tint30: '#bc9d7d', // Skin 4 - Tint 30
|
||||
skin4Tint50: '#d0b9a2', // Skin 4 - Tint 50
|
||||
skin4Tint70: '#e2d5c8', // Skin 4 - Tint 70
|
||||
skin4Tint90: '#f6f1ed', // Skin 4 - Tint 90
|
||||
skin5: '#6a4f3b', // Skin 5
|
||||
skin5Dark3: '#453223', // Skin 5 - Dark 3
|
||||
skin5Dark2: '#503b2c', // Skin 5 - Dark 2
|
||||
skin5Dark1: '#624733', // Skin 5 - Dark 1
|
||||
skin5Tint15: '#8a715b', // Skin 5 - Tint 15
|
||||
skin5Tint30: '#9f8a79', // Skin 5 - Tint 30
|
||||
skin5Tint50: '#baaca0', // Skin 5 - Tint 50
|
||||
skin5Tint70: '#d5cdc6', // Skin 5 - Tint 70
|
||||
skin5Tint90: '#f2efec', // Skin 5 - Tint 90
|
||||
// macOS system colors
|
||||
macOSHighlight: '#dbe7fa', // used for text selection, tokens, etc.
|
||||
macOSHighlightActive: '#85afee', // active tokens
|
||||
macOSTitleBarBackgroundTop: '#eae9eb',
|
||||
macOSTitleBarBackgroundBottom: '#dcdbdc',
|
||||
macOSTitleBarBackgroundBlur: '#f6f6f6',
|
||||
macOSTitleBarBorder: '#c1c0c2',
|
||||
macOSTitleBarBorderBlur: '#cecece',
|
||||
macOSTitleBarIcon: '#6f6f6f',
|
||||
macOSTitleBarIconBlur: '#acacac',
|
||||
macOSTitleBarIconSelected: '#4d84f5',
|
||||
macOSTitleBarIconSelectedBlur: '#80a6f5',
|
||||
macOSTitleBarIconActive: '#4c4c4c',
|
||||
macOSTitleBarButtonBorder: '#d3d2d3',
|
||||
macOSTitleBarButtonBorderBottom: '#b0afb0',
|
||||
macOSTitleBarButtonBorderBlur: '#dbdbdb',
|
||||
macOSTitleBarButtonBackground: 'rgba(0,0,0,0.05)',
|
||||
macOSTitleBarButtonBackgroundBlur: '#f6f6f6',
|
||||
macOSTitleBarButtonBackgroundActiveHighlight: '#ededed',
|
||||
macOSTitleBarButtonBackgroundActive: '#e5e5e5',
|
||||
macOSSidebarSectionTitle: '#777',
|
||||
macOSSidebarSectionItem: '#434343',
|
||||
macOSSidebarPanelSeperator: '#b3b3b3',
|
||||
sectionHeaderBorder: '#DDDFE2',
|
||||
placeholder: '#A7AAB1',
|
||||
info: '#5ACFEC',
|
||||
};
|
||||
|
||||
export const darkColors = {
|
||||
activeBackground: colors.dark80,
|
||||
backgroundWash: colors.dark95,
|
||||
barBackground: colors.dark90,
|
||||
barText: colors.dark20,
|
||||
dividers: colors.whiteAlpha10,
|
||||
};
|
||||
|
||||
export const brandColors = {
|
||||
Facebook: '#4267B2',
|
||||
Messenger: '#0088FA',
|
||||
Instagram: '#E61E68',
|
||||
Sonar: '#8155cb',
|
||||
};
|
||||
213
src/ui/components/console.js
Normal file
213
src/ui/components/console.js
Normal file
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* 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 {Component} from 'react';
|
||||
import {
|
||||
CodeBlock,
|
||||
colors,
|
||||
ManagedTable,
|
||||
FlexColumn,
|
||||
Text,
|
||||
ManagedDataInspector,
|
||||
Input,
|
||||
View,
|
||||
} from '../index';
|
||||
import type {TableBodyRow, TableRows} from 'sonar';
|
||||
import type {PluginClient} from '../../plugin';
|
||||
|
||||
type ValueWithType = {|
|
||||
type: string,
|
||||
value: any,
|
||||
|};
|
||||
type SuccessResult = {|
|
||||
isSuccess: true,
|
||||
value: ValueWithType,
|
||||
|};
|
||||
type FailedResult = {|
|
||||
isSuccess: false,
|
||||
error: string,
|
||||
|};
|
||||
|
||||
type CommandResult = SuccessResult | FailedResult;
|
||||
|
||||
type Props = {
|
||||
client: PluginClient,
|
||||
getContext: () => string,
|
||||
};
|
||||
type State = {
|
||||
isConsoleEnabled: boolean,
|
||||
script: string,
|
||||
previousExecutions: Array<{
|
||||
command: string,
|
||||
result: CommandResult,
|
||||
}>,
|
||||
};
|
||||
|
||||
class ConsoleError extends Component<{
|
||||
error: Error | string | void,
|
||||
className?: string,
|
||||
}> {
|
||||
static Container = CodeBlock.extends({
|
||||
backgroundColor: colors.redTint,
|
||||
color: colors.red,
|
||||
overflow: 'auto',
|
||||
flexGrow: 1,
|
||||
margin: '0 -8px',
|
||||
padding: '0 8px',
|
||||
});
|
||||
|
||||
render() {
|
||||
const {className, error} = this.props;
|
||||
|
||||
return (
|
||||
<ConsoleError.Container className={className}>
|
||||
{error}
|
||||
</ConsoleError.Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class Console extends Component<Props, State> {
|
||||
static title = 'Console';
|
||||
static id = 'Console';
|
||||
static icon = 'chevron-right';
|
||||
|
||||
static TableColumns = {
|
||||
command: {
|
||||
value: 'Commands',
|
||||
},
|
||||
};
|
||||
|
||||
static CommandsTable = ManagedTable.extends({
|
||||
flexGrow: 1,
|
||||
});
|
||||
static Window = FlexColumn.extends({
|
||||
padding: '15px',
|
||||
flexGrow: 1,
|
||||
});
|
||||
static Input = Input.extends({
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
isConsoleEnabled: false,
|
||||
script: '',
|
||||
previousExecutions: [],
|
||||
};
|
||||
}
|
||||
|
||||
executeScriptOnDevice = () => {
|
||||
this.props.client
|
||||
.call('executeCommand', {
|
||||
command: this.state.script,
|
||||
context: this.props.getContext(),
|
||||
})
|
||||
.then((result: ValueWithType) => {
|
||||
this.setState({
|
||||
script: '',
|
||||
previousExecutions: [
|
||||
...this.state.previousExecutions,
|
||||
{
|
||||
command: this.state.script,
|
||||
result: {isSuccess: true, value: result},
|
||||
},
|
||||
],
|
||||
});
|
||||
})
|
||||
.catch((onReject?) => {
|
||||
this.setState({
|
||||
previousExecutions: [
|
||||
...this.state.previousExecutions,
|
||||
{
|
||||
command: this.state.script,
|
||||
result: {
|
||||
isSuccess: false,
|
||||
error: (onReject && onReject.message) || '',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
onInputChange = (event: SyntheticInputEvent<>) => {
|
||||
this.setState({script: event.target.value});
|
||||
};
|
||||
|
||||
onSubmit = (event: SyntheticEvent<>) => {
|
||||
if (this.state.script != '') {
|
||||
this.executeScriptOnDevice();
|
||||
}
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
buildCommandResultRowPair(
|
||||
command: string,
|
||||
result: CommandResult,
|
||||
index: number,
|
||||
): TableRows {
|
||||
const key = index * 2;
|
||||
const commandRow: TableBodyRow = {
|
||||
columns: {
|
||||
command: {value: <Text>{command}</Text>},
|
||||
},
|
||||
key: key.toString(),
|
||||
};
|
||||
const resultRow: TableBodyRow = {
|
||||
columns: {
|
||||
command: {
|
||||
value: result.isSuccess ? (
|
||||
<ManagedDataInspector
|
||||
data={result.value}
|
||||
expandRoot={true}
|
||||
collapsed={true}
|
||||
/>
|
||||
) : (
|
||||
<ConsoleError error={result.error} />
|
||||
),
|
||||
},
|
||||
},
|
||||
key: (key + 1).toString(),
|
||||
};
|
||||
return [commandRow, resultRow];
|
||||
}
|
||||
|
||||
renderPreviousCommands() {
|
||||
const rows: TableRows = this.state.previousExecutions
|
||||
.map(({command, result}, index) =>
|
||||
this.buildCommandResultRowPair(command, result, index),
|
||||
)
|
||||
.reduce((x, y) => x.concat(y), []);
|
||||
return rows.length ? (
|
||||
<Console.CommandsTable
|
||||
columns={Console.TableColumns}
|
||||
rows={rows}
|
||||
multiline={true}
|
||||
stickyBottom={true}
|
||||
highlightableRows={false}
|
||||
hideHeader={true}
|
||||
autoHeight={true}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Console.Window>
|
||||
<View fill>{this.renderPreviousCommands()}</View>
|
||||
<form onSubmit={this.onSubmit}>
|
||||
<Console.Input
|
||||
onChange={this.onInputChange}
|
||||
placeholder="Command"
|
||||
value={this.state.script}
|
||||
/>
|
||||
</form>
|
||||
</Console.Window>
|
||||
);
|
||||
}
|
||||
}
|
||||
521
src/ui/components/data-inspector/DataDescription.js
Normal file
521
src/ui/components/data-inspector/DataDescription.js
Normal file
@@ -0,0 +1,521 @@
|
||||
/**
|
||||
* 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 {DataInspectorSetValue} from './DataInspector.js';
|
||||
import {PureComponent} from 'react';
|
||||
import styled from '../../styled/index.js';
|
||||
import {SketchPicker} from 'react-color';
|
||||
import {Component} from 'react';
|
||||
import Popover from '../Popover.js';
|
||||
import {colors} from '../colors.js';
|
||||
import Input from '../Input.js';
|
||||
|
||||
const NullValue = styled.text({
|
||||
color: 'rgb(128, 128, 128)',
|
||||
});
|
||||
|
||||
const UndefinedValue = styled.text({
|
||||
color: 'rgb(128, 128, 128)',
|
||||
});
|
||||
|
||||
const StringValue = styled.text({
|
||||
color: colors.cherryDark1,
|
||||
});
|
||||
|
||||
const ColorValue = styled.text({
|
||||
color: colors.blueGrey,
|
||||
});
|
||||
|
||||
const SymbolValue = styled.text({
|
||||
color: 'rgb(196, 26, 22)',
|
||||
});
|
||||
|
||||
const NumberValue = styled.text({
|
||||
color: colors.tealDark1,
|
||||
});
|
||||
|
||||
const ColorBox = styled.text(
|
||||
{
|
||||
backgroundColor: props => props.color,
|
||||
boxShadow: 'inset 0 0 1px rgba(0, 0, 0, 1)',
|
||||
display: 'inline-block',
|
||||
height: 12,
|
||||
marginRight: 5,
|
||||
verticalAlign: 'middle',
|
||||
width: 12,
|
||||
},
|
||||
{
|
||||
ignoreAttributes: ['color'],
|
||||
},
|
||||
);
|
||||
|
||||
const FunctionKeyword = styled.text({
|
||||
color: 'rgb(170, 13, 145)',
|
||||
fontStyle: 'italic',
|
||||
});
|
||||
|
||||
const FunctionName = styled.text({
|
||||
fontStyle: 'italic',
|
||||
});
|
||||
|
||||
const ColorPickerDescription = styled.view({
|
||||
display: 'inline',
|
||||
position: 'relative',
|
||||
});
|
||||
|
||||
type DataDescriptionProps = {|
|
||||
path?: Array<string>,
|
||||
type: string,
|
||||
value: any,
|
||||
setValue: ?DataInspectorSetValue,
|
||||
|};
|
||||
|
||||
type DescriptionCommitOptions = {|
|
||||
value: any,
|
||||
keep: boolean,
|
||||
clear: boolean,
|
||||
|};
|
||||
|
||||
class NumberTextEditor extends PureComponent<{
|
||||
commit: (opts: DescriptionCommitOptions) => void,
|
||||
type: string,
|
||||
value: any,
|
||||
origValue: any,
|
||||
}> {
|
||||
onNumberTextInputChange = (e: SyntheticInputEvent<HTMLInputElement>) => {
|
||||
const val =
|
||||
this.props.type === 'number'
|
||||
? parseFloat(e.target.value)
|
||||
: e.target.value;
|
||||
this.props.commit({
|
||||
clear: false,
|
||||
keep: true,
|
||||
value: val,
|
||||
});
|
||||
};
|
||||
|
||||
onNumberTextInputKeyDown = (e: SyntheticKeyboardEvent<*>) => {
|
||||
if (e.key === 'Enter') {
|
||||
const val =
|
||||
this.props.type === 'number'
|
||||
? parseFloat(this.props.value)
|
||||
: this.props.value;
|
||||
this.props.commit({clear: true, keep: true, value: val});
|
||||
} else if (e.key === 'Escape') {
|
||||
this.props.commit({
|
||||
clear: true,
|
||||
keep: false,
|
||||
value: this.props.origValue,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
onNumberTextRef = (ref: ?HTMLElement) => {
|
||||
if (ref) {
|
||||
ref.focus();
|
||||
}
|
||||
};
|
||||
|
||||
onNumberTextBlur = () => {
|
||||
this.props.commit({clear: true, keep: true, value: this.props.value});
|
||||
};
|
||||
|
||||
render() {
|
||||
const extraProps: Object = {};
|
||||
if (this.props.type === 'number') {
|
||||
// render as a HTML number input
|
||||
extraProps.type = 'number';
|
||||
|
||||
// step="any" allows any sort of float to be input, otherwise we're limited
|
||||
// to decimal
|
||||
extraProps.step = 'any';
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
key="input"
|
||||
{...extraProps}
|
||||
compact={true}
|
||||
onChange={this.onNumberTextInputChange}
|
||||
onKeyDown={this.onNumberTextInputKeyDown}
|
||||
innerRef={this.onNumberTextRef}
|
||||
onBlur={this.onNumberTextBlur}
|
||||
value={this.props.value}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type DataDescriptionState = {|
|
||||
editing: boolean,
|
||||
origValue: any,
|
||||
value: any,
|
||||
|};
|
||||
|
||||
export default class DataDescription extends PureComponent<
|
||||
DataDescriptionProps,
|
||||
DataDescriptionState,
|
||||
> {
|
||||
constructor(props: DataDescriptionProps, context: Object) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
editing: false,
|
||||
origValue: '',
|
||||
value: '',
|
||||
};
|
||||
}
|
||||
|
||||
commit = (opts: DescriptionCommitOptions) => {
|
||||
const {path, setValue} = this.props;
|
||||
if (opts.keep && setValue && path) {
|
||||
const val = opts.value;
|
||||
this.setState({value: val});
|
||||
setValue(path, val);
|
||||
}
|
||||
|
||||
if (opts.clear) {
|
||||
this.setState({
|
||||
editing: false,
|
||||
origValue: '',
|
||||
value: '',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
_renderEditing() {
|
||||
const {type} = this.props;
|
||||
const {origValue, value} = this.state;
|
||||
|
||||
if (
|
||||
type === 'string' ||
|
||||
type === 'text' ||
|
||||
type === 'number' ||
|
||||
type === 'enum'
|
||||
) {
|
||||
return (
|
||||
<NumberTextEditor
|
||||
type={type}
|
||||
value={value}
|
||||
origValue={origValue}
|
||||
commit={this.commit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'color') {
|
||||
return <ColorEditor value={value} commit={this.commit} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
_hasEditUI() {
|
||||
const {type} = this.props;
|
||||
return (
|
||||
type === 'string' ||
|
||||
type === 'text' ||
|
||||
type === 'number' ||
|
||||
type === 'enum' ||
|
||||
type === 'color'
|
||||
);
|
||||
}
|
||||
|
||||
onEditStart = () => {
|
||||
this.setState({
|
||||
editing: this._hasEditUI(),
|
||||
origValue: this.props.value,
|
||||
value: this.props.value,
|
||||
});
|
||||
};
|
||||
|
||||
render(): any {
|
||||
if (this.state.editing) {
|
||||
return this._renderEditing();
|
||||
} else {
|
||||
return (
|
||||
<DataDescriptionPreview
|
||||
type={this.props.type}
|
||||
value={this.props.value}
|
||||
editable={Boolean(this.props.setValue)}
|
||||
commit={this.commit}
|
||||
onEdit={this.onEditStart}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ColorEditor extends Component<{
|
||||
value: any,
|
||||
commit: (opts: DescriptionCommitOptions) => void,
|
||||
}> {
|
||||
onBlur = () => {
|
||||
this.props.commit({clear: true, keep: false, value: this.props.value});
|
||||
};
|
||||
|
||||
onChange = ({
|
||||
hex,
|
||||
rgb: {a, b, g, r},
|
||||
}: {
|
||||
hex: string,
|
||||
rgb: {a: number, b: number, g: number, r: number},
|
||||
}) => {
|
||||
const prev = this.props.value;
|
||||
|
||||
let val;
|
||||
if (typeof prev === 'string') {
|
||||
if (a === 1) {
|
||||
// hex is fine and has an implicit 100% alpha
|
||||
val = hex;
|
||||
} else {
|
||||
// turn into a css rgba value
|
||||
val = `rgba(${r}, ${g}, ${b}, ${a})`;
|
||||
}
|
||||
} else if (typeof prev === 'number') {
|
||||
// compute RRGGBBAA value
|
||||
val = (Math.round(a * 255) & 0xff) << 24;
|
||||
val |= (r & 0xff) << 16;
|
||||
val |= (g & 0xff) << 8;
|
||||
val |= b & 0xff;
|
||||
|
||||
const prevClear = ((prev >> 24) & 0xff) === 0;
|
||||
const onlyAlphaChanged = (prev & 0x00ffffff) === (val & 0x00ffffff);
|
||||
|
||||
if (!onlyAlphaChanged && prevClear) {
|
||||
val = 0xff000000 | (val & 0x00ffffff);
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.commit({clear: false, keep: true, value: val});
|
||||
};
|
||||
|
||||
render() {
|
||||
const colorInfo = parseColor(this.props.value);
|
||||
if (!colorInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<ColorPickerDescription>
|
||||
<DataDescriptionPreview
|
||||
type="color"
|
||||
value={this.props.value}
|
||||
editable={false}
|
||||
commit={this.props.commit}
|
||||
/>
|
||||
<Popover onDismiss={this.onBlur}>
|
||||
<SketchPicker
|
||||
color={colorInfo}
|
||||
presetColors={[
|
||||
colors.blue,
|
||||
colors.green,
|
||||
colors.red,
|
||||
colors.blueGrey,
|
||||
colors.slate,
|
||||
colors.aluminum,
|
||||
colors.seaFoam,
|
||||
colors.teal,
|
||||
colors.lime,
|
||||
colors.lemon,
|
||||
colors.orange,
|
||||
colors.tomato,
|
||||
colors.cherry,
|
||||
colors.pink,
|
||||
colors.grape,
|
||||
]}
|
||||
onChange={this.onChange}
|
||||
/>
|
||||
</Popover>
|
||||
</ColorPickerDescription>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DataDescriptionPreview extends Component<{
|
||||
type: string,
|
||||
value: any,
|
||||
editable: boolean,
|
||||
commit: (opts: DescriptionCommitOptions) => void,
|
||||
onEdit?: () => void,
|
||||
}> {
|
||||
onClick = () => {
|
||||
const {onEdit} = this.props;
|
||||
if (this.props.editable && onEdit) {
|
||||
onEdit();
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {type, value} = this.props;
|
||||
|
||||
const description = (
|
||||
<DataDescriptionContainer
|
||||
type={type}
|
||||
value={value}
|
||||
editable={this.props.editable}
|
||||
commit={this.props.commit}
|
||||
/>
|
||||
);
|
||||
|
||||
// booleans are always editable so don't require the onEditStart handler
|
||||
if (type === 'boolean') {
|
||||
return description;
|
||||
}
|
||||
|
||||
return (
|
||||
<span onClick={this.onClick} role="button" tabIndex={-1}>
|
||||
{description}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function parseColor(
|
||||
val: string | number,
|
||||
): ?{|
|
||||
r: number,
|
||||
g: number,
|
||||
b: number,
|
||||
a: number,
|
||||
|} {
|
||||
if (typeof val === 'number') {
|
||||
const a = ((val >> 24) & 0xff) / 255;
|
||||
const r = (val >> 16) & 0xff;
|
||||
const g = (val >> 8) & 0xff;
|
||||
const b = val & 0xff;
|
||||
return {a, b, g, r};
|
||||
}
|
||||
if (typeof val !== 'string') {
|
||||
return;
|
||||
}
|
||||
if (val[0] !== '#') {
|
||||
return;
|
||||
}
|
||||
|
||||
// remove leading hash
|
||||
val = val.slice(1);
|
||||
|
||||
// only allow RGB and ARGB hex values
|
||||
if (val.length !== 3 && val.length !== 6 && val.length !== 8) {
|
||||
return;
|
||||
}
|
||||
|
||||
// split every 2 characters
|
||||
const parts = val.match(/.{1,2}/g);
|
||||
if (!parts) {
|
||||
return;
|
||||
}
|
||||
|
||||
// get the alpha value
|
||||
let a = 1;
|
||||
|
||||
// extract alpha if passed AARRGGBB
|
||||
if (val.length === 8) {
|
||||
a = parseInt(parts.shift(), 16) / 255;
|
||||
}
|
||||
|
||||
const size = val.length;
|
||||
const [r, g, b] = parts.map(num => {
|
||||
if (size === 3) {
|
||||
return parseInt(num + num, 16);
|
||||
} else {
|
||||
return parseInt(num, 16);
|
||||
}
|
||||
});
|
||||
|
||||
return {a, b, g, r};
|
||||
}
|
||||
|
||||
class DataDescriptionContainer extends Component<{
|
||||
type: string,
|
||||
value: any,
|
||||
editable: boolean,
|
||||
commit: (opts: DescriptionCommitOptions) => void,
|
||||
}> {
|
||||
onChangeCheckbox = (e: SyntheticInputEvent<HTMLInputElement>) => {
|
||||
this.props.commit({clear: true, keep: true, value: e.target.checked});
|
||||
};
|
||||
|
||||
render(): any {
|
||||
const {type, editable, value: val} = this.props;
|
||||
|
||||
switch (type) {
|
||||
case 'number':
|
||||
return <NumberValue>{Number(val)}</NumberValue>;
|
||||
|
||||
case 'color': {
|
||||
const colorInfo = parseColor(val);
|
||||
if (colorInfo) {
|
||||
const {a, b, g, r} = colorInfo;
|
||||
return [
|
||||
<ColorBox key="color-box" color={`rgba(${r}, ${g}, ${b}, ${a})`} />,
|
||||
<ColorValue key="value">
|
||||
rgba({r}, {g}, {b}, {a === 1 ? '1' : a.toFixed(2)})
|
||||
</ColorValue>,
|
||||
];
|
||||
} else {
|
||||
return <span>Malformed color</span>;
|
||||
}
|
||||
}
|
||||
|
||||
case 'text':
|
||||
case 'string':
|
||||
return <StringValue>"{String(val || '')}"</StringValue>;
|
||||
|
||||
case 'enum':
|
||||
return <StringValue>{String(val)}</StringValue>;
|
||||
|
||||
case 'boolean':
|
||||
return editable ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(val)}
|
||||
disabled={!editable}
|
||||
onChange={this.onChangeCheckbox}
|
||||
/>
|
||||
) : (
|
||||
<StringValue>{String(val)}</StringValue>
|
||||
);
|
||||
|
||||
case 'undefined':
|
||||
return <UndefinedValue>undefined</UndefinedValue>;
|
||||
|
||||
case 'date':
|
||||
if (Object.prototype.toString.call(val) === '[object Date]') {
|
||||
return <span>{val.toString()}</span>;
|
||||
} else {
|
||||
return <span>{val}</span>;
|
||||
}
|
||||
|
||||
case 'null':
|
||||
return <NullValue>null</NullValue>;
|
||||
|
||||
case 'array':
|
||||
case 'object':
|
||||
// no description necessary as we'll typically wrap it in [] or {} which already denotes the
|
||||
// type
|
||||
return null;
|
||||
|
||||
case 'function':
|
||||
return (
|
||||
<span>
|
||||
<FunctionKeyword>function</FunctionKeyword>
|
||||
<FunctionName> {val.name}()</FunctionName>
|
||||
</span>
|
||||
);
|
||||
|
||||
case 'symbol':
|
||||
return <SymbolValue>Symbol()</SymbolValue>;
|
||||
|
||||
default:
|
||||
return <span>Unknown type "{type}"</span>;
|
||||
}
|
||||
}
|
||||
}
|
||||
616
src/ui/components/data-inspector/DataInspector.js
Normal file
616
src/ui/components/data-inspector/DataInspector.js
Normal file
@@ -0,0 +1,616 @@
|
||||
/**
|
||||
* 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 DataDescription from './DataDescription.js';
|
||||
import {Component} from 'react';
|
||||
import ContextMenu from '../ContextMenu.js';
|
||||
import styled from '../../styled/index.js';
|
||||
import DataPreview from './DataPreview.js';
|
||||
import createPaste from '../../../utils/createPaste.js';
|
||||
import {reportInteraction} from '../../../utils/InteractionTracker.js';
|
||||
import {getSortedKeys} from './utils.js';
|
||||
import {colors} from '../colors.js';
|
||||
import {clipboard} from 'electron';
|
||||
|
||||
const deepEqual = require('deep-equal');
|
||||
|
||||
const BaseContainer = styled.view(
|
||||
{
|
||||
fontFamily: 'Menlo, monospace',
|
||||
fontSize: 11,
|
||||
lineHeight: '17px',
|
||||
filter: props => (props.disabled ? 'grayscale(100%)' : ''),
|
||||
margin: props => (props.depth === 0 ? '7.5px 0' : '0'),
|
||||
paddingLeft: 10,
|
||||
userSelect: 'text',
|
||||
},
|
||||
{
|
||||
ignoreAttributes: ['depth', 'disabled'],
|
||||
},
|
||||
);
|
||||
|
||||
const RecursiveBaseWrapper = styled.text({
|
||||
color: colors.red,
|
||||
});
|
||||
|
||||
const Wrapper = styled.text({
|
||||
color: '#555',
|
||||
});
|
||||
|
||||
const PropertyContainer = styled.text({
|
||||
paddingTop: '2px',
|
||||
});
|
||||
|
||||
const ExpandControl = styled.text({
|
||||
color: '#6e6e6e',
|
||||
fontSize: 10,
|
||||
marginLeft: -11,
|
||||
marginRight: 5,
|
||||
whiteSpace: 'pre',
|
||||
});
|
||||
|
||||
export const InspectorName = styled.text({
|
||||
color: colors.grapeDark1,
|
||||
});
|
||||
|
||||
export type DataValueExtractor = (
|
||||
value: any,
|
||||
depth: number,
|
||||
) => ?{|
|
||||
mutable: boolean,
|
||||
type: string,
|
||||
value: any,
|
||||
|};
|
||||
|
||||
export type DataInspectorSetValue = (path: Array<string>, val: any) => void;
|
||||
|
||||
export type DataInspectorExpanded = {
|
||||
[key: string]: boolean,
|
||||
};
|
||||
|
||||
export type DiffMetadataExtractor = (
|
||||
data: any,
|
||||
diff: any,
|
||||
key: string,
|
||||
) => Array<{|
|
||||
data: any,
|
||||
diff?: any,
|
||||
status?: 'added' | 'removed',
|
||||
|}>;
|
||||
|
||||
type DataInspectorProps = {
|
||||
/**
|
||||
* Object to inspect.
|
||||
*/
|
||||
data: any,
|
||||
/**
|
||||
* Object to compare with the provided `data` property.
|
||||
* Differences will be styled accordingly in the UI.
|
||||
*/
|
||||
diff?: any,
|
||||
/**
|
||||
* Current name of this value.
|
||||
*/
|
||||
name?: string,
|
||||
/**
|
||||
* Current depth.
|
||||
*/
|
||||
depth: number,
|
||||
/**
|
||||
* An array containing the current location of the data relative to it's root.
|
||||
*/
|
||||
path: Array<string>,
|
||||
/**
|
||||
* Whether to expand the root by default.
|
||||
*/
|
||||
expandRoot?: boolean,
|
||||
/**
|
||||
* An array of paths that are currently expanded.
|
||||
*/
|
||||
expanded: DataInspectorExpanded,
|
||||
/**
|
||||
* An optional callback that will explode a value into it's type and value.
|
||||
* Useful for inspecting serialised data.
|
||||
*/
|
||||
extractValue?: DataValueExtractor,
|
||||
/**
|
||||
* Callback whenever the current expanded paths is changed.
|
||||
*/
|
||||
onExpanded?: ?(expanded: DataInspectorExpanded) => void,
|
||||
/**
|
||||
* Callback when a value is edited.
|
||||
*/
|
||||
setValue?: ?DataInspectorSetValue,
|
||||
/**
|
||||
* Whether all objects and arrays should be collapsed by default.
|
||||
*/
|
||||
collapsed?: boolean,
|
||||
/**
|
||||
* Ancestry of parent objects, used to avoid recursive objects.
|
||||
*/
|
||||
ancestry: Array<Object>,
|
||||
};
|
||||
|
||||
const defaultValueExtractor: DataValueExtractor = (value: any) => {
|
||||
const type = typeof value;
|
||||
|
||||
if (type === 'number') {
|
||||
return {mutable: true, type: 'number', value};
|
||||
}
|
||||
|
||||
if (type === 'string') {
|
||||
return {mutable: true, type: 'string', value};
|
||||
}
|
||||
|
||||
if (type === 'boolean') {
|
||||
return {mutable: true, type: 'boolean', value};
|
||||
}
|
||||
|
||||
if (type === 'undefined') {
|
||||
return {mutable: true, type: 'undefined', value};
|
||||
}
|
||||
|
||||
if (value === null) {
|
||||
return {mutable: true, type: 'null', value};
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return {mutable: true, type: 'array', value};
|
||||
}
|
||||
|
||||
if (Object.prototype.toString.call(value) === '[object Date]') {
|
||||
return {mutable: true, type: 'date', value};
|
||||
}
|
||||
|
||||
if (type === 'object') {
|
||||
return {mutable: true, type: 'object', value};
|
||||
}
|
||||
};
|
||||
|
||||
const rootContextMenuCache: WeakMap<
|
||||
Object,
|
||||
Array<Electron$MenuItemOptions>,
|
||||
> = new WeakMap();
|
||||
|
||||
function getRootContextMenu(data: Object): Array<Electron$MenuItemOptions> {
|
||||
const cached = rootContextMenuCache.get(data);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const stringValue = JSON.stringify(data, null, 2);
|
||||
const menu: Array<Electron$MenuItemOptions> = [
|
||||
{
|
||||
label: 'Copy entire tree',
|
||||
click: () => clipboard.writeText(stringValue),
|
||||
},
|
||||
{
|
||||
type: 'separator',
|
||||
},
|
||||
{
|
||||
label: 'Create paste',
|
||||
click: () => {
|
||||
createPaste(stringValue);
|
||||
},
|
||||
},
|
||||
];
|
||||
rootContextMenuCache.set(data, menu);
|
||||
return menu;
|
||||
}
|
||||
|
||||
function isPureObject(obj: Object) {
|
||||
return (
|
||||
Object.prototype.toString.call(obj) !== '[object Date]' &&
|
||||
typeof obj === 'object'
|
||||
);
|
||||
}
|
||||
|
||||
const diffMetadataExtractor: DiffMetadataExtractor = (
|
||||
data: any,
|
||||
diff?: any,
|
||||
key: string,
|
||||
) => {
|
||||
if (diff == null) {
|
||||
return [{data: data[key]}];
|
||||
}
|
||||
|
||||
const val = data[key];
|
||||
const diffVal = diff[key];
|
||||
if (!data.hasOwnProperty(key)) {
|
||||
return [{data: diffVal, status: 'added'}];
|
||||
}
|
||||
if (!diff.hasOwnProperty(key)) {
|
||||
return [{data: val, status: 'removed'}];
|
||||
}
|
||||
|
||||
if (isPureObject(diffVal) && isPureObject(val)) {
|
||||
return [{data: val, diff: diffVal}];
|
||||
}
|
||||
|
||||
if (diffVal !== val) {
|
||||
// Check if there's a difference between the original value and
|
||||
// the value from the diff prop
|
||||
// The property name still exists, but the values may be different.
|
||||
return [{data: diffVal, status: 'added'}, {data: val, status: 'removed'}];
|
||||
}
|
||||
|
||||
return Object.prototype.hasOwnProperty.call(data, key) ? [{data: val}] : [];
|
||||
};
|
||||
|
||||
function isComponentExpanded(
|
||||
data: any,
|
||||
diffType: string,
|
||||
diffValue: any,
|
||||
isExpanded: boolean,
|
||||
) {
|
||||
if (isExpanded) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (diffValue == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (diffType === 'object') {
|
||||
const sortedDataValues = Object.keys(data)
|
||||
.sort()
|
||||
.map(key => data[key]);
|
||||
const sortedDiffValues = Object.keys(diffValue)
|
||||
.sort()
|
||||
.map(key => diffValue[key]);
|
||||
if (JSON.stringify(sortedDataValues) !== JSON.stringify(sortedDiffValues)) {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
if (data !== diffValue) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* An expandable data inspector.
|
||||
*
|
||||
* This component is fairly low level. It's likely you're looking for
|
||||
* [`<ManagedDataInspector>`]().
|
||||
*/
|
||||
export default class DataInspector extends Component<DataInspectorProps> {
|
||||
static defaultProps: {|
|
||||
expanded: DataInspectorExpanded,
|
||||
depth: number,
|
||||
path: Array<string>,
|
||||
ancestry: Array<Object>,
|
||||
|} = {
|
||||
expanded: {},
|
||||
depth: 0,
|
||||
path: [],
|
||||
ancestry: [],
|
||||
};
|
||||
|
||||
interaction: (name: string) => void;
|
||||
|
||||
constructor(props: DataInspectorProps) {
|
||||
super();
|
||||
this.interaction = reportInteraction('DataInspector', props.path.join(':'));
|
||||
}
|
||||
|
||||
static isExpandable(data: any) {
|
||||
return (
|
||||
typeof data === 'object' && data !== null && Object.keys(data).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps: DataInspectorProps) {
|
||||
const {props} = this;
|
||||
|
||||
// check if any expanded paths effect this subtree
|
||||
if (nextProps.expanded !== props.expanded) {
|
||||
const path = nextProps.path.join('.');
|
||||
|
||||
for (const key in nextProps.expanded) {
|
||||
if (key.startsWith(path) === false) {
|
||||
// this key doesn't effect us
|
||||
continue;
|
||||
}
|
||||
|
||||
if (nextProps.expanded[key] !== props.expanded[key]) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// basic equality checks for the rest
|
||||
return (
|
||||
nextProps.data !== props.data ||
|
||||
nextProps.diff !== props.diff ||
|
||||
nextProps.name !== props.name ||
|
||||
nextProps.depth !== props.depth ||
|
||||
!deepEqual(nextProps.path, props.path) ||
|
||||
nextProps.onExpanded !== props.onExpanded ||
|
||||
nextProps.setValue !== props.setValue
|
||||
);
|
||||
}
|
||||
|
||||
isExpanded(pathParts: Array<string>) {
|
||||
const {expanded} = this.props;
|
||||
|
||||
// if we no expanded object then expand everything
|
||||
if (expanded == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const path = pathParts.join('.');
|
||||
|
||||
// check if there's a setting for this path
|
||||
if (Object.prototype.hasOwnProperty.call(expanded, path)) {
|
||||
return expanded[path];
|
||||
}
|
||||
|
||||
// check if all paths are collapsed
|
||||
if (this.props.collapsed === true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// by default all items are expanded
|
||||
return true;
|
||||
}
|
||||
|
||||
setExpanded(pathParts: Array<string>, isExpanded: boolean) {
|
||||
const {expanded, onExpanded} = this.props;
|
||||
if (!onExpanded || !expanded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const path = pathParts.join('.');
|
||||
|
||||
onExpanded({
|
||||
...expanded,
|
||||
[path]: isExpanded,
|
||||
});
|
||||
}
|
||||
|
||||
handleClick = () => {
|
||||
const isExpanded = this.isExpanded(this.props.path);
|
||||
this.interaction(isExpanded ? 'collapsed' : 'expanded');
|
||||
this.setExpanded(this.props.path, !isExpanded);
|
||||
};
|
||||
|
||||
extractValue = (data: any, depth: number) => {
|
||||
let res;
|
||||
|
||||
const {extractValue} = this.props;
|
||||
if (extractValue) {
|
||||
res = extractValue(data, depth);
|
||||
}
|
||||
|
||||
if (!res) {
|
||||
res = defaultValueExtractor(data, depth);
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
render(): any {
|
||||
const {
|
||||
data,
|
||||
diff,
|
||||
depth,
|
||||
expanded: expandedPaths,
|
||||
expandRoot,
|
||||
extractValue,
|
||||
name,
|
||||
onExpanded,
|
||||
path,
|
||||
ancestry,
|
||||
collapsed,
|
||||
} = this.props;
|
||||
|
||||
// the data inspector makes values read only when setValue isn't set so we just need to set it
|
||||
// to null and the readOnly status will be propagated to all children
|
||||
let {setValue} = this.props;
|
||||
|
||||
const res = this.extractValue(data, depth);
|
||||
const resDiff = this.extractValue(diff, depth);
|
||||
|
||||
let type;
|
||||
let value;
|
||||
if (res) {
|
||||
if (!res.mutable) {
|
||||
setValue = null;
|
||||
}
|
||||
|
||||
({type, value} = res);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (ancestry.includes(value)) {
|
||||
return <RecursiveBaseWrapper>Recursive</RecursiveBaseWrapper>;
|
||||
}
|
||||
|
||||
const isExpandable = DataInspector.isExpandable(value);
|
||||
const isExpanded =
|
||||
isExpandable &&
|
||||
(resDiff != null
|
||||
? isComponentExpanded(
|
||||
value,
|
||||
resDiff.type,
|
||||
resDiff.value,
|
||||
expandRoot === true || this.isExpanded(path),
|
||||
)
|
||||
: expandRoot === true || this.isExpanded(path));
|
||||
|
||||
let expandGlyph = '';
|
||||
if (isExpandable) {
|
||||
if (isExpanded) {
|
||||
expandGlyph = '▼';
|
||||
} else {
|
||||
expandGlyph = '▶';
|
||||
}
|
||||
} else {
|
||||
if (depth !== 0) {
|
||||
expandGlyph = ' ';
|
||||
}
|
||||
}
|
||||
|
||||
let propertyNodesContainer;
|
||||
if (isExpandable && isExpanded) {
|
||||
const propertyNodes = [];
|
||||
|
||||
// ancestry of children, including it's owner object
|
||||
const childAncestry = ancestry.concat([value]);
|
||||
|
||||
const diffValue = diff && resDiff ? resDiff.value : null;
|
||||
|
||||
const keys = getSortedKeys({...value, ...diffValue});
|
||||
|
||||
const Added = styled.view({
|
||||
backgroundColor: colors.tealTint70,
|
||||
});
|
||||
const Removed = styled.view({
|
||||
backgroundColor: colors.cherryTint70,
|
||||
});
|
||||
|
||||
for (const key of keys) {
|
||||
const diffMetadataArr = diffMetadataExtractor(value, diffValue, key);
|
||||
for (const metadata of diffMetadataArr) {
|
||||
const dataInspectorNode = (
|
||||
<DataInspector
|
||||
ancestry={childAncestry}
|
||||
extractValue={extractValue}
|
||||
setValue={setValue}
|
||||
expanded={expandedPaths}
|
||||
collapsed={collapsed}
|
||||
onExpanded={onExpanded}
|
||||
path={path.concat(key)}
|
||||
depth={depth + 1}
|
||||
key={key}
|
||||
name={key}
|
||||
data={metadata.data}
|
||||
diff={metadata.diff}
|
||||
/>
|
||||
);
|
||||
|
||||
switch (metadata.status) {
|
||||
case 'added':
|
||||
propertyNodes.push(<Added>{dataInspectorNode}</Added>);
|
||||
break;
|
||||
case 'removed':
|
||||
propertyNodes.push(<Removed>{dataInspectorNode}</Removed>);
|
||||
break;
|
||||
default:
|
||||
propertyNodes.push(dataInspectorNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
propertyNodesContainer = propertyNodes;
|
||||
}
|
||||
|
||||
if (expandRoot === true) {
|
||||
return (
|
||||
<ContextMenu component="span" items={getRootContextMenu(data)}>
|
||||
{propertyNodesContainer}
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
// create name components
|
||||
const nameElems = [];
|
||||
if (typeof name !== 'undefined') {
|
||||
nameElems.push(<InspectorName key="name">{name}</InspectorName>);
|
||||
nameElems.push(<span key="sep">: </span>);
|
||||
}
|
||||
|
||||
// create description or preview
|
||||
let descriptionOrPreview;
|
||||
if (isExpanded || !isExpandable) {
|
||||
descriptionOrPreview = (
|
||||
<DataDescription
|
||||
path={path}
|
||||
setValue={setValue}
|
||||
type={type}
|
||||
value={value}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
descriptionOrPreview = (
|
||||
<DataPreview
|
||||
type={type}
|
||||
value={value}
|
||||
setValue={setValue}
|
||||
extractValue={this.extractValue}
|
||||
depth={depth}
|
||||
/>
|
||||
);
|
||||
}
|
||||
descriptionOrPreview = (
|
||||
<span>
|
||||
{nameElems}
|
||||
{descriptionOrPreview}
|
||||
</span>
|
||||
);
|
||||
|
||||
let wrapperStart;
|
||||
let wrapperEnd;
|
||||
if (isExpanded) {
|
||||
if (type === 'object') {
|
||||
wrapperStart = <Wrapper>{'{'}</Wrapper>;
|
||||
wrapperEnd = <Wrapper>{'}'}</Wrapper>;
|
||||
}
|
||||
|
||||
if (type === 'array') {
|
||||
wrapperStart = <Wrapper>{'['}</Wrapper>;
|
||||
wrapperEnd = <Wrapper>{']'}</Wrapper>;
|
||||
}
|
||||
}
|
||||
|
||||
const contextMenuItems = [];
|
||||
|
||||
if (isExpandable) {
|
||||
contextMenuItems.push(
|
||||
{
|
||||
label: isExpanded ? 'Collapse' : 'Expand',
|
||||
click: this.handleClick,
|
||||
},
|
||||
{
|
||||
type: 'separator',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
contextMenuItems.push(
|
||||
{
|
||||
label: 'Copy',
|
||||
click: () => clipboard.writeText(window.getSelection().toString()),
|
||||
},
|
||||
{
|
||||
label: 'Copy value',
|
||||
click: () => clipboard.writeText(JSON.stringify(data, null, 2)),
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseContainer
|
||||
depth={depth}
|
||||
disabled={
|
||||
Boolean(this.props.setValue) === true && Boolean(setValue) === false
|
||||
}>
|
||||
<ContextMenu component="span" items={contextMenuItems}>
|
||||
<PropertyContainer onClick={isExpandable ? this.handleClick : null}>
|
||||
{expandedPaths && <ExpandControl>{expandGlyph}</ExpandControl>}
|
||||
{descriptionOrPreview}
|
||||
{wrapperStart}
|
||||
</PropertyContainer>
|
||||
</ContextMenu>
|
||||
{propertyNodesContainer}
|
||||
{wrapperEnd}
|
||||
</BaseContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
109
src/ui/components/data-inspector/DataPreview.js
Executable file
109
src/ui/components/data-inspector/DataPreview.js
Executable file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 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 {DataValueExtractor} from './DataInspector.js';
|
||||
import DataDescription from './DataDescription.js';
|
||||
import {InspectorName} from './DataInspector.js';
|
||||
import styled from '../../styled/index.js';
|
||||
import {getSortedKeys} from './utils.js';
|
||||
import {PureComponent} from 'react';
|
||||
|
||||
const PreviewContainer = styled.text({
|
||||
fontStyle: 'italic',
|
||||
});
|
||||
|
||||
function intersperse(arr, sep) {
|
||||
if (arr.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return arr.slice(1).reduce(
|
||||
(xs, x, i) => {
|
||||
return xs.concat([sep, x]);
|
||||
},
|
||||
[arr[0]],
|
||||
);
|
||||
}
|
||||
|
||||
export default class DataPreview extends PureComponent<{
|
||||
type: string,
|
||||
value: any,
|
||||
depth: number,
|
||||
extractValue: DataValueExtractor,
|
||||
maxProperties: number,
|
||||
}> {
|
||||
static defaultProps = {
|
||||
maxProperties: 5,
|
||||
};
|
||||
|
||||
render() {
|
||||
const {depth, extractValue, type, value} = this.props;
|
||||
|
||||
if (type === 'array') {
|
||||
return (
|
||||
<PreviewContainer>
|
||||
{'['}
|
||||
{intersperse(
|
||||
value.map((element, index) => {
|
||||
const res = extractValue(element, depth + 1);
|
||||
if (!res) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {type, value} = res;
|
||||
return (
|
||||
<DataDescription
|
||||
key={index}
|
||||
type={type}
|
||||
value={value}
|
||||
setValue={null}
|
||||
/>
|
||||
);
|
||||
}),
|
||||
', ',
|
||||
)}
|
||||
{']'}
|
||||
</PreviewContainer>
|
||||
);
|
||||
} else if (type === 'date') {
|
||||
return <span>{value.toString()}</span>;
|
||||
} else if (type === 'object') {
|
||||
const propertyNodes = [];
|
||||
|
||||
const keys = getSortedKeys(value);
|
||||
let i = 0;
|
||||
for (const key of keys) {
|
||||
let ellipsis;
|
||||
i++;
|
||||
if (i >= this.props.maxProperties) {
|
||||
ellipsis = <span key={'ellipsis'}>…</span>;
|
||||
}
|
||||
|
||||
propertyNodes.push(
|
||||
<span key={key}>
|
||||
<InspectorName>{key}</InspectorName>
|
||||
{ellipsis}
|
||||
</span>,
|
||||
);
|
||||
|
||||
if (ellipsis) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PreviewContainer>
|
||||
{'{'}
|
||||
{intersperse(propertyNodes, ', ')}
|
||||
{'}'}
|
||||
</PreviewContainer>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
105
src/ui/components/data-inspector/ManagedDataInspector.js
Normal file
105
src/ui/components/data-inspector/ManagedDataInspector.js
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* 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 {
|
||||
DataValueExtractor,
|
||||
DataInspectorExpanded,
|
||||
} from './DataInspector.js';
|
||||
import {PureComponent} from 'react';
|
||||
import DataInspector from './DataInspector.js';
|
||||
|
||||
type ManagedDataInspectorProps = {|
|
||||
/**
|
||||
* Object to inspect.
|
||||
*/
|
||||
data: any,
|
||||
/**
|
||||
* Object to compare with the provided `data` property.
|
||||
* Differences will be styled accordingly in the UI.
|
||||
*/
|
||||
diff?: any,
|
||||
/**
|
||||
* Whether to expand the root by default.
|
||||
*/
|
||||
expandRoot?: boolean,
|
||||
/**
|
||||
* An optional callback that will explode a value into it's type and value.
|
||||
* Useful for inspecting serialised data.
|
||||
*/
|
||||
extractValue?: DataValueExtractor,
|
||||
/**
|
||||
* Callback when a value is edited.
|
||||
*/
|
||||
setValue?: (path: Array<string>, val: any) => void,
|
||||
/**
|
||||
* Whether all objects and arrays should be collapsed by default.
|
||||
*/
|
||||
collapsed?: boolean,
|
||||
|};
|
||||
|
||||
type ManagedDataInspectorState = {|
|
||||
expanded: DataInspectorExpanded,
|
||||
|};
|
||||
|
||||
/**
|
||||
* Wrapper around `DataInspector` that handles expanded state.
|
||||
*
|
||||
* If you require lower level access to the state then use `DataInspector`
|
||||
* directly.
|
||||
*
|
||||
* @example Plain object
|
||||
* <ManagedDataInspector data={{
|
||||
* a: '',
|
||||
* b: [1, 2, 3, 4],
|
||||
* c: {foo: 'bar'},
|
||||
* d: 4,
|
||||
* }} />
|
||||
* @example Expanded root
|
||||
* <ManagedDataInspector expandRoot={true} data={{
|
||||
* a: '',
|
||||
* b: [1, 2, 3, 4],
|
||||
* c: {foo: 'bar'},
|
||||
* d: 4,
|
||||
* }} />
|
||||
* @example Editable
|
||||
* <ManagedDataInspector setValue={() => {}} data={{
|
||||
* a: '',
|
||||
* b: [1, 2, 3, 4],
|
||||
* c: {foo: 'bar'},
|
||||
* d: 4,
|
||||
* }} />
|
||||
*/
|
||||
export default class ManagedDataInspector extends PureComponent<
|
||||
ManagedDataInspectorProps,
|
||||
ManagedDataInspectorState,
|
||||
> {
|
||||
constructor(props: ManagedDataInspectorProps, context: Object) {
|
||||
super(props, context);
|
||||
this.state = {
|
||||
expanded: {},
|
||||
};
|
||||
}
|
||||
|
||||
onExpanded = (expanded: DataInspectorExpanded) => {
|
||||
this.setState({expanded});
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<DataInspector
|
||||
data={this.props.data}
|
||||
diff={this.props.diff}
|
||||
extractValue={this.props.extractValue}
|
||||
setValue={this.props.setValue}
|
||||
expanded={this.state.expanded}
|
||||
onExpanded={this.onExpanded}
|
||||
expandRoot={this.props.expandRoot}
|
||||
collapsed={this.props.collapsed}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
12
src/ui/components/data-inspector/utils.js
Normal file
12
src/ui/components/data-inspector/utils.js
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
const naturalCompare = require('string-natural-compare');
|
||||
|
||||
export function getSortedKeys(obj: Object): Array<string> {
|
||||
return Object.keys(obj).sort(naturalCompare);
|
||||
}
|
||||
132
src/ui/components/desktop-toolbar.js
Normal file
132
src/ui/components/desktop-toolbar.js
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* 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 styled from '../styled/index.js';
|
||||
import {colors, darkColors} from './colors.js';
|
||||
|
||||
const React = require('react');
|
||||
|
||||
const DesktopDropdownContainer = styled.view({
|
||||
borderBottom: `1px solid ${darkColors.dividers}`,
|
||||
lineHeight: '25px',
|
||||
marginTop: 5,
|
||||
maxHeight: 600,
|
||||
minWidth: 180,
|
||||
overflow: 'auto',
|
||||
padding: 0,
|
||||
paddingBottom: 5,
|
||||
textAlign: 'left',
|
||||
width: 'auto',
|
||||
|
||||
'&:last-child': {
|
||||
borderBottom: 'none',
|
||||
},
|
||||
});
|
||||
|
||||
export function DesktopDropdown(props: {|
|
||||
deactivate?: () => void,
|
||||
children?: any,
|
||||
|}) {
|
||||
return (
|
||||
<DesktopDropdownContainer>
|
||||
{React.Children.map(props.children, child => {
|
||||
return (
|
||||
child &&
|
||||
React.cloneElement(child, {
|
||||
deactivate: props.deactivate,
|
||||
})
|
||||
);
|
||||
})}
|
||||
</DesktopDropdownContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const DesktopDropdownItemContainer = styled.view(
|
||||
{
|
||||
listStyle: 'none',
|
||||
opacity: props => (props.onClick || props.onHover ? 1 : 0.5),
|
||||
padding: '0 20px',
|
||||
|
||||
'&:hover': {
|
||||
backgroundColor: props =>
|
||||
props.onClick || props.onHover ? colors.highlight : '',
|
||||
color: props => (props.onClick || props.onHover ? '#fff' : 'inherit'),
|
||||
},
|
||||
},
|
||||
{
|
||||
ignoreAttributes: [],
|
||||
},
|
||||
);
|
||||
|
||||
type DesktopDropdownItemState = {|hovered: boolean|};
|
||||
|
||||
type DesktopDropdownItemProps = {
|
||||
onClick?: false | ?(event: SyntheticMouseEvent<>) => void,
|
||||
onHover?: false | ?() => React$Node,
|
||||
children?: React$Node,
|
||||
deactivate?: () => void,
|
||||
};
|
||||
|
||||
export class DesktopDropdownItem extends styled.StylableComponent<
|
||||
DesktopDropdownItemProps,
|
||||
DesktopDropdownItemState,
|
||||
> {
|
||||
constructor(props: DesktopDropdownItemProps, context: Object) {
|
||||
super(props, context);
|
||||
this.state = {hovered: false};
|
||||
}
|
||||
|
||||
onMouseEnter = () => {
|
||||
this.setState({hovered: true});
|
||||
};
|
||||
|
||||
onMouseLeave = () => {
|
||||
this.setState({hovered: false});
|
||||
};
|
||||
|
||||
onClick = (event: SyntheticMouseEvent<>) => {
|
||||
const {deactivate, onClick} = this.props;
|
||||
if (typeof onClick === 'function') {
|
||||
if (deactivate) {
|
||||
deactivate();
|
||||
}
|
||||
onClick(event);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {hovered} = this.state;
|
||||
const {
|
||||
children,
|
||||
deactivate: _deactivate,
|
||||
onClick,
|
||||
onHover,
|
||||
...props
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<DesktopDropdownItemContainer
|
||||
{...props}
|
||||
onMouseEnter={this.onMouseEnter}
|
||||
onMouseLeave={this.onMouseLeave}
|
||||
onClick={typeof onClick === 'function' && this.onClick}>
|
||||
{children}
|
||||
{hovered && typeof onHover === 'function' && onHover()}
|
||||
</DesktopDropdownItemContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const DesktopDropdownSelectedItem = DesktopDropdownItem.extends({
|
||||
position: 'relative',
|
||||
|
||||
'&::before': {
|
||||
content: "'✔'",
|
||||
marginLeft: '-15px',
|
||||
position: 'absolute',
|
||||
},
|
||||
});
|
||||
83
src/ui/components/elements-inspector/ElementsInspector.js
Normal file
83
src/ui/components/elements-inspector/ElementsInspector.js
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 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 {Component} from 'react';
|
||||
import FlexRow from '../FlexRow.js';
|
||||
import {Elements} from './elements.js';
|
||||
|
||||
export type ElementID = string;
|
||||
|
||||
export type ElementSearchResultSet = {|
|
||||
query: string,
|
||||
matches: Set<ElementID>,
|
||||
|};
|
||||
|
||||
export type ElementData = {
|
||||
[name: ElementID]: {
|
||||
[key: string]:
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| {|
|
||||
__type__: string,
|
||||
value: any,
|
||||
|},
|
||||
},
|
||||
};
|
||||
|
||||
export type ElementAttribute = {|
|
||||
name: string,
|
||||
value: string,
|
||||
|};
|
||||
|
||||
export type Element = {|
|
||||
id: ElementID,
|
||||
name: string,
|
||||
expanded: boolean,
|
||||
children: Array<ElementID>,
|
||||
attributes: Array<ElementAttribute>,
|
||||
data: ElementData,
|
||||
decoration: string,
|
||||
|};
|
||||
|
||||
export default class ElementsInspector extends Component<{
|
||||
onElementExpanded: (key: ElementID, deep: boolean) => void,
|
||||
onElementSelected: (key: ElementID) => void,
|
||||
onElementHovered: ?(key: ?ElementID) => void,
|
||||
onValueChanged: ?(path: Array<string>, val: any) => void,
|
||||
selected: ?ElementID,
|
||||
searchResults?: ?ElementSearchResultSet,
|
||||
root: ?ElementID,
|
||||
elements: {[key: ElementID]: Element},
|
||||
useAppSidebar?: boolean,
|
||||
}> {
|
||||
render() {
|
||||
const {
|
||||
selected,
|
||||
elements,
|
||||
root,
|
||||
onElementExpanded,
|
||||
onElementSelected,
|
||||
onElementHovered,
|
||||
searchResults,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<FlexRow fill={true}>
|
||||
<Elements
|
||||
onElementExpanded={onElementExpanded}
|
||||
onElementSelected={onElementSelected}
|
||||
onElementHovered={onElementHovered}
|
||||
selected={selected}
|
||||
searchResults={searchResults}
|
||||
root={root}
|
||||
elements={elements}
|
||||
/>
|
||||
</FlexRow>
|
||||
);
|
||||
}
|
||||
}
|
||||
595
src/ui/components/elements-inspector/elements.js
Normal file
595
src/ui/components/elements-inspector/elements.js
Normal file
@@ -0,0 +1,595 @@
|
||||
/**
|
||||
* 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';
|
||||
import ContextMenu from '../ContextMenu.js';
|
||||
import {PureComponent} 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 {FixedList} from '../../virtualized/index.js';
|
||||
import {clipboard} from 'electron';
|
||||
|
||||
const ROW_HEIGHT = 23;
|
||||
|
||||
const ElementsRowContainer = ContextMenu.extends(
|
||||
{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: props => {
|
||||
if (props.selected) {
|
||||
return colors.macOSTitleBarIconSelected;
|
||||
} else if (props.even) {
|
||||
return colors.light02;
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
},
|
||||
color: props => (props.selected ? colors.white : colors.grapeDark3),
|
||||
flexShrink: 0,
|
||||
flexWrap: 'nowrap',
|
||||
height: ROW_HEIGHT,
|
||||
minWidth: '100%',
|
||||
paddingLeft: props => (props.level - 1) * 12,
|
||||
paddingRight: 20,
|
||||
position: 'relative',
|
||||
|
||||
'& *': {
|
||||
color: props => (props.selected ? `${colors.white} !important` : ''),
|
||||
},
|
||||
|
||||
'&:hover': {
|
||||
backgroundColor: props =>
|
||||
props.selected ? colors.macOSTitleBarIconSelected : '#EBF1FB',
|
||||
},
|
||||
},
|
||||
{
|
||||
ignoreAttributes: ['level', 'selected', 'even'],
|
||||
},
|
||||
);
|
||||
|
||||
const ElementsRowDecoration = FlexRow.extends({
|
||||
flexShrink: 0,
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
marginRight: 4,
|
||||
position: 'relative',
|
||||
width: 16,
|
||||
top: -1,
|
||||
});
|
||||
|
||||
const ElementsLine = styled.view(
|
||||
{
|
||||
backgroundColor: colors.light20,
|
||||
height: props => props.childrenCount * ROW_HEIGHT - 4,
|
||||
position: 'absolute',
|
||||
right: 3,
|
||||
top: ROW_HEIGHT - 3,
|
||||
zIndex: 2,
|
||||
width: 2,
|
||||
borderRadius: '999em',
|
||||
},
|
||||
{
|
||||
ignoreAttributes: ['childrenCount'],
|
||||
},
|
||||
);
|
||||
|
||||
const DecorationImage = styled.image({
|
||||
height: 12,
|
||||
marginRight: 5,
|
||||
width: 12,
|
||||
});
|
||||
|
||||
const NoShrinkText = Text.extends({
|
||||
flexShrink: 0,
|
||||
flexWrap: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
userSelect: 'none',
|
||||
fontWeight: 400,
|
||||
});
|
||||
|
||||
const ElementsRowAttributeContainer = NoShrinkText.extends({
|
||||
color: colors.dark80,
|
||||
fontWeight: 300,
|
||||
marginLeft: 5,
|
||||
});
|
||||
|
||||
const ElementsRowAttributeKey = styled.text({
|
||||
color: colors.tomato,
|
||||
});
|
||||
|
||||
const ElementsRowAttributeValue = styled.text({
|
||||
color: colors.slateDark3,
|
||||
});
|
||||
|
||||
class PartialHighlight extends PureComponent<{
|
||||
selected: boolean,
|
||||
highlighted: ?string,
|
||||
content: string,
|
||||
}> {
|
||||
static HighlightedText = styled.text({
|
||||
backgroundColor: '#ffff33',
|
||||
color: props =>
|
||||
props.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 = [
|
||||
<span>
|
||||
{before}
|
||||
<PartialHighlight.HighlightedText selected={selected}>
|
||||
{match}
|
||||
</PartialHighlight.HighlightedText>
|
||||
{after}
|
||||
</span>,
|
||||
];
|
||||
} else {
|
||||
renderedValue = <span>{content}</span>;
|
||||
}
|
||||
return renderedValue;
|
||||
}
|
||||
}
|
||||
|
||||
class ElementsRowAttribute extends PureComponent<{
|
||||
name: string,
|
||||
value: string,
|
||||
matchingSearchQuery: ?string,
|
||||
selected: boolean,
|
||||
}> {
|
||||
render() {
|
||||
const {name, value, matchingSearchQuery, selected} = this.props;
|
||||
return (
|
||||
<ElementsRowAttributeContainer code={true}>
|
||||
<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,
|
||||
matchingSearchQuery: ?string,
|
||||
element: Element,
|
||||
even: boolean,
|
||||
onElementSelected: (key: ElementID) => void,
|
||||
onElementExpanded: (key: ElementID, deep: boolean) => void,
|
||||
childrenCount: number,
|
||||
onElementHovered: ?(key: ?ElementID) => void,
|
||||
style: ?Object,
|
||||
};
|
||||
|
||||
type ElementsRowState = {|
|
||||
hovered: boolean,
|
||||
|};
|
||||
|
||||
class ElementsRow extends PureComponent<ElementsRowProps, ElementsRowState> {
|
||||
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<Electron$MenuItemOptions> => {
|
||||
const {props} = this;
|
||||
return [
|
||||
{
|
||||
type: 'separator',
|
||||
},
|
||||
{
|
||||
label: 'Copy',
|
||||
click: () => {
|
||||
clipboard.writeText(props.element.name);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: props.element.expanded ? 'Collapse' : 'Expand',
|
||||
click: () => {
|
||||
this.props.onElementExpanded(this.props.id, false);
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
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,
|
||||
style,
|
||||
even,
|
||||
matchingSearchQuery,
|
||||
} = this.props;
|
||||
const hasChildren = element.children && element.children.length > 0;
|
||||
|
||||
let arrow;
|
||||
if (hasChildren) {
|
||||
arrow = (
|
||||
<span onClick={this.onDoubleClick} role="button" tabIndex={-1}>
|
||||
<Glyph
|
||||
size={8}
|
||||
name={element.expanded ? 'chevron-down' : 'chevron-right'}
|
||||
color={selected ? 'white' : colors.light80}
|
||||
/>
|
||||
</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 = (() => {
|
||||
switch (element.decoration) {
|
||||
case 'litho':
|
||||
return <DecorationImage src="icons/litho-logo.png" />;
|
||||
case 'componentkit':
|
||||
return <DecorationImage src="icons/componentkit-logo.png" />;
|
||||
case 'componentscript':
|
||||
return <DecorationImage src="icons/componentscript-logo.png" />;
|
||||
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 = <ElementsLine childrenCount={this.props.childrenCount} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ElementsRowContainer
|
||||
buildItems={this.getContextMenu}
|
||||
key={id}
|
||||
level={level}
|
||||
selected={selected}
|
||||
matchingSearchQuery={matchingSearchQuery}
|
||||
even={even}
|
||||
onClick={this.onClick}
|
||||
onDoubleClick={this.onDoubleClick}
|
||||
onMouseEnter={this.onMouseEnter}
|
||||
onMouseLeave={this.onMouseLeave}
|
||||
style={style}>
|
||||
<ElementsRowDecoration>
|
||||
{line}
|
||||
{arrow}
|
||||
</ElementsRowDecoration>
|
||||
<NoShrinkText code={true}>
|
||||
{decoration}
|
||||
<PartialHighlight
|
||||
content={element.name}
|
||||
highlighted={matchingSearchQuery}
|
||||
selected={selected}
|
||||
/>
|
||||
</NoShrinkText>
|
||||
{attributes}
|
||||
</ElementsRowContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const ElementsContainer = FlexColumn.extends({
|
||||
backgroundColor: colors.white,
|
||||
minHeight: '100%',
|
||||
minWidth: '100%',
|
||||
overflow: 'auto',
|
||||
});
|
||||
|
||||
const ElementsBox = FlexColumn.extends({
|
||||
alignItems: 'flex-start',
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
});
|
||||
|
||||
type ElementsProps = {|
|
||||
root: ?ElementID,
|
||||
selected: ?ElementID,
|
||||
searchResults: ?ElementSearchResultSet,
|
||||
elements: {[key: ElementID]: Element},
|
||||
onElementSelected: (key: ElementID) => void,
|
||||
onElementExpanded: (key: ElementID, deep: boolean) => void,
|
||||
onElementHovered: ?(key: ?ElementID) => void,
|
||||
|};
|
||||
|
||||
type ElementsState = {|
|
||||
flatKeys: Array<ElementID>,
|
||||
flatElements: FlatElements,
|
||||
maxDepth: number,
|
||||
|};
|
||||
|
||||
export class Elements extends PureComponent<ElementsProps, ElementsState> {
|
||||
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 = ({index, style}: {index: number, style: Object}) => {
|
||||
const {
|
||||
elements,
|
||||
onElementExpanded,
|
||||
onElementHovered,
|
||||
onElementSelected,
|
||||
selected,
|
||||
searchResults,
|
||||
} = this.props;
|
||||
const {flatElements} = this.state;
|
||||
const row = flatElements[index];
|
||||
|
||||
let childrenCount = 0;
|
||||
for (let i = index + 1; i < flatElements.length; i++) {
|
||||
const child = flatElements[i];
|
||||
if (child.level <= row.level) {
|
||||
break;
|
||||
} else {
|
||||
childrenCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ElementsRow
|
||||
level={row.level}
|
||||
id={row.key}
|
||||
key={row.key}
|
||||
even={index % 2 === 0}
|
||||
onElementExpanded={onElementExpanded}
|
||||
onElementHovered={onElementHovered}
|
||||
onElementSelected={onElementSelected}
|
||||
selected={selected === row.key}
|
||||
matchingSearchQuery={
|
||||
searchResults && searchResults.matches.has(row.key)
|
||||
? searchResults.query
|
||||
: null
|
||||
}
|
||||
element={row.element}
|
||||
elements={elements}
|
||||
childrenCount={childrenCount}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
keyMapper = (index: number): string => {
|
||||
return this.state.flatElements[index].key;
|
||||
};
|
||||
|
||||
render() {
|
||||
const items = this.state.flatElements;
|
||||
|
||||
return (
|
||||
<ElementsBox>
|
||||
<ElementsContainer tabIndex="0" onKeyDown={this.onKeyDown}>
|
||||
<FixedList
|
||||
pureData={items}
|
||||
keyMapper={this.keyMapper}
|
||||
rowCount={items.length}
|
||||
rowHeight={ROW_HEIGHT}
|
||||
rowRenderer={this.buildRow}
|
||||
sideScrollable={true}
|
||||
/>
|
||||
</ElementsContainer>
|
||||
</ElementsBox>
|
||||
);
|
||||
}
|
||||
}
|
||||
134
src/ui/components/elements-inspector/sidebar.js
Normal file
134
src/ui/components/elements-inspector/sidebar.js
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* 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 {Element} from './ElementsInspector.js';
|
||||
import type {PluginClient} from '../../../plugin';
|
||||
import Panel from '../Panel.js';
|
||||
import ManagedDataInspector from '../data-inspector/ManagedDataInspector.js';
|
||||
import {Component} from 'react';
|
||||
import {Console} from '../console';
|
||||
import {GK} from 'sonar';
|
||||
|
||||
const deepEqual = require('deep-equal');
|
||||
|
||||
type OnValueChanged = (path: Array<string>, val: any) => void;
|
||||
|
||||
type InspectorSidebarSectionProps = {
|
||||
data: any,
|
||||
id: string,
|
||||
onValueChanged: ?OnValueChanged,
|
||||
};
|
||||
|
||||
class InspectorSidebarSection extends Component<InspectorSidebarSectionProps> {
|
||||
setValue = (path: Array<string>, value: any) => {
|
||||
if (this.props.onValueChanged) {
|
||||
this.props.onValueChanged([this.props.id, ...path], value);
|
||||
}
|
||||
};
|
||||
|
||||
shouldComponentUpdate(nextProps: InspectorSidebarSectionProps) {
|
||||
return (
|
||||
!deepEqual(nextProps, this.props) ||
|
||||
this.props.id !== nextProps.id ||
|
||||
this.props.onValueChanged !== nextProps.onValueChanged
|
||||
);
|
||||
}
|
||||
|
||||
extractValue = (val: any, depth: number) => {
|
||||
if (val && val.__type__) {
|
||||
return {
|
||||
mutable: Boolean(val.__mutable__),
|
||||
type: val.__type__ === 'auto' ? typeof val.value : val.__type__,
|
||||
value: val.value,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
mutable: typeof val === 'object',
|
||||
type: typeof val,
|
||||
value: val,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {id} = this.props;
|
||||
return (
|
||||
<Panel heading={id} floating={false} fill={false}>
|
||||
<ManagedDataInspector
|
||||
data={this.props.data}
|
||||
setValue={this.props.onValueChanged ? this.setValue : undefined}
|
||||
extractValue={this.extractValue}
|
||||
expandRoot={true}
|
||||
collapsed={true}
|
||||
/>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type Props = {|
|
||||
element: ?Element,
|
||||
onValueChanged: ?OnValueChanged,
|
||||
client: PluginClient,
|
||||
|};
|
||||
type State = {|
|
||||
isConsoleEnabled: boolean,
|
||||
|};
|
||||
|
||||
export class InspectorSidebar extends Component<Props, State> {
|
||||
state = {
|
||||
isConsoleEnabled: false,
|
||||
};
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.checkIfConsoleIsEnabled();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props, prevState: State) {
|
||||
if (prevProps.client !== this.props.client) {
|
||||
this.checkIfConsoleIsEnabled();
|
||||
}
|
||||
}
|
||||
|
||||
checkIfConsoleIsEnabled() {
|
||||
this.props.client
|
||||
.call('isConsoleEnabled')
|
||||
.then((result: {isEnabled: boolean}) => {
|
||||
this.setState({isConsoleEnabled: result.isEnabled});
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const {element} = this.props;
|
||||
if (!element || !element.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sections = [];
|
||||
for (const key in element.data) {
|
||||
sections.push(
|
||||
<InspectorSidebarSection
|
||||
key={key}
|
||||
id={key}
|
||||
data={element.data[key]}
|
||||
onValueChanged={this.props.onValueChanged}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
if (GK.get('sonar_show_console_plugin') && this.state.isConsoleEnabled) {
|
||||
sections.push(
|
||||
<Panel heading="JS Console" floating={false} fill={false}>
|
||||
<Console client={this.props.client} getContext={() => element.id} />
|
||||
</Panel>,
|
||||
);
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
}
|
||||
88
src/ui/components/filter/FilterRow.js
Normal file
88
src/ui/components/filter/FilterRow.js
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* 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 {Filter} from './types.js';
|
||||
import {PureComponent} from 'react';
|
||||
import ContextMenu from '../ContextMenu.js';
|
||||
import textContent from '../../../utils/textContent.js';
|
||||
import styled from '../../styled/index.js';
|
||||
import {colors} from '../colors.js';
|
||||
|
||||
const FilterText = styled.view(
|
||||
{
|
||||
display: 'flex',
|
||||
alignSelf: 'baseline',
|
||||
userSelect: 'none',
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
maxWidth: '100%',
|
||||
'&:hover': {
|
||||
color: colors.white,
|
||||
},
|
||||
'&:hover::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 3,
|
||||
bottom: -2,
|
||||
left: -6,
|
||||
right: -6,
|
||||
borderRadius: '999em',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
},
|
||||
'&:hover *': {
|
||||
color: `${colors.white} !important`,
|
||||
zIndex: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
ignoreAttributes: ['filterKey', 'addFilter'],
|
||||
},
|
||||
);
|
||||
|
||||
type Props = {
|
||||
children: React.Node,
|
||||
addFilter: (filter: Filter) => void,
|
||||
filterKey: string,
|
||||
};
|
||||
|
||||
export default class FilterRow extends PureComponent<Props> {
|
||||
onClick = (e: SyntheticMouseEvent<>) => {
|
||||
if (e.button === 0) {
|
||||
this.props.addFilter({
|
||||
type: e.metaKey || e.altKey ? 'exclude' : 'include',
|
||||
key: this.props.filterKey,
|
||||
value: textContent(this.props.children),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
menuItems = [
|
||||
{
|
||||
label: 'Filter this value',
|
||||
click: () =>
|
||||
this.props.addFilter({
|
||||
type: 'include',
|
||||
key: this.props.filterKey,
|
||||
value: textContent(this.props.children),
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
render() {
|
||||
const {children, ...props} = this.props;
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
items={this.menuItems}
|
||||
component={FilterText}
|
||||
onClick={this.onClick}
|
||||
{...props}>
|
||||
{children}
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
}
|
||||
24
src/ui/components/filter/types.js
Normal file
24
src/ui/components/filter/types.js
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
export type Filter =
|
||||
| {
|
||||
key: string,
|
||||
value: string,
|
||||
type: 'include' | 'exclude',
|
||||
}
|
||||
| {
|
||||
key: string,
|
||||
value: Array<string>,
|
||||
type: 'enum',
|
||||
enum: Array<{
|
||||
label: string,
|
||||
color: string,
|
||||
value: string,
|
||||
}>,
|
||||
persistent?: boolean,
|
||||
};
|
||||
107
src/ui/components/intro/intro.js
Normal file
107
src/ui/components/intro/intro.js
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* 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 {
|
||||
FlexBox,
|
||||
FlexColumn,
|
||||
FlexRow,
|
||||
Text,
|
||||
View,
|
||||
styled,
|
||||
Glyph,
|
||||
colors,
|
||||
brandColors,
|
||||
PureComponent,
|
||||
} from 'sonar';
|
||||
|
||||
const Containter = FlexColumn.extends({
|
||||
fontSize: 17,
|
||||
justifyContent: 'center',
|
||||
marginLeft: 60,
|
||||
marginRight: 60,
|
||||
width: 'auto',
|
||||
fontWeight: 300,
|
||||
lineHeight: '140%',
|
||||
maxWidth: 700,
|
||||
minWidth: 450,
|
||||
});
|
||||
|
||||
const TitleRow = FlexRow.extends({
|
||||
alignItems: 'center',
|
||||
marginBottom: 40,
|
||||
});
|
||||
|
||||
const Icon = FlexBox.extends({
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: brandColors.Sonar,
|
||||
width: 32,
|
||||
height: 32,
|
||||
flexShrink: 0,
|
||||
borderRadius: 6,
|
||||
});
|
||||
|
||||
const Title = Text.extends({
|
||||
fontSize: 30,
|
||||
fontWeight: 300,
|
||||
paddingLeft: 10,
|
||||
});
|
||||
|
||||
const Button = View.extends({
|
||||
marginTop: 40,
|
||||
marginBottom: 30,
|
||||
borderRadius: 6,
|
||||
color: colors.white,
|
||||
border: 'none',
|
||||
background: brandColors.Sonar,
|
||||
padding: '10px 30px',
|
||||
fontWeight: 500,
|
||||
fontSize: '1em',
|
||||
alignSelf: 'flex-start',
|
||||
});
|
||||
|
||||
const Screenshot = styled.customHTMLTag('img', {
|
||||
alignSelf: 'center',
|
||||
boxShadow: '0 5px 35px rgba(0,0,0,0.3)',
|
||||
borderRadius: 5,
|
||||
border: `1px solid ${colors.macOSTitleBarBorder}`,
|
||||
transform: 'translateX(5px)',
|
||||
overflow: 'hidden',
|
||||
maxHeight: '80%',
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
type Props = {
|
||||
title: string,
|
||||
icon?: string,
|
||||
screenshot?: ?string,
|
||||
children: React.Node,
|
||||
onDismiss: () => void,
|
||||
};
|
||||
|
||||
export default class Intro extends PureComponent<Props> {
|
||||
render() {
|
||||
const {icon, children, title, onDismiss, screenshot} = this.props;
|
||||
return (
|
||||
<FlexRow fill={true}>
|
||||
<Containter>
|
||||
<TitleRow>
|
||||
{icon != null && (
|
||||
<Icon>
|
||||
<Glyph name={icon} size={24} color={colors.white} />
|
||||
</Icon>
|
||||
)}
|
||||
<Title>{title}</Title>
|
||||
</TitleRow>
|
||||
{children}
|
||||
<Button onClick={onDismiss}>Let's go</Button>
|
||||
</Containter>
|
||||
{screenshot != null && <Screenshot src={screenshot} />}
|
||||
</FlexRow>
|
||||
);
|
||||
}
|
||||
}
|
||||
251
src/ui/components/searchable/FilterToken.js
Normal file
251
src/ui/components/searchable/FilterToken.js
Normal file
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* 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 {Filter} from 'sonar';
|
||||
import {PureComponent} from 'react';
|
||||
import Text from '../Text.js';
|
||||
import styled from '../../styled/index.js';
|
||||
import {findDOMNode} from 'react-dom';
|
||||
import {colors} from '../colors.js';
|
||||
import electron from 'electron';
|
||||
|
||||
const Token = Text.extends(
|
||||
{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
backgroundColor: props =>
|
||||
props.focused
|
||||
? colors.macOSHighlightActive
|
||||
: props.color || colors.macOSHighlight,
|
||||
borderRadius: 4,
|
||||
marginRight: 4,
|
||||
padding: 4,
|
||||
paddingLeft: 6,
|
||||
height: 21,
|
||||
color: props => (props.focused ? 'white' : 'inherit'),
|
||||
'&:active': {
|
||||
backgroundColor: colors.macOSHighlightActive,
|
||||
color: colors.white,
|
||||
},
|
||||
'&:first-of-type': {
|
||||
marginLeft: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
ignoreAttributes: ['focused', 'color'],
|
||||
},
|
||||
);
|
||||
|
||||
const Key = Text.extends(
|
||||
{
|
||||
position: 'relative',
|
||||
fontWeight: 500,
|
||||
paddingRight: 12,
|
||||
textTransform: 'capitalize',
|
||||
lineHeight: '21px',
|
||||
'&:after': {
|
||||
content: props => (props.type === 'exclude' ? '"≠"' : '"="'),
|
||||
paddingLeft: 5,
|
||||
position: 'absolute',
|
||||
top: -1,
|
||||
right: 0,
|
||||
fontSize: 14,
|
||||
},
|
||||
'&:active:after': {
|
||||
backgroundColor: colors.macOSHighlightActive,
|
||||
},
|
||||
},
|
||||
{
|
||||
ignoreAttributes: ['type', 'focused'],
|
||||
},
|
||||
);
|
||||
|
||||
const Value = Text.extends({
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: 160,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
lineHeight: '21px',
|
||||
paddingLeft: 3,
|
||||
});
|
||||
|
||||
const Chevron = styled.view(
|
||||
{
|
||||
border: 0,
|
||||
paddingLeft: 3,
|
||||
paddingRight: 1,
|
||||
marginRight: 0,
|
||||
fontSize: 16,
|
||||
backgroundColor: 'transparent',
|
||||
position: 'relative',
|
||||
top: -2,
|
||||
height: 'auto',
|
||||
lineHeight: 'initial',
|
||||
color: props => (props.focused ? colors.white : 'inherit'),
|
||||
'&:hover, &:active, &:focus': {
|
||||
color: 'inherit',
|
||||
border: 0,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
},
|
||||
{
|
||||
ignoreAttributes: ['focused'],
|
||||
},
|
||||
);
|
||||
|
||||
type Props = {|
|
||||
filter: Filter,
|
||||
focused: boolean,
|
||||
index: number,
|
||||
onFocus: (focusedToken: number) => void,
|
||||
onBlur: () => void,
|
||||
onDelete: (deletedToken: number) => void,
|
||||
onReplace: (index: number, filter: Filter) => void,
|
||||
|};
|
||||
|
||||
export default class FilterToken extends PureComponent<Props> {
|
||||
_ref: ?Element;
|
||||
|
||||
onMouseDown = () => {
|
||||
if (
|
||||
this.props.filter.persistent == null ||
|
||||
this.props.filter.persistent === false
|
||||
) {
|
||||
this.props.onFocus(this.props.index);
|
||||
}
|
||||
this.showDetails();
|
||||
};
|
||||
|
||||
showDetails = () => {
|
||||
const menuTemplate = [];
|
||||
|
||||
if (this.props.filter.type === 'enum') {
|
||||
menuTemplate.push(
|
||||
...this.props.filter.enum.map(({value, label}) => ({
|
||||
label,
|
||||
click: () => this.changeEnum(value),
|
||||
type: 'checkbox',
|
||||
checked: this.props.filter.value.indexOf(value) > -1,
|
||||
})),
|
||||
);
|
||||
} else {
|
||||
if (this.props.filter.value.length > 23) {
|
||||
menuTemplate.push(
|
||||
{
|
||||
label: this.props.filter.value,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
type: 'separator',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
menuTemplate.push(
|
||||
{
|
||||
label:
|
||||
this.props.filter.type === 'include'
|
||||
? `Entries excluding "${this.props.filter.value}"`
|
||||
: `Entries including "${this.props.filter.value}"`,
|
||||
click: this.toggleFilter,
|
||||
},
|
||||
{
|
||||
label: 'Remove this filter',
|
||||
click: () => this.props.onDelete(this.props.index),
|
||||
},
|
||||
);
|
||||
}
|
||||
const menu = electron.remote.Menu.buildFromTemplate(menuTemplate);
|
||||
const {bottom, left} = this._ref ? this._ref.getBoundingClientRect() : {};
|
||||
menu.popup(electron.remote.getCurrentWindow(), {
|
||||
async: true,
|
||||
x: parseInt(left, 10),
|
||||
y: parseInt(bottom, 10) + 8,
|
||||
});
|
||||
};
|
||||
|
||||
toggleFilter = () => {
|
||||
const {filter, index} = this.props;
|
||||
if (filter.type !== 'enum') {
|
||||
const newFilter: Filter = {
|
||||
...filter,
|
||||
type: filter.type === 'include' ? 'exclude' : 'include',
|
||||
};
|
||||
this.props.onReplace(index, newFilter);
|
||||
}
|
||||
};
|
||||
|
||||
changeEnum = (newValue: string) => {
|
||||
const {filter, index} = this.props;
|
||||
if (filter.type === 'enum') {
|
||||
let {value} = filter;
|
||||
if (value.indexOf(newValue) > -1) {
|
||||
value = value.filter(v => v !== newValue);
|
||||
} else {
|
||||
value = value.concat([newValue]);
|
||||
}
|
||||
if (value.length === filter.enum.length) {
|
||||
value = [];
|
||||
}
|
||||
const newFilter: Filter = {
|
||||
type: 'enum',
|
||||
...filter,
|
||||
value,
|
||||
};
|
||||
this.props.onReplace(index, newFilter);
|
||||
}
|
||||
};
|
||||
|
||||
setRef = (ref: React.ElementRef<*>) => {
|
||||
const element = findDOMNode(ref);
|
||||
if (element instanceof HTMLElement) {
|
||||
this._ref = element;
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {filter} = this.props;
|
||||
let color;
|
||||
let value = '';
|
||||
|
||||
if (filter.type === 'enum') {
|
||||
const getEnum = value => filter.enum.find(e => e.value === value);
|
||||
const firstValue = getEnum(filter.value[0]);
|
||||
const secondValue = getEnum(filter.value[1]);
|
||||
if (filter.value.length === 0) {
|
||||
value = 'All';
|
||||
} else if (filter.value.length === 2 && firstValue && secondValue) {
|
||||
value = `${firstValue.label} or ${secondValue.label}`;
|
||||
} else if (filter.value.length === 1 && firstValue) {
|
||||
value = firstValue.label;
|
||||
color = firstValue.color;
|
||||
} else if (firstValue) {
|
||||
value = `${firstValue.label} or ${filter.value.length - 1} others`;
|
||||
}
|
||||
} else {
|
||||
value = filter.value;
|
||||
}
|
||||
|
||||
return (
|
||||
<Token
|
||||
key={`${filter.key}:${value}=${filter.type}`}
|
||||
tabIndex={-1}
|
||||
onMouseDown={this.onMouseDown}
|
||||
focused={this.props.focused}
|
||||
color={color}
|
||||
innerRef={this.setRef}>
|
||||
<Key type={this.props.filter.type} focused={this.props.focused}>
|
||||
{filter.key}
|
||||
</Key>
|
||||
<Value>{value}</Value>
|
||||
<Chevron tabIndex={-1} focused={this.props.focused}>
|
||||
⌄
|
||||
</Chevron>
|
||||
</Token>
|
||||
);
|
||||
}
|
||||
}
|
||||
392
src/ui/components/searchable/Searchable.js
Normal file
392
src/ui/components/searchable/Searchable.js
Normal file
@@ -0,0 +1,392 @@
|
||||
/**
|
||||
* 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 {Filter} from 'sonar';
|
||||
import {PureComponent} from 'react';
|
||||
import Toolbar from '../Toolbar.js';
|
||||
import FlexRow from '../FlexRow.js';
|
||||
import Input from '../Input.js';
|
||||
import {colors} from '../colors.js';
|
||||
import Text from '../Text.js';
|
||||
import FlexBox from '../FlexBox.js';
|
||||
import Glyph from '../Glyph.js';
|
||||
import FilterToken from './FilterToken.js';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const SEARCHABLE_STORAGE_KEY = (key: string) => `SEARCHABLE_STORAGE_KEY_${key}`;
|
||||
|
||||
const SearchBar = Toolbar.extends({
|
||||
height: 42,
|
||||
padding: 6,
|
||||
});
|
||||
|
||||
export const SearchBox = FlexBox.extends({
|
||||
backgroundColor: colors.white,
|
||||
borderRadius: '999em',
|
||||
border: `1px solid ${colors.light15}`,
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
paddingLeft: 4,
|
||||
});
|
||||
|
||||
export const SearchInput = Input.extends({
|
||||
border: props => (props.focus ? '1px solid black' : 0),
|
||||
padding: 0,
|
||||
fontSize: '1em',
|
||||
flexGrow: 1,
|
||||
height: 'auto',
|
||||
lineHeight: '100%',
|
||||
marginLeft: 2,
|
||||
width: '100%',
|
||||
'&::-webkit-input-placeholder': {
|
||||
color: colors.placeholder,
|
||||
fontWeight: 300,
|
||||
},
|
||||
});
|
||||
|
||||
const Clear = Text.extends({
|
||||
position: 'absolute',
|
||||
right: 6,
|
||||
top: '50%',
|
||||
marginTop: -9,
|
||||
fontSize: 16,
|
||||
width: 17,
|
||||
height: 17,
|
||||
borderRadius: 999,
|
||||
lineHeight: '15.5px',
|
||||
textAlign: 'center',
|
||||
backgroundColor: 'rgba(0,0,0,0.1)',
|
||||
color: colors.white,
|
||||
display: 'block',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(0,0,0,0.15)',
|
||||
},
|
||||
});
|
||||
|
||||
export const SearchIcon = Glyph.extends({
|
||||
marginRight: 3,
|
||||
marginLeft: 3,
|
||||
marginTop: -1,
|
||||
minWidth: 16,
|
||||
});
|
||||
|
||||
const Actions = FlexRow.extends({
|
||||
marginLeft: 8,
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
export type SearchableProps = {|
|
||||
addFilter: (filter: Filter) => void,
|
||||
searchTerm: string,
|
||||
filters: Array<Filter>,
|
||||
|};
|
||||
|
||||
type Props = {|
|
||||
placeholder?: string,
|
||||
actions: React.Node,
|
||||
tableKey: string,
|
||||
onFilterChange: (filters: Array<Filter>) => void,
|
||||
defaultFilters: Array<Filter>,
|
||||
|};
|
||||
|
||||
type State = {
|
||||
filters: Array<Filter>,
|
||||
focusedToken: number,
|
||||
searchTerm: string,
|
||||
hasFocus: boolean,
|
||||
};
|
||||
|
||||
const Searchable = (
|
||||
Component: React.ComponentType<any>,
|
||||
): React.ComponentType<any> =>
|
||||
class extends PureComponent<Props, State> {
|
||||
static defaultProps = {
|
||||
placeholder: 'Search...',
|
||||
};
|
||||
|
||||
static contextTypes = {
|
||||
plugin: PropTypes.string,
|
||||
};
|
||||
|
||||
state = {
|
||||
filters: [],
|
||||
focusedToken: -1,
|
||||
searchTerm: '',
|
||||
hasFocus: false,
|
||||
};
|
||||
|
||||
_inputRef: ?HTMLInputElement;
|
||||
|
||||
componentDidMount() {
|
||||
window.document.addEventListener('keydown', this.onKeyDown);
|
||||
const {defaultFilters} = this.props;
|
||||
let savedState;
|
||||
let key = this.context.plugin + this.props.tableKey;
|
||||
try {
|
||||
savedState = JSON.parse(
|
||||
window.localStorage.getItem(SEARCHABLE_STORAGE_KEY(key)) || 'null',
|
||||
);
|
||||
} catch (e) {
|
||||
window.localStorage.removeItem(SEARCHABLE_STORAGE_KEY(key));
|
||||
}
|
||||
if (savedState) {
|
||||
if (this.props.onFilterChange != null) {
|
||||
this.props.onFilterChange(savedState.filters);
|
||||
}
|
||||
if (defaultFilters != null) {
|
||||
const savedStateFilters = savedState.filters;
|
||||
defaultFilters.forEach(defaultFilter => {
|
||||
const filterIndex = savedStateFilters.findIndex(
|
||||
f => f.key === defaultFilter.key,
|
||||
);
|
||||
if (filterIndex > -1) {
|
||||
const defaultFilter: Filter = defaultFilters[filterIndex];
|
||||
if (defaultFilter.type === 'enum') {
|
||||
savedStateFilters[filterIndex].enum = defaultFilter.enum;
|
||||
}
|
||||
const filters = new Set(
|
||||
savedStateFilters[filterIndex].enum.map(filter => filter.value),
|
||||
);
|
||||
savedStateFilters[filterIndex].value = savedStateFilters[
|
||||
filterIndex
|
||||
].value.filter(value => filters.has(value));
|
||||
}
|
||||
});
|
||||
}
|
||||
this.setState({
|
||||
searchTerm: savedState.searchTerm || '',
|
||||
filters: savedState.filters || [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props, prevState: State) {
|
||||
if (
|
||||
this.context.plugin &&
|
||||
(prevState.searchTerm !== this.state.searchTerm ||
|
||||
prevState.filters !== this.state.filters)
|
||||
) {
|
||||
let key = this.context.plugin + this.props.tableKey;
|
||||
window.localStorage.setItem(
|
||||
SEARCHABLE_STORAGE_KEY(key),
|
||||
JSON.stringify({
|
||||
searchTerm: this.state.searchTerm,
|
||||
filters: this.state.filters,
|
||||
}),
|
||||
);
|
||||
if (this.props.onFilterChange != null) {
|
||||
this.props.onFilterChange(this.state.filters);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.document.removeEventListener('keydown', this.onKeyDown);
|
||||
}
|
||||
|
||||
onKeyDown = (e: SyntheticKeyboardEvent<>) => {
|
||||
const ctrlOrCmd = e =>
|
||||
(e.metaKey && process.platform === 'darwin') ||
|
||||
(e.ctrlKey && process.platform !== 'darwin');
|
||||
|
||||
if (e.key === 'f' && ctrlOrCmd(e) && this._inputRef) {
|
||||
e.preventDefault();
|
||||
if (this._inputRef) {
|
||||
this._inputRef.focus();
|
||||
}
|
||||
} else if (e.key === 'Escape' && this._inputRef) {
|
||||
this._inputRef.blur();
|
||||
this.setState({searchTerm: ''});
|
||||
} else if (e.key === 'Backspace' && this.hasFocus()) {
|
||||
if (
|
||||
this.state.focusedToken === -1 &&
|
||||
this.state.searchTerm === '' &&
|
||||
this._inputRef &&
|
||||
!this.state.filters[this.state.filters.length - 1].persistent
|
||||
) {
|
||||
this._inputRef.blur();
|
||||
this.setState({focusedToken: this.state.filters.length - 1});
|
||||
} else {
|
||||
this.removeFilter(this.state.focusedToken);
|
||||
}
|
||||
} else if (
|
||||
e.key === 'Delete' &&
|
||||
this.hasFocus() &&
|
||||
this.state.focusedToken > -1
|
||||
) {
|
||||
this.removeFilter(this.state.focusedToken);
|
||||
} else if (e.key === 'Enter' && this.hasFocus() && this._inputRef) {
|
||||
this.matchTags(this._inputRef.value, true);
|
||||
}
|
||||
};
|
||||
|
||||
onChangeSearchTerm = (e: SyntheticInputEvent<HTMLInputElement>) =>
|
||||
this.matchTags(e.target.value, false);
|
||||
|
||||
matchTags = (searchTerm: string, matchEnd: boolean) => {
|
||||
const filterPattern = matchEnd
|
||||
? /([a-z][a-z0-9]*[!]?[:=][^\s]+)($|\s)/gi
|
||||
: /([a-z][a-z0-9]*[!]?[:=][^\s]+)\s/gi;
|
||||
const match = searchTerm.match(filterPattern);
|
||||
if (match && match.length > 0) {
|
||||
match.forEach((filter: string) => {
|
||||
const separator =
|
||||
filter.indexOf(':') > filter.indexOf('=') ? ':' : '=';
|
||||
let [key, ...value] = filter.split(separator);
|
||||
value = value.join(separator).trim();
|
||||
let type = 'include';
|
||||
// if value starts with !, it's an exclude filter
|
||||
if (value.indexOf('!') === 0) {
|
||||
type = 'exclude';
|
||||
value = value.substring(1);
|
||||
}
|
||||
// if key ends with !, it's an exclude filter
|
||||
if (key.indexOf('!') === key.length - 1) {
|
||||
type = 'exclude';
|
||||
key = key.slice(0, -1);
|
||||
}
|
||||
this.addFilter({
|
||||
type,
|
||||
key,
|
||||
value,
|
||||
});
|
||||
});
|
||||
|
||||
searchTerm = searchTerm.replace(filterPattern, '');
|
||||
}
|
||||
this.setState({searchTerm});
|
||||
};
|
||||
|
||||
setInputRef = (ref: ?HTMLInputElement) => {
|
||||
this._inputRef = ref;
|
||||
};
|
||||
|
||||
addFilter = (filter: Filter) => {
|
||||
const filterIndex = this.state.filters.findIndex(
|
||||
f => f.key === filter.key,
|
||||
);
|
||||
if (filterIndex > -1) {
|
||||
const filters = [...this.state.filters];
|
||||
const defaultFilter: Filter = this.props.defaultFilters[filterIndex];
|
||||
if (
|
||||
defaultFilter != null &&
|
||||
defaultFilter.type === 'enum' &&
|
||||
filters[filterIndex].type === 'enum'
|
||||
) {
|
||||
filters[filterIndex].enum = defaultFilter.enum;
|
||||
}
|
||||
this.setState({filters});
|
||||
// filter for this key already exists
|
||||
return;
|
||||
}
|
||||
// persistent filters are always at the front
|
||||
const filters =
|
||||
filter.persistent === true
|
||||
? [filter, ...this.state.filters]
|
||||
: this.state.filters.concat(filter);
|
||||
this.setState({
|
||||
filters,
|
||||
focusedToken: -1,
|
||||
});
|
||||
};
|
||||
|
||||
removeFilter = (index: number) => {
|
||||
const filters = this.state.filters.filter((_, i) => i !== index);
|
||||
const focusedToken = -1;
|
||||
this.setState({filters, focusedToken}, () => {
|
||||
if (this._inputRef) {
|
||||
this._inputRef.focus();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
replaceFilter = (index: number, filter: Filter) => {
|
||||
const filters = [...this.state.filters];
|
||||
filters.splice(index, 1, filter);
|
||||
this.setState({filters});
|
||||
};
|
||||
|
||||
onInputFocus = () =>
|
||||
this.setState({
|
||||
focusedToken: -1,
|
||||
hasFocus: true,
|
||||
});
|
||||
|
||||
onInputBlur = () =>
|
||||
setTimeout(
|
||||
() =>
|
||||
this.setState({
|
||||
hasFocus: false,
|
||||
}),
|
||||
100,
|
||||
);
|
||||
|
||||
onTokenFocus = (focusedToken: number) => this.setState({focusedToken});
|
||||
|
||||
onTokenBlur = () => this.setState({focusedToken: -1});
|
||||
|
||||
hasFocus = (): boolean => {
|
||||
return this.state.focusedToken !== -1 || this.state.hasFocus;
|
||||
};
|
||||
|
||||
clear = () =>
|
||||
this.setState({
|
||||
filters: this.state.filters.filter(
|
||||
f => f.persistent != null && f.persistent === true,
|
||||
),
|
||||
searchTerm: '',
|
||||
});
|
||||
|
||||
render(): React.Node {
|
||||
const {placeholder, actions, ...props} = this.props;
|
||||
return [
|
||||
<SearchBar position="top" key="searchbar">
|
||||
<SearchBox tabIndex={-1}>
|
||||
<SearchIcon
|
||||
name="magnifying-glass"
|
||||
color={colors.macOSTitleBarIcon}
|
||||
size={16}
|
||||
/>
|
||||
{this.state.filters.map((filter, i) => (
|
||||
<FilterToken
|
||||
key={`${filter.key}:${filter.type}`}
|
||||
index={i}
|
||||
filter={filter}
|
||||
focused={i === this.state.focusedToken}
|
||||
onFocus={this.onTokenFocus}
|
||||
onDelete={this.removeFilter}
|
||||
onReplace={this.replaceFilter}
|
||||
onBlur={this.onTokenBlur}
|
||||
/>
|
||||
))}
|
||||
<SearchInput
|
||||
placeholder={placeholder}
|
||||
onChange={this.onChangeSearchTerm}
|
||||
value={this.state.searchTerm}
|
||||
innerRef={this.setInputRef}
|
||||
onFocus={this.onInputFocus}
|
||||
onBlur={this.onInputBlur}
|
||||
/>
|
||||
{(this.state.searchTerm || this.state.filters.length > 0) && (
|
||||
<Clear onClick={this.clear}>×</Clear>
|
||||
)}
|
||||
</SearchBox>
|
||||
{actions != null && <Actions>{actions}</Actions>}
|
||||
</SearchBar>,
|
||||
<Component
|
||||
{...props}
|
||||
key="table"
|
||||
addFilter={this.addFilter}
|
||||
searchTerm={this.state.searchTerm}
|
||||
filters={this.state.filters}
|
||||
/>,
|
||||
];
|
||||
}
|
||||
};
|
||||
|
||||
export default Searchable;
|
||||
107
src/ui/components/searchable/SearchableTable.js
Normal file
107
src/ui/components/searchable/SearchableTable.js
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* 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 {ManagedTableProps, TableBodyRow, Filter} from 'sonar';
|
||||
import type {SearchableProps} from './Searchable.js';
|
||||
import {PureComponent} from 'react';
|
||||
import ManagedTable from '../table/ManagedTable.js';
|
||||
|
||||
import textContent from '../../../utils/textContent.js';
|
||||
import Searchable from './Searchable.js';
|
||||
import deepEqual from 'deep-equal';
|
||||
|
||||
type Props = {|
|
||||
...ManagedTableProps,
|
||||
...SearchableProps,
|
||||
innerRef?: (ref: React.ElementRef<*>) => void,
|
||||
defaultFilters: Array<Filter>,
|
||||
filter: empty,
|
||||
filterValue: empty,
|
||||
|};
|
||||
|
||||
type State = {
|
||||
filterRows: (row: TableBodyRow) => boolean,
|
||||
};
|
||||
|
||||
const filterRowsFactory = (filters: Array<Filter>, searchTerm: string) => (
|
||||
row: TableBodyRow,
|
||||
): boolean =>
|
||||
filters
|
||||
.map((filter: Filter) => {
|
||||
if (filter.type === 'enum' && row.type != null) {
|
||||
return filter.value.length === 0 || filter.value.indexOf(row.type) > -1;
|
||||
} else if (filter.type === 'include') {
|
||||
return (
|
||||
textContent(row.columns[filter.key].value).toLowerCase() ===
|
||||
filter.value.toLowerCase()
|
||||
);
|
||||
} else if (filter.type === 'exclude') {
|
||||
return (
|
||||
textContent(row.columns[filter.key].value).toLowerCase() !==
|
||||
filter.value.toLowerCase()
|
||||
);
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
})
|
||||
.reduce((acc, cv) => acc && cv, true) &&
|
||||
(searchTerm != null && searchTerm.length > 0
|
||||
? Object.keys(row.columns)
|
||||
.map(key => textContent(row.columns[key].value))
|
||||
.join('~~') // prevent from matching text spanning multiple columns
|
||||
.toLowerCase()
|
||||
.includes(searchTerm.toLowerCase())
|
||||
: true);
|
||||
|
||||
class SearchableManagedTable extends PureComponent<Props, State> {
|
||||
static defaultProps = {
|
||||
defaultFilters: [],
|
||||
};
|
||||
|
||||
state = {
|
||||
filterRows: filterRowsFactory(this.props.filters, this.props.searchTerm),
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.props.defaultFilters.map(this.props.addFilter);
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps: Props) {
|
||||
// ManagedTable is a PureComponent and does not update when this.filterRows
|
||||
// would return a different value. This is why we update the funtion reference
|
||||
// once the results of the function changed.
|
||||
if (
|
||||
nextProps.searchTerm !== this.props.searchTerm ||
|
||||
!deepEqual(this.props.filters, nextProps.filters)
|
||||
) {
|
||||
this.setState({
|
||||
filterRows: filterRowsFactory(nextProps.filters, nextProps.searchTerm),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
addFilter,
|
||||
searchTerm: _searchTerm,
|
||||
filters: _filters,
|
||||
innerRef,
|
||||
...props
|
||||
} = this.props;
|
||||
return (
|
||||
// $FlowFixMe
|
||||
<ManagedTable
|
||||
{...props}
|
||||
filter={this.state.filterRows}
|
||||
onAddFilter={addFilter}
|
||||
ref={innerRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Searchable(SearchableManagedTable);
|
||||
231
src/ui/components/table/ManagedTable.js
Normal file
231
src/ui/components/table/ManagedTable.js
Normal file
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* 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 {
|
||||
TableColumnRawOrder,
|
||||
TableColumnOrder,
|
||||
TableColumnSizes,
|
||||
TableColumns,
|
||||
TableHighlightedRows,
|
||||
TableRowSortOrder,
|
||||
TableRows,
|
||||
TableBodyRow,
|
||||
TableOnAddFilter,
|
||||
} from './types.js';
|
||||
import styled from '../../styled/index.js';
|
||||
import Table from './Table.js';
|
||||
|
||||
export type ManagedTableProps = {|
|
||||
/**
|
||||
* Column definitions.
|
||||
*/
|
||||
columns: TableColumns,
|
||||
/**
|
||||
* Row definitions.
|
||||
*/
|
||||
rows: TableRows,
|
||||
/**
|
||||
* Whether to use a virtual list. Items visible in the viewport are the only
|
||||
* included in the DOM. This can have a noticable performance improvement.
|
||||
*/
|
||||
virtual?: boolean,
|
||||
/**
|
||||
* Whether the table has a border.
|
||||
*/
|
||||
floating?: boolean,
|
||||
/**
|
||||
* Whether a row can span over multiple lines. Otherwise lines cannot wrap and
|
||||
* are truncated.
|
||||
*/
|
||||
multiline?: boolean,
|
||||
/**
|
||||
* Whether the body is scrollable. When this is set to `true` then the table
|
||||
* is not scrollable.
|
||||
*/
|
||||
autoHeight?: boolean,
|
||||
/**
|
||||
* Order of columns.
|
||||
*/
|
||||
columnOrder?: ?TableColumnRawOrder,
|
||||
/**
|
||||
* Size of the columns.
|
||||
*/
|
||||
columnSizes?: TableColumnSizes,
|
||||
/**
|
||||
* Value to filter rows on. Alternative to the `filter` prop.
|
||||
*/
|
||||
filterValue?: string,
|
||||
/**
|
||||
* Callback to filter rows.
|
||||
*/
|
||||
filter?: (row: TableBodyRow) => boolean,
|
||||
/**
|
||||
* Callback when the highlighted rows change.
|
||||
*/
|
||||
onRowHighlighted?: (keys: TableHighlightedRows) => void,
|
||||
/**
|
||||
* Whether rows can be highlighted or not.
|
||||
*/
|
||||
highlightableRows?: boolean,
|
||||
/**
|
||||
* Whether multiple rows can be highlighted or not.
|
||||
*/
|
||||
multiHighlight?: boolean,
|
||||
/**
|
||||
* Height of each row.
|
||||
*/
|
||||
rowLineHeight?: number,
|
||||
/**
|
||||
* This makes it so the scroll position sticks to the bottom of the window.
|
||||
* Useful for streaming data like requests, logs etc.
|
||||
*/
|
||||
stickyBottom?: boolean,
|
||||
/**
|
||||
* Used by SearchableTable to add filters for rows
|
||||
*/
|
||||
onAddFilter?: TableOnAddFilter,
|
||||
/**
|
||||
* Enable or disable zebra striping
|
||||
*/
|
||||
zebra?: boolean,
|
||||
/**
|
||||
* Whether to hide the column names at the top of the table.
|
||||
*/
|
||||
hideHeader?: boolean,
|
||||
|};
|
||||
|
||||
type ManagedTableState = {|
|
||||
highlightedRows: TableHighlightedRows,
|
||||
sortOrder: ?TableRowSortOrder,
|
||||
columnOrder: ?TableColumnRawOrder,
|
||||
columnSizes: ?TableColumnSizes,
|
||||
|};
|
||||
|
||||
/**
|
||||
* Wrapper around `Table` that handles row state.
|
||||
*
|
||||
* If you require lower level access to the state then use [`<Table>`]()
|
||||
* directly.
|
||||
*/
|
||||
export default class ManagedTable extends styled.StylablePureComponent<
|
||||
ManagedTableProps,
|
||||
ManagedTableState,
|
||||
> {
|
||||
getTableKey = (): string => {
|
||||
return (
|
||||
'TABLE_COLUMNS_' +
|
||||
Object.keys(this.props.columns)
|
||||
.join('_')
|
||||
.toUpperCase()
|
||||
);
|
||||
};
|
||||
|
||||
state = {
|
||||
columnOrder:
|
||||
JSON.parse(window.localStorage.getItem(this.getTableKey()) || 'null') ||
|
||||
this.props.columnOrder,
|
||||
columnSizes: this.props.columnSizes,
|
||||
highlightedRows: [],
|
||||
sortOrder: null,
|
||||
};
|
||||
|
||||
tableRef: ?Table;
|
||||
|
||||
componentWillReceiveProps(nextProps: ManagedTableProps) {
|
||||
// if columnSizes has changed
|
||||
if (nextProps.columnSizes !== this.props.columnSizes) {
|
||||
this.setState({
|
||||
columnSizes: {
|
||||
...(this.state.columnSizes || {}),
|
||||
...nextProps.columnSizes,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// if columnOrder has changed
|
||||
if (nextProps.columnOrder !== this.props.columnOrder) {
|
||||
this.setState({
|
||||
columnOrder: nextProps.columnOrder,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onHighlight = (highlightedRows: TableHighlightedRows) => {
|
||||
if (this.props.highlightableRows === false) {
|
||||
return;
|
||||
}
|
||||
if (this.props.multiHighlight !== true) {
|
||||
highlightedRows = highlightedRows.slice(0, 1);
|
||||
}
|
||||
|
||||
this.setState({highlightedRows});
|
||||
|
||||
if (this.props.onRowHighlighted) {
|
||||
this.props.onRowHighlighted(highlightedRows);
|
||||
}
|
||||
};
|
||||
|
||||
onSort = (sortOrder: TableRowSortOrder) => {
|
||||
this.setState({sortOrder});
|
||||
};
|
||||
|
||||
onColumnOrder = (columnOrder: TableColumnOrder) => {
|
||||
// $FlowFixMe
|
||||
this.setState({columnOrder});
|
||||
// persist column order
|
||||
window.localStorage.setItem(
|
||||
this.getTableKey(),
|
||||
JSON.stringify(columnOrder),
|
||||
);
|
||||
};
|
||||
|
||||
onColumnResize = (columnSizes: TableColumnSizes) => {
|
||||
this.setState({columnSizes});
|
||||
};
|
||||
|
||||
setRef = (table: ?Table) => {
|
||||
this.tableRef = table;
|
||||
};
|
||||
|
||||
scrollToBottom() {
|
||||
const {tableRef} = this;
|
||||
if (tableRef) {
|
||||
tableRef.scrollToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {props, state} = this;
|
||||
|
||||
return (
|
||||
<Table
|
||||
ref={this.setRef}
|
||||
virtual={props.virtual}
|
||||
floating={props.floating}
|
||||
multiline={props.multiline}
|
||||
columns={props.columns}
|
||||
rows={props.rows}
|
||||
rowLineHeight={props.rowLineHeight}
|
||||
autoHeight={props.autoHeight}
|
||||
filter={props.filter}
|
||||
filterValue={props.filterValue}
|
||||
highlightedRows={state.highlightedRows}
|
||||
onHighlight={this.onHighlight}
|
||||
sortOrder={state.sortOrder}
|
||||
onSort={this.onSort}
|
||||
columnOrder={state.columnOrder}
|
||||
onColumnOrder={this.onColumnOrder}
|
||||
columnSizes={state.columnSizes}
|
||||
onColumnResize={this.onColumnResize}
|
||||
stickyBottom={props.stickyBottom}
|
||||
onAddFilter={props.onAddFilter}
|
||||
zebra={props.zebra}
|
||||
hideHeader={props.hideHeader}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
606
src/ui/components/table/Table.js
Normal file
606
src/ui/components/table/Table.js
Normal file
@@ -0,0 +1,606 @@
|
||||
/**
|
||||
* 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 {
|
||||
TableColumnRawOrder,
|
||||
TableColumnKeys,
|
||||
TableColumnOrder,
|
||||
TableColumnSizes,
|
||||
TableColumns,
|
||||
TableHighlightedRows,
|
||||
TableOnColumnOrder,
|
||||
TableOnColumnResize,
|
||||
TableOnHighlight,
|
||||
TableOnSort,
|
||||
TableRowSortOrder,
|
||||
TableBodyRow,
|
||||
TableRows,
|
||||
TableOnAddFilter,
|
||||
} from './types.js';
|
||||
import {PureComponent} from 'react';
|
||||
import FlexColumn from '../FlexColumn.js';
|
||||
import TableHead from './TableHead.js';
|
||||
import TableBody from './TableBody.js';
|
||||
import FlexBox from '../FlexBox.js';
|
||||
import createPaste from '../../../utils/createPaste.js';
|
||||
import textContent from '../../../utils/textContent.js';
|
||||
import {clipboard} from 'electron';
|
||||
|
||||
const TableInner = FlexColumn.extends(
|
||||
{
|
||||
minWidth: props => props.minWidth || '0',
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
},
|
||||
{
|
||||
ignoreAttributes: ['minWidth'],
|
||||
},
|
||||
);
|
||||
|
||||
const TableOuter = FlexBox.extends(
|
||||
{
|
||||
width: '100%',
|
||||
backgroundColor: '#fff',
|
||||
border: props => (props.floating ? '1px solid #c9ced4' : 'none'),
|
||||
borderRadius: props => (props.floating ? 2 : 'none'),
|
||||
height: props => (props.autoHeight ? 'auto' : '100%'),
|
||||
overflow: props => (props.autoHeight ? 'visible' : 'auto'),
|
||||
},
|
||||
{
|
||||
ignoreAttributes: ['floating', 'autoHeight'],
|
||||
},
|
||||
);
|
||||
|
||||
function getColumnOrder(
|
||||
colOrder: ?TableColumnRawOrder,
|
||||
cols: TableColumns,
|
||||
): TableColumnOrder {
|
||||
// we have a specific column order, let's validate it
|
||||
if (colOrder) {
|
||||
const computedOrder = [];
|
||||
for (const obj of colOrder) {
|
||||
if (typeof obj === 'string') {
|
||||
computedOrder.push({key: obj, visible: true});
|
||||
} else {
|
||||
computedOrder.push(obj);
|
||||
}
|
||||
}
|
||||
return computedOrder;
|
||||
}
|
||||
|
||||
// produce a column order
|
||||
const keys = Object.keys(cols);
|
||||
const computedOrder = [];
|
||||
for (const key of keys) {
|
||||
computedOrder.push({key, visible: true});
|
||||
}
|
||||
return computedOrder;
|
||||
}
|
||||
|
||||
const sortedBodyCache: WeakMap<
|
||||
TableRows,
|
||||
{
|
||||
sortOrder: TableRowSortOrder,
|
||||
rows: TableRows,
|
||||
},
|
||||
> = new WeakMap();
|
||||
function getSortedRows(
|
||||
maybeSortOrder: ?TableRowSortOrder,
|
||||
rows: TableRows,
|
||||
): TableRows {
|
||||
if (!maybeSortOrder) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
const sortOrder: TableRowSortOrder = maybeSortOrder;
|
||||
|
||||
const cached = sortedBodyCache.get(rows);
|
||||
if (cached && cached.sortOrder === sortOrder) {
|
||||
return cached.rows;
|
||||
}
|
||||
|
||||
let sortedRows = rows.sort((a, b) => {
|
||||
const aVal = a.columns[sortOrder.key].sortValue;
|
||||
const bVal = b.columns[sortOrder.key].sortValue;
|
||||
|
||||
if (typeof aVal === 'string' && typeof bVal === 'string') {
|
||||
return aVal.localeCompare(bVal);
|
||||
} else if (typeof aVal === 'number' && typeof bVal === 'number') {
|
||||
return aVal - bVal;
|
||||
} else {
|
||||
throw new Error('Unsure how to sort this');
|
||||
}
|
||||
});
|
||||
|
||||
if (sortOrder.direction === 'up') {
|
||||
sortedRows = sortedRows.reverse();
|
||||
}
|
||||
|
||||
sortedBodyCache.set(rows, {
|
||||
rows: sortedRows,
|
||||
sortOrder,
|
||||
});
|
||||
|
||||
return sortedRows;
|
||||
}
|
||||
|
||||
const getRowsInRange = (
|
||||
from: string,
|
||||
to: string,
|
||||
rows: TableRows,
|
||||
): TableHighlightedRows => {
|
||||
let fromFound = false;
|
||||
let toFound = false;
|
||||
const range = [];
|
||||
if (from === to) {
|
||||
return [from];
|
||||
}
|
||||
for (const {key} of rows) {
|
||||
if (key === from) {
|
||||
fromFound = true;
|
||||
} else if (key === to) {
|
||||
toFound = true;
|
||||
}
|
||||
|
||||
if (fromFound && !toFound) {
|
||||
// range going downwards
|
||||
range.push(key);
|
||||
} else if (toFound && !fromFound) {
|
||||
// range going upwards
|
||||
range.unshift(key);
|
||||
} else if (fromFound && toFound) {
|
||||
// add last item
|
||||
if (key === from) {
|
||||
range.unshift(key);
|
||||
} else {
|
||||
range.push(key);
|
||||
}
|
||||
// we're done
|
||||
break;
|
||||
}
|
||||
}
|
||||
return range;
|
||||
};
|
||||
|
||||
const filterRows = (
|
||||
rows: TableRows,
|
||||
filterValue: ?string,
|
||||
filter: ?(row: TableBodyRow) => boolean,
|
||||
): TableRows => {
|
||||
// check that we don't have a filter
|
||||
const hasFilterValue = filterValue !== '' && filterValue != null;
|
||||
const hasFilter = hasFilterValue || typeof filter === 'function';
|
||||
if (!hasFilter) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
let filteredRows = [];
|
||||
|
||||
if (hasFilter) {
|
||||
for (const row of rows) {
|
||||
let keep = false;
|
||||
|
||||
// check if this row's filterValue contains the current filter
|
||||
if (filterValue != null && row.filterValue != null) {
|
||||
keep = row.filterValue.includes(filterValue);
|
||||
}
|
||||
|
||||
// call filter() prop
|
||||
if (keep === false && typeof filter === 'function') {
|
||||
keep = filter(row);
|
||||
}
|
||||
|
||||
if (keep) {
|
||||
filteredRows.push(row);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
filteredRows = rows;
|
||||
}
|
||||
|
||||
return filteredRows;
|
||||
};
|
||||
|
||||
type TableProps = {|
|
||||
/**
|
||||
* Column definitions.
|
||||
*/
|
||||
columns: TableColumns,
|
||||
/**
|
||||
* Row definitions.
|
||||
*/
|
||||
rows: TableRows,
|
||||
|
||||
/**
|
||||
* Minimum width of the table. If the table is sized smaller than this then
|
||||
* it's scrollable.
|
||||
*/
|
||||
minWidth?: number,
|
||||
|
||||
/**
|
||||
* Whether to use a virtual list. Items visible in the viewport are the only
|
||||
* included in the DOM. This can have a noticable performance improvement.
|
||||
*/
|
||||
virtual?: boolean,
|
||||
/**
|
||||
* Whether the table has a border.
|
||||
*/
|
||||
floating?: boolean,
|
||||
/**
|
||||
* Whether a row can span over multiple lines. Otherwise lines cannot wrap and
|
||||
* are truncated.
|
||||
*/
|
||||
multiline?: boolean,
|
||||
/**
|
||||
* Height of each row.
|
||||
*/
|
||||
rowLineHeight?: number,
|
||||
/**
|
||||
* Whether the body is scrollable. When this is set to `true` then the table
|
||||
* is not scrollable.
|
||||
*/
|
||||
autoHeight?: boolean,
|
||||
/**
|
||||
* Highlighted rows.
|
||||
*/
|
||||
highlightedRows?: ?TableHighlightedRows,
|
||||
/**
|
||||
* Callback when the highlighted rows change.
|
||||
*/
|
||||
onHighlight?: ?TableOnHighlight,
|
||||
/**
|
||||
* Enable or disable zebra striping
|
||||
*/
|
||||
zebra?: boolean,
|
||||
/**
|
||||
* Value to filter rows on. Alternative to the `filter` prop.
|
||||
*/
|
||||
filterValue?: string,
|
||||
/**
|
||||
* Callback to filter rows.
|
||||
*/
|
||||
filter?: (row: TableBodyRow) => boolean,
|
||||
|
||||
/**
|
||||
* Sort order.
|
||||
*/
|
||||
sortOrder?: ?TableRowSortOrder,
|
||||
/**
|
||||
* Callback when the sort order changes.
|
||||
*/
|
||||
onSort?: ?TableOnSort,
|
||||
|
||||
/**
|
||||
* Order of columns.
|
||||
*/
|
||||
columnOrder?: ?TableColumnRawOrder,
|
||||
/**
|
||||
* Callback when a column is reordered or visibility changed.
|
||||
*/
|
||||
onColumnOrder?: ?TableOnColumnOrder,
|
||||
|
||||
/**
|
||||
* Size of the columns.
|
||||
*/
|
||||
columnSizes?: ?TableColumnSizes,
|
||||
/**
|
||||
* Callback for when a column size changes.
|
||||
*/
|
||||
onColumnResize?: ?TableOnColumnResize,
|
||||
/**
|
||||
* This makes it so the scroll position sticks to the bottom of the window.
|
||||
* Useful for streaming data like requests, logs etc.
|
||||
*/
|
||||
stickyBottom?: ?boolean,
|
||||
/**
|
||||
* Used by SearchableTable to add filters for rows.
|
||||
*/
|
||||
onAddFilter?: TableOnAddFilter,
|
||||
/**
|
||||
* Whether to hide the column names at the top of the table.
|
||||
*/
|
||||
hideHeader?: boolean,
|
||||
|};
|
||||
|
||||
type TableState = {
|
||||
columnOrder: TableColumnOrder,
|
||||
columnSizes: TableColumnSizes,
|
||||
columnKeys: TableColumnKeys,
|
||||
sortedRows: TableRows,
|
||||
dragStartingKey?: ?string,
|
||||
};
|
||||
|
||||
const NO_COLUMN_SIZE: TableColumnSizes = {};
|
||||
|
||||
/**
|
||||
* A table component with all the native features you would expect.
|
||||
*
|
||||
* - Row sorting
|
||||
* - Row filtering
|
||||
* - Row highlight
|
||||
* - Row keyboard navigation
|
||||
* - Column reordering
|
||||
* - Column visibility
|
||||
*
|
||||
* This component is fairly low level. It's likely you're looking for
|
||||
* [`<ManagedTable>`]().
|
||||
*/
|
||||
export default class Table extends PureComponent<TableProps, TableState> {
|
||||
constructor(props: TableProps, context: Object) {
|
||||
super(props, context);
|
||||
this.state = this.deriveState(props);
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
floating: true,
|
||||
virtual: true,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
// listning to mouseUp event on document to catch events even when
|
||||
// the cursor moved outside the table while dragging
|
||||
document.addEventListener('mouseup', this.onMouseUp);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener('mouseup', this.onMouseUp);
|
||||
}
|
||||
|
||||
deriveState(props: TableProps): TableState {
|
||||
const columnSizes: TableColumnSizes = props.columnSizes || NO_COLUMN_SIZE;
|
||||
const columnOrder: TableColumnOrder = getColumnOrder(
|
||||
props.columnOrder,
|
||||
props.columns,
|
||||
);
|
||||
|
||||
let columnKeys;
|
||||
if (this.state && this.state.columnOrder === columnOrder) {
|
||||
columnKeys = this.state.columnKeys;
|
||||
} else {
|
||||
columnKeys = [];
|
||||
for (const {key, visible} of columnOrder) {
|
||||
if (visible) {
|
||||
columnKeys.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let sortedRows = [];
|
||||
if (
|
||||
!this.state ||
|
||||
this.props.filter !== props.filter ||
|
||||
this.props.filterValue !== props.filterValue ||
|
||||
this.props.sortOrder !== props.sortOrder ||
|
||||
this.props.rows !== props.rows
|
||||
) {
|
||||
// need to reorder or refilter the rows
|
||||
sortedRows = getSortedRows(
|
||||
props.sortOrder,
|
||||
filterRows(props.rows, props.filterValue, props.filter),
|
||||
);
|
||||
} else {
|
||||
sortedRows = this.state.sortedRows;
|
||||
}
|
||||
|
||||
return {
|
||||
columnKeys,
|
||||
columnOrder,
|
||||
columnSizes,
|
||||
sortedRows,
|
||||
};
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps: TableProps) {
|
||||
this.setState(this.deriveState(nextProps));
|
||||
}
|
||||
|
||||
onMouseUp = () => this.setState({dragStartingKey: null});
|
||||
|
||||
onKeyDown = (e: SyntheticKeyboardEvent<HTMLElement>) => {
|
||||
const {onHighlight, highlightedRows} = this.props;
|
||||
const {sortedRows} = this.state;
|
||||
const currentlyHighlightedRows = highlightedRows || [];
|
||||
let selectedRow: ?string;
|
||||
|
||||
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (currentlyHighlightedRows.length === 0) {
|
||||
// no selection yet
|
||||
const index = e.key === 'ArrowUp' ? sortedRows.length - 1 : 0;
|
||||
selectedRow = sortedRows[index].key;
|
||||
} else {
|
||||
// determine sibling row to select
|
||||
const prevRowFinder = (row, index) =>
|
||||
index < sortedRows.length - 1
|
||||
? sortedRows[index + 1].key ===
|
||||
currentlyHighlightedRows[currentlyHighlightedRows.length - 1]
|
||||
: false;
|
||||
|
||||
const nextRowFinder = (row, index) =>
|
||||
index > 0
|
||||
? sortedRows[index - 1].key ===
|
||||
currentlyHighlightedRows[currentlyHighlightedRows.length - 1]
|
||||
: false;
|
||||
|
||||
const siblingRow = sortedRows.find(
|
||||
e.key === 'ArrowUp' ? prevRowFinder : nextRowFinder,
|
||||
);
|
||||
if (siblingRow) {
|
||||
selectedRow = siblingRow.key;
|
||||
}
|
||||
}
|
||||
|
||||
if (onHighlight && selectedRow != null) {
|
||||
// scroll into view
|
||||
const index = sortedRows.findIndex(row => row.key === selectedRow);
|
||||
if (this.tableBodyRef && index) {
|
||||
this.tableBodyRef.scrollRowIntoView(index);
|
||||
}
|
||||
|
||||
if (e.shiftKey) {
|
||||
onHighlight(
|
||||
currentlyHighlightedRows
|
||||
.filter(row => selectedRow !== row)
|
||||
.concat([selectedRow]),
|
||||
e,
|
||||
);
|
||||
} else {
|
||||
onHighlight([selectedRow], e);
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
highlightedRows &&
|
||||
e.key === 'c' &&
|
||||
((e.metaKey && process.platform === 'darwin') ||
|
||||
(e.ctrlKey && process.platform !== 'darwin'))
|
||||
) {
|
||||
e.preventDefault();
|
||||
this.onCopyRows();
|
||||
}
|
||||
};
|
||||
|
||||
getRowText = (): string => {
|
||||
const {highlightedRows} = this.props;
|
||||
const {sortedRows} = this.state;
|
||||
const visibleColums = this.state.columnOrder
|
||||
.filter(({visible}) => visible)
|
||||
.map(({key}) => key);
|
||||
|
||||
const rows =
|
||||
!highlightedRows || highlightedRows.length === 0
|
||||
? sortedRows
|
||||
: sortedRows.filter(row => highlightedRows.indexOf(row.key) > -1);
|
||||
|
||||
return rows
|
||||
.map(
|
||||
row =>
|
||||
row.copyText != null
|
||||
? row.copyText
|
||||
: visibleColums
|
||||
.map(col => textContent(row.columns[col].value))
|
||||
.filter(Boolean)
|
||||
.join('\t'),
|
||||
)
|
||||
.join('\n');
|
||||
};
|
||||
|
||||
onCopyRows = () => {
|
||||
clipboard.writeText(this.getRowText());
|
||||
};
|
||||
|
||||
onCreatePaste = () => {
|
||||
createPaste(this.getRowText());
|
||||
};
|
||||
|
||||
onHighlight = (
|
||||
newHighlightedRows: TableHighlightedRows,
|
||||
e: SyntheticKeyboardEvent<*>,
|
||||
) => {
|
||||
const {onHighlight, highlightedRows} = this.props;
|
||||
if (!onHighlight) {
|
||||
return;
|
||||
}
|
||||
if (e.shiftKey === true && highlightedRows && highlightedRows.length > 0) {
|
||||
const from = highlightedRows[highlightedRows.length - 1];
|
||||
const to = newHighlightedRows[0];
|
||||
const range = getRowsInRange(from, to, this.state.sortedRows);
|
||||
newHighlightedRows = highlightedRows
|
||||
.filter(key => range.indexOf(key) === -1)
|
||||
.concat(range);
|
||||
} else {
|
||||
this.setState({dragStartingKey: newHighlightedRows[0]});
|
||||
}
|
||||
|
||||
onHighlight(newHighlightedRows, e);
|
||||
};
|
||||
|
||||
onDragSelect = (e: SyntheticMouseEvent<>, key: string, index: number) => {
|
||||
const {dragStartingKey, sortedRows} = this.state;
|
||||
const {onHighlight} = this.props;
|
||||
if (dragStartingKey != null && onHighlight != null) {
|
||||
const range = getRowsInRange(dragStartingKey, key, this.state.sortedRows);
|
||||
if (this.tableBodyRef) {
|
||||
const startIndex = sortedRows.findIndex(
|
||||
row => row.key === dragStartingKey,
|
||||
);
|
||||
const nextIndex = startIndex < index ? index + 1 : index - 1;
|
||||
// only scroll one row every 100ms to not scroll to the end of the table immediatelly
|
||||
setTimeout(
|
||||
() =>
|
||||
this.tableBodyRef && this.tableBodyRef.scrollRowIntoView(nextIndex),
|
||||
100,
|
||||
);
|
||||
}
|
||||
onHighlight(range, e);
|
||||
}
|
||||
};
|
||||
|
||||
scrollToBottom() {
|
||||
const {tableBodyRef} = this;
|
||||
if (tableBodyRef) {
|
||||
tableBodyRef.scrollToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
tableBodyRef: ?TableBody;
|
||||
|
||||
setTableBodyRef = (ref: ?TableBody) => {
|
||||
this.tableBodyRef = ref;
|
||||
};
|
||||
|
||||
render() {
|
||||
const {props, state} = this;
|
||||
|
||||
const tableHead =
|
||||
props.hideHeader === true ? null : (
|
||||
<TableHead
|
||||
columnOrder={state.columnOrder}
|
||||
onColumnOrder={props.onColumnOrder}
|
||||
columnKeys={state.columnKeys}
|
||||
columns={props.columns}
|
||||
sortOrder={props.sortOrder}
|
||||
onSort={props.onSort}
|
||||
columnSizes={state.columnSizes}
|
||||
onColumnResize={props.onColumnResize}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<TableOuter
|
||||
floating={props.floating}
|
||||
autoHeight={props.autoHeight}
|
||||
onKeyDown={this.onKeyDown}
|
||||
tabIndex={0}>
|
||||
<TableInner minWidth={props.minWidth}>
|
||||
{tableHead}
|
||||
|
||||
<TableBody
|
||||
ref={this.setTableBodyRef}
|
||||
virtual={props.virtual}
|
||||
filter={props.filter}
|
||||
filterValue={props.filterValue}
|
||||
autoHeight={props.autoHeight}
|
||||
rowLineHeight={props.rowLineHeight}
|
||||
multiline={props.multiline}
|
||||
onHighlight={this.onHighlight}
|
||||
highlightedRows={props.highlightedRows}
|
||||
columnKeys={state.columnKeys}
|
||||
rows={state.sortedRows}
|
||||
columnSizes={state.columnSizes}
|
||||
stickyBottom={props.stickyBottom}
|
||||
isDragging={Boolean(state.dragStartingKey)}
|
||||
zebra={props.zebra}
|
||||
onDragSelect={this.onDragSelect}
|
||||
onCopyRows={this.onCopyRows}
|
||||
onCreatePaste={this.onCreatePaste}
|
||||
onAddFilter={props.onAddFilter}
|
||||
/>
|
||||
</TableInner>
|
||||
</TableOuter>
|
||||
);
|
||||
}
|
||||
}
|
||||
559
src/ui/components/table/TableBody.js
Normal file
559
src/ui/components/table/TableBody.js
Normal file
@@ -0,0 +1,559 @@
|
||||
/**
|
||||
* 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 {
|
||||
TableBodyRow,
|
||||
TableColumnKeys,
|
||||
TableColumnSizes,
|
||||
TableHighlightedRows,
|
||||
TableOnDragSelect,
|
||||
TableOnHighlight,
|
||||
TableRows,
|
||||
TableOnAddFilter,
|
||||
} from './types.js';
|
||||
import {FixedList, DynamicList} from '../../../ui/virtualized/index.js';
|
||||
import {normaliseColumnWidth} from './utils.js';
|
||||
import {PureComponent} from 'react';
|
||||
|
||||
import FilterRow from '../filter/FilterRow.js';
|
||||
import {DEFAULT_ROW_HEIGHT} from './types.js';
|
||||
import styled from '../../styled/index.js';
|
||||
import FlexColumn from '../FlexColumn.js';
|
||||
import {ContextMenu} from 'sonar';
|
||||
|
||||
import FlexRow from '../FlexRow.js';
|
||||
import {colors} from '../colors.js';
|
||||
|
||||
const TableBodyContainer = FlexColumn.extends(
|
||||
{
|
||||
backgroundColor: colors.white,
|
||||
zIndex: 1,
|
||||
flexGrow: props => (props.autoHeight ? 0 : 1),
|
||||
flexShrink: props => (props.autoHeight ? 0 : 1),
|
||||
flexBasis: props => (props.autoHeight ? 'content' : 0),
|
||||
overflow: props => (props.autoHeight ? 'hidden' : 'auto'),
|
||||
maxWidth: '100%',
|
||||
},
|
||||
{
|
||||
ignoreAttributes: ['autoHeight'],
|
||||
},
|
||||
);
|
||||
|
||||
const TableBodyRowContainer = FlexRow.extends(
|
||||
{
|
||||
backgroundColor: props => {
|
||||
if (props.highlighted) {
|
||||
if (props.highlightedBackgroundColor) {
|
||||
return props.highlightedBackgroundColor;
|
||||
} else {
|
||||
return colors.macOSTitleBarIconSelected;
|
||||
}
|
||||
} else {
|
||||
if (props.backgroundColor) {
|
||||
return props.backgroundColor;
|
||||
} else if (props.even && props.zebra) {
|
||||
return colors.light02;
|
||||
} else {
|
||||
return 'transparent';
|
||||
}
|
||||
}
|
||||
},
|
||||
boxShadow: props => {
|
||||
if (props.backgroundColor || props.zebra === false) {
|
||||
return 'inset 0 -1px #E9EBEE';
|
||||
} else {
|
||||
return 'none';
|
||||
}
|
||||
},
|
||||
color: props =>
|
||||
props.highlighted ? colors.white : props.color || 'inherit',
|
||||
'& *': {
|
||||
color: props => (props.highlighted ? `${colors.white} !important` : null),
|
||||
},
|
||||
'& img': {
|
||||
backgroundColor: props =>
|
||||
props.highlighted ? `${colors.white} !important` : 'none',
|
||||
},
|
||||
height: props => (props.multiline ? 'auto' : props.rowLineHeight),
|
||||
lineHeight: props => `${String(props.rowLineHeight)}px`,
|
||||
fontWeight: props => props.fontWeight || 'inherit',
|
||||
overflow: 'hidden',
|
||||
width: '100%',
|
||||
userSelect: 'none',
|
||||
flexShrink: 0,
|
||||
'&:hover': {
|
||||
backgroundColor: props =>
|
||||
!props.highlighted && props.highlightOnHover ? colors.light02 : 'none',
|
||||
},
|
||||
},
|
||||
{
|
||||
ignoreAttributes: [
|
||||
'highlightedBackgroundColor',
|
||||
'highlightOnHover',
|
||||
'backgroundColor',
|
||||
'rowLineHeight',
|
||||
'highlighted',
|
||||
'multiline',
|
||||
'hasHover',
|
||||
'zebra',
|
||||
'even',
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
const TableBodyColumnContainer = styled.view(
|
||||
{
|
||||
display: 'flex',
|
||||
flexShrink: props => (props.width === 'flex' ? 1 : 0),
|
||||
overflow: 'hidden',
|
||||
padding: '0 8px',
|
||||
userSelect: 'none',
|
||||
textOverflow: 'ellipsis',
|
||||
verticalAlign: 'top',
|
||||
whiteSpace: props => (props.multiline ? 'normal' : 'nowrap'),
|
||||
wordWrap: props => (props.multiline ? 'break-word' : 'normal'),
|
||||
width: props => (props.width === 'flex' ? '100%' : props.width),
|
||||
maxWidth: '100%',
|
||||
},
|
||||
{
|
||||
ignoreAttributes: ['multiline', 'width'],
|
||||
},
|
||||
);
|
||||
|
||||
type TableBodyRowElementProps = {
|
||||
columnSizes: TableColumnSizes,
|
||||
columnKeys: TableColumnKeys,
|
||||
onHighlight: ?TableOnHighlight,
|
||||
onMouseEnter?: (e: SyntheticMouseEvent<>) => void,
|
||||
multiline: ?boolean,
|
||||
rowLineHeight: number,
|
||||
highlightedRows: ?TableHighlightedRows,
|
||||
row: TableBodyRow,
|
||||
columnNo: number,
|
||||
style: ?Object,
|
||||
onCopyRows: () => void,
|
||||
onCreatePaste: () => void,
|
||||
onAddFilter?: TableOnAddFilter,
|
||||
zebra: ?boolean,
|
||||
};
|
||||
|
||||
type TableBodyRowElementState = {
|
||||
contextMenu: any,
|
||||
};
|
||||
|
||||
class TableBodyRowElement extends PureComponent<
|
||||
TableBodyRowElementProps,
|
||||
TableBodyRowElementState,
|
||||
> {
|
||||
static defaultProps = {
|
||||
zebra: true,
|
||||
};
|
||||
|
||||
onMouseDown = (e: SyntheticMouseEvent<>) => {
|
||||
if (e.button !== 0) {
|
||||
// Only highlight rows when using primary mouse button,
|
||||
// otherwise do nothing, to not interfere context menus.
|
||||
return;
|
||||
}
|
||||
if (e.shiftKey) {
|
||||
// prevents text selection
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
const {highlightedRows, onHighlight, row} = this.props;
|
||||
if (!onHighlight) {
|
||||
return;
|
||||
}
|
||||
|
||||
let newHighlightedRows = highlightedRows ? highlightedRows.slice() : [];
|
||||
const alreadyHighlighted = newHighlightedRows.includes(row.key);
|
||||
if (
|
||||
(e.metaKey && process.platform === 'darwin') ||
|
||||
(e.ctrlKey && process.platform !== 'darwin')
|
||||
) {
|
||||
if (alreadyHighlighted) {
|
||||
newHighlightedRows.splice(newHighlightedRows.indexOf(row.key), 1);
|
||||
} else {
|
||||
newHighlightedRows.push(row.key);
|
||||
}
|
||||
} else {
|
||||
newHighlightedRows = [row.key];
|
||||
}
|
||||
onHighlight(newHighlightedRows, e);
|
||||
};
|
||||
|
||||
getContextMenu = () => {
|
||||
const {highlightedRows, onCopyRows, onCreatePaste} = this.props;
|
||||
return [
|
||||
{
|
||||
label:
|
||||
highlightedRows && highlightedRows.length > 1
|
||||
? `Copy ${highlightedRows.length} items`
|
||||
: 'Copy all',
|
||||
click: onCopyRows,
|
||||
},
|
||||
{
|
||||
label:
|
||||
highlightedRows && highlightedRows.length > 1
|
||||
? `Create paste from selection`
|
||||
: 'Create paste',
|
||||
click: onCreatePaste,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
columnNo,
|
||||
highlightedRows,
|
||||
rowLineHeight,
|
||||
row,
|
||||
style,
|
||||
multiline,
|
||||
columnKeys,
|
||||
columnSizes,
|
||||
onMouseEnter,
|
||||
zebra,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<ContextMenu buildItems={this.getContextMenu}>
|
||||
<TableBodyRowContainer
|
||||
rowLineHeight={rowLineHeight}
|
||||
highlightedBackgroundColor={row.highlightedBackgroundColor}
|
||||
backgroundColor={row.backgroundColor}
|
||||
highlighted={highlightedRows && highlightedRows.includes(row.key)}
|
||||
onDoubleClick={row.onDoubleClick}
|
||||
multiline={multiline}
|
||||
even={columnNo % 2 === 0}
|
||||
zebra={zebra}
|
||||
onMouseDown={this.onMouseDown}
|
||||
onMouseEnter={onMouseEnter}
|
||||
style={style}
|
||||
highlightOnHover={row.highlightOnHover}
|
||||
data-key={row.key}
|
||||
{...row.style}>
|
||||
{columnKeys.map(key => {
|
||||
const col = row.columns[key];
|
||||
if (col == null) {
|
||||
throw new Error(
|
||||
`Trying to access column "${key}" which does not exist on row. Make sure buildRow is returning a valid row.`,
|
||||
);
|
||||
}
|
||||
const isFilterable = col.isFilterable || false;
|
||||
const value = col ? col.value : '';
|
||||
const title = col ? col.title : '';
|
||||
return (
|
||||
<TableBodyColumnContainer
|
||||
key={key}
|
||||
title={title}
|
||||
multiline={multiline}
|
||||
width={normaliseColumnWidth(columnSizes[key])}>
|
||||
{isFilterable && this.props.onAddFilter != null ? (
|
||||
<FilterRow addFilter={this.props.onAddFilter} filterKey={key}>
|
||||
{value}
|
||||
</FilterRow>
|
||||
) : (
|
||||
value
|
||||
)}
|
||||
</TableBodyColumnContainer>
|
||||
);
|
||||
})}
|
||||
</TableBodyRowContainer>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type TableBodyProps = {
|
||||
virtual: ?boolean,
|
||||
autoHeight: ?boolean,
|
||||
multiline: ?boolean,
|
||||
rowLineHeight: number,
|
||||
stickyBottom: ?boolean,
|
||||
zebra?: boolean,
|
||||
|
||||
onHighlight: ?TableOnHighlight,
|
||||
highlightedRows: ?TableHighlightedRows,
|
||||
|
||||
columnKeys: TableColumnKeys,
|
||||
columnSizes: TableColumnSizes,
|
||||
|
||||
rows: TableRows,
|
||||
|
||||
filterValue?: string,
|
||||
filter?: (row: TableBodyRow) => boolean,
|
||||
|
||||
isDragging: boolean,
|
||||
onDragSelect: TableOnDragSelect,
|
||||
onCopyRows: () => void,
|
||||
onCreatePaste: () => void,
|
||||
onAddFilter?: TableOnAddFilter,
|
||||
};
|
||||
|
||||
type TableBodyState = {
|
||||
atScrollBottom: boolean,
|
||||
pureBodyData: Array<any>,
|
||||
};
|
||||
|
||||
export default class TableBody extends PureComponent<
|
||||
TableBodyProps,
|
||||
TableBodyState,
|
||||
> {
|
||||
static defaultProps = {
|
||||
rowLineHeight: DEFAULT_ROW_HEIGHT,
|
||||
};
|
||||
|
||||
state = {
|
||||
atScrollBottom: true,
|
||||
pureBodyData: [
|
||||
this.props.columnSizes,
|
||||
this.props.rows,
|
||||
this.props.highlightedRows,
|
||||
],
|
||||
};
|
||||
|
||||
listRef: ?DynamicList;
|
||||
scrollRef: ?any;
|
||||
keepSelectedRowInView: ?[number, number];
|
||||
|
||||
buildElement = (
|
||||
key: string,
|
||||
row: TableBodyRow,
|
||||
index: number,
|
||||
style?: Object,
|
||||
) => {
|
||||
let onMouseEnter;
|
||||
if (this.props.isDragging) {
|
||||
onMouseEnter = (e: SyntheticMouseEvent<>) =>
|
||||
this.props.onDragSelect(e, key, index);
|
||||
}
|
||||
return (
|
||||
<TableBodyRowElement
|
||||
key={key}
|
||||
columnNo={index}
|
||||
rowLineHeight={this.props.rowLineHeight}
|
||||
row={row}
|
||||
style={style}
|
||||
columnSizes={this.props.columnSizes}
|
||||
multiline={this.props.multiline}
|
||||
columnKeys={this.props.columnKeys}
|
||||
highlightedRows={this.props.highlightedRows}
|
||||
zebra={this.props.zebra}
|
||||
onHighlight={this.props.onHighlight}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onCopyRows={this.props.onCopyRows}
|
||||
onCreatePaste={this.props.onCreatePaste}
|
||||
onAddFilter={this.props.onAddFilter}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
buildVirtualElement = ({index, style}: {index: number, style: Object}) => {
|
||||
const row = this.props.rows[index];
|
||||
return this.buildElement(row.key, row, index, style);
|
||||
};
|
||||
|
||||
buildAutoElement = (row: TableBodyRow, index: number) => {
|
||||
return this.buildElement(row.key, row, index);
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.maybeScrollToBottom();
|
||||
}
|
||||
|
||||
componentWillUpdate(nextProps: TableBodyProps) {
|
||||
if (
|
||||
nextProps.highlightedRows != null &&
|
||||
nextProps.highlightedRows.length === 1 &&
|
||||
nextProps.filter !== this.props.filter &&
|
||||
nextProps.rows.length !== this.props.rows.length &&
|
||||
this.listRef != null
|
||||
) {
|
||||
// We want to keep the selected row in the view once the filter changes.
|
||||
// Here we get the current position, in componentDidUpdate it is scrolled into view
|
||||
const {highlightedRows} = nextProps;
|
||||
const selectedIndex = nextProps.rows.findIndex(
|
||||
row => row.key === highlightedRows[0],
|
||||
);
|
||||
if (
|
||||
nextProps.rows[selectedIndex] != null &&
|
||||
nextProps.rows[selectedIndex].key != null
|
||||
) {
|
||||
const rowDOMNode = document.querySelector(
|
||||
`[data-key="${nextProps.rows[selectedIndex].key}"]`,
|
||||
);
|
||||
let offset = 0;
|
||||
if (
|
||||
rowDOMNode != null &&
|
||||
rowDOMNode.parentElement instanceof HTMLElement
|
||||
) {
|
||||
offset = rowDOMNode.parentElement.offsetTop;
|
||||
}
|
||||
this.keepSelectedRowInView = [selectedIndex, offset];
|
||||
}
|
||||
} else {
|
||||
this.keepSelectedRowInView = null;
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: TableBodyProps) {
|
||||
if (this.listRef != null && this.keepSelectedRowInView != null) {
|
||||
this.listRef.scrollToIndex(...this.keepSelectedRowInView);
|
||||
} else {
|
||||
this.maybeScrollToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
maybeScrollToBottom = () => {
|
||||
// we only care if we have the stickyBottom prop
|
||||
if (this.props.stickyBottom !== true) {
|
||||
return;
|
||||
}
|
||||
|
||||
// we only want to scroll to the bottom if we're actually at the bottom
|
||||
if (this.state.atScrollBottom === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.scrollToBottom();
|
||||
};
|
||||
|
||||
scrollToBottom() {
|
||||
// only handle non-virtualised scrolling, virtualised scrolling is handled
|
||||
// by the getScrollToIndex method
|
||||
if (this.isVirtualisedDisabled()) {
|
||||
const {scrollRef} = this;
|
||||
if (scrollRef != null) {
|
||||
scrollRef.scrollTop = scrollRef.scrollHeight;
|
||||
}
|
||||
} else {
|
||||
const {listRef} = this;
|
||||
if (listRef != null) {
|
||||
listRef.scrollToIndex(this.props.rows.length - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scrollRowIntoView(index: number) {
|
||||
if (
|
||||
this.isVirtualisedDisabled() &&
|
||||
this.scrollRef &&
|
||||
index < this.scrollRef.children.length
|
||||
) {
|
||||
this.scrollRef.children[index].scrollIntoViewIfNeeded();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps: TableBodyProps) {
|
||||
if (
|
||||
nextProps.columnSizes !== this.props.columnSizes ||
|
||||
nextProps.rows !== this.props.rows ||
|
||||
nextProps.highlightedRows !== this.props.highlightedRows
|
||||
) {
|
||||
this.setState({
|
||||
pureBodyData: [
|
||||
nextProps.columnSizes,
|
||||
nextProps.rows,
|
||||
nextProps.highlightedRows,
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setListRef = (ref: ?DynamicList) => {
|
||||
this.listRef = ref;
|
||||
};
|
||||
|
||||
setNonVirtualScrollRef = (ref: any) => {
|
||||
this.scrollRef = ref;
|
||||
this.scrollToBottom();
|
||||
};
|
||||
|
||||
onScroll = ({
|
||||
clientHeight,
|
||||
scrollHeight,
|
||||
scrollTop,
|
||||
}: {
|
||||
clientHeight: number,
|
||||
scrollHeight: number,
|
||||
scrollTop: number,
|
||||
}) => {
|
||||
// check if the user has scrolled within 20px of the bottom
|
||||
const bottom = scrollTop + clientHeight;
|
||||
const atScrollBottom = Math.abs(bottom - scrollHeight) < 20;
|
||||
|
||||
if (atScrollBottom !== this.state.atScrollBottom) {
|
||||
this.setState({atScrollBottom});
|
||||
}
|
||||
};
|
||||
|
||||
isVirtualisedDisabled() {
|
||||
return this.props.virtual === false || this.props.autoHeight === true;
|
||||
}
|
||||
|
||||
keyMapper = (index: number): string => {
|
||||
return this.props.rows[index].key;
|
||||
};
|
||||
|
||||
getPrecalculatedDimensions = (index: number) => {
|
||||
const row = this.props.rows[index];
|
||||
if (row != null && row.height != null) {
|
||||
return {
|
||||
height: row.height,
|
||||
width: '100%',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.isVirtualisedDisabled()) {
|
||||
return (
|
||||
<TableBodyContainer
|
||||
innerRef={this.setNonVirtualScrollRef}
|
||||
onScroll={this.onScroll}
|
||||
autoHeight={true}>
|
||||
{this.props.rows.map(this.buildAutoElement)}
|
||||
</TableBodyContainer>
|
||||
);
|
||||
}
|
||||
|
||||
let children;
|
||||
|
||||
if (this.props.multiline === true) {
|
||||
// multiline has a virtual list with dynamic heights
|
||||
children = (
|
||||
<DynamicList
|
||||
ref={this.setListRef}
|
||||
pureData={this.state.pureBodyData}
|
||||
keyMapper={this.keyMapper}
|
||||
rowCount={this.props.rows.length}
|
||||
rowRenderer={this.buildVirtualElement}
|
||||
onScroll={this.onScroll}
|
||||
getPrecalculatedDimensions={this.getPrecalculatedDimensions}
|
||||
onMount={this.maybeScrollToBottom}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
// virtual list with a fixed row height
|
||||
children = (
|
||||
<FixedList
|
||||
pureData={this.state.pureBodyData}
|
||||
keyMapper={this.keyMapper}
|
||||
rowCount={this.props.rows.length}
|
||||
rowHeight={this.props.rowLineHeight}
|
||||
rowRenderer={this.buildVirtualElement}
|
||||
onScroll={this.onScroll}
|
||||
innerRef={this.setListRef}
|
||||
onMount={this.maybeScrollToBottom}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <TableBodyContainer>{children}</TableBodyContainer>;
|
||||
}
|
||||
}
|
||||
305
src/ui/components/table/TableHead.js
Normal file
305
src/ui/components/table/TableHead.js
Normal file
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* 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 {
|
||||
TableColumnKeys,
|
||||
TableColumnOrder,
|
||||
TableColumnSizes,
|
||||
TableColumns,
|
||||
TableOnColumnResize,
|
||||
TableOnSort,
|
||||
TableRowSortOrder,
|
||||
} from './types.js';
|
||||
|
||||
import {normaliseColumnWidth, isPercentage} from './utils.js';
|
||||
import {PureComponent} from 'react';
|
||||
import ContextMenu from '../ContextMenu.js';
|
||||
import Interactive from '../Interactive.js';
|
||||
import styled from '../../styled/index.js';
|
||||
import {colors} from '../colors.js';
|
||||
|
||||
import FlexRow from '../FlexRow.js';
|
||||
|
||||
const invariant = require('invariant');
|
||||
|
||||
const TableHeaderArrow = styled.text({
|
||||
float: 'right',
|
||||
});
|
||||
|
||||
const TableHeaderColumnInteractive = Interactive.extends({
|
||||
display: 'inline-block',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
const TableHeaderColumnContainer = styled.view({
|
||||
padding: '0 8px',
|
||||
});
|
||||
|
||||
const TableHeadContainer = FlexRow.extends({
|
||||
borderBottom: `1px solid ${colors.sectionHeaderBorder}`,
|
||||
color: colors.light50,
|
||||
flexShrink: 0,
|
||||
left: 0,
|
||||
overflow: 'hidden',
|
||||
position: 'sticky',
|
||||
right: 0,
|
||||
textAlign: 'left',
|
||||
top: 0,
|
||||
zIndex: 2,
|
||||
});
|
||||
|
||||
const TableHeadColumnContainer = styled.view(
|
||||
{
|
||||
position: 'relative',
|
||||
backgroundColor: colors.white,
|
||||
flexShrink: props => (props.width === 'flex' ? 1 : 0),
|
||||
height: 23,
|
||||
lineHeight: '23px',
|
||||
fontSize: '0.85em',
|
||||
fontWeight: 500,
|
||||
width: props => (props.width === 'flex' ? '100%' : props.width),
|
||||
'&::after': {
|
||||
position: 'absolute',
|
||||
content: '""',
|
||||
right: 0,
|
||||
top: 5,
|
||||
height: 13,
|
||||
width: 1,
|
||||
background: colors.light15,
|
||||
},
|
||||
'&:last-child::after': {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
{
|
||||
ignoreAttributes: ['width'],
|
||||
},
|
||||
);
|
||||
|
||||
const RIGHT_RESIZABLE = {right: true};
|
||||
|
||||
function calculatePercentage(parentWidth: number, selfWidth: number): string {
|
||||
return `${100 / parentWidth * selfWidth}%`;
|
||||
}
|
||||
|
||||
class TableHeadColumn extends PureComponent<{
|
||||
id: string,
|
||||
width: string | number,
|
||||
sortable: ?boolean,
|
||||
isResizable: boolean,
|
||||
leftHasResizer: boolean,
|
||||
hasFlex: boolean,
|
||||
sortOrder: ?TableRowSortOrder,
|
||||
onSort: ?TableOnSort,
|
||||
columnSizes: TableColumnSizes,
|
||||
onColumnResize: ?TableOnColumnResize,
|
||||
children?: React$Node,
|
||||
title?: string,
|
||||
}> {
|
||||
ref: HTMLElement;
|
||||
|
||||
onClick = () => {
|
||||
const {id, onSort, sortOrder} = this.props;
|
||||
|
||||
const direction =
|
||||
sortOrder && sortOrder.key === id && sortOrder.direction === 'down'
|
||||
? 'up'
|
||||
: 'down';
|
||||
|
||||
if (onSort) {
|
||||
onSort({
|
||||
direction,
|
||||
key: id,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
onResize = (newWidth: number) => {
|
||||
const {id, columnSizes, onColumnResize, width} = this.props;
|
||||
if (!onColumnResize) {
|
||||
return;
|
||||
}
|
||||
|
||||
let normalizedWidth = newWidth;
|
||||
|
||||
// normalise number to a percentage if we were originally passed a percentage
|
||||
if (isPercentage(width)) {
|
||||
const {parentElement} = this.ref;
|
||||
invariant(parentElement, 'expected there to be parentElement');
|
||||
|
||||
const parentWidth = parentElement.clientWidth;
|
||||
const {childNodes} = parentElement;
|
||||
|
||||
const lastElem = childNodes[childNodes.length - 1];
|
||||
const right =
|
||||
lastElem instanceof HTMLElement
|
||||
? lastElem.offsetLeft + lastElem.clientWidth + 1
|
||||
: 0;
|
||||
|
||||
if (right < parentWidth) {
|
||||
normalizedWidth = calculatePercentage(parentWidth, newWidth);
|
||||
}
|
||||
}
|
||||
|
||||
onColumnResize({
|
||||
...columnSizes,
|
||||
[id]: normalizedWidth,
|
||||
});
|
||||
};
|
||||
|
||||
setRef = (ref: HTMLElement) => {
|
||||
this.ref = ref;
|
||||
};
|
||||
|
||||
render() {
|
||||
const {isResizable, sortable, width, title} = this.props;
|
||||
let {children} = this.props;
|
||||
children = (
|
||||
<TableHeaderColumnContainer>{children}</TableHeaderColumnContainer>
|
||||
);
|
||||
|
||||
if (isResizable) {
|
||||
children = (
|
||||
<TableHeaderColumnInteractive
|
||||
fill={true}
|
||||
resizable={RIGHT_RESIZABLE}
|
||||
onResize={this.onResize}>
|
||||
{children}
|
||||
</TableHeaderColumnInteractive>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TableHeadColumnContainer
|
||||
width={width}
|
||||
title={title}
|
||||
onClick={sortable === true ? this.onClick : undefined}
|
||||
innerRef={this.setRef}>
|
||||
{children}
|
||||
</TableHeadColumnContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default class TableHead extends PureComponent<{
|
||||
columnOrder: TableColumnOrder,
|
||||
onColumnOrder: ?(order: TableColumnOrder) => void,
|
||||
columnKeys: TableColumnKeys,
|
||||
columns: TableColumns,
|
||||
sortOrder: ?TableRowSortOrder,
|
||||
onSort: ?TableOnSort,
|
||||
columnSizes: TableColumnSizes,
|
||||
onColumnResize: ?TableOnColumnResize,
|
||||
}> {
|
||||
buildContextMenu = () => {
|
||||
return Object.keys(this.props.columns).map(key => {
|
||||
const visible = this.props.columnKeys.includes(key);
|
||||
return {
|
||||
label: this.props.columns[key].value,
|
||||
click: () => {
|
||||
const {onColumnOrder, columnOrder} = this.props;
|
||||
if (onColumnOrder) {
|
||||
const newOrder = columnOrder.slice();
|
||||
let hasVisibleItem = false;
|
||||
for (let i = 0; i < newOrder.length; i++) {
|
||||
const info = newOrder[i];
|
||||
if (info.key === key) {
|
||||
newOrder[i] = {key, visible: !visible};
|
||||
}
|
||||
hasVisibleItem = hasVisibleItem || newOrder[i].visible;
|
||||
}
|
||||
|
||||
// Dont allow hiding all columns
|
||||
if (hasVisibleItem) {
|
||||
onColumnOrder(newOrder);
|
||||
}
|
||||
}
|
||||
},
|
||||
type: 'checkbox',
|
||||
checked: visible,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
columnOrder,
|
||||
columns,
|
||||
columnSizes,
|
||||
onColumnResize,
|
||||
onSort,
|
||||
sortOrder,
|
||||
} = this.props;
|
||||
const elems = [];
|
||||
|
||||
let hasFlex = false;
|
||||
for (const column of columnOrder) {
|
||||
if (column.visible && columnSizes[column.key] === 'flex') {
|
||||
hasFlex = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let lastResizable = true;
|
||||
|
||||
const colElems = {};
|
||||
for (const column of columnOrder) {
|
||||
if (!column.visible) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = column.key;
|
||||
const col = columns[key];
|
||||
|
||||
let arrow;
|
||||
if (col.sortable === true && sortOrder && sortOrder.key === key) {
|
||||
arrow = (
|
||||
<TableHeaderArrow>
|
||||
{sortOrder.direction === 'up' ? '▲' : '▼'}
|
||||
</TableHeaderArrow>
|
||||
);
|
||||
}
|
||||
|
||||
const width = normaliseColumnWidth(columnSizes[key]);
|
||||
const isResizable = col.resizable !== false;
|
||||
|
||||
const elem = (
|
||||
<TableHeadColumn
|
||||
key={key}
|
||||
id={key}
|
||||
hasFlex={hasFlex}
|
||||
isResizable={isResizable}
|
||||
leftHasResizer={lastResizable}
|
||||
width={width}
|
||||
sortable={col.sortable}
|
||||
sortOrder={sortOrder}
|
||||
onSort={onSort}
|
||||
columnSizes={columnSizes}
|
||||
onColumnResize={onColumnResize}
|
||||
title={key}>
|
||||
{col.value}
|
||||
{arrow}
|
||||
</TableHeadColumn>
|
||||
);
|
||||
|
||||
elems.push(elem);
|
||||
|
||||
colElems[key] = elem;
|
||||
|
||||
lastResizable = isResizable;
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextMenu buildItems={this.buildContextMenu}>
|
||||
<TableHeadContainer>{elems}</TableHeadContainer>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
}
|
||||
86
src/ui/components/table/types.js
Normal file
86
src/ui/components/table/types.js
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* 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 {Filter} from '../filter/types.js';
|
||||
|
||||
export const MINIMUM_COLUMN_WIDTH = 100;
|
||||
export const DEFAULT_COLUMN_WIDTH = 200;
|
||||
export const DEFAULT_ROW_HEIGHT = 23;
|
||||
|
||||
type TableColumnOrderVal = {
|
||||
key: string,
|
||||
visible: boolean,
|
||||
};
|
||||
|
||||
export type TableColumnRawOrder = Array<string | TableColumnOrderVal>;
|
||||
|
||||
export type TableColumnOrder = Array<TableColumnOrderVal>;
|
||||
|
||||
export type TableColumnSizes = {
|
||||
[key: string]: string | number,
|
||||
};
|
||||
|
||||
export type TableHighlightedRows = Array<string>;
|
||||
|
||||
export type TableColumnKeys = Array<string>;
|
||||
|
||||
export type TableOnColumnResize = (sizes: TableColumnSizes) => void;
|
||||
export type TableOnColumnOrder = (order: TableColumnOrder) => void;
|
||||
export type TableOnSort = (order: TableRowSortOrder) => void;
|
||||
export type TableOnHighlight = (
|
||||
highlightedRows: TableHighlightedRows,
|
||||
e: SyntheticUIEvent<>,
|
||||
) => void;
|
||||
|
||||
export type TableHeaderColumn = {|
|
||||
value: string,
|
||||
sortable?: boolean,
|
||||
resizable?: boolean,
|
||||
|};
|
||||
|
||||
export type TableBodyRow = {|
|
||||
key: string,
|
||||
height?: ?number,
|
||||
filterValue?: ?string,
|
||||
backgroundColor?: ?string,
|
||||
sortKey?: string | number,
|
||||
style?: Object,
|
||||
type?: ?string,
|
||||
highlightedBackgroundColor?: ?string,
|
||||
onDoubleClick?: (e: SyntheticMouseEvent<>) => void,
|
||||
copyText?: string,
|
||||
highlightOnHover?: boolean,
|
||||
columns: {
|
||||
[key: string]: TableBodyColumn,
|
||||
},
|
||||
|};
|
||||
|
||||
export type TableBodyColumn = {|
|
||||
sortValue?: string | number,
|
||||
isFilterable?: boolean,
|
||||
value: any,
|
||||
title?: string,
|
||||
|};
|
||||
|
||||
export type TableColumns = {
|
||||
[key: string]: TableHeaderColumn,
|
||||
};
|
||||
|
||||
export type TableRows = Array<TableBodyRow>;
|
||||
|
||||
export type TableRowSortOrder = {|
|
||||
key: string,
|
||||
direction: 'up' | 'down',
|
||||
|};
|
||||
|
||||
export type TableOnDragSelect = (
|
||||
e: SyntheticMouseEvent<>,
|
||||
key: string,
|
||||
index: number,
|
||||
) => void;
|
||||
|
||||
export type TableOnAddFilter = (filter: Filter) => void;
|
||||
31
src/ui/components/table/utils.js
Normal file
31
src/ui/components/table/utils.js
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
export function normaliseColumnWidth(
|
||||
width: void | string | number,
|
||||
): number | string {
|
||||
if (width == null || width === 'flex') {
|
||||
// default
|
||||
return 'flex';
|
||||
}
|
||||
|
||||
if (isPercentage(width)) {
|
||||
// percentage eg. 50%
|
||||
return width;
|
||||
}
|
||||
|
||||
if (typeof width === 'number') {
|
||||
// pixel width
|
||||
return width;
|
||||
}
|
||||
|
||||
throw new TypeError(`Unknown value ${width} for table column width`);
|
||||
}
|
||||
|
||||
export function isPercentage(width: mixed): boolean {
|
||||
return typeof width === 'string' && width[width.length - 1] === '%';
|
||||
}
|
||||
162
src/ui/index.js
Normal file
162
src/ui/index.js
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
export type {StyledComponent} from './styled/index.js';
|
||||
|
||||
//
|
||||
export {default as Button} from './components/Button.js';
|
||||
export {default as ToggleButton} from './components/ToggleSwitch.js';
|
||||
export {
|
||||
default as ButtonNavigationGroup,
|
||||
} from './components/ButtonNavigationGroup.js';
|
||||
export {default as ButtonGroup} from './components/ButtonGroup.js';
|
||||
|
||||
//
|
||||
export {colors, darkColors, brandColors} from './components/colors.js';
|
||||
|
||||
//
|
||||
export {default as Glyph, ColoredIcon} from './components/Glyph.js';
|
||||
|
||||
//
|
||||
export {default as LoadingIndicator} from './components/LoadingIndicator.js';
|
||||
|
||||
//
|
||||
export {default as Popover} from './components/Popover.js';
|
||||
|
||||
//
|
||||
export {default as ClickableList} from './components/ClickableList.js';
|
||||
export {default as ClickableListItem} from './components/ClickableListItem.js';
|
||||
|
||||
//
|
||||
export type {
|
||||
TableColumns,
|
||||
TableRows,
|
||||
TableBodyColumn,
|
||||
TableBodyRow,
|
||||
TableHighlightedRows,
|
||||
TableRowSortOrder,
|
||||
TableColumnOrder,
|
||||
TableColumnSizes,
|
||||
} from './components/table/types.js';
|
||||
export {default as Table} from './components/table/Table.js';
|
||||
export {default as ManagedTable} from './components/table/ManagedTable.js';
|
||||
export type {ManagedTableProps} from './components/table/ManagedTable.js';
|
||||
|
||||
//
|
||||
export type {
|
||||
DataValueExtractor,
|
||||
DataInspectorExpanded,
|
||||
} from './components/data-inspector/DataInspector.js';
|
||||
export {
|
||||
default as DataInspector,
|
||||
} from './components/data-inspector/DataInspector.js';
|
||||
export {
|
||||
default as ManagedDataInspector,
|
||||
} from './components/data-inspector/ManagedDataInspector.js';
|
||||
|
||||
// tabs
|
||||
export {default as Tabs} from './components/Tabs.js';
|
||||
export {default as Tab} from './components/Tab.js';
|
||||
|
||||
// inputs
|
||||
export {default as Input} from './components/Input.js';
|
||||
export {default as Textarea} from './components/Textarea.js';
|
||||
export {default as Select} from './components/Select.js';
|
||||
export {default as Checkbox} from './components/Checkbox.js';
|
||||
|
||||
// code
|
||||
export {default as CodeBlock} from './components/CodeBlock.js';
|
||||
|
||||
// error
|
||||
export {default as ErrorBlock} from './components/ErrorBlock.js';
|
||||
export {default as ErrorBoundary} from './components/ErrorBoundary.js';
|
||||
|
||||
// interactive components
|
||||
export type {OrderableOrder} from './components/Orderable.js';
|
||||
export {default as Interactive} from './components/Interactive.js';
|
||||
export {default as Orderable} from './components/Orderable.js';
|
||||
export {default as VirtualList} from './components/VirtualList.js';
|
||||
|
||||
// base components
|
||||
export {Component, PureComponent} from 'react';
|
||||
|
||||
// context menus and dropdowns
|
||||
export {
|
||||
default as ContextMenuProvider,
|
||||
} from './components/ContextMenuProvider.js';
|
||||
export {default as ContextMenu} from './components/ContextMenu.js';
|
||||
export {default as InlineContextMenu} from './components/InlineContextMenu.js';
|
||||
export {default as Dropdown} from './components/Dropdown.js';
|
||||
|
||||
// file
|
||||
export type {FileListFile, FileListFiles} from './components/FileList.js';
|
||||
export {default as FileList} from './components/FileList.js';
|
||||
export {default as File} from './components/File.js';
|
||||
|
||||
// context menu items
|
||||
export {
|
||||
DesktopDropdownItem,
|
||||
DesktopDropdownSelectedItem,
|
||||
DesktopDropdown,
|
||||
} from './components/desktop-toolbar.js';
|
||||
|
||||
// utility elements
|
||||
export {default as View} from './components/View.js';
|
||||
export {default as ViewWithSize} from './components/ViewWithSize.js';
|
||||
export {default as Block} from './components/Block.js';
|
||||
export {default as FocusableBox} from './components/FocusableBox.js';
|
||||
export {default as Sidebar} from './components/Sidebar.js';
|
||||
export {default as SidebarLabel} from './components/SidebarLabel.js';
|
||||
export {default as Box} from './components/Box.js';
|
||||
export {default as FlexBox} from './components/FlexBox.js';
|
||||
export {default as FlexRow} from './components/FlexRow.js';
|
||||
export {default as FlexColumn} from './components/FlexColumn.js';
|
||||
export {default as FlexCenter} from './components/FlexCenter.js';
|
||||
export {default as Toolbar, Spacer} from './components/Toolbar.js';
|
||||
export {default as Panel} from './components/Panel.js';
|
||||
export {default as Text} from './components/Text.js';
|
||||
export {default as TextParagraph} from './components/TextParagraph.js';
|
||||
export {default as Link} from './components/Link.js';
|
||||
export {default as PathBreadcrumbs} from './components/PathBreadcrumbs.js';
|
||||
export {default as ModalOverlay} from './components/ModalOverlay.js';
|
||||
export {default as Tooltip} from './components/Tooltip.js';
|
||||
export {default as TooltipProvider} from './components/TooltipProvider.js';
|
||||
export {default as ResizeSensor} from './components/ResizeSensor.js';
|
||||
|
||||
// typhography
|
||||
export {default as HorizontalRule} from './components/HorizontalRule.js';
|
||||
export {default as Label} from './components/Label.js';
|
||||
export {default as Heading} from './components/Heading.js';
|
||||
|
||||
// filters
|
||||
export type {Filter} from './components/filter/types.js';
|
||||
|
||||
//
|
||||
export {
|
||||
SearchBox,
|
||||
SearchInput,
|
||||
SearchIcon,
|
||||
default as Searchable,
|
||||
} from './components/searchable/Searchable.js';
|
||||
export {
|
||||
default as SearchableTable,
|
||||
} from './components/searchable/SearchableTable.js';
|
||||
export type {SearchableProps} from './components/searchable/Searchable.js';
|
||||
|
||||
//
|
||||
export type {
|
||||
ElementID,
|
||||
ElementData,
|
||||
ElementAttribute,
|
||||
Element,
|
||||
ElementSearchResultSet,
|
||||
} from './components/elements-inspector/ElementsInspector.js';
|
||||
export {
|
||||
default as ElementsInspector,
|
||||
} from './components/elements-inspector/ElementsInspector.js';
|
||||
export {InspectorSidebar} from './components/elements-inspector/sidebar.js';
|
||||
|
||||
export {Console} from './components/console.js';
|
||||
145
src/ui/styled/__tests__/gc.node.js
Normal file
145
src/ui/styled/__tests__/gc.node.js
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* 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 {Tracker} from '../index.js';
|
||||
import {GarbageCollector} from '../gc.js';
|
||||
import {StyleSheet} from '../sheet.js';
|
||||
|
||||
function createGC(): {|
|
||||
gc: GarbageCollector,
|
||||
tracker: Tracker,
|
||||
|} {
|
||||
const sheet = new StyleSheet();
|
||||
const tracker = new Map();
|
||||
const rulesToClass = new WeakMap();
|
||||
|
||||
const gc = new GarbageCollector(sheet, tracker, rulesToClass);
|
||||
return {gc, tracker};
|
||||
}
|
||||
|
||||
test('register classes to be garbage collected when no references exist', () => {
|
||||
const {gc} = createGC();
|
||||
|
||||
gc.registerClassUse('foo');
|
||||
expect(gc.getCollectionQueue()).toEqual([]);
|
||||
|
||||
gc.deregisterClassUse('foo');
|
||||
expect(gc.getCollectionQueue()).toEqual(['foo']);
|
||||
});
|
||||
|
||||
test('cancel garbage collection for classes used before actual collection happens', () => {
|
||||
const {gc} = createGC();
|
||||
|
||||
gc.registerClassUse('foo');
|
||||
expect(gc.getCollectionQueue()).toEqual([]);
|
||||
|
||||
gc.deregisterClassUse('foo');
|
||||
expect(gc.getCollectionQueue()).toEqual(['foo']);
|
||||
|
||||
gc.registerClassUse('foo');
|
||||
expect(gc.getCollectionQueue()).toEqual([]);
|
||||
});
|
||||
|
||||
test('garbage collector removes unreferenced classes', () => {
|
||||
const {gc, tracker} = createGC();
|
||||
|
||||
tracker.set('foo', {
|
||||
displayName: 'foo',
|
||||
namespace: '',
|
||||
selector: '',
|
||||
style: {},
|
||||
rules: {},
|
||||
});
|
||||
|
||||
gc.registerClassUse('foo');
|
||||
expect(gc.getCollectionQueue()).toEqual([]);
|
||||
|
||||
gc.deregisterClassUse('foo');
|
||||
expect(gc.getCollectionQueue()).toEqual(['foo']);
|
||||
expect(gc.hasQueuedCollection()).toBe(true);
|
||||
expect(tracker.has('foo')).toBe(true);
|
||||
|
||||
gc.collectGarbage();
|
||||
expect(gc.hasQueuedCollection()).toBe(false);
|
||||
expect(gc.getCollectionQueue()).toEqual([]);
|
||||
expect(tracker.has('foo')).toBe(false);
|
||||
});
|
||||
|
||||
test('properly tracks reference counts', () => {
|
||||
const {gc} = createGC();
|
||||
|
||||
gc.registerClassUse('foo');
|
||||
gc.registerClassUse('foo');
|
||||
gc.registerClassUse('bar');
|
||||
expect(gc.getReferenceCount('foo')).toBe(2);
|
||||
expect(gc.getReferenceCount('bar')).toBe(1);
|
||||
|
||||
gc.deregisterClassUse('bar');
|
||||
expect(gc.getReferenceCount('bar')).toBe(0);
|
||||
|
||||
gc.deregisterClassUse('foo');
|
||||
expect(gc.getReferenceCount('foo')).toBe(1);
|
||||
|
||||
gc.deregisterClassUse('foo');
|
||||
expect(gc.getReferenceCount('foo')).toBe(0);
|
||||
});
|
||||
|
||||
test("gracefully handle deregistering classes we don't have a count for", () => {
|
||||
const {gc} = createGC();
|
||||
gc.deregisterClassUse('not-tracking');
|
||||
});
|
||||
|
||||
test('only halt garbage collection if there is nothing left in the queue', () => {
|
||||
const {gc} = createGC();
|
||||
|
||||
gc.registerClassUse('foo');
|
||||
expect(gc.hasQueuedCollection()).toBe(false);
|
||||
|
||||
gc.deregisterClassUse('foo');
|
||||
expect(gc.hasQueuedCollection()).toBe(true);
|
||||
|
||||
gc.registerClassUse('bar');
|
||||
expect(gc.hasQueuedCollection()).toBe(true);
|
||||
|
||||
gc.deregisterClassUse('bar');
|
||||
expect(gc.hasQueuedCollection()).toBe(true);
|
||||
|
||||
gc.registerClassUse('bar');
|
||||
expect(gc.hasQueuedCollection()).toBe(true);
|
||||
|
||||
gc.registerClassUse('foo');
|
||||
expect(gc.hasQueuedCollection()).toBe(false);
|
||||
});
|
||||
|
||||
test('ensure garbage collection happens', () => {
|
||||
const {gc} = createGC();
|
||||
|
||||
gc.registerClassUse('foo');
|
||||
gc.deregisterClassUse('foo');
|
||||
expect(gc.hasQueuedCollection()).toBe(true);
|
||||
expect(gc.getCollectionQueue()).toEqual(['foo']);
|
||||
|
||||
jest.runAllTimers();
|
||||
expect(gc.hasQueuedCollection()).toBe(false);
|
||||
expect(gc.getCollectionQueue()).toEqual([]);
|
||||
});
|
||||
|
||||
test('flush', () => {
|
||||
const {gc} = createGC();
|
||||
|
||||
gc.registerClassUse('bar');
|
||||
gc.deregisterClassUse('bar');
|
||||
expect(gc.getCollectionQueue()).toEqual(['bar']);
|
||||
expect(gc.getReferenceCount('bar')).toBe(0);
|
||||
|
||||
gc.registerClassUse('foo');
|
||||
expect(gc.getReferenceCount('foo')).toBe(1);
|
||||
|
||||
gc.flush();
|
||||
expect(gc.getCollectionQueue()).toEqual([]);
|
||||
expect(gc.getReferenceCount('foo')).toBe(0);
|
||||
});
|
||||
14
src/ui/styled/__tests__/hash.node.js
Normal file
14
src/ui/styled/__tests__/hash.node.js
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 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 hash from '../hash.js';
|
||||
|
||||
test('hash', () => {
|
||||
expect(hash('f')).toBe('1xwd1rk');
|
||||
expect(hash('foobar')).toBe('slolri');
|
||||
expect(hash('foobar2')).toBe('34u6r4');
|
||||
});
|
||||
387
src/ui/styled/__tests__/index.node.js
Normal file
387
src/ui/styled/__tests__/index.node.js
Normal file
@@ -0,0 +1,387 @@
|
||||
/**
|
||||
* 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 styled, {buildKeyframes, flush, gc, tracker} from '../index.js';
|
||||
|
||||
const ReactTestRenderer = require('react-test-renderer');
|
||||
const invariant = require('invariant');
|
||||
const React = require('react'); // eslint-disable-line
|
||||
|
||||
const BasicComponent = styled.view({
|
||||
color: 'red',
|
||||
});
|
||||
|
||||
const DynamicComponent = styled.view({
|
||||
color: props => props.color,
|
||||
});
|
||||
|
||||
test('can create a basic component without any errors', () => {
|
||||
let component;
|
||||
|
||||
try {
|
||||
component = ReactTestRenderer.create(<BasicComponent />);
|
||||
component.toJSON();
|
||||
component.unmount();
|
||||
gc.flush();
|
||||
} finally {
|
||||
if (component) {
|
||||
component.unmount();
|
||||
}
|
||||
gc.flush();
|
||||
}
|
||||
});
|
||||
|
||||
test('can create a basic component and garbage collect', () => {
|
||||
let component;
|
||||
|
||||
try {
|
||||
component = ReactTestRenderer.create(<BasicComponent />);
|
||||
const tree = component.toJSON();
|
||||
|
||||
expect(tree.type).toBe('div');
|
||||
|
||||
const className = tree.props.className;
|
||||
expect(gc.hasQueuedCollection()).toBe(false);
|
||||
expect(gc.getReferenceCount(className)).toBe(1);
|
||||
|
||||
component.unmount();
|
||||
expect(gc.getReferenceCount(className)).toBe(0);
|
||||
expect(gc.hasQueuedCollection()).toBe(true);
|
||||
} finally {
|
||||
if (component) {
|
||||
component.unmount();
|
||||
}
|
||||
gc.flush();
|
||||
}
|
||||
});
|
||||
|
||||
test('remove outdated classes when updating component', () => {
|
||||
let component;
|
||||
|
||||
try {
|
||||
component = ReactTestRenderer.create(<DynamicComponent color="red" />);
|
||||
const tree = component.toJSON();
|
||||
|
||||
const className = tree.props.className;
|
||||
expect(gc.hasQueuedCollection()).toBe(false);
|
||||
expect(gc.getReferenceCount(className)).toBe(1);
|
||||
|
||||
// updating with the same props should generate the same style and not trigger a collection
|
||||
component.update(<DynamicComponent color="red" />);
|
||||
expect(gc.hasQueuedCollection()).toBe(false);
|
||||
expect(gc.getReferenceCount(className)).toBe(1);
|
||||
|
||||
// change style
|
||||
component.update(<DynamicComponent color="blue" />);
|
||||
expect(gc.hasQueuedCollection()).toBe(true);
|
||||
expect(gc.getReferenceCount(className)).toBe(0);
|
||||
} finally {
|
||||
if (component) {
|
||||
component.unmount();
|
||||
}
|
||||
gc.flush();
|
||||
}
|
||||
});
|
||||
|
||||
test('extra class names should be preserved', () => {
|
||||
let component;
|
||||
|
||||
try {
|
||||
component = ReactTestRenderer.create(<BasicComponent className="foo" />);
|
||||
const tree = component.toJSON();
|
||||
expect(tree.props.className.split(' ').includes('foo')).toBe(true);
|
||||
} finally {
|
||||
if (component) {
|
||||
component.unmount();
|
||||
}
|
||||
gc.flush();
|
||||
}
|
||||
});
|
||||
|
||||
test('should inherit component when passed as first arg to styled', () => {
|
||||
let component;
|
||||
|
||||
try {
|
||||
const InheritComponent = BasicComponent.extends({
|
||||
backgroundColor: 'black',
|
||||
});
|
||||
|
||||
component = ReactTestRenderer.create(<InheritComponent />);
|
||||
const tree = component.toJSON();
|
||||
|
||||
const rules = tracker.get(tree.props.className);
|
||||
invariant(rules, 'expected rules');
|
||||
expect(rules.style).toEqual({
|
||||
'background-color': 'black',
|
||||
color: 'red',
|
||||
});
|
||||
} finally {
|
||||
if (component) {
|
||||
component.unmount();
|
||||
}
|
||||
gc.flush();
|
||||
}
|
||||
});
|
||||
|
||||
test("when passed class name of another styled component it's rules should be inherited", () => {
|
||||
let component;
|
||||
|
||||
try {
|
||||
class BaseComponent extends styled.StylableComponent<{
|
||||
className: string,
|
||||
}> {
|
||||
render() {
|
||||
return <BasicComponent className={this.props.className} />;
|
||||
}
|
||||
}
|
||||
|
||||
const InheritComponent = BaseComponent.extends({
|
||||
backgroundColor: 'black',
|
||||
});
|
||||
|
||||
component = ReactTestRenderer.create(<InheritComponent />);
|
||||
const tree = component.toJSON();
|
||||
|
||||
const rules = tracker.get(tree.props.className);
|
||||
invariant(rules, 'expected rules');
|
||||
expect(rules.style).toEqual({
|
||||
'background-color': 'black',
|
||||
color: 'red',
|
||||
});
|
||||
} finally {
|
||||
if (component) {
|
||||
component.unmount();
|
||||
}
|
||||
gc.flush();
|
||||
}
|
||||
});
|
||||
|
||||
test('supports pseudo selectors', () => {
|
||||
let component;
|
||||
|
||||
try {
|
||||
const Component = styled.view({
|
||||
'&:hover': {
|
||||
color: 'red',
|
||||
},
|
||||
});
|
||||
|
||||
component = ReactTestRenderer.create(<Component />);
|
||||
const tree = component.toJSON();
|
||||
|
||||
const rules = tracker.get(tree.props.className);
|
||||
invariant(rules, 'expected rules');
|
||||
expect(rules.style).toEqual({
|
||||
color: 'red',
|
||||
});
|
||||
} finally {
|
||||
if (component) {
|
||||
component.unmount();
|
||||
}
|
||||
gc.flush();
|
||||
}
|
||||
});
|
||||
|
||||
test('supports multiple pseudo selectors', () => {
|
||||
let component;
|
||||
|
||||
try {
|
||||
const Component = styled.view({
|
||||
'&:active': {
|
||||
color: 'blue',
|
||||
},
|
||||
|
||||
'&:hover': {
|
||||
color: 'red',
|
||||
},
|
||||
});
|
||||
|
||||
component = ReactTestRenderer.create(<Component />);
|
||||
const tree = component.toJSON();
|
||||
|
||||
const classes = tree.props.className.split(' ');
|
||||
expect(classes.length).toBe(2);
|
||||
|
||||
const hoverRules = tracker.get(classes[1]);
|
||||
invariant(hoverRules, 'expected hoverRules');
|
||||
expect(hoverRules.style).toEqual({
|
||||
color: 'red',
|
||||
});
|
||||
expect(hoverRules.namespace).toBe('&:hover');
|
||||
expect(hoverRules.selector.endsWith(':hover')).toBe(true);
|
||||
|
||||
const activeRules = tracker.get(classes[0]);
|
||||
invariant(activeRules, 'expected activeRules');
|
||||
expect(activeRules.style).toEqual({
|
||||
color: 'blue',
|
||||
});
|
||||
expect(activeRules.namespace).toBe('&:active');
|
||||
expect(activeRules.selector.endsWith(':active')).toBe(true);
|
||||
} finally {
|
||||
if (component) {
|
||||
component.unmount();
|
||||
}
|
||||
gc.flush();
|
||||
}
|
||||
});
|
||||
|
||||
test('supports child selectors', () => {
|
||||
let component;
|
||||
|
||||
try {
|
||||
const Component = styled.view({
|
||||
'> li': {
|
||||
color: 'red',
|
||||
},
|
||||
});
|
||||
|
||||
component = ReactTestRenderer.create(<Component />);
|
||||
const tree = component.toJSON();
|
||||
|
||||
const classes = tree.props.className.split(' ');
|
||||
expect(classes.length).toBe(1);
|
||||
|
||||
const rules = tracker.get(classes[0]);
|
||||
invariant(rules, 'expected rules');
|
||||
|
||||
expect(rules.style).toEqual({
|
||||
color: 'red',
|
||||
});
|
||||
expect(rules.namespace).toBe('> li');
|
||||
expect(rules.selector.endsWith(' > li')).toBe(true);
|
||||
} finally {
|
||||
if (component) {
|
||||
component.unmount();
|
||||
}
|
||||
gc.flush();
|
||||
}
|
||||
});
|
||||
|
||||
test('flush', () => {
|
||||
flush();
|
||||
});
|
||||
|
||||
test('innerRef works on styled components', () => {
|
||||
let component;
|
||||
|
||||
try {
|
||||
const Component = styled.view({});
|
||||
|
||||
let called = false;
|
||||
const innerRef = ref => {
|
||||
called = true;
|
||||
};
|
||||
ReactTestRenderer.create(<Component innerRef={innerRef} />);
|
||||
expect(called).toBe(true);
|
||||
} finally {
|
||||
if (component) {
|
||||
component.unmount();
|
||||
}
|
||||
gc.flush();
|
||||
}
|
||||
});
|
||||
|
||||
test('ignoreAttributes', () => {
|
||||
let component;
|
||||
|
||||
try {
|
||||
const Component = styled.view(
|
||||
{
|
||||
color: props => props.color,
|
||||
},
|
||||
{
|
||||
ignoreAttributes: ['color'],
|
||||
},
|
||||
);
|
||||
|
||||
component = ReactTestRenderer.create(<Component color="red" />);
|
||||
const tree = component.toJSON();
|
||||
|
||||
expect(tree.props.color).toBe(undefined);
|
||||
|
||||
const rules = tracker.get(tree.props.className);
|
||||
invariant(rules, 'expected rules');
|
||||
expect(rules.style).toEqual({
|
||||
color: 'red',
|
||||
});
|
||||
} finally {
|
||||
if (component) {
|
||||
component.unmount();
|
||||
}
|
||||
gc.flush();
|
||||
}
|
||||
});
|
||||
test('buildKeyframes', () => {
|
||||
const css = buildKeyframes({
|
||||
'0%': {
|
||||
opacity: 0,
|
||||
},
|
||||
|
||||
'50%': {
|
||||
height: 50,
|
||||
opacity: 0.8,
|
||||
},
|
||||
|
||||
'100%': {
|
||||
opacity: 1,
|
||||
},
|
||||
});
|
||||
|
||||
expect(css).toBe(
|
||||
[
|
||||
' 0% {',
|
||||
' opacity: 0;',
|
||||
' }',
|
||||
' 50% {',
|
||||
' height: 50px;',
|
||||
' opacity: 0.8;',
|
||||
' }',
|
||||
' 100% {',
|
||||
' opacity: 1;',
|
||||
' }',
|
||||
].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
test('keyframes', () => {
|
||||
const className = styled.keyframes({
|
||||
'0%': {
|
||||
opacity: 0,
|
||||
},
|
||||
|
||||
'50%': {
|
||||
opacity: 0.8,
|
||||
},
|
||||
|
||||
'100%': {
|
||||
opacity: 1,
|
||||
},
|
||||
});
|
||||
expect(typeof className).toBe('string');
|
||||
});
|
||||
|
||||
test('buildKeyframes only accepts string property values', () => {
|
||||
expect(() => {
|
||||
buildKeyframes({
|
||||
// $FlowFixMe: ignore
|
||||
'0%': {
|
||||
fn: () => {},
|
||||
},
|
||||
});
|
||||
}).toThrow('Keyframe objects must only have strings values');
|
||||
});
|
||||
|
||||
test('buildKeyframes only accepts object specs', () => {
|
||||
expect(() => {
|
||||
buildKeyframes({
|
||||
// $FlowFixMe: ignore
|
||||
'0%': () => {
|
||||
return '';
|
||||
},
|
||||
});
|
||||
}).toThrow('Keyframe spec must only have objects');
|
||||
});
|
||||
76
src/ui/styled/__tests__/rules.node.js
Normal file
76
src/ui/styled/__tests__/rules.node.js
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* 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 {buildRules, normaliseRules} from '../rules.js';
|
||||
|
||||
describe('normaliseRules', () => {
|
||||
test('ensure top level values are expanded', () => {
|
||||
const normalisedRules = normaliseRules({height: '4px'});
|
||||
expect(normalisedRules['&'].height).toBe('4px');
|
||||
});
|
||||
|
||||
test('ensure keys are dashed', () => {
|
||||
const normalisedRules = normaliseRules({
|
||||
// $FlowFixMe: ignore
|
||||
'&:hover': {
|
||||
lineHeight: '4px',
|
||||
WebkitAppRegion: 'drag',
|
||||
},
|
||||
});
|
||||
const hoverRules = normalisedRules['&:hover'];
|
||||
expect(Object.keys(hoverRules).length).toBe(2);
|
||||
expect(hoverRules['line-height']).toBe('4px');
|
||||
expect(hoverRules['-webkit-app-region']).toBe('drag');
|
||||
});
|
||||
|
||||
test('exclude empty objects', () => {
|
||||
const normalisedRules = normaliseRules({
|
||||
'&:hover': {},
|
||||
});
|
||||
|
||||
expect(normalisedRules['&:hover']).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildRules', () => {
|
||||
test('ensure null values are left out', () => {
|
||||
const builtRules = buildRules({height: (null: any)}, {}, {});
|
||||
expect('height' in builtRules).toBe(false);
|
||||
|
||||
const builtRules2 = buildRules(
|
||||
{
|
||||
height() {
|
||||
return (null: any);
|
||||
},
|
||||
},
|
||||
{},
|
||||
{},
|
||||
);
|
||||
expect('height' in builtRules2).toBe(false);
|
||||
});
|
||||
|
||||
test('ensure numbers are appended with px', () => {
|
||||
expect(buildRules({height: 40}, {}, {}).height).toBe('40px');
|
||||
});
|
||||
|
||||
test("ensure unitless numbers aren't appended with px", () => {
|
||||
expect(buildRules({'z-index': 4}, {}, {})['z-index']).toBe('4');
|
||||
});
|
||||
|
||||
test('ensure functions are called with props', () => {
|
||||
const thisProps = {};
|
||||
expect(
|
||||
buildRules(
|
||||
{
|
||||
border: props => (props === thisProps ? 'foo' : 'bar'),
|
||||
},
|
||||
thisProps,
|
||||
{},
|
||||
).border,
|
||||
).toBe('foo');
|
||||
});
|
||||
});
|
||||
74
src/ui/styled/__tests__/sheet.node.js
Normal file
74
src/ui/styled/__tests__/sheet.node.js
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* 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 {StyleSheet} from '../sheet.js';
|
||||
|
||||
describe('flush', () => {
|
||||
test('should remove all rules', () => {
|
||||
const sheet = new StyleSheet();
|
||||
expect(sheet.getRuleCount()).toBe(0);
|
||||
|
||||
sheet.insert('foo', 'div {color: red;}');
|
||||
expect(sheet.getRuleCount()).toBe(1);
|
||||
|
||||
sheet.flush();
|
||||
expect(sheet.getRuleCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('inject', () => {
|
||||
test("throw's an error when already injected", () => {
|
||||
const sheet = new StyleSheet();
|
||||
|
||||
expect(() => {
|
||||
sheet.inject();
|
||||
sheet.inject();
|
||||
}).toThrow('already injected stylesheet!');
|
||||
});
|
||||
});
|
||||
|
||||
describe('insert', () => {
|
||||
test('non-speedy', () => {
|
||||
const sheet = new StyleSheet();
|
||||
|
||||
expect(sheet.getRuleCount()).toBe(0);
|
||||
sheet.insert('foo', 'div {color: red;}');
|
||||
expect(sheet.getRuleCount()).toBe(1);
|
||||
});
|
||||
|
||||
test('speedy', () => {
|
||||
const sheet = new StyleSheet(true);
|
||||
|
||||
expect(sheet.getRuleCount()).toBe(0);
|
||||
sheet.insert('foo', 'div {color: red;}');
|
||||
expect(sheet.getRuleCount()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
test('non-speedy', () => {
|
||||
const sheet = new StyleSheet();
|
||||
|
||||
expect(sheet.getRuleCount()).toBe(0);
|
||||
sheet.insert('foo', 'div {color: red;}');
|
||||
expect(sheet.getRuleCount()).toBe(1);
|
||||
|
||||
sheet.delete('foo');
|
||||
expect(sheet.getRuleCount()).toBe(0);
|
||||
});
|
||||
|
||||
test('speedy', () => {
|
||||
const sheet = new StyleSheet(true);
|
||||
|
||||
expect(sheet.getRuleCount()).toBe(0);
|
||||
sheet.insert('foo', 'div {color: red;}');
|
||||
expect(sheet.getRuleCount()).toBe(1);
|
||||
|
||||
sheet.delete('foo');
|
||||
expect(sheet.getRuleCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
114
src/ui/styled/gc.js
Normal file
114
src/ui/styled/gc.js
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* 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 {Tracker, RulesToClass} from './index.js';
|
||||
import type {StyleSheet} from './sheet.js';
|
||||
|
||||
const invariant = require('invariant');
|
||||
|
||||
export class GarbageCollector {
|
||||
constructor(sheet: StyleSheet, tracker: Tracker, rulesToClass: RulesToClass) {
|
||||
this.sheet = sheet;
|
||||
this.tracker = tracker;
|
||||
|
||||
// used to keep track of what classes are actively in use
|
||||
this.usedClasses = new Map();
|
||||
|
||||
// classes to be removed, we put this in a queue and perform it in bulk rather than straight away
|
||||
// since by the time the next tick happens this style could have been reinserted
|
||||
this.classRemovalQueue = new Set();
|
||||
|
||||
this.rulesToClass = rulesToClass;
|
||||
}
|
||||
|
||||
tracker: Tracker;
|
||||
sheet: StyleSheet;
|
||||
usedClasses: Map<string, number>;
|
||||
garbageTimer: ?TimeoutID;
|
||||
classRemovalQueue: Set<string>;
|
||||
rulesToClass: RulesToClass;
|
||||
|
||||
hasQueuedCollection(): boolean {
|
||||
return Boolean(this.garbageTimer);
|
||||
}
|
||||
|
||||
getReferenceCount(key: string): number {
|
||||
return this.usedClasses.get(key) || 0;
|
||||
}
|
||||
|
||||
// component has been mounted so make sure it's being depended on
|
||||
registerClassUse(name: string) {
|
||||
const count = this.usedClasses.get(name) || 0;
|
||||
this.usedClasses.set(name, count + 1);
|
||||
if (this.classRemovalQueue.has(name)) {
|
||||
this.classRemovalQueue.delete(name);
|
||||
|
||||
if (this.classRemovalQueue.size === 0) {
|
||||
this.haltGarbage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// component has been unmounted so remove it's dependencies
|
||||
deregisterClassUse(name: string) {
|
||||
let count = this.usedClasses.get(name);
|
||||
if (count == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
count--;
|
||||
this.usedClasses.set(name, count);
|
||||
|
||||
if (count === 0) {
|
||||
this.classRemovalQueue.add(name);
|
||||
this.scheduleGarbage();
|
||||
}
|
||||
}
|
||||
|
||||
scheduleGarbage() {
|
||||
if (this.garbageTimer != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.garbageTimer = setTimeout(() => {
|
||||
this.collectGarbage();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
haltGarbage() {
|
||||
if (this.garbageTimer) {
|
||||
clearTimeout(this.garbageTimer);
|
||||
this.garbageTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
getCollectionQueue() {
|
||||
return Array.from(this.classRemovalQueue);
|
||||
}
|
||||
|
||||
collectGarbage() {
|
||||
this.haltGarbage();
|
||||
for (const name of this.classRemovalQueue) {
|
||||
const trackerInfo = this.tracker.get(name);
|
||||
invariant(trackerInfo != null, 'trying to remove unknown class');
|
||||
|
||||
const {rules} = trackerInfo;
|
||||
this.rulesToClass.delete(rules);
|
||||
|
||||
this.sheet.delete(name);
|
||||
this.tracker.delete(name);
|
||||
this.usedClasses.delete(name);
|
||||
}
|
||||
this.classRemovalQueue.clear();
|
||||
}
|
||||
|
||||
flush() {
|
||||
this.haltGarbage();
|
||||
this.classRemovalQueue.clear();
|
||||
this.usedClasses.clear();
|
||||
}
|
||||
}
|
||||
74
src/ui/styled/hash.js
Normal file
74
src/ui/styled/hash.js
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
export default function hash(str: string): string {
|
||||
const m = 0x5bd1e995;
|
||||
const r = 24;
|
||||
let h = str.length;
|
||||
let length = str.length;
|
||||
let currentIndex = 0;
|
||||
|
||||
while (length >= 4) {
|
||||
let k = UInt32(str, currentIndex);
|
||||
|
||||
k = Umul32(k, m);
|
||||
k ^= k >>> r;
|
||||
k = Umul32(k, m);
|
||||
|
||||
h = Umul32(h, m);
|
||||
h ^= k;
|
||||
|
||||
currentIndex += 4;
|
||||
length -= 4;
|
||||
}
|
||||
|
||||
switch (length) {
|
||||
case 3:
|
||||
h ^= UInt16(str, currentIndex);
|
||||
h ^= str.charCodeAt(currentIndex + 2) << 16;
|
||||
h = Umul32(h, m);
|
||||
break;
|
||||
|
||||
case 2:
|
||||
h ^= UInt16(str, currentIndex);
|
||||
h = Umul32(h, m);
|
||||
break;
|
||||
|
||||
case 1:
|
||||
h ^= str.charCodeAt(currentIndex);
|
||||
h = Umul32(h, m);
|
||||
break;
|
||||
}
|
||||
|
||||
h ^= h >>> 13;
|
||||
h = Umul32(h, m);
|
||||
h ^= h >>> 15;
|
||||
|
||||
return (h >>> 0).toString(36);
|
||||
}
|
||||
|
||||
function UInt32(str: string, pos: number): number {
|
||||
return (
|
||||
str.charCodeAt(pos++) +
|
||||
(str.charCodeAt(pos++) << 8) +
|
||||
(str.charCodeAt(pos++) << 16) +
|
||||
(str.charCodeAt(pos) << 24)
|
||||
);
|
||||
}
|
||||
|
||||
function UInt16(str: string, pos: number): number {
|
||||
return str.charCodeAt(pos++) + (str.charCodeAt(pos++) << 8);
|
||||
}
|
||||
|
||||
function Umul32(n: number, m: number): number {
|
||||
n |= 0;
|
||||
m |= 0;
|
||||
const nlo = n & 0xffff;
|
||||
const nhi = n >>> 16;
|
||||
const res = (nlo * m + (((nhi * m) & 0xffff) << 16)) | 0;
|
||||
return res;
|
||||
}
|
||||
440
src/ui/styled/index.js
Normal file
440
src/ui/styled/index.js
Normal file
@@ -0,0 +1,440 @@
|
||||
/**
|
||||
* 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 {BaseRules, KeyframeRules, RawRules} from './rules.js';
|
||||
import {buildKeyframeRules, buildRules, normaliseRules} from './rules.js';
|
||||
import assignDeep from '../../utils/assignDeep.js';
|
||||
import * as performance from '../../utils/performance.js';
|
||||
import {GarbageCollector} from './gc.js';
|
||||
import {StyleSheet} from './sheet.js';
|
||||
import hash from './hash.js';
|
||||
|
||||
const React = require('react');
|
||||
|
||||
export type Tracker = Map<
|
||||
string,
|
||||
{
|
||||
displayName: ?string,
|
||||
namespace: string,
|
||||
rules: BaseRules,
|
||||
selector: string,
|
||||
style: Object,
|
||||
},
|
||||
>;
|
||||
|
||||
export type RulesToClass = WeakMap<BaseRules, string>;
|
||||
|
||||
// map of inserted classes and metadata about them
|
||||
export const tracker: Tracker = new Map();
|
||||
|
||||
// map of rules to their class
|
||||
const rulesToClass: RulesToClass = new WeakMap();
|
||||
|
||||
export const sheet = new StyleSheet(process.env.NODE_ENV === 'production');
|
||||
export const gc = new GarbageCollector(sheet, tracker, rulesToClass);
|
||||
|
||||
function addRules(
|
||||
displayName: string,
|
||||
rules: BaseRules,
|
||||
namespace,
|
||||
props: Object,
|
||||
context: Object,
|
||||
) {
|
||||
// if these rules have been cached to a className then retrieve it
|
||||
const cachedClass = rulesToClass.get(rules);
|
||||
if (cachedClass != null) {
|
||||
return cachedClass;
|
||||
}
|
||||
|
||||
//
|
||||
const declarations = [];
|
||||
const style = buildRules(rules, props, context);
|
||||
|
||||
// generate css declarations based on the style object
|
||||
for (const key in style) {
|
||||
const val = style[key];
|
||||
declarations.push(` ${key}: ${val};`);
|
||||
}
|
||||
const css = declarations.join('\n');
|
||||
|
||||
// build the class name with the display name of the styled component and a unique id based on the css and namespace
|
||||
const className = displayName + '__' + hash(namespace + css);
|
||||
|
||||
// this is the first time we've found this className
|
||||
if (!tracker.has(className)) {
|
||||
// build up the correct selector, explode on commas to allow multiple selectors
|
||||
const selector = namespace
|
||||
.split(', ')
|
||||
.map(part => {
|
||||
if (part[0] === '&') {
|
||||
return '.' + className + part.slice(1);
|
||||
} else {
|
||||
return '.' + className + ' ' + part;
|
||||
}
|
||||
})
|
||||
.join(', ');
|
||||
|
||||
// insert the new style text
|
||||
tracker.set(className, {displayName, namespace, rules, selector, style});
|
||||
sheet.insert(className, `${selector} {\n${css}\n}`);
|
||||
|
||||
// if there's no dynamic rules then cache this
|
||||
if (hasDynamicRules(rules) === false) {
|
||||
rulesToClass.set(rules, className);
|
||||
}
|
||||
}
|
||||
|
||||
return className;
|
||||
}
|
||||
|
||||
// remove all styhles
|
||||
export function flush() {
|
||||
gc.flush();
|
||||
tracker.clear();
|
||||
sheet.flush();
|
||||
}
|
||||
|
||||
export type TagName = string | Function;
|
||||
|
||||
type StyledComponentState = {|
|
||||
extraClassNames: Array<string>,
|
||||
classNames: Array<string>,
|
||||
lastBuiltRules: ?Object,
|
||||
lastBuiltRulesIsDynamic: boolean,
|
||||
|};
|
||||
|
||||
export class StylableComponent<
|
||||
Props = void,
|
||||
State = void,
|
||||
> extends React.Component<Props, State> {
|
||||
static extends(
|
||||
rules: RawRules,
|
||||
opts?: StyledComponentOpts,
|
||||
): StyledComponent<any> {
|
||||
return createStyledComponent(this, rules, opts);
|
||||
}
|
||||
}
|
||||
|
||||
class StylablePureComponent<
|
||||
Props = void,
|
||||
State = void,
|
||||
> extends React.PureComponent<Props, State> {
|
||||
static extends(
|
||||
rules: RawRules,
|
||||
opts?: StyledComponentOpts,
|
||||
): StyledComponent<any> {
|
||||
return createStyledComponent(this, rules, opts);
|
||||
}
|
||||
}
|
||||
|
||||
class StyledComponentBase<Props> extends React.PureComponent<
|
||||
Props,
|
||||
StyledComponentState,
|
||||
> {
|
||||
constructor(props: Props, context: Object): void {
|
||||
super(props, context);
|
||||
this.state = {
|
||||
classNames: [],
|
||||
extraClassNames: [],
|
||||
lastBuiltRulesIsDynamic: false,
|
||||
lastBuiltRules: null,
|
||||
};
|
||||
}
|
||||
|
||||
static defaultProps: ?$Shape<Props>;
|
||||
|
||||
static STYLED_CONFIG: {|
|
||||
tagName: TagName,
|
||||
ignoreAttributes: ?Array<string>,
|
||||
builtRules: any,
|
||||
|};
|
||||
|
||||
static extends(
|
||||
rules: RawRules,
|
||||
opts?: StyledComponentOpts,
|
||||
): StyledComponent<any> {
|
||||
return createStyledComponent(this, rules, opts);
|
||||
}
|
||||
|
||||
componentWillMount(): void {
|
||||
this.generateClassnames(this.props, null);
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps: Props): void {
|
||||
this.generateClassnames(nextProps, this.props);
|
||||
}
|
||||
|
||||
componentWillUnmount(): void {
|
||||
for (const name of this.state.classNames) {
|
||||
gc.deregisterClassUse(name);
|
||||
}
|
||||
}
|
||||
|
||||
generateClassnames(props: Props, prevProps: ?Props): void {
|
||||
throw new Error('unimplemented');
|
||||
}
|
||||
}
|
||||
|
||||
function hasDynamicRules(rules: Object): boolean {
|
||||
for (const key in rules) {
|
||||
const val = rules[key];
|
||||
|
||||
if (typeof val === 'function') {
|
||||
return true;
|
||||
} else if (typeof val === 'object' && hasDynamicRules(val)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasEquivProps(props: Object, nextProps: Object): boolean {
|
||||
// check if the props are equivalent
|
||||
for (const key in props) {
|
||||
// ignore `children` since we do that check later
|
||||
if (key === 'children') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// check strict equality of prop value
|
||||
if (nextProps[key] !== props[key]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// check if nextProps has any values that props doesn't
|
||||
for (const key in nextProps) {
|
||||
if (!(key in props)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// check if the boolean equality of children is equivalent
|
||||
if (Boolean(props.children) !== Boolean(nextProps.children)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export type StyledComponent<Props> = Class<StyledComponentBase<Props>>;
|
||||
|
||||
type StyledComponentOpts = {
|
||||
displayName?: string,
|
||||
contextTypes?: Object,
|
||||
ignoreAttributes?: Array<string>,
|
||||
};
|
||||
|
||||
function createStyledComponent(
|
||||
tagName: TagName,
|
||||
rules: RawRules,
|
||||
opts?: StyledComponentOpts = {},
|
||||
): StyledComponent<any> {
|
||||
let {contextTypes = {}, ignoreAttributes} = opts;
|
||||
|
||||
// build up rules
|
||||
let builtRules = normaliseRules(rules);
|
||||
|
||||
// if inheriting from another styled component then take all of it's properties
|
||||
if (typeof tagName === 'function' && tagName.STYLED_CONFIG) {
|
||||
// inherit context types
|
||||
if (tagName.contextTypes) {
|
||||
contextTypes = {...contextTypes, ...tagName.contextTypes};
|
||||
}
|
||||
|
||||
const parentConfig = tagName.STYLED_CONFIG;
|
||||
|
||||
// inherit tagname
|
||||
tagName = parentConfig.tagName;
|
||||
|
||||
// inherit ignoreAttributes
|
||||
if (parentConfig.ignoreAttributes) {
|
||||
if (ignoreAttributes) {
|
||||
ignoreAttributes = ignoreAttributes.concat(
|
||||
parentConfig.ignoreAttributes,
|
||||
);
|
||||
} else {
|
||||
ignoreAttributes = parentConfig.ignoreAttributes;
|
||||
}
|
||||
}
|
||||
|
||||
// inherit rules
|
||||
builtRules = assignDeep({}, parentConfig.builtRules, builtRules);
|
||||
}
|
||||
|
||||
const displayName: string =
|
||||
opts.displayName == null ? 'StyledComponent' : opts.displayName;
|
||||
const isDOM = typeof tagName === 'string';
|
||||
|
||||
class Constructor<Props: Object> extends StyledComponentBase<Props> {
|
||||
generateClassnames(props: Props, prevProps: ?Props) {
|
||||
// if this is a secondary render then check if the props are essentially equivalent
|
||||
// NOTE: hasEquivProps is not a standard shallow equality test
|
||||
if (prevProps != null && hasEquivProps(props, prevProps)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const debugId = performance.mark();
|
||||
const extraClassNames = [];
|
||||
|
||||
let myBuiltRules = builtRules;
|
||||
|
||||
// if passed any classes from another styled component, ignore that class and merge in their
|
||||
// resolved styles
|
||||
if (props.className) {
|
||||
const propClassNames = props.className.trim().split(/[\s]+/g);
|
||||
for (const className of propClassNames) {
|
||||
const classInfo = tracker.get(className);
|
||||
if (classInfo) {
|
||||
const {namespace, style} = classInfo;
|
||||
myBuiltRules = assignDeep({}, myBuiltRules, {[namespace]: style});
|
||||
} else {
|
||||
extraClassNames.push(className);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// if we had the exact same rules as last time and they weren't dynamic then we can bail out here
|
||||
if (
|
||||
myBuiltRules !== this.state.lastBuiltRules ||
|
||||
this.state.lastBuiltRulesIsDynamic !== false
|
||||
) {
|
||||
const prevClasses = this.state.classNames;
|
||||
const classNames = [];
|
||||
|
||||
// add rules
|
||||
for (const namespace in myBuiltRules) {
|
||||
const className = addRules(
|
||||
displayName,
|
||||
myBuiltRules[namespace],
|
||||
namespace,
|
||||
props,
|
||||
this.context,
|
||||
);
|
||||
classNames.push(className);
|
||||
|
||||
// if this is the first mount render or we didn't previously have this class then add it as new
|
||||
if (prevProps == null || !prevClasses.includes(className)) {
|
||||
gc.registerClassUse(className);
|
||||
}
|
||||
}
|
||||
|
||||
// check what classNames have been removed if this is a secondary render
|
||||
if (prevProps != null) {
|
||||
for (const className of prevClasses) {
|
||||
// if this previous class isn't in the current classes then deregister it
|
||||
if (!classNames.includes(className)) {
|
||||
gc.deregisterClassUse(className);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({
|
||||
classNames,
|
||||
lastBuiltRules: myBuiltRules,
|
||||
lastBuiltRulesIsDynamic: hasDynamicRules(myBuiltRules),
|
||||
extraClassNames,
|
||||
});
|
||||
}
|
||||
|
||||
performance.measure(
|
||||
debugId,
|
||||
`🚀 ${this.constructor.name} [style calculate]`,
|
||||
);
|
||||
}
|
||||
render() {
|
||||
const {children, innerRef, ...props} = this.props;
|
||||
|
||||
if (ignoreAttributes) {
|
||||
for (const key of ignoreAttributes) {
|
||||
delete props[key];
|
||||
}
|
||||
}
|
||||
// build class names
|
||||
const className = this.state.classNames
|
||||
.concat(this.state.extraClassNames)
|
||||
.join(' ');
|
||||
if (props.is) {
|
||||
props.class = className;
|
||||
} else {
|
||||
props.className = className;
|
||||
}
|
||||
//
|
||||
if (innerRef) {
|
||||
if (isDOM) {
|
||||
// dom ref
|
||||
props.ref = innerRef;
|
||||
} else {
|
||||
// probably another styled component so pass it down
|
||||
props.innerRef = innerRef;
|
||||
}
|
||||
}
|
||||
return React.createElement(tagName, props, children);
|
||||
}
|
||||
}
|
||||
Constructor.STYLED_CONFIG = {
|
||||
builtRules,
|
||||
ignoreAttributes,
|
||||
tagName,
|
||||
};
|
||||
|
||||
Constructor.contextTypes = {
|
||||
...contextTypes,
|
||||
};
|
||||
|
||||
Object.defineProperty(Constructor, 'name', {
|
||||
value: displayName,
|
||||
});
|
||||
|
||||
return Constructor;
|
||||
}
|
||||
export function buildKeyframes(spec: KeyframeRules) {
|
||||
let css = [];
|
||||
|
||||
const builtRules = buildKeyframeRules(spec);
|
||||
for (const key in builtRules) {
|
||||
const declarations = [];
|
||||
const rules = builtRules[key];
|
||||
|
||||
for (const key in rules) {
|
||||
declarations.push(` ${key}: ${String(rules[key])};`);
|
||||
}
|
||||
css.push(` ${key} {`);
|
||||
css = css.concat(declarations);
|
||||
css.push(' }');
|
||||
}
|
||||
css = css.join('\n');
|
||||
return css;
|
||||
}
|
||||
function createKeyframes(spec: KeyframeRules): string {
|
||||
const body = buildKeyframes(spec);
|
||||
const className = `animation-${hash(body)}`;
|
||||
|
||||
const css = `@keyframes ${className} {\n${body}\n}`;
|
||||
sheet.insert(className, css);
|
||||
return className;
|
||||
}
|
||||
type StyledComponentFactory = (
|
||||
rules: RawRules,
|
||||
opts?: StyledComponentOpts,
|
||||
) => StyledComponent<any>;
|
||||
|
||||
function createStyledComponentFactory(tagName: string): StyledComponentFactory {
|
||||
return (rules: RawRules, opts?: StyledComponentOpts) => {
|
||||
return createStyledComponent(tagName, rules, opts);
|
||||
};
|
||||
}
|
||||
export default {
|
||||
image: createStyledComponentFactory('img'),
|
||||
view: createStyledComponentFactory('div'),
|
||||
text: createStyledComponentFactory('span'),
|
||||
textInput: createStyledComponentFactory('input'),
|
||||
customHTMLTag: createStyledComponent,
|
||||
keyframes: createKeyframes,
|
||||
StylableComponent,
|
||||
StylablePureComponent,
|
||||
};
|
||||
180
src/ui/styled/rules.js
Normal file
180
src/ui/styled/rules.js
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* 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 {CSSPropertySet, CSSPropertyValue} from './types.js';
|
||||
|
||||
const dashify = require('dashify');
|
||||
|
||||
export type NormalisedRules = {
|
||||
[namespace: string]: BaseRules,
|
||||
};
|
||||
|
||||
export type BaseRules = {
|
||||
[key: string]: CSSPropertyValue<string | number>,
|
||||
};
|
||||
|
||||
export type PlainRules = {
|
||||
[key: string]: string,
|
||||
};
|
||||
|
||||
export type NormalisedKeyframeRules = {
|
||||
[key: string]: PlainRules,
|
||||
};
|
||||
|
||||
export type KeyframeRules = {
|
||||
[key: string]: CSSPropertySet,
|
||||
};
|
||||
|
||||
export type RawRules = {
|
||||
...CSSPropertySet,
|
||||
[key: string]: CSSPropertySet,
|
||||
};
|
||||
|
||||
const unitlessNumberProperties = new Set([
|
||||
'animation-iteration-count',
|
||||
'border-image-outset',
|
||||
'border-image-slice',
|
||||
'border-image-width',
|
||||
'column-count',
|
||||
'flex',
|
||||
'flex-grow',
|
||||
'flex-positive',
|
||||
'flex-shrink',
|
||||
'flex-order',
|
||||
'grid-row',
|
||||
'grid-column',
|
||||
'font-weight',
|
||||
'line-clamp',
|
||||
'line-height',
|
||||
'opacity',
|
||||
'order',
|
||||
'orphans',
|
||||
'tab-size',
|
||||
'widows',
|
||||
'z-index',
|
||||
'zoom',
|
||||
'fill-opacity',
|
||||
'flood-opacity',
|
||||
'stop-opacity',
|
||||
'stroke-dasharray',
|
||||
'stroke-dashoffset',
|
||||
'stroke-miterlimit',
|
||||
'stroke-opacity',
|
||||
'stroke-width',
|
||||
]);
|
||||
|
||||
// put top level styles into an '&' object
|
||||
function expandRules(rules: RawRules): NormalisedRules {
|
||||
const expandedRules = {};
|
||||
const rootRules = {};
|
||||
|
||||
for (const key in rules) {
|
||||
const val = rules[key];
|
||||
|
||||
if (typeof val === 'object') {
|
||||
expandedRules[key] = val;
|
||||
} else {
|
||||
rootRules[key] = val;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(rootRules).length) {
|
||||
expandedRules['&'] = rootRules;
|
||||
}
|
||||
|
||||
return expandedRules;
|
||||
}
|
||||
|
||||
function shouldAppendPixel(key: string, val: mixed): boolean {
|
||||
return (
|
||||
typeof val === 'number' && !unitlessNumberProperties.has(key) && !isNaN(val)
|
||||
);
|
||||
}
|
||||
|
||||
export function normaliseRules(rules: RawRules): NormalisedRules {
|
||||
const expandedRules = expandRules(rules);
|
||||
|
||||
const builtRules = {};
|
||||
|
||||
for (const key in expandedRules) {
|
||||
const rules = expandedRules[key];
|
||||
const myRules = {};
|
||||
|
||||
for (const key in rules) {
|
||||
const val = rules[key];
|
||||
|
||||
let dashedKey = dashify(key);
|
||||
if (/[A-Z]/.test(key[0])) {
|
||||
dashedKey = `-${dashedKey}`;
|
||||
}
|
||||
|
||||
myRules[dashedKey] = val;
|
||||
}
|
||||
|
||||
if (Object.keys(myRules).length) {
|
||||
builtRules[key] = myRules;
|
||||
}
|
||||
}
|
||||
|
||||
return builtRules;
|
||||
}
|
||||
|
||||
export function buildKeyframeRules(
|
||||
rules: KeyframeRules,
|
||||
): NormalisedKeyframeRules {
|
||||
const spec = {};
|
||||
|
||||
for (const selector in rules) {
|
||||
const newRules = {};
|
||||
|
||||
const rules2 = rules[selector];
|
||||
if (!rules2 || typeof rules2 !== 'object') {
|
||||
throw new Error('Keyframe spec must only have objects');
|
||||
}
|
||||
|
||||
for (const key in rules2) {
|
||||
let val = rules2[key];
|
||||
|
||||
if (shouldAppendPixel(key, val)) {
|
||||
val += 'px';
|
||||
} else if (typeof val === 'number') {
|
||||
val = String(val);
|
||||
}
|
||||
|
||||
if (typeof val !== 'string') {
|
||||
throw new Error('Keyframe objects must only have strings values');
|
||||
}
|
||||
|
||||
newRules[key] = val;
|
||||
}
|
||||
|
||||
spec[selector] = newRules;
|
||||
}
|
||||
|
||||
return spec;
|
||||
}
|
||||
|
||||
export function buildRules(
|
||||
rules: BaseRules,
|
||||
props: NormalisedRules,
|
||||
context: Object,
|
||||
): PlainRules {
|
||||
const style = {};
|
||||
for (const key in rules) {
|
||||
let val = rules[key];
|
||||
if (typeof val === 'function') {
|
||||
val = val(props, context);
|
||||
}
|
||||
if (val != null && shouldAppendPixel(key, val)) {
|
||||
val += 'px';
|
||||
}
|
||||
if (val != null) {
|
||||
style[key] = String(val);
|
||||
}
|
||||
}
|
||||
return style;
|
||||
}
|
||||
92
src/ui/styled/sheet.js
Normal file
92
src/ui/styled/sheet.js
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
const invariant = require('invariant');
|
||||
|
||||
function makeStyleTag(): HTMLStyleElement {
|
||||
const tag = document.createElement('style');
|
||||
tag.type = 'text/css';
|
||||
tag.appendChild(document.createTextNode(''));
|
||||
|
||||
const {head} = document;
|
||||
invariant(head, 'expected head');
|
||||
head.appendChild(tag);
|
||||
|
||||
return tag;
|
||||
}
|
||||
|
||||
export class StyleSheet {
|
||||
constructor(isSpeedy?: boolean) {
|
||||
this.injected = false;
|
||||
this.isSpeedy = Boolean(isSpeedy);
|
||||
|
||||
this.flush();
|
||||
this.inject();
|
||||
}
|
||||
|
||||
ruleIndexes: Array<string>;
|
||||
injected: boolean;
|
||||
isSpeedy: boolean;
|
||||
tag: HTMLStyleElement;
|
||||
|
||||
getRuleCount(): number {
|
||||
return this.ruleIndexes.length;
|
||||
}
|
||||
|
||||
flush() {
|
||||
this.ruleIndexes = [];
|
||||
if (this.tag) {
|
||||
this.tag.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
inject() {
|
||||
if (this.injected) {
|
||||
throw new Error('already injected stylesheet!');
|
||||
}
|
||||
|
||||
this.tag = makeStyleTag();
|
||||
this.injected = true;
|
||||
}
|
||||
|
||||
delete(key: string) {
|
||||
const index = this.ruleIndexes.indexOf(key);
|
||||
if (index < 0) {
|
||||
// TODO maybe error
|
||||
return;
|
||||
}
|
||||
|
||||
this.ruleIndexes.splice(index, 1);
|
||||
|
||||
const tag = this.tag;
|
||||
if (this.isSpeedy) {
|
||||
const sheet = tag.sheet;
|
||||
invariant(sheet, 'expected sheet');
|
||||
|
||||
// $FlowFixMe: sheet is actually CSSStylesheet
|
||||
sheet.deleteRule(index);
|
||||
} else {
|
||||
tag.removeChild(tag.childNodes[index + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
insert(key: string, rule: string) {
|
||||
const tag = this.tag;
|
||||
|
||||
if (this.isSpeedy) {
|
||||
const sheet = tag.sheet;
|
||||
invariant(sheet, 'expected sheet');
|
||||
|
||||
// $FlowFixMe: sheet is actually CSSStylesheet
|
||||
sheet.insertRule(rule, sheet.cssRules.length);
|
||||
} else {
|
||||
tag.appendChild(document.createTextNode(rule));
|
||||
}
|
||||
|
||||
this.ruleIndexes.push(key);
|
||||
}
|
||||
}
|
||||
1280
src/ui/styled/types.js
Normal file
1280
src/ui/styled/types.js
Normal file
File diff suppressed because it is too large
Load Diff
392
src/ui/virtualized/DynamicList.js
Normal file
392
src/ui/virtualized/DynamicList.js
Normal file
@@ -0,0 +1,392 @@
|
||||
/**
|
||||
* 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 {RowRenderer, OnScroll, KeyMapper} from './types.js';
|
||||
import {PureComponent, Component} from 'react';
|
||||
import {ResizeSensor} from '../index.js';
|
||||
import {findDOMNode} from 'react-dom';
|
||||
|
||||
type RowMeasureProps = {
|
||||
id: string,
|
||||
onMount: (key: string, ref: ?Text | Element) => void,
|
||||
children: React$Node,
|
||||
};
|
||||
|
||||
class RowMeasure extends PureComponent<RowMeasureProps> {
|
||||
componentDidMount() {
|
||||
this.props.onMount(this.props.id, findDOMNode(this));
|
||||
}
|
||||
|
||||
render() {
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
const CONTAINER_STYLE = {
|
||||
position: 'relative',
|
||||
overflow: 'auto',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
};
|
||||
|
||||
type DynamicListProps = {
|
||||
pureData: any,
|
||||
onMount?: () => void,
|
||||
getPrecalculatedDimensions: (
|
||||
index: number,
|
||||
) => ?{
|
||||
width: number | string,
|
||||
height: number,
|
||||
},
|
||||
rowCount: number,
|
||||
rowRenderer: RowRenderer,
|
||||
keyMapper: KeyMapper,
|
||||
onScroll?: OnScroll,
|
||||
sideScrollable?: boolean,
|
||||
};
|
||||
|
||||
type DynamicListState = {
|
||||
mounted: boolean,
|
||||
startIndex: number,
|
||||
endIndex: number,
|
||||
containerStyle: Object,
|
||||
innerStyle: Object,
|
||||
scrollHeight: number,
|
||||
scrollTop: number,
|
||||
height: number,
|
||||
width: number,
|
||||
};
|
||||
|
||||
export default class DynamicList extends Component<
|
||||
DynamicListProps,
|
||||
DynamicListState,
|
||||
> {
|
||||
constructor(props: DynamicListProps, context: Object) {
|
||||
super(props, context);
|
||||
|
||||
this.topPositionToIndex = new Map();
|
||||
this.positions = new Map();
|
||||
this.dimensions = new Map();
|
||||
this.measureQueue = new Map();
|
||||
|
||||
this.state = {
|
||||
mounted: false,
|
||||
startIndex: -1,
|
||||
endIndex: -1,
|
||||
containerStyle: {},
|
||||
innerStyle: {},
|
||||
scrollHeight: 0,
|
||||
scrollTop: 0,
|
||||
height: 0,
|
||||
width: 0,
|
||||
};
|
||||
}
|
||||
|
||||
containerRef: ?HTMLDivElement;
|
||||
|
||||
measureQueue: Map<string, React$Node>;
|
||||
|
||||
topPositionToIndex: Map<number, number>;
|
||||
positions: Map<
|
||||
number,
|
||||
{
|
||||
top: number,
|
||||
style: Object,
|
||||
},
|
||||
>;
|
||||
|
||||
dimensions: Map<
|
||||
string,
|
||||
{
|
||||
width: number | string,
|
||||
height: number,
|
||||
},
|
||||
>;
|
||||
|
||||
scrollToIndex = (index: number, additionalOffset: number = 0) => {
|
||||
const pos = this.positions.get(index);
|
||||
const ref = this.getContainerRef();
|
||||
if (pos != null && ref != null) {
|
||||
ref.scrollTop = pos.top - additionalOffset;
|
||||
}
|
||||
};
|
||||
|
||||
setContainerRef = (ref: ?HTMLDivElement) => {
|
||||
this.containerRef = ref;
|
||||
};
|
||||
|
||||
getContainerRef(): ?HTMLDivElement {
|
||||
return this.containerRef;
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps: DynamicListProps) {
|
||||
if (
|
||||
nextProps.rowCount !== this.props.rowCount ||
|
||||
nextProps.pureData !== this.props.pureData
|
||||
) {
|
||||
this.queueMeasurements(nextProps);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// perform initial measurements and container dimension calculation
|
||||
this.recalculateContainerDimensions();
|
||||
this.queueMeasurements(this.props);
|
||||
|
||||
// if onMount we didn't add any measurements then we've successfully calculated all row sizes
|
||||
if (this.measureQueue.size === 0) {
|
||||
this.onMount();
|
||||
}
|
||||
}
|
||||
|
||||
onMount() {
|
||||
this.setState(state => {
|
||||
if (state.mounted === false && this.props.onMount != null) {
|
||||
this.props.onMount();
|
||||
}
|
||||
return {mounted: true};
|
||||
});
|
||||
}
|
||||
|
||||
// called when the window is resized, we recalculate the positions and visibility of rows
|
||||
onResize = (e: UIEvent) => {
|
||||
this.dimensions.clear();
|
||||
this.queueMeasurements(this.props);
|
||||
this.recalculateContainerDimensions();
|
||||
this.recalculateVisibleRows(this.props);
|
||||
};
|
||||
|
||||
queueMeasurements(props: DynamicListProps) {
|
||||
// create measurements for new rows
|
||||
for (let i = 0; i < props.rowCount; i++) {
|
||||
const key = props.keyMapper(i);
|
||||
if (this.dimensions.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const precalculated = props.getPrecalculatedDimensions(i);
|
||||
if (precalculated) {
|
||||
this.dimensions.set(key, precalculated);
|
||||
continue;
|
||||
}
|
||||
|
||||
this.measureQueue.set(
|
||||
key,
|
||||
props.rowRenderer({
|
||||
index: i,
|
||||
style: {
|
||||
visibility: 'hidden',
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// recalculate the visibility and positions of all rows
|
||||
this.recalculatePositions(props);
|
||||
this.recalculateVisibleRows(props);
|
||||
}
|
||||
|
||||
recalculateContainerDimensions = () => {
|
||||
const container = this.getContainerRef();
|
||||
if (container != null) {
|
||||
this.setState({
|
||||
scrollTop: container.scrollTop,
|
||||
height: container.clientHeight,
|
||||
width: container.clientWidth,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
recalculateVisibleRows = (props: DynamicListProps) => {
|
||||
this.setState(state => {
|
||||
let startTop = 0;
|
||||
|
||||
// find the start index
|
||||
let startIndex = 0;
|
||||
let scrollTop = state.scrollTop;
|
||||
do {
|
||||
const index = this.topPositionToIndex.get(scrollTop);
|
||||
if (index != null) {
|
||||
const startPos = this.positions.get(index);
|
||||
if (startPos != null) {
|
||||
startTop = startPos.top;
|
||||
startIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
scrollTop--;
|
||||
} while (scrollTop > 0);
|
||||
|
||||
// find the end index
|
||||
let endIndex = startIndex;
|
||||
let scrollBottom = state.scrollTop + state.height;
|
||||
while (true) {
|
||||
// if the scrollBottom is equal to the height of the scrollable area then
|
||||
// we were unable to find the end index because we're at the bottom of the
|
||||
// list
|
||||
if (scrollBottom >= state.scrollHeight) {
|
||||
endIndex = props.rowCount - 1;
|
||||
break;
|
||||
}
|
||||
|
||||
const index = this.topPositionToIndex.get(scrollBottom);
|
||||
if (index != null) {
|
||||
endIndex = index;
|
||||
break;
|
||||
}
|
||||
|
||||
scrollBottom++;
|
||||
}
|
||||
|
||||
if (
|
||||
startIndex === state.startIndex &&
|
||||
endIndex === state.endIndex &&
|
||||
startTop === state.containerStyle.top
|
||||
) {
|
||||
// this is to ensure that we don't create a new containerStyle object and obey reference equality for purity checks
|
||||
return {};
|
||||
}
|
||||
|
||||
const sideScrollable = props.sideScrollable || false;
|
||||
|
||||
return {
|
||||
startIndex,
|
||||
endIndex,
|
||||
containerStyle: sideScrollable
|
||||
? {
|
||||
position: 'absolute',
|
||||
top: startTop,
|
||||
left: 0,
|
||||
minWidth: '100%',
|
||||
}
|
||||
: {
|
||||
position: 'absolute',
|
||||
top: startTop,
|
||||
right: 0,
|
||||
left: 0,
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
onRowMeasured = (key: string, elem: ?Text | Element) => {
|
||||
if (elem != null && elem instanceof HTMLElement) {
|
||||
const dim = {
|
||||
height: elem.clientHeight,
|
||||
width: elem.clientWidth,
|
||||
};
|
||||
this.dimensions.set(key, dim);
|
||||
}
|
||||
|
||||
this.measureQueue.delete(key);
|
||||
|
||||
if (this.measureQueue.size === 0) {
|
||||
this.recalculatePositions(this.props);
|
||||
|
||||
if (this.state.mounted === false) {
|
||||
// we triggered measurements on componentDidMount and they're now complete!
|
||||
this.onMount();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handleScroll = () => {
|
||||
// recalcualte visible rows
|
||||
const ref = this.getContainerRef();
|
||||
if (ref != null) {
|
||||
this.setState({
|
||||
scrollTop: ref.scrollTop,
|
||||
});
|
||||
this.recalculateVisibleRows(this.props);
|
||||
|
||||
this.props.onScroll &&
|
||||
this.props.onScroll({
|
||||
clientHeight: ref.clientHeight,
|
||||
scrollHeight: ref.scrollHeight,
|
||||
scrollTop: ref.scrollTop,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
recalculatePositions(props: DynamicListProps) {
|
||||
this.positions.clear();
|
||||
this.topPositionToIndex.clear();
|
||||
|
||||
let top = 0;
|
||||
|
||||
for (let i = 0; i < props.rowCount; i++) {
|
||||
const key = props.keyMapper(i);
|
||||
const dim = this.dimensions.get(key);
|
||||
if (dim == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.positions.set(i, {
|
||||
top,
|
||||
style: {
|
||||
width: dim.width,
|
||||
height: dim.height,
|
||||
},
|
||||
});
|
||||
|
||||
this.topPositionToIndex.set(top, i);
|
||||
|
||||
top += dim.height;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
scrollHeight: top,
|
||||
innerStyle: {
|
||||
height: top,
|
||||
overflow: 'visibile',
|
||||
position: 'relative',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
// add elements to be measured
|
||||
const measureChildren = [];
|
||||
for (const [key, value] of this.measureQueue) {
|
||||
measureChildren.push(
|
||||
<RowMeasure key={key} id={key} onMount={this.onRowMeasured}>
|
||||
{value}
|
||||
</RowMeasure>,
|
||||
);
|
||||
}
|
||||
|
||||
// add visible rows
|
||||
const children = [];
|
||||
for (let i = this.state.startIndex; i <= this.state.endIndex; i++) {
|
||||
const pos = this.positions.get(i);
|
||||
if (pos == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
children.push(
|
||||
this.props.rowRenderer({
|
||||
index: i,
|
||||
style: pos.style,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={this.setContainerRef}
|
||||
onScroll={this.handleScroll}
|
||||
style={CONTAINER_STYLE}>
|
||||
<ResizeSensor onResize={this.onResize} />
|
||||
<div style={this.state.innerStyle}>
|
||||
<div style={this.state.containerStyle}>{children}</div>
|
||||
</div>
|
||||
{measureChildren}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
47
src/ui/virtualized/FixedList.js
Normal file
47
src/ui/virtualized/FixedList.js
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 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 {RowRenderer, OnScroll, KeyMapper} from './types.js';
|
||||
import DynamicList from './DynamicList.js';
|
||||
import {PureComponent} from 'react';
|
||||
|
||||
type FixedListProps = {
|
||||
pureData: any,
|
||||
rowCount: number,
|
||||
rowHeight: number,
|
||||
rowRenderer: RowRenderer,
|
||||
onScroll?: OnScroll,
|
||||
keyMapper: KeyMapper,
|
||||
innerRef?: (ref: DynamicList) => void,
|
||||
onMount?: () => void,
|
||||
sideScrollable?: boolean,
|
||||
};
|
||||
|
||||
export default class FixedList extends PureComponent<FixedListProps> {
|
||||
getPrecalculatedDimensions = () => {
|
||||
return {
|
||||
height: this.props.rowHeight,
|
||||
width: '100%',
|
||||
};
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<DynamicList
|
||||
ref={(this.props.innerRef: any)}
|
||||
onMount={this.props.onMount}
|
||||
pureData={this.props.pureData}
|
||||
rowCount={this.props.rowCount}
|
||||
rowRenderer={this.props.rowRenderer}
|
||||
keyMapper={this.props.keyMapper}
|
||||
onScroll={this.props.onScroll}
|
||||
sideScrollable={this.props.sideScrollable}
|
||||
getPrecalculatedDimensions={this.getPrecalculatedDimensions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
9
src/ui/virtualized/index.js
Normal file
9
src/ui/virtualized/index.js
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
export {default as FixedList} from './FixedList.js';
|
||||
export {default as DynamicList} from './DynamicList.js';
|
||||
19
src/ui/virtualized/types.js
Normal file
19
src/ui/virtualized/types.js
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
export type OnScroll = (params: {
|
||||
scrollHeight: number,
|
||||
scrollTop: number,
|
||||
clientHeight: number,
|
||||
}) => void;
|
||||
|
||||
export type KeyMapper = (index: number) => string;
|
||||
|
||||
export type RowRenderer = (params: {
|
||||
index: number,
|
||||
style: Object,
|
||||
}) => React$Node;
|
||||
Reference in New Issue
Block a user