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:
Daniel Büchele
2018-04-13 08:38:06 -07:00
committed by Daniel Buchele
commit fbbf8cf16b
659 changed files with 87130 additions and 0 deletions

View 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
View 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
View 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>);

View 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,
};

View 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>
);
}

View 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}
/>
);
}
}

View 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,
});

View 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'],
},
);

View 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',
});

View 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,
);
}
}

View 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>
);
}
}

View 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>;
}
}

View 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>
);
}
}

View 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
View 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;
}
}
}

View 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()));
}
}
}

View 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'],
},
);

View 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',
});

View 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',
});

View 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',
});

View 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
View 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)}
/>
);
}
}

View 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>;
}
}

View 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',
});

View 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>;
}
}

View 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;

View 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>
);
}
}

View 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
View 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>
);
}
}

View 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;

View 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>
);
}
}

View 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
View 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>
);
}
}

View 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>
);
}

View 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>,
];
}
}

View 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);
};
}

View 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>
);
}
}

View 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>
);
}
}

View 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
View 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
View 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
View 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;

View 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;

View 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'],
},
);

View 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} />
);
}
}

View 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;

View 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>
);
}
}

View 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
View 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;

View 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);
}
}

View 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
View 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',
};

View 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>
);
}
}

View 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>&nbsp;{val.name}()</FunctionName>
</span>
);
case 'symbol':
return <SymbolValue>Symbol()</SymbolValue>;
default:
return <span>Unknown type "{type}"</span>;
}
}
}

View 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>
);
}
}

View 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;
}
}
}

View 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}
/>
);
}
}

View 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);
}

View 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',
},
});

View 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>
);
}
}

View 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>
);
}
}

View 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;
}
}

View 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>
);
}
}

View 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,
};

View 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>
);
}
}

View 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}>
&#8964;
</Chevron>
</Token>
);
}
}

View 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}>&times;</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;

View 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);

View 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}
/>
);
}
}

View 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>
);
}
}

View 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>;
}
}

View 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>
);
}
}

View 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;

View 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
View 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';

View 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);
});

View 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');
});

View 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');
});

View 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');
});
});

View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

View 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>
);
}
}

View 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}
/>
);
}
}

View 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';

View 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;