feat(@cockpit/editor): Make tabs draggable

Make tabs draggable so they can be arranged how the user would like.

The dragging functionality locks the tabs to the parent container.

Support for multiple rows of tabs.

Styling updates for selected tabs.
This commit is contained in:
emizzle 2019-03-29 18:02:59 +11:00 committed by Iuri Matias
parent c23316351e
commit f27cde9261
11 changed files with 248 additions and 81 deletions

View File

@ -50,6 +50,7 @@
"@storybook/react": "^4.1.6",
"@svgr/webpack": "2.4.1",
"ansi-to-html": "0.6.8",
"array-move": "2.0.0",
"autoscroll-react": "3.2.0",
"babel-core": "7.0.0-bridge.0",
"babel-eslint": "9.0.0",
@ -111,6 +112,7 @@
"react-redux": "5.1.1",
"react-router-dom": "4.3.1",
"react-scroll-to-component": "1.0.2",
"react-sortable-hoc": "1.8.3",
"react-treebeard": "3.1.0",
"reactstrap": "6.5.0",
"redux": "4.0.1",

View File

@ -449,6 +449,13 @@ export const removeEditorTabs = {
failure: () => action(REMOVE_EDITOR_TABS[FAILURE])
};
export const UPDATE_EDITOR_TABS = createRequestTypes('UPDATE_EDITOR_TABS');
export const updateEditorTabs = {
request: (editorTabs) => action(UPDATE_EDITOR_TABS[REQUEST], {editorTabs}),
success: () => action(UPDATE_EDITOR_TABS[SUCCESS]),
failure: () => action(UPDATE_EDITOR_TABS[FAILURE])
};
export const INIT_REGULAR_TXS = createRequestTypes('INIT_REGULAR_TXS');
export const initRegularTxs = {
request: () => action(INIT_REGULAR_TXS[REQUEST], {mode: 'on'}),

View File

@ -3,6 +3,8 @@ import * as monaco from 'monaco-editor';
import PropTypes from 'prop-types';
import FontAwesomeIcon from 'react-fontawesome';
import classNames from 'classnames';
import {SortableContainer, SortableElement} from 'react-sortable-hoc';
import arrayMove from 'array-move';
import {DARK_THEME, LIGHT_THEME} from '../constants';
@ -25,10 +27,43 @@ const initMonaco = (value, theme) => {
});
};
const Tab = SortableElement(({file, onTabClick, onTabClose, theme}) => {
return (
<li key={file.name} className={classNames("tab", "p-2", "pl-3", "pr-3", "list-inline-item", "mr-0", "border-right", "border-bottom",
{
'border-light': LIGHT_THEME === theme,
'border-dark': DARK_THEME === theme
},
{
'active': file.active
})}>
<a
href="#switch-file"
onClick={(e) => onTabClick(e, file)}
className="mr-3 text-muted d-inline-block align-middle"
>
{file.name}
</a>
<FontAwesomeIcon style={{cursor: 'pointer'}} onClick={() => onTabClose(file)} className="px-0 align-middle" name="close"/>
</li>
);
});
const Tabs = SortableContainer(({files, onTabClick, onTabClose, theme}) => {
return (
<ul className="list-inline m-0 p-0">
{files && files.map((file, index) => (
<Tab key={file.name} index={index} file={file} onTabClick={onTabClick} onTabClose={onTabClose} theme={theme} />
))}
</ul>
);
});
class TextEditor extends React.Component {
constructor(props) {
super(props);
this.state = {decorations: []};
this.tabsContainerRef = React.createRef();
}
componentDidMount() {
initMonaco();
@ -142,36 +177,33 @@ class TextEditor extends React.Component {
this.handleResize();
}
addEditorTabs(e, file) {
e.preventDefault(); this.props.addEditorTabs(file);
addEditorTabs = (e, file) => {
e.preventDefault();
this.props.addEditorTabs(file);
}
renderTabs() {
return (
<ul className="list-inline m-0 p-0">
{this.props.editorTabs.map(file => (
<li key={file.name} className={classNames("p-2", "list-inline-item", "mr-0", "border-right", {
'bg-white': file.active && LIGHT_THEME === this.props.theme,
'bg-black': file.active && DARK_THEME === this.props.theme
})}>
<a
href="#switch-tab"
onClick={(e) => this.addEditorTabs(e, file)}
className="p-2 text-muted"
>
{file.name}
</a>
<FontAwesomeIcon style={{cursor: 'pointer'}} onClick={() => this.props.removeEditorTabs(file)} className="px-1" name="close"/>
</li>
))}
</ul>
);
onSortEnd = ({oldIndex, newIndex}) => {
const editorTabs = arrayMove(this.props.editorTabs, oldIndex, newIndex);
this.props.updateEditorTabs(editorTabs);
}
render() {
return (
<div className="h-100 d-flex flex-column">
{this.renderTabs()}
<div className="h-100 d-flex flex-column tabs-container" ref={this.tabsContainerRef}>
<Tabs
axis="xy"
lockToContainerEdges={true}
lockOffset={0}
disableAutoScroll={true}
helperClass={"dragging"}
helperContainer={this.tabsContainerRef.current}
files={this.props.editorTabs}
theme={this.props.theme}
onTabClick={this.addEditorTabs}
onTabClose={this.props.removeEditorTabs}
onSortEnd={this.onSortEnd}
distance={3}
/>
<div style={{height: '100%'}} id={EDITOR_ID}/>
</div>
);
@ -187,7 +219,8 @@ TextEditor.propTypes = {
editorTabs: PropTypes.array,
removeEditorTabs: PropTypes.func,
addEditorTabs: PropTypes.func,
theme: PropTypes.string
theme: PropTypes.string,
updateEditorTabs: PropTypes.func
};
export default TextEditor;

View File

@ -1,46 +0,0 @@
.editor--grid {
margin-left: -30px !important;
margin-right: -30px !important;
margin-top: -1.5rem !important;
}
.hidden-toggle {
position: absolute;
bottom: 0;
right: 0;
left: 0;
height: 40px;
}
.text-editor__debuggerLine {
opacity: 0.4;
background-color: #20a8d8;
}
.text-editor-container {
overflow: hidden;
}
.editor-aside {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow-x: hidden;
overflow-y: auto;
}
@media (max-width: 768px) {
.editor-aside {
position: relative;
}
}
.resizer-handle {
width: 15px !important;
}
.resize-handle-horizontal {
height: 15px !important;
}

View File

@ -22,7 +22,7 @@ import {OPERATIONS} from '../constants';
import { TextEditorToolbarTabs } from '../components/TextEditorToolbar';
import PageHead from '../components/PageHead';
import './EditorContainer.css';
import './EditorContainer.scss';
class EditorContainer extends React.Component {
constructor(props) {
@ -207,19 +207,20 @@ class EditorContainer extends React.Component {
<React.Fragment>
<PageHead title="Editor" description="Create, read, edit, and delete your dApp's files. Interact and debug your dApp's contracts. Live preview your dApp when changes are saved." />
<Row noGutters
className={classnames('h-100', 'editor--grid', {'aside-opened': this.state.currentAsideTab.label})}>
className={classnames('editor--grid', 'editor--toolbar', {'aside-opened': this.state.currentAsideTab.label})}>
<Col xs={12}>
<TextEditorToolbarContainer toggleAsideTab={(newTab) => this.toggleAsideTab(newTab)}
isContract={this.isContract()}
currentFile={this.state.currentFile}
activeTab={this.state.currentAsideTab}/>
</Col>
</Row>
<Row noGutters
className={classnames('h-100', 'editor--grid', {'aside-opened': this.state.currentAsideTab.label})}>
<Col className="border-right">
<FileExplorerContainer showHiddenFiles={this.state.showHiddenFiles}
toggleShowHiddenFiles={() => this.toggleShowHiddenFiles()}/>
</Col>
{this.renderTextEditor()}
{this.renderAside()}

View File

@ -0,0 +1,118 @@
@import "../scss/variables/colors";
.editor--grid {
margin-left: -30px !important;
margin-right: -30px !important;
}
.editor--toolbar {
margin-top: -1.5rem !important;
}
.hidden-toggle {
position: absolute;
bottom: 0;
right: 0;
left: 0;
height: 40px;
}
.text-editor__debuggerLine {
opacity: 0.4;
background-color: #20a8d8;
}
.text-editor-container {
overflow: hidden;
}
.editor-aside {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow-x: hidden;
overflow-y: auto;
}
@media (max-width: 768px) {
.editor-aside {
position: relative;
}
}
.resizer-handle {
width: 15px !important;
}
.resize-handle-horizontal {
height: 15px !important;
}
.tabs-container {
ul {
background-color: $gray-100;
}
.tab {
background-color: $gray-200; /*#d2d2d2;*/
border-bottom-color: transparent !important;
margin-bottom: 1px;
/* disable text highlighting while dragging */
a {
-webkit-touch-callout: none; /* iOS Safari */
-webkit-user-select: none; /* Safari */
-khtml-user-select: none; /* Konqueror HTML */
-moz-user-select: none; /* Firefox */
-ms-user-select: none; /* Internet Explorer/Edge */
user-select: none; /* Non-prefixed version, currently
supported by Chrome and Opera */
}
a:hover {
text-decoration: none;
}
&:hover {
background-color: $gray-300;/*#b6b6b6;*/
}
&.active {
background-color: $gray-500; /*#949494;*/
border-bottom-color: $blue /* #3e2df4 */ !important;
a {
color: $white !important;
}
}
&.dragging .fa-close {
width: 10px;
}
&.border-right:not(:last-child) {
border-right: 1px solid $gray-100 /*#e4e5e6*/ !important;
}
}
.dark-theme & {
ul {
background-color: $gray-900; /*#212429;*/
}
.tab {
background-color: $gray-800;/*#2d3239;*/
&.active {
background-color: lighten($gray-base, 2%); /*#1c1c1c;*/
border-bottom-color: $green /*#05a720*/ !important;
}
&.border-right:not(:last-child) {
border-right: 1px solid $gray-900 /*#212328*/ !important;
}
}
}
}

View File

@ -6,7 +6,8 @@ import {
addEditorTabs as addEditorTabsAction,
fetchEditorTabs as fetchEditorTabsAction,
removeEditorTabs as removeEditorTabsAction,
toggleBreakpoint
toggleBreakpoint,
updateEditorTabs as updateEditorTabsAction
} from '../actions';
import {getBreakpointsByFilename, getDebuggerLine, getEditorTabs, getTheme} from '../reducers/selectors';
@ -25,6 +26,7 @@ class TextEditorContainer extends React.Component {
editorTabs={this.props.editorTabs}
removeEditorTabs={this.props.removeEditorTabs}
addEditorTabs={this.props.addEditorTabs}
updateEditorTabs={this.props.updateEditorTabs}
debuggerLine={this.props.debuggerLine}
onFileContentChange={this.props.onFileContentChange}
theme={this.props.theme}
@ -52,6 +54,7 @@ TextEditorContainer.propTypes = {
fetchEditorTabs: PropTypes.func,
removeEditorTabs: PropTypes.func,
addEditorTabs: PropTypes.func,
updateEditorTabs: PropTypes.func,
debuggerLine: PropTypes.number,
editorTabs: PropTypes.array,
theme: PropTypes.string
@ -63,7 +66,8 @@ export default connect(
toggleBreakpoint: toggleBreakpoint.request,
fetchEditorTabs: fetchEditorTabsAction.request,
removeEditorTabs: removeEditorTabsAction.request,
addEditorTabs: addEditorTabsAction.request
addEditorTabs: addEditorTabsAction.request,
updateEditorTabs: updateEditorTabsAction.request
},
null,
{ withRef: true }

View File

@ -78,9 +78,6 @@
text-decoration: none;
}
.bg-black {
background-color: #1C1C1C;
}
.dark-theme .modal-header span {
color: #ffffff;

View File

@ -105,6 +105,7 @@ export const verifyMessage = doRequest.bind(null, actions.verifyMessage, api.ver
export const fetchEditorTabs = doRequest.bind(null, actions.fetchEditorTabs, storage.fetchEditorTabs);
export const addEditorTabs = doRequest.bind(null, actions.addEditorTabs, storage.addEditorTabs);
export const removeEditorTabs = doRequest.bind(null, actions.removeEditorTabs, storage.removeEditorTabs);
export const updateEditorTabs = doRequest.bind(null, actions.updateEditorTabs, storage.updateEditorTabs);
export const explorerSearch = searchExplorer.bind(null, actions.explorerSearch);
@ -368,6 +369,11 @@ export function *watchRemoveEditorTabs() {
yield takeEvery(actions.REMOVE_EDITOR_TABS[actions.REQUEST], removeEditorTabs);
}
export function *watchUpdateEditorTabs() {
yield takeEvery(actions.UPDATE_EDITOR_TABS[actions.REQUEST], updateEditorTabs);
yield takeEvery(actions.UPDATE_EDITOR_TABS[actions.SUCCESS], fetchEditorTabs);
}
export function *watchAddEditorTabsSuccess() {
yield takeEvery(actions.ADD_EDITOR_TABS[actions.SUCCESS], fetchEditorTabs);
}
@ -643,6 +649,7 @@ export default function *root() {
fork(watchFetchEditorTabs),
fork(watchAddEditorTabs),
fork(watchRemoveEditorTabs),
fork(watchUpdateEditorTabs),
fork(watchAddEditorTabsSuccess),
fork(watchRemoveEditorTabsSuccess),
fork(watchPostFileSuccess),

View File

@ -1,4 +1,8 @@
/* global localStorage */
export async function updateEditorTabs({editorTabs}) {
localStorage.setItem('editorTabs', JSON.stringify(editorTabs));
return {response: {data: JSON.parse(localStorage.getItem('editorTabs'))}};
}
export async function addEditorTabs({file}) {
const editorTabs = findOrCreateEditorTabs();
@ -30,7 +34,7 @@ export async function fetchEditorTabs() {
export async function removeEditorTabs({file}) {
const editorTabs = findOrCreateEditorTabs();
const filtered = editorTabs.filter(value => value.name !== file.name);
if (file.active && filtered.length) {
if (filtered.length && (file.active || !filtered.some((tab) => tab.active))) {
filtered[0].active = true;
}
localStorage.setItem('editorTabs', JSON.stringify(filtered));

View File

@ -1271,6 +1271,13 @@
dependencies:
regenerator-runtime "^0.12.0"
"@babel/runtime@^7.2.0":
version "7.4.2"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.4.2.tgz#f5ab6897320f16decd855eed70b705908a313fe8"
integrity sha512-7Bl2rALb7HpvXFL7TETNzKSAeBVCPHELzc0C//9FCxN8nsiueWSJBqaF+2oIJScyILStASR/Cx5WMkXGYTiJFA==
dependencies:
regenerator-runtime "^0.13.2"
"@babel/template@^7.0.0", "@babel/template@^7.1.0", "@babel/template@^7.1.2":
version "7.1.2"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.1.2.tgz#090484a574fef5a2d2d7726a674eceda5c5b5644"
@ -3719,6 +3726,11 @@ array-map@~0.0.0:
resolved "https://registry.yarnpkg.com/array-map/-/array-map-0.0.0.tgz#88a2bab73d1cf7bcd5c1b118a003f66f665fa662"
integrity sha1-iKK6tz0c97zVwbEYoAP2b2ZfpmI=
array-move@2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/array-move/-/array-move-2.0.0.tgz#4aa2262560ec75bae7a08a69e186ef80d439f770"
integrity sha512-bF5o1eewLiCJeumuh6vRZsW7ouLCSAEWIpXSliFWIa8/6Cs630a69ooR+H8ExLz8p17/LVUbpXDoJdP08Cpv5A==
array-reduce@~0.0.0:
version "0.0.0"
resolved "https://registry.yarnpkg.com/array-reduce/-/array-reduce-0.0.0.tgz#173899d3ffd1c7d9383e4479525dbe278cab5f2b"
@ -15475,6 +15487,15 @@ prop-types@15.6.2, prop-types@^15.5.10, prop-types@^15.5.4, prop-types@^15.5.6,
loose-envify "^1.3.1"
object-assign "^4.1.1"
prop-types@^15.5.7:
version "15.7.2"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5"
integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==
dependencies:
loose-envify "^1.4.0"
object-assign "^4.1.1"
react-is "^16.8.1"
property-information@^5.0.0, property-information@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/property-information/-/property-information-5.0.1.tgz#c3b09f4f5750b1634c0b24205adbf78f18bdf94f"
@ -16007,6 +16028,11 @@ react-is@^16.3.2, react-is@^16.6.0, react-is@^16.7.0:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.7.0.tgz#c1bd21c64f1f1364c6f70695ec02d69392f41bfa"
integrity sha512-Z0VRQdF4NPDoI0tsXVMLkJLiwEBa+RP66g0xDHxgxysxSoCUccSten4RTF/UFvZF1dZvZ9Zu1sx+MDXwcOR34g==
react-is@^16.8.1:
version "16.8.6"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.8.6.tgz#5bbc1e2d29141c9fbdfed456343fe2bc430a6a16"
integrity sha512-aUk3bHfZ2bRSVFFbbeVS4i+lNPZr3/WM5jT2J5omUVV1zzcs1nAaf3l51ctA5FFvCRbhrH0bdAsRRQddFJZPtA==
react-json-view@1.19.1:
version "1.19.1"
resolved "https://registry.yarnpkg.com/react-json-view/-/react-json-view-1.19.1.tgz#95d8e59e024f08a25e5dc8f076ae304eed97cf5c"
@ -16147,6 +16173,15 @@ react-side-effect@^1.1.0:
exenv "^1.2.1"
shallowequal "^1.0.1"
react-sortable-hoc@1.8.3:
version "1.8.3"
resolved "https://registry.yarnpkg.com/react-sortable-hoc/-/react-sortable-hoc-1.8.3.tgz#0c80cda29f305038f985cbdb34d3030220e1ab5d"
integrity sha512-gGYj4Ph8rxmxOrW3gubvtlRqf0/3cpPnz/pd3s3msvgxYEtYZKhVBywFaHz12Xy9krN4H2YRWjhL9fnL9A/Smg==
dependencies:
"@babel/runtime" "^7.2.0"
invariant "^2.2.4"
prop-types "^15.5.7"
react-split-pane@^0.1.84:
version "0.1.85"
resolved "https://registry.yarnpkg.com/react-split-pane/-/react-split-pane-0.1.85.tgz#64819946a99b617ffa2d20f6f45a0056b6ee4faa"
@ -16492,6 +16527,11 @@ regenerator-runtime@^0.12.0, regenerator-runtime@^0.12.1:
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.12.1.tgz#fa1a71544764c036f8c49b13a08b2594c9f8a0de"
integrity sha512-odxIc1/vDlo4iZcfXqRYFj0vpXFNoGdKMAUieAlFYO6m/nl5e9KR/beGf41z4a1FI+aQgtjhuaSlDxQ0hmkrHg==
regenerator-runtime@^0.13.2:
version "0.13.2"
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.2.tgz#32e59c9a6fb9b1a4aff09b4930ca2d4477343447"
integrity sha512-S/TQAZJO+D3m9xeN1WTI8dLKBBiRgXBlTJvbWjCThHWZj9EvHK70Ff50/tYj2J/fvBY6JtFVwRuazHN2E7M9BA==
regenerator-transform@^0.10.0:
version "0.10.1"
resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.10.1.tgz#1e4996837231da8b7f3cf4114d71b5691a0680dd"