2018-02-12 09:28:31 -08:00
|
|
|
/**
|
|
|
|
* Copyright (c) 2014-present, Facebook, Inc.
|
|
|
|
* All rights reserved.
|
|
|
|
*
|
|
|
|
* This source code is licensed under the BSD-style license found in the
|
|
|
|
* LICENSE file in the root directory of this source tree. An additional grant
|
|
|
|
* of patent rights can be found in the PATENTS file in the same directory.
|
|
|
|
*
|
|
|
|
* @flow
|
|
|
|
* @format
|
|
|
|
*/
|
|
|
|
|
|
|
|
import React, {Component} from 'react';
|
2018-02-13 06:13:25 -08:00
|
|
|
import yoga from 'yoga-layout/dist/entry-browser';
|
2018-02-12 09:28:31 -08:00
|
|
|
import YogaNode from './YogaNode';
|
2018-02-14 10:51:57 -08:00
|
|
|
import CodeGenerators from './CodeGenerators';
|
|
|
|
import URLShortener from './URLShortener';
|
2018-02-12 09:28:31 -08:00
|
|
|
import Editor from './Editor';
|
|
|
|
import {List, setIn} from 'immutable';
|
|
|
|
import PositionRecord from './PositionRecord';
|
|
|
|
import LayoutRecord from './LayoutRecord';
|
|
|
|
import Sidebar from './Sidebar';
|
2018-02-14 10:51:57 -08:00
|
|
|
import {Row, Col} from 'antd';
|
2018-02-12 09:28:31 -08:00
|
|
|
import type {LayoutRecordT} from './LayoutRecord';
|
|
|
|
import type {Yoga$Direction} from 'yoga-layout';
|
|
|
|
import './index.css';
|
|
|
|
|
|
|
|
type Props = {
|
2018-02-14 07:53:08 -08:00
|
|
|
layoutDefinition: Object,
|
2018-02-12 09:28:31 -08:00
|
|
|
direction: Yoga$Direction,
|
|
|
|
maxDepth: number,
|
|
|
|
maxChildren?: number,
|
|
|
|
minChildren?: number,
|
|
|
|
selectedNodePath?: Array<number>,
|
|
|
|
showGuides: boolean,
|
|
|
|
className?: string,
|
|
|
|
height?: string | number,
|
2018-02-12 10:25:02 -08:00
|
|
|
persist?: boolean,
|
2018-02-12 09:28:31 -08:00
|
|
|
renderSidebar?: (layoutDefinition: LayoutRecordT, onChange: Function) => any,
|
|
|
|
};
|
|
|
|
|
|
|
|
type State = {
|
|
|
|
selectedNodePath: ?Array<number>,
|
|
|
|
layoutDefinition: LayoutRecordT,
|
|
|
|
direction: Yoga$Direction,
|
|
|
|
};
|
|
|
|
|
|
|
|
function getPath(path: Array<number>): Array<mixed> {
|
|
|
|
return path.reduce((acc, cv) => acc.concat('children', cv), []);
|
|
|
|
}
|
|
|
|
|
|
|
|
export default class Playground extends Component<Props, State> {
|
|
|
|
_containerRef: ?HTMLElement;
|
|
|
|
|
|
|
|
static defaultProps = {
|
2018-02-14 07:53:08 -08:00
|
|
|
layoutDefinition: {
|
2018-02-13 11:27:39 -08:00
|
|
|
width: 500,
|
|
|
|
height: 500,
|
2018-02-15 16:42:43 -08:00
|
|
|
children: [
|
|
|
|
{width: 100, height: 100},
|
|
|
|
{width: 100, height: 100},
|
|
|
|
{width: 100, height: 100},
|
|
|
|
],
|
2018-02-14 07:53:08 -08:00
|
|
|
},
|
2018-02-12 09:28:31 -08:00
|
|
|
direction: yoga.DIRECTION_LTR,
|
|
|
|
maxDepth: 3,
|
|
|
|
showGuides: true,
|
2018-02-12 10:25:02 -08:00
|
|
|
persist: false,
|
2018-02-12 09:28:31 -08:00
|
|
|
};
|
|
|
|
|
2018-02-14 07:53:08 -08:00
|
|
|
rehydrate = (node: Object): LayoutRecord => {
|
|
|
|
let record = LayoutRecord(node);
|
|
|
|
record = record.set('padding', PositionRecord(record.padding));
|
|
|
|
record = record.set('border', PositionRecord(record.border));
|
|
|
|
record = record.set('margin', PositionRecord(record.margin));
|
|
|
|
record = record.set('position', PositionRecord(record.position));
|
|
|
|
record = record.set('children', List(record.children.map(this.rehydrate)));
|
|
|
|
return record;
|
|
|
|
};
|
|
|
|
|
2018-02-12 09:28:31 -08:00
|
|
|
state = {
|
|
|
|
selectedNodePath: this.props.selectedNodePath,
|
2018-02-14 07:53:08 -08:00
|
|
|
layoutDefinition: this.rehydrate(this.props.layoutDefinition),
|
2018-02-12 09:28:31 -08:00
|
|
|
direction: this.props.direction,
|
|
|
|
};
|
|
|
|
|
|
|
|
componentDidMount() {
|
|
|
|
document.addEventListener('keydown', this.onKeyDown);
|
|
|
|
|
|
|
|
// rehydrate
|
|
|
|
if (window.location.hash && window.location.hash.length > 1) {
|
|
|
|
try {
|
|
|
|
const restoredState = JSON.parse(atob(window.location.hash.substr(1)));
|
|
|
|
this.setState({layoutDefinition: this.rehydrate(restoredState)});
|
|
|
|
} catch (e) {
|
|
|
|
window.location.hash = '';
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
componentWillUnmount() {
|
|
|
|
document.removeEventListener('keydown', this.onKeyDown);
|
|
|
|
}
|
|
|
|
|
|
|
|
onKeyDown = (e: KeyboardEvent) => {
|
|
|
|
if (e.key === 'Escape') {
|
|
|
|
this.hideSidePanes();
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
onMouseDown = (e: MouseEvent) => {
|
|
|
|
if (e.target === this._containerRef) {
|
|
|
|
this.hideSidePanes();
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
hideSidePanes() {
|
|
|
|
if (!Boolean(this.props.renderSidebar)) {
|
|
|
|
// only unselect if we don't have an external sidebar, otherwise the
|
|
|
|
// sidebar may rely on a certain node to be selected
|
|
|
|
this.setState({
|
|
|
|
selectedNodePath: null,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
onChangeLayout = (key: string, value: any) => {
|
|
|
|
const {selectedNodePath} = this.state;
|
|
|
|
if (selectedNodePath) {
|
|
|
|
this.modifyAtPath([...getPath(selectedNodePath), key], value);
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
onRemove = () => {
|
|
|
|
const {selectedNodePath, layoutDefinition} = this.state;
|
|
|
|
if (selectedNodePath) {
|
|
|
|
const index = selectedNodePath.pop();
|
2018-02-13 06:13:27 -08:00
|
|
|
const path = getPath(selectedNodePath).concat('children');
|
|
|
|
const updatedChildren = layoutDefinition.getIn(path).delete(index);
|
2018-02-12 09:28:31 -08:00
|
|
|
this.modifyAtPath(path, updatedChildren);
|
|
|
|
this.setState({selectedNodePath: null});
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
onAdd = () => {
|
|
|
|
const {selectedNodePath, layoutDefinition} = this.state;
|
|
|
|
if (selectedNodePath) {
|
2018-02-13 06:13:27 -08:00
|
|
|
const path = getPath(selectedNodePath).concat('children');
|
2018-02-15 16:42:43 -08:00
|
|
|
const updatedChildren = layoutDefinition
|
|
|
|
.getIn(path)
|
|
|
|
.push(LayoutRecord({width: 100, height: 100}));
|
2018-02-16 05:47:31 -08:00
|
|
|
this.modifyAtPath(path, updatedChildren);
|
2018-02-12 09:28:31 -08:00
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
modifyAtPath(
|
|
|
|
path: Array<any>,
|
|
|
|
value: any,
|
|
|
|
selectedNodePath?: ?Array<number> = this.state.selectedNodePath,
|
|
|
|
) {
|
|
|
|
// $FlowFixMe
|
|
|
|
const layoutDefinition = setIn(this.state.layoutDefinition, path, value);
|
|
|
|
this.setState({
|
|
|
|
layoutDefinition,
|
|
|
|
selectedNodePath,
|
|
|
|
});
|
|
|
|
|
2018-02-12 10:25:02 -08:00
|
|
|
if (this.props.persist) {
|
|
|
|
window.location.hash = btoa(
|
|
|
|
JSON.stringify(this.removeUnchangedProperties(layoutDefinition)),
|
|
|
|
);
|
|
|
|
}
|
2018-02-12 09:28:31 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
removeUnchangedProperties = (node: LayoutRecordT): Object => {
|
|
|
|
const untouchedLayout = LayoutRecord({});
|
|
|
|
const untouchedPosition = PositionRecord({});
|
|
|
|
const result = {};
|
|
|
|
if (!node.equals(untouchedLayout)) {
|
|
|
|
Object.keys(node.toJS()).forEach(key => {
|
|
|
|
if (key === 'children' && node.children.size > 0) {
|
|
|
|
result.children = node.children
|
|
|
|
.toJSON()
|
|
|
|
.map(this.removeUnchangedProperties);
|
|
|
|
} else if (
|
|
|
|
node[key] instanceof PositionRecord &&
|
|
|
|
!node[key].equals(untouchedPosition)
|
|
|
|
) {
|
|
|
|
result[key] = {};
|
|
|
|
Object.keys(untouchedPosition.toJS()).forEach(position => {
|
|
|
|
if (node[key][position] !== untouchedPosition[position]) {
|
|
|
|
result[key][position] = node[key][position];
|
|
|
|
}
|
|
|
|
});
|
|
|
|
} else if (node[key] !== untouchedLayout[key]) {
|
|
|
|
result[key] = node[key];
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
return result;
|
|
|
|
};
|
|
|
|
|
|
|
|
getChildrenCountForSelectedPath = (): number => {
|
|
|
|
const selectedNode: ?LayoutRecordT = (
|
|
|
|
this.state.selectedNodePath || []
|
|
|
|
).reduce(
|
|
|
|
(node: LayoutRecordT, cv) => node.children.get(cv),
|
|
|
|
this.state.layoutDefinition,
|
|
|
|
);
|
|
|
|
return selectedNode ? selectedNode.children.size : 0;
|
|
|
|
};
|
|
|
|
|
|
|
|
render() {
|
2018-02-14 10:51:57 -08:00
|
|
|
const {layoutDefinition, selectedNodePath, direction} = this.state;
|
2018-02-12 09:28:31 -08:00
|
|
|
const {height} = this.props;
|
|
|
|
|
|
|
|
const selectedNode: ?LayoutRecordT = selectedNodePath
|
|
|
|
? layoutDefinition.getIn(getPath(selectedNodePath))
|
|
|
|
: null;
|
|
|
|
|
|
|
|
const playground = (
|
|
|
|
<div
|
2018-02-15 08:25:14 -08:00
|
|
|
className={`Playground ${this.props.renderSidebar ? '' : 'standalone'}`}
|
2018-02-12 09:28:31 -08:00
|
|
|
onMouseDown={this.onMouseDown}
|
|
|
|
style={{height, maxHeight: height}}
|
|
|
|
ref={ref => {
|
|
|
|
this._containerRef = ref;
|
|
|
|
}}>
|
|
|
|
<YogaNode
|
|
|
|
layoutDefinition={layoutDefinition}
|
|
|
|
selectedNodePath={selectedNodePath}
|
2018-02-14 10:51:57 -08:00
|
|
|
onClick={selectedNodePath => this.setState({selectedNodePath})}
|
2018-02-12 09:28:31 -08:00
|
|
|
onDoubleClick={this.onAdd}
|
2018-02-14 10:51:57 -08:00
|
|
|
direction={direction}
|
2018-02-12 09:28:31 -08:00
|
|
|
showGuides={this.props.showGuides}
|
|
|
|
/>
|
2018-02-15 08:25:14 -08:00
|
|
|
{!this.props.renderSidebar && (
|
|
|
|
<Sidebar>
|
|
|
|
<div className="Actions">
|
|
|
|
<Row gutter={15}>
|
|
|
|
<Col span={12}>
|
|
|
|
<CodeGenerators
|
|
|
|
layoutDefinition={layoutDefinition}
|
|
|
|
direction={direction}
|
|
|
|
/>
|
|
|
|
</Col>
|
|
|
|
<Col span={12}>
|
|
|
|
<URLShortener />
|
|
|
|
</Col>
|
|
|
|
</Row>
|
2018-02-14 10:51:57 -08:00
|
|
|
</div>
|
2018-02-15 08:25:14 -08:00
|
|
|
{this.state.selectedNodePath ? (
|
|
|
|
<Editor
|
|
|
|
node={selectedNode}
|
|
|
|
selectedNodeIsRoot={
|
|
|
|
selectedNodePath ? selectedNodePath.length === 0 : false
|
|
|
|
}
|
|
|
|
onChangeLayout={this.onChangeLayout}
|
|
|
|
onChangeSetting={(key, value) => this.setState({[key]: value})}
|
|
|
|
direction={direction}
|
|
|
|
onRemove={
|
|
|
|
selectedNodePath && selectedNodePath.length > 0
|
|
|
|
? this.onRemove
|
|
|
|
: undefined
|
|
|
|
}
|
|
|
|
onAdd={
|
|
|
|
selectedNodePath &&
|
|
|
|
selectedNodePath.length < this.props.maxDepth
|
|
|
|
? this.onAdd
|
|
|
|
: undefined
|
|
|
|
}
|
|
|
|
/>
|
|
|
|
) : (
|
|
|
|
<div className="NoContent">
|
|
|
|
Select a node to edit its properties
|
|
|
|
</div>
|
|
|
|
)}
|
|
|
|
</Sidebar>
|
|
|
|
)}
|
2018-02-12 09:28:31 -08:00
|
|
|
</div>
|
|
|
|
);
|
|
|
|
|
|
|
|
if (this.props.renderSidebar) {
|
|
|
|
return (
|
|
|
|
<div className={`PlaygroundContainer ${this.props.className || ''}`}>
|
|
|
|
<div>
|
|
|
|
{this.props.renderSidebar(
|
2018-02-12 10:25:02 -08:00
|
|
|
layoutDefinition.getIn(getPath(selectedNodePath)),
|
2018-02-12 09:28:31 -08:00
|
|
|
this.onChangeLayout,
|
|
|
|
)}
|
|
|
|
</div>
|
|
|
|
{playground}
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
} else {
|
|
|
|
return playground;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|