diff --git a/src/components/toolchain/GraphicalEditor.css b/src/components/toolchain/GraphicalEditor.css index e759f5f9a8ccd2b7ecf08596185f95d329ffbb7c..1090005ef406a202452c30fa2c9b1a1d993a3101 100644 --- a/src/components/toolchain/GraphicalEditor.css +++ b/src/components/toolchain/GraphicalEditor.css @@ -3,14 +3,24 @@ svg { } .editorWrapper { - display: relative; height: 500px; width: 100%; background-color: grey; } -.editorWrapper > div { - display: absolute; +.editorWrapper.maximized { + position: fixed; + left: 5%; + top: 5%; + width: 90%; + height: 90%; + background-color: grey; + border: solid 1px grey; + border-radius: 2px; + box-shadow: 0 0 5px grey; +} + +.editorWrapper2 { height: 100%; width: 100%; background-color: white; @@ -25,3 +35,23 @@ svg { .fo:hover { filter: brightness(95%); } + +.tcBlock > .highlighted { + stroke-dasharray: 10, 10; +} + +.editorMenu { + background: rgba(255, 255, 255, 0.5); + position: absolute; + margin: auto; + margin-top: 0.5em; + right: 2em; +} + +.editorWrapper.maximized > .editorMenu { + right: 1em; +} + +text { + user-select: none; +} diff --git a/src/components/toolchain/GraphicalEditor.jsx b/src/components/toolchain/GraphicalEditor.jsx index 7da5c72ecf26e89dbde1a1f94af7b0578edc267b..b44c7e62c636f78d5346855bbd7456eaf5165583 100644 --- a/src/components/toolchain/GraphicalEditor.jsx +++ b/src/components/toolchain/GraphicalEditor.jsx @@ -2,11 +2,17 @@ import './GraphicalEditor.css'; import * as React from 'react'; import * as d3 from 'd3'; +import { + Button, + ButtonGroup, +} from 'reactstrap'; +import cn from 'classnames'; import Block from './ToolchainBlock.jsx'; import type { BlockType, NormalBlock, AnalyzerBlock, DatasetBlock } from './ToolchainBlock.jsx'; import type { BlockSet } from './ToolchainBlock.jsx'; import Connection from './ToolchainConnection.jsx'; import type { ConnectionType } from './ToolchainConnection.jsx'; +import HelpModal from './GraphicalEditorHelpModal.jsx'; import { ContextMenu, MenuItem, ContextMenuTrigger } from 'react-contextmenu'; import { changeObjFieldName, generateNewKey } from '@helpers'; @@ -52,6 +58,8 @@ type State = { y: number, }, selectedBlocks: BlockType[], + maximized: boolean, + helpActive: boolean, }; // note: all measurements are in px by default for svg @@ -76,6 +84,8 @@ export default class GraphicalEditor extends React.Component<Props, State> { y: -1, }, selectedBlocks: [], + maximized: false, + helpActive: false, }; // initializes d3 behaviours (most SVG interactions) @@ -102,11 +112,15 @@ export default class GraphicalEditor extends React.Component<Props, State> { svgContextMenu = null; // adjusts the zoomlevel - setZoomLevel = (zoomLevel: number) => this.setState({...this.state, zoomLevel: zoomLevel}); + setZoomLevel = (zoomLevel: number) => this.setState({zoomLevel: zoomLevel}); // zoom out zoomOut = () => this.setZoomLevel(this.state.zoomLevel + zoomAdjustmentAmount); // zoom in zoomIn = () => this.setZoomLevel(this.state.zoomLevel - zoomAdjustmentAmount); + // toggle maximized + toggleMaximized = () => this.setState({ maximized: !this.state.maximized }); + // toggles the help modal + toggleHelp = () => this.setState({ helpActive: !this.state.helpActive }); // generates the string representing the viewbox of the svg. The first two numbers represent the offset, the last two the scaling. getSvgViewBox = () => `0 0 ${ this.state.zoomLevel } ${ this.state.zoomLevel }`; @@ -179,7 +193,6 @@ export default class GraphicalEditor extends React.Component<Props, State> { // manually triggers the svg's context menu triggerSvgAction = (x: number, y: number, clickEvent: any) => { this.setState({ - ...this.state, svgActionLocation: { x, y, @@ -204,7 +217,6 @@ export default class GraphicalEditor extends React.Component<Props, State> { selectBlocks = (blocks: BlockType[]) => { this.setState({ - ...this.state, selectedBlocks: blocks, }); this.initD3Drag(); @@ -556,8 +568,37 @@ export default class GraphicalEditor extends React.Component<Props, State> { const selectBoxCoords = getBlocksBoundingBox(this.state.selectedBlocks.map(b => b.name)); return ( - <div className='editorWrapper'> - <div id='graph'> + <div className={cn('editorWrapper', {maximized: this.state.maximized})}> + <div className='editorMenu'> + <ButtonGroup> + <Button + size='sm' + onClick={this.zoomOut} + > + Zoom out + </Button> + <Button + size='sm' + onClick={this.zoomIn} + > + Zoom in + </Button> + <Button + size='sm' + onClick={this.toggleMaximized} + > + Maximize + </Button> + <Button + color='primary' + size='sm' + onClick={this.toggleHelp} + > + See help + </Button> + </ButtonGroup> + </div> + <div className='editorWrapper2'> <svg className='background' width={svgWidth} @@ -704,6 +745,7 @@ export default class GraphicalEditor extends React.Component<Props, State> { locMap={locMap[name]} data={data} set={set} + highlighted={isSelected !== undefined} handleClick={() => this.props.handleBlockClick ? this.props.handleBlockClick(name, set) : undefined} handleShiftClick={() => { const sels = this.state.selectedBlocks; @@ -740,6 +782,10 @@ export default class GraphicalEditor extends React.Component<Props, State> { </ContextMenuTrigger> { this.props.divChildren } </div> + <HelpModal + toggle={this.toggleHelp} + active={this.state.helpActive} + /> </div> ); } diff --git a/src/components/toolchain/GraphicalEditorHelpModal.jsx b/src/components/toolchain/GraphicalEditorHelpModal.jsx new file mode 100644 index 0000000000000000000000000000000000000000..db5656a5b0b847e80800d7f2900dccb9839071e7 --- /dev/null +++ b/src/components/toolchain/GraphicalEditorHelpModal.jsx @@ -0,0 +1,190 @@ +// @flow +import * as React from 'react'; +import { + Container, + Row, + Col, + TabContent, TabPane, + Alert, + Badge, + Modal, ModalBody, ModalHeader, ModalFooter, + Table, +} from 'reactstrap'; + +type Props = { + // func to close modal + toggle: () => any, + // is the modal active? + active: boolean, +}; + +class GraphicalEditorHelpModal extends React.PureComponent<Props> { + constructor(props: Props){ + super(props); + } + + render = () => { + return ( + <Modal + size='lg' + isOpen={this.props.active} + toggle={this.props.toggle} + fade={true} + onOpened={e => { + /* + const f = document.querySelector('#tcModalInitFocus'); + if(f && f.focus) + f.focus(); + */ + }} + > + <ModalHeader toggle={this.props.toggle}> + Graphical Editor + </ModalHeader> + <ModalBody> + <h3>Keyboard Shortcuts</h3> + <Table striped> + <thead> + <tr> + <th>Action</th> + <th>Shortcut</th> + <th>Additional Info</th> + </tr> + </thead> + <tbody> + <tr> + <td> + Pan vertically + </td> + <td><pre className='preInline'> + Scroll up/down + </pre></td> + <td> + You can also manually drag the scroll bar on the right edge of the editor. + </td> + </tr> + <tr> + <td> + Pan horizontally + </td> + <td> + <pre className='preInline'>Scroll left/right</pre> + {' '} + <span> + or + </span> + {' '} + <pre className='preInline'>Left arrow key & Right arrow key</pre> + </td> + <td> + You can also manually drag the scroll bar on the bottom edge of the editor. To scroll left or right, three-button mice will usually let you click & hold on the middle button to scroll freely. + </td> + </tr> + <tr> + <td> + Zoom in/out + </td> + <td><pre className='preInline'> + Shift + scroll up/down + </pre></td> + <td> + You can also use the {`"Zoom in"`} and {`"Zoom out"`} buttons in the top-right menu. + </td> + </tr> + <tr> + <td> + Edit a block + </td> + <td><pre className='preInline'> + Left click + </pre></td> + <td> + Left-clicking a block will open a modal where you can edit the block name, the synchronized channel, inputs, and outputs. + </td> + </tr> + <tr> + <td> + Move blocks + </td> + <td><pre className='preInline'> + Left click + drag + </pre></td> + <td> + You may move a block at a time by clicking & dragging on a block. You may move a selection or group of blocks by dragging the grey rectangle representing the selection or group. + </td> + </tr> + <tr> + <td> + Open a context menu + </td> + <td><pre className='preInline'> + Right click + </pre></td> + <td> + There are context menus for: blocks, connections, area selections, groups, and the background. + </td> + </tr> + <tr> + <td> + Select Multiple Blocks + </td> + <td> + <pre className='preInline'> + Shift + left click + </pre> + {' '} + <span> + or + </span> + {' '} + <pre className='preInline'> + Click + drag + </pre> + </td> + <td> + Shift + left-clicking a block will toggle selection. + </td> + </tr> + <tr> + <td> + Create a Connection between an output and an input + </td> + <td> + <pre className='preInline'> + Left click + drag + </pre> + {' '} + <span> + on the black rectangle next to the desired output, and release the drag on the black rectangle next to the desired input. + </span> + </td> + <td> + </td> + </tr> + <tr> + <td> + See more information + </td> + <td> + <pre className='preInline'> + Hover + </pre> + {' '} + <span> + Some elements in the editor have extra information found by hovering over the element for a couple seconds. + </span> + </td> + <td> + </td> + </tr> + </tbody> + </Table> + </ModalBody> + <ModalFooter> + </ModalFooter> + </Modal> + ); + } +} + +export default GraphicalEditorHelpModal; diff --git a/src/components/toolchain/ToolchainBlock.jsx b/src/components/toolchain/ToolchainBlock.jsx index 489dc80a0bcd266501278d164d44f95eacca21ef..a6c979588b95f7f204da78949bc9522e75b15012 100644 --- a/src/components/toolchain/ToolchainBlock.jsx +++ b/src/components/toolchain/ToolchainBlock.jsx @@ -25,6 +25,7 @@ import { Badge, } from 'reactstrap'; import { ContextMenuTrigger } from 'react-contextmenu'; +import cn from 'classnames'; export type NormalBlock = { inputs: string[], @@ -72,6 +73,7 @@ export type Props = { handleShiftClick: (name: string, set: BlockSet) => any, // the color of the channel the block is sync'd to channelColor: string, + highlighted: boolean, }; type State = { @@ -109,30 +111,20 @@ class ToolchainBlock extends React.PureComponent<Props, State> { return ( <g className='tcBlock'> - {/* right-click menu that wraps the background rectangle of the block */} - <ContextMenuTrigger - holdToDisplay={-1} - id='blockContextMenu' - name={name} - set={this.props.set} - collect={(props) => ({ name: props.name, set: props.set })} - renderTag='g' - > - {/* the background rectangle with the click handler */} - <rect id={blockNameToId(name)} className='fo' x={x} y={y} height={height} width={width} - stroke='black' - fill={color} - onClick={e => { - if(e.shiftKey) - this.props.handleShiftClick(name, this.props.set); - else - this.props.handleClick(name, this.props.set); - e.stopPropagation(); - e.preventDefault(); - }} - /> - </ContextMenuTrigger> - {/* block name clip path */} + <rect className={cn({highlighted: this.props.highlighted})} + x={x} y={y} height={height} width={width} + fill='white' + stroke='black' + /> + {/* rect indicating the sync'd channel */} + <rect + x={x + width - 16} + y={y} + width={16} + height={16} + fill={this.props.channelColor} + /> + {/* block name */} <text x={x} y={y + 16} @@ -142,15 +134,6 @@ class ToolchainBlock extends React.PureComponent<Props, State> { { name } </text> {/* the little upper-right block showing the color of the currently sync'd channel */} - <rect - x={x + width - 16} - y={y} - width={16} - height={16} - fill={this.props.channelColor} - > - <title>Synchronized to the {this.props.data.synchronized_channel || name} channel</title> - </rect> { /* draws the inputs (both the black rects for connections and the corresponding input name */ @@ -221,7 +204,7 @@ class ToolchainBlock extends React.PureComponent<Props, State> { <rect x={x} y={y} width={width} height={height}/> </clipPath> <clipPath id={nameClipId}> - <rect x={x} y={y} width={width} height={20}/> + <rect x={x} y={y} width={width - 16} height={20}/> </clipPath> <clipPath id={inputClipId}> <rect x={x} y={y} width={width / 2} height={height}/> @@ -229,6 +212,32 @@ class ToolchainBlock extends React.PureComponent<Props, State> { <clipPath id={outputClipId}> <rect x={x + width / 2} y={y} width={width / 2} height={height}/> </clipPath> + {/* right-click menu that wraps the background rectangle of the block */} + <ContextMenuTrigger + holdToDisplay={-1} + id='blockContextMenu' + name={name} + set={this.props.set} + collect={(props) => ({ name: props.name, set: props.set })} + renderTag='g' + > + {/* the background rectangle with the click handler */} + <rect id={blockNameToId(name)} className='fo' + x={x} y={y} height={height} width={width} + fill='white' + opacity={0.0001} + onClick={e => { + if(e.shiftKey) + this.props.handleShiftClick(name, this.props.set); + else + this.props.handleClick(name, this.props.set); + e.stopPropagation(); + e.preventDefault(); + }} + > + <title>{ name } synchronized to the {this.props.data.synchronized_channel || name} channel</title> + </rect> + </ContextMenuTrigger> </g> ); } diff --git a/src/components/toolchain/ToolchainEditor.css b/src/components/toolchain/ToolchainEditor.css index 0a3c27ec745236e1bea6399d552c7d0b685e9cd7..98e55a63e86957aa27ad681d0ad270e3cd20753d 100644 --- a/src/components/toolchain/ToolchainEditor.css +++ b/src/components/toolchain/ToolchainEditor.css @@ -5,7 +5,6 @@ svg { text:hover { clip-path: none; - user-select: text; } .preInline { diff --git a/src/components/toolchain/ToolchainEditor.jsx b/src/components/toolchain/ToolchainEditor.jsx index 8ba4f6a79acbea9cd7e5f226d0519b8b11c1cfa1..e666d5170742f058a11dedc87d4c1b73abd6f7d2 100644 --- a/src/components/toolchain/ToolchainEditor.jsx +++ b/src/components/toolchain/ToolchainEditor.jsx @@ -122,7 +122,6 @@ export class ToolchainEditor extends React.Component<Props, State> { componentWillReceiveProps = (nextProps: Props) => { this.setState({ - ...this.state, cache: getValidObj(nextProps.data), }); } @@ -246,7 +245,6 @@ export class ToolchainEditor extends React.Component<Props, State> { const newGroups = this.state.cache.extraContents.groups .map(({ name, blocks }) => ({ name, blocks: blocks.map(n => n === oldName ? newName : n) })); this.setState({ - ...this.state, cache: { ...this.state.cache, contents: newContents, @@ -424,7 +422,6 @@ export class ToolchainEditor extends React.Component<Props, State> { .map(({ name, blocks }) => ({ name, blocks: blocks.filter(n => n !== name) })) .filter(g => g.blocks.length !== 0); this.setState({ - ...this.state, cache: { ...this.state.cache, contents: newContents, @@ -600,7 +597,6 @@ export class ToolchainEditor extends React.Component<Props, State> { // copy either a block or array of blocks to the clipboard copyToClipboard = (data: any[]) => { this.setState({ - ...this.state, clipboard: data, }); } @@ -676,7 +672,6 @@ export class ToolchainEditor extends React.Component<Props, State> { active: true, }; this.setState({ - ...this.state, modalBlockInfo: newMBI, }); } @@ -687,98 +682,96 @@ export class ToolchainEditor extends React.Component<Props, State> { <Row> </Row> <Row> - <Col sm='12'> - <GraphicalEditor - interactable - repData={this.state.cache.contents.representation} - blocks={this.state.cache.contents.blocks} - datasets={this.state.cache.contents.datasets} - analyzers={this.state.cache.contents.analyzers} - connections={this.state.cache.contents.connections} - groups={this.state.cache.extraContents.groups} - handleBlockClick={this.handleBlockClick} - updateBlockLocation={this.updateBlockLocation} - createConnection={this.createConnection} - divChildren={ - <React.Fragment> - <ContextMenu id='blockContextMenu'> - <MenuItem - data={{ clicked: 'delete'}} - onClick={this.handleBlockContextMenu} - > - Delete Block - </MenuItem> - <MenuItem - data={{ clicked: 'copy'}} - onClick={this.handleBlockContextMenu} - > - Copy Block - </MenuItem> - </ContextMenu> - <ContextMenu id='connectionContextMenu'> - <MenuItem - data={{ clicked: 'delete'}} - onClick={this.handleConnectionContextMenu} - > - Delete Connection - </MenuItem> - </ContextMenu> - <ContextMenu id='svgContextMenu'> - <MenuItem - data={{ clicked: 'addBlock'}} - onClick={this.handleSvgContextMenu} - > - Add Block Here - </MenuItem> - <MenuItem - data={{ clicked: 'addDataset'}} - onClick={this.handleSvgContextMenu} - > - Add Dataset Here - </MenuItem> - <MenuItem - data={{ clicked: 'addAnalyzer'}} - onClick={this.handleSvgContextMenu} - > - Add Analyzer Here - </MenuItem> - { - this.state.clipboard !== undefined && - <MenuItem - data={{ clicked: 'paste'}} - onClick={this.handleSvgContextMenu} - > - Paste Here - </MenuItem> - } - </ContextMenu> - <ContextMenu id='groupContextMenu'> - <MenuItem - data={{ clicked: 'delete'}} - onClick={this.handleGroupContextMenu} - > - Delete Group - </MenuItem> - </ContextMenu> - <ContextMenu id='areaSelectContextMenu'> - <MenuItem - data={{ clicked: 'copy'}} - onClick={this.handleAreaSelectContextMenu} - > - Copy Blocks - </MenuItem> - <MenuItem - data={{ clicked: 'createGroup'}} - onClick={this.handleAreaSelectContextMenu} - > - Create Group - </MenuItem> - </ContextMenu> - </React.Fragment> - } - > - </GraphicalEditor> - </Col> + <GraphicalEditor + interactable + repData={this.state.cache.contents.representation} + blocks={this.state.cache.contents.blocks} + datasets={this.state.cache.contents.datasets} + analyzers={this.state.cache.contents.analyzers} + connections={this.state.cache.contents.connections} + groups={this.state.cache.extraContents.groups} + handleBlockClick={this.handleBlockClick} + updateBlockLocation={this.updateBlockLocation} + createConnection={this.createConnection} + divChildren={ + <React.Fragment> + <ContextMenu id='blockContextMenu'> + <MenuItem + data={{ clicked: 'delete'}} + onClick={this.handleBlockContextMenu} + > + Delete Block + </MenuItem> + <MenuItem + data={{ clicked: 'copy'}} + onClick={this.handleBlockContextMenu} + > + Copy Block + </MenuItem> + </ContextMenu> + <ContextMenu id='connectionContextMenu'> + <MenuItem + data={{ clicked: 'delete'}} + onClick={this.handleConnectionContextMenu} + > + Delete Connection + </MenuItem> + </ContextMenu> + <ContextMenu id='svgContextMenu'> + <MenuItem + data={{ clicked: 'addBlock'}} + onClick={this.handleSvgContextMenu} + > + Add Block Here + </MenuItem> + <MenuItem + data={{ clicked: 'addDataset'}} + onClick={this.handleSvgContextMenu} + > + Add Dataset Here + </MenuItem> + <MenuItem + data={{ clicked: 'addAnalyzer'}} + onClick={this.handleSvgContextMenu} + > + Add Analyzer Here + </MenuItem> + { + this.state.clipboard !== undefined && + <MenuItem + data={{ clicked: 'paste'}} + onClick={this.handleSvgContextMenu} + > + Paste Here + </MenuItem> + } + </ContextMenu> + <ContextMenu id='groupContextMenu'> + <MenuItem + data={{ clicked: 'delete'}} + onClick={this.handleGroupContextMenu} + > + Delete Group + </MenuItem> + </ContextMenu> + <ContextMenu id='areaSelectContextMenu'> + <MenuItem + data={{ clicked: 'copy'}} + onClick={this.handleAreaSelectContextMenu} + > + Copy Blocks + </MenuItem> + <MenuItem + data={{ clicked: 'createGroup'}} + onClick={this.handleAreaSelectContextMenu} + > + Create Group + </MenuItem> + </ContextMenu> + </React.Fragment> + } + > + </GraphicalEditor> </Row> </Container> ); @@ -796,7 +789,6 @@ export class ToolchainEditor extends React.Component<Props, State> { active={active} toggle={() => { this.setState({ - ...this.state, modalBlockInfo: { ...this.state.modalBlockInfo, active: false, @@ -857,7 +849,7 @@ export class ToolchainEditor extends React.Component<Props, State> { className='tcName' placeholder='New toolchain name...' value={this.state.cache.name} - onChange={e => this.setState({ ...this.state, cache: {...this.state.cache, 'name': e.target.value}})} + onChange={e => this.setState({ cache: {...this.state.cache, 'name': e.target.value}})} validateFunc={str => this.nameIsValid(str)} /> </FormGroup>