Bug 1568404 - Drag/drop to reorder file tabs in editor panel r=jlast
authorBryan Kok <bryan.wyern1@gmail.com>
Mon, 10 Feb 2020 20:54:30 +0000
changeset 513126 07ba3a3f8ce6b144c20ec7b23b32685b10f8f06e
parent 513125 b8476d9a1437c4ec66366d8abcd3645d11a428f5
child 513127 b1c545e004154c23d47600769388ed61a3d21ad7
push id37110
push usernbeleuzu@mozilla.com
push dateTue, 11 Feb 2020 09:46:07 +0000
treeherdermozilla-central@11c9c5ce3955 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersjlast
bugs1568404
milestone75.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1568404 - Drag/drop to reorder file tabs in editor panel r=jlast Differential Revision: https://phabricator.services.mozilla.com/D50755
devtools/client/debugger/src/actions/tabs.js
devtools/client/debugger/src/actions/types/SourceAction.js
devtools/client/debugger/src/components/Editor/Tab.js
devtools/client/debugger/src/components/Editor/Tabs.js
devtools/client/debugger/src/reducers/tabs.js
--- a/devtools/client/debugger/src/actions/tabs.js
+++ b/devtools/client/debugger/src/actions/tabs.js
@@ -49,16 +49,24 @@ export function addTab(source: Source): 
 export function moveTab(url: string, tabIndex: number): Action {
   return {
     type: "MOVE_TAB",
     url,
     tabIndex,
   };
 }
 
+export function moveTabBySourceId(sourceId: string, tabIndex: number): Action {
+  return {
+    type: "MOVE_TAB_BY_SOURCE_ID",
+    sourceId,
+    tabIndex,
+  };
+}
+
 /**
  * @memberof actions/tabs
  * @static
  */
 export function closeTab(cx: Context, source: Source) {
   return ({ dispatch, getState, client }: ThunkArgs) => {
     removeDocument(source.id);
 
--- a/devtools/client/debugger/src/actions/types/SourceAction.js
+++ b/devtools/client/debugger/src/actions/types/SourceAction.js
@@ -57,16 +57,21 @@ export type SourceAction =
       |}
     >
   | {|
       +type: "MOVE_TAB",
       +url: string,
       +tabIndex: number,
     |}
   | {|
+      +type: "MOVE_TAB_BY_SOURCE_ID",
+      +sourceId: string,
+      +tabIndex: number,
+    |}
+  | {|
       +type: "CLOSE_TAB",
       +source: Source,
     |}
   | {|
       +type: "CLOSE_TABS",
       +sources: Array<Source>,
     |}
   | {|
--- a/devtools/client/debugger/src/components/Editor/Tab.js
+++ b/devtools/client/debugger/src/components/Editor/Tab.js
@@ -37,22 +37,28 @@ import {
   getContext,
 } from "../../selectors";
 import type { ActiveSearchType } from "../../selectors";
 
 import classnames from "classnames";
 
 type OwnProps = {|
   source: Source,
+  onDragOver: Function,
+  onDragStart: Function,
+  onDragEnd: Function,
 |};
 type Props = {
   cx: Context,
   tabSources: TabsSources,
   selectedSource: ?Source,
   source: Source,
+  onDragOver: Function,
+  onDragStart: Function,
+  onDragEnd: Function,
   activeSearch: ?ActiveSearchType,
   hasSiblingOfSameName: boolean,
   selectSource: typeof actions.selectSource,
   closeTab: typeof actions.closeTab,
   closeTabs: typeof actions.closeTabs,
   copyToClipboard: typeof actions.copyToClipboard,
   togglePrettyPrint: typeof actions.togglePrettyPrint,
   showSource: typeof actions.showSource,
@@ -181,16 +187,19 @@ class Tab extends PureComponent<Props> {
     const {
       cx,
       selectedSource,
       selectSource,
       closeTab,
       source,
       tabSources,
       hasSiblingOfSameName,
+      onDragOver,
+      onDragStart,
+      onDragEnd,
     } = this.props;
     const sourceId = source.id;
     const active =
       selectedSource &&
       sourceId == selectedSource.id &&
       (!this.isProjectSearchEnabled() && !this.isSourceSearchEnabled());
     const isPrettyCode = isPretty(source);
 
@@ -210,16 +219,20 @@ class Tab extends PureComponent<Props> {
       pretty: isPrettyCode,
     });
 
     const path = getDisplayPath(source, tabSources);
     const query = hasSiblingOfSameName ? getSourceQueryString(source) : "";
 
     return (
       <div
+        draggable
+        onDragOver={onDragOver}
+        onDragStart={onDragStart}
+        onDragEnd={onDragEnd}
         className={className}
         key={sourceId}
         onClick={handleTabClick}
         // Accommodate middle click to close tab
         onMouseUp={e => e.button === 1 && closeTab(cx, source)}
         onContextMenu={e => this.onTabContextMenu(e, sourceId)}
         title={getFileURL(source, false)}
       >
@@ -257,10 +270,14 @@ export default connect<Props, OwnProps, 
   {
     selectSource: actions.selectSource,
     copyToClipboard: actions.copyToClipboard,
     closeTab: actions.closeTab,
     closeTabs: actions.closeTabs,
     togglePrettyPrint: actions.togglePrettyPrint,
     showSource: actions.showSource,
     toggleBlackBox: actions.toggleBlackBox,
+  },
+  null,
+  {
+    withRef: true,
   }
 )(Tab);
--- a/devtools/client/debugger/src/components/Editor/Tabs.js
+++ b/devtools/client/debugger/src/components/Editor/Tabs.js
@@ -1,15 +1,16 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
 
 // @flow
 
 import React, { PureComponent } from "react";
+import ReactDOM from "react-dom";
 import { connect } from "../../utils/connect";
 
 import {
   getSelectedSource,
   getSourcesForTabs,
   getIsPaused,
   getCurrentThread,
   getContext,
@@ -40,16 +41,17 @@ type OwnProps = {|
 type Props = {
   cx: Context,
   tabSources: TabsSources,
   selectedSource: ?Source,
   horizontal: boolean,
   startPanelCollapsed: boolean,
   endPanelCollapsed: boolean,
   moveTab: typeof actions.moveTab,
+  moveTabBySourceId: typeof actions.moveTabBySourceId,
   closeTab: typeof actions.closeTab,
   togglePaneCollapse: typeof actions.togglePaneCollapse,
   showSource: typeof actions.showSource,
   selectSource: typeof actions.selectSource,
   isPaused: boolean,
 };
 
 type State = {
@@ -80,29 +82,49 @@ class Tabs extends PureComponent<Props, 
   updateHiddenTabs: Function;
   toggleSourcesDropdown: Function;
   renderDropdownSource: Function;
   renderTabs: Function;
   renderDropDown: Function;
   renderStartPanelToggleButton: Function;
   renderEndPanelToggleButton: Function;
   onResize: Function;
+  _draggedSource: ?Source;
+  _draggedSourceIndex: ?number;
 
   constructor(props: Props) {
     super(props);
     this.state = {
       dropdownShown: false,
       hiddenTabs: [],
     };
 
     this.onResize = debounce(() => {
       this.updateHiddenTabs();
     });
   }
 
+  get draggedSource() {
+    return this._draggedSource == null
+      ? { url: null, id: null }
+      : this._draggedSource;
+  }
+
+  set draggedSource(source: ?Source) {
+    this._draggedSource = source;
+  }
+
+  get draggedSourceIndex() {
+    return this._draggedSourceIndex == null ? -1 : this._draggedSourceIndex;
+  }
+
+  set draggedSourceIndex(index: ?number) {
+    this._draggedSourceIndex = index;
+  }
+
   componentDidUpdate(prevProps: Props) {
     if (
       this.props.selectedSource !== prevProps.selectedSource ||
       haveTabSourcesChanged(this.props.tabSources, prevProps.tabSources)
     ) {
       this.updateHiddenTabs();
     }
   }
@@ -171,27 +193,90 @@ class Tabs extends PureComponent<Props, 
         <AccessibleImage
           className={`dropdown-icon ${this.getIconClass(source)}`}
         />
         <span className="dropdown-label">{filename}</span>
       </li>
     );
   };
 
+  onTabDragStart = (source: Source, index: number) => {
+    this.draggedSource = source;
+    this.draggedSourceIndex = index;
+  };
+
+  onTabDragEnd = () => {
+    this.draggedSource = null;
+    this.draggedSourceIndex = null;
+  };
+
+  onTabDragOver = (e: any, source: Source, hoveredTabIndex: number) => {
+    const { moveTabBySourceId } = this.props;
+    if (hoveredTabIndex === this.draggedSourceIndex) {
+      return;
+    }
+
+    const tabDOM = ReactDOM.findDOMNode(
+      this.refs[`tab_${source.id}`].getWrappedInstance()
+    );
+
+    /* $FlowIgnore: tabDOM.nodeType will always be of Node.ELEMENT_NODE since it comes from a ref;
+      however; the return type of findDOMNode is null | Element | Text */
+    const tabDOMRect = tabDOM.getBoundingClientRect();
+    const { pageX: mouseCursorX } = e;
+    if (
+      /* Case: the mouse cursor moves into the left half of any target tab */
+      mouseCursorX - tabDOMRect.left <
+      tabDOMRect.width / 2
+    ) {
+      // The current tab goes to the left of the target tab
+      const targetTab =
+        hoveredTabIndex > this.draggedSourceIndex
+          ? hoveredTabIndex - 1
+          : hoveredTabIndex;
+      moveTabBySourceId(this.draggedSource.id, targetTab);
+      this.draggedSourceIndex = targetTab;
+    } else if (
+      /* Case: the mouse cursor moves into the right half of any target tab */
+      mouseCursorX - tabDOMRect.left >=
+      tabDOMRect.width / 2
+    ) {
+      // The current tab goes to the right of the target tab
+      const targetTab =
+        hoveredTabIndex < this.draggedSourceIndex
+          ? hoveredTabIndex + 1
+          : hoveredTabIndex;
+      moveTabBySourceId(this.draggedSource.id, targetTab);
+      this.draggedSourceIndex = targetTab;
+    }
+  };
+
   renderTabs() {
     const { tabSources } = this.props;
     if (!tabSources) {
       return;
     }
 
     return (
       <div className="source-tabs" ref="sourceTabs">
-        {tabSources.map((source, index) => (
-          <Tab key={index} source={source} />
-        ))}
+        {tabSources.map((source, index) => {
+          return (
+            <Tab
+              onDragStart={_ => this.onTabDragStart(source, index)}
+              onDragOver={e => {
+                this.onTabDragOver(e, source, index);
+                e.preventDefault();
+              }}
+              onDragEnd={this.onTabDragEnd}
+              key={index}
+              source={source}
+              ref={`tab_${source.id}`}
+            />
+          );
+        })}
       </div>
     );
   }
 
   renderDropdown() {
     const hiddenTabs = this.state.hiddenTabs;
     if (!hiddenTabs || hiddenTabs.length == 0) {
       return null;
@@ -258,13 +343,14 @@ const mapStateToProps = state => ({
   isPaused: getIsPaused(state, getCurrentThread(state)),
 });
 
 export default connect<Props, OwnProps, _, _, _, _>(
   mapStateToProps,
   {
     selectSource: actions.selectSource,
     moveTab: actions.moveTab,
+    moveTabBySourceId: actions.moveTabBySourceId,
     closeTab: actions.closeTab,
     togglePaneCollapse: actions.togglePaneCollapse,
     showSource: actions.showSource,
   }
 )(Tabs);
--- a/devtools/client/debugger/src/reducers/tabs.js
+++ b/devtools/client/debugger/src/reducers/tabs.js
@@ -61,16 +61,18 @@ function update(
 ): TabsState {
   switch (action.type) {
     case "ADD_TAB":
     case "UPDATE_TAB":
       return updateTabList(state, action);
 
     case "MOVE_TAB":
       return moveTabInList(state, action);
+    case "MOVE_TAB_BY_SOURCE_ID":
+      return moveTabInListBySourceId(state, action);
 
     case "CLOSE_TAB":
       return removeSourceFromTabList(state, action);
 
     case "CLOSE_TABS":
       return removeSourcesFromTabList(state, action);
 
     case "ADD_SOURCE":
@@ -253,16 +255,26 @@ function updateTabList(
 
 function moveTabInList(state: TabsState, { url, tabIndex: newIndex }) {
   let { tabs } = state;
   const currentIndex = tabs.findIndex(tab => tab.url == url);
   tabs = move(tabs, currentIndex, newIndex);
   return { tabs };
 }
 
+function moveTabInListBySourceId(
+  state: TabsState,
+  { sourceId, tabIndex: newIndex }
+) {
+  let { tabs } = state;
+  const currentIndex = tabs.findIndex(tab => tab.sourceId == sourceId);
+  tabs = move(tabs, currentIndex, newIndex);
+  return { tabs };
+}
+
 // Selectors
 
 export const getTabs = (state: State): TabList => state.tabs.tabs;
 
 export const getSourceTabs: Selector<VisibleTab[]> = createSelector(
   state => state.tabs,
   ({ tabs }) => tabs.filter(tab => tab.sourceId)
 );