Commit aa89aa7d authored by John Lee's avatar John Lee Committed by Commit Bot

WebUI Tab Strip: Update tab moves on drop, not drag update

This CLs updates the drag and drop interactions such that only
the DOM is updated as the user drags a tab or group element. The
tab strip model is only changed once the user commits the move by
dropping the element in place.

The DragManager now initializes a new DragSession every time a
drag begins, and uses the session to store and retrieve indices.

This will also make it easier and more consistent to drop a tab or
group from another window into a specific index. The idea is to create
a TabElement or TabGroupElement as a drag enters from another
window and keep most of the code the same.

Bug: 1048894
Change-Id: I92e9866ce2b57aae2736fa81b7a5e9e1b334cdfe
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2065329Reviewed-by: default avatarDemetrios Papadopoulos <dpapad@chromium.org>
Commit-Queue: John Lee <johntlee@chromium.org>
Cr-Commit-Position: refs/heads/master@{#743378}
parent c6c55006
......@@ -35,6 +35,20 @@ export class DragManagerDelegate {
*/
getIndexOfTab(tabElement) {}
/**
* @param {!TabElement} element
* @param {number} index
* @param {boolean} pinned
* @param {string|undefined} groupId
*/
placeTabElement(element, index, pinned, groupId) {}
/**
* @param {!TabGroupElement} element
* @param {number} index
*/
placeTabGroupElement(element, index) {}
/** @param {!Element} element */
showDropPlaceholder(element) {}
}
......@@ -42,44 +56,137 @@ export class DragManagerDelegate {
/** @typedef {!DragManagerDelegate|!HTMLElement} */
let DragManagerDelegateElement;
export class DragManager {
/** @param {!DragManagerDelegateElement} delegate */
constructor(delegate) {
/** @private {!DragManagerDelegateElement} */
class DragSession {
/**
* @param {!DragManagerDelegateElement} delegate
* @param {!TabElement|!TabGroupElement} element
* @param {number} srcIndex
* @param {string=} srcGroup
*/
constructor(delegate, element, srcIndex, srcGroup) {
/** @const @private {!DragManagerDelegateElement} */
this.delegate_ = delegate;
/** @const {!TabElement|!TabGroupElement} */
this.element_ = element;
/** @const {number} */
this.srcIndex = srcIndex;
/** @const {string|undefined} */
this.srcGroup = srcGroup;
/** @private @const {!TabsApiProxy} */
this.tabsProxy_ = TabsApiProxy.getInstance();
}
/**
* The element currently being dragged.
* @type {?TabElement|?TabGroupElement}
* @param {!DragManagerDelegateElement} delegate
* @param {!TabElement|!TabGroupElement} element
* @return {!DragSession}
*/
this.draggedItem_ = null;
static createFromElement(delegate, element) {
if (isTabGroupElement(element)) {
return new DragSession(
delegate, element,
delegate.getIndexOfTab(
/** @type {!TabElement} */ (element.firstElementChild)));
}
/** @type {!Element} */
this.dropPlaceholder_ = document.createElement('div');
this.dropPlaceholder_.id = 'dropPlaceholder';
const srcIndex = delegate.getIndexOfTab(
/** @type {!TabElement} */ (element));
const srcGroup =
(element.parentElement && isTabGroupElement(element.parentElement)) ?
element.parentElement.dataset.groupId :
undefined;
return new DragSession(delegate, element, srcIndex, srcGroup);
}
/** @private {!TabsApiProxy} */
this.tabsProxy_ = TabsApiProxy.getInstance();
/** @return {string|undefined} */
get dstGroup() {
if (isTabElement(this.element_) && this.element_.parentElement &&
isTabGroupElement(this.element_.parentElement)) {
return this.element_.parentElement.dataset.groupId;
}
cancelDrag() {
this.dropPlaceholder_.remove();
return undefined;
}
/** @return {number} */
get dstIndex() {
if (isTabElement(this.element_)) {
return this.delegate_.getIndexOfTab(
/** @type {!TabElement} */ (this.element_));
}
// If a tab group is moving backwards (to the front of the tab strip), the
// new index is the index of the first tab in that group. If a tab group is
// moving forwards (to the end of the tab strip), the new index is the index
// of the last tab in that group.
let dstIndex = this.delegate_.getIndexOfTab(
/** @type {!TabElement} */ (this.element_.firstElementChild));
if (this.srcIndex <= dstIndex) {
dstIndex += this.element_.childElementCount - 1;
}
return dstIndex;
}
cancel() {
if (isTabGroupElement(this.element_)) {
this.delegate_.placeTabGroupElement(
/** @type {!TabGroupElement} */ (this.element_), this.srcIndex);
} else if (isTabElement(this.element_)) {
this.delegate_.placeTabElement(
/** @type {!TabElement} */ (this.element_), this.srcIndex,
this.element_.tab.pinned, this.srcGroup);
}
this.element_.setDragging(false);
}
finish() {
const dstGroupId = this.dstGroup;
if (dstGroupId && dstGroupId !== this.srcGroup) {
this.tabsProxy_.groupTab(this.element_.tab.id, dstGroupId);
} else if (!dstGroupId && this.srcGroup) {
this.tabsProxy_.ungroupTab(this.element_.tab.id);
}
const dstIndex = this.dstIndex;
if (isTabElement(this.element_)) {
this.tabsProxy_.moveTab(this.element_.tab.id, dstIndex);
} else if (isTabGroupElement(this.element_)) {
this.tabsProxy_.moveGroup(this.element_.dataset.groupId, dstIndex);
}
this.element_.setDragging(false);
}
/** @param {!DragEvent} event */
continueDrag(event) {
event.preventDefault();
start(event) {
event.dataTransfer.effectAllowed = 'move';
const draggedItemRect = this.element_.getBoundingClientRect();
this.element_.setDragging(true);
event.dataTransfer.setDragImage(
this.element_.getDragImage(), event.clientX - draggedItemRect.left,
event.clientY - draggedItemRect.top);
if (!this.draggedItem_) {
this.delegate_.showDropPlaceholder(this.dropPlaceholder_);
return;
if (isTabElement(this.element_)) {
event.dataTransfer.setData(
getTabIdDataType(), this.element_.tab.id.toString());
} else if (isTabGroupElement(this.element_)) {
event.dataTransfer.setData(
getGroupIdDataType(), this.element_.dataset.groupId);
}
}
/** @param {!DragEvent} event */
update(event) {
event.dataTransfer.dropEffect = 'move';
if (isTabGroupElement(this.draggedItem_)) {
this.continueDragWithGroupElement_(event);
} else if (isTabElement(this.draggedItem_)) {
this.continueDragWithTabElement_(event);
if (isTabGroupElement(this.element_)) {
this.updateForTabGroupElement_(event);
} else if (isTabElement(this.element_)) {
this.updateForTabElement_(event);
}
}
......@@ -87,9 +194,11 @@ export class DragManager {
* @param {!DragEvent} event
* @private
*/
continueDragWithGroupElement_(event) {
updateForTabGroupElement_(event) {
const tabGroupElement =
/** @type {!TabGroupElement} */ (this.element_);
const composedPath = /** @type {!Array<!Element>} */ (event.composedPath());
if (composedPath.includes(assert(this.draggedItem_))) {
if (composedPath.includes(assert(this.element_))) {
// Dragging over itself or a child of itself.
return;
}
......@@ -98,8 +207,7 @@ export class DragManager {
/** @type {!TabElement|undefined} */ (composedPath.find(isTabElement));
if (dragOverTabElement && !dragOverTabElement.tab.pinned) {
const dragOverIndex = this.delegate_.getIndexOfTab(dragOverTabElement);
this.tabsProxy_.moveGroup(
this.draggedItem_.dataset.groupId, dragOverIndex);
this.delegate_.placeTabGroupElement(tabGroupElement, dragOverIndex);
return;
}
......@@ -107,8 +215,7 @@ export class DragManager {
if (dragOverGroupElement) {
const dragOverIndex = this.delegate_.getIndexOfTab(
/** @type {!TabElement} */ (dragOverGroupElement.firstElementChild));
this.tabsProxy_.moveGroup(
this.draggedItem_.dataset.groupId, dragOverIndex);
this.delegate_.placeTabGroupElement(tabGroupElement, dragOverIndex);
}
}
......@@ -116,27 +223,34 @@ export class DragManager {
* @param {!DragEvent} event
* @private
*/
continueDragWithTabElement_(event) {
updateForTabElement_(event) {
const tabElement = /** @type {!TabElement} */ (this.element_);
const composedPath = /** @type {!Array<!Element>} */ (event.composedPath());
const dragOverTabElement =
/** @type {?TabElement} */ (composedPath.find(isTabElement));
if (dragOverTabElement &&
dragOverTabElement.tab.pinned !== this.draggedItem_.tab.pinned) {
dragOverTabElement.tab.pinned !== tabElement.tab.pinned) {
// Can only drag between the same pinned states.
return;
}
const previousGroupId = (tabElement.parentElement &&
isTabGroupElement(tabElement.parentElement)) ?
tabElement.parentElement.dataset.groupId :
undefined;
const dragOverTabGroup =
/** @type {?TabGroupElement} */ (composedPath.find(isTabGroupElement));
if (dragOverTabGroup &&
dragOverTabGroup.dataset.groupId !== this.draggedItem_.tab.groupId) {
this.tabsProxy_.groupTab(
this.draggedItem_.tab.id, dragOverTabGroup.dataset.groupId);
dragOverTabGroup.dataset.groupId !== previousGroupId) {
this.delegate_.placeTabElement(
tabElement, this.dstIndex, false, dragOverTabGroup.dataset.groupId);
return;
}
if (!dragOverTabGroup && this.draggedItem_.tab.groupId) {
this.tabsProxy_.ungroupTab(this.draggedItem_.tab.id);
if (!dragOverTabGroup && previousGroupId) {
this.delegate_.placeTabElement(
tabElement, this.dstIndex, false, undefined);
return;
}
......@@ -145,36 +259,51 @@ export class DragManager {
}
const dragOverIndex = this.delegate_.getIndexOfTab(dragOverTabElement);
this.tabsProxy_.moveTab(this.draggedItem_.tab.id, dragOverIndex);
this.delegate_.placeTabElement(
tabElement, dragOverIndex, tabElement.tab.pinned, previousGroupId);
}
}
/**
* @param {!DragEvent} event
*/
drop(event) {
if (this.draggedItem_) {
// If there is a valid dragged item, the drag originated from this TabList
// and is handled already by previous dragover events.
return;
export class DragManager {
/** @param {!DragManagerDelegateElement} delegate */
constructor(delegate) {
/** @private {!DragManagerDelegateElement} */
this.delegate_ = delegate;
/** @type {?DragSession} */
this.dragSession_ = null;
/** @type {!Element} */
this.dropPlaceholder_ = document.createElement('div');
this.dropPlaceholder_.id = 'dropPlaceholder';
/** @private {!TabsApiProxy} */
this.tabsProxy_ = TabsApiProxy.getInstance();
}
/** @private */
onDragLeave_() {
// TODO(johntlee): Handle drag and drop from other windows with
// DragSession.
this.dropPlaceholder_.remove();
}
if (event.dataTransfer.types.includes(getTabIdDataType())) {
const tabId = Number(event.dataTransfer.getData(getTabIdDataType()));
if (Number.isNaN(tabId)) {
// Invalid tab ID. Return silently.
/** @param {!DragEvent} event */
onDragOver_(event) {
event.preventDefault();
if (!this.dragSession_) {
// TODO(johntlee): Handle drag and drop from other windows with
// DragSession.
this.delegate_.showDropPlaceholder(this.dropPlaceholder_);
return;
}
this.tabsProxy_.moveTab(tabId, -1);
} else if (event.dataTransfer.types.includes(getGroupIdDataType())) {
const groupId = event.dataTransfer.getData(getGroupIdDataType());
this.tabsProxy_.moveGroup(groupId, -1);
}
this.dragSession_.update(event);
}
/** @param {!DragEvent} event */
startDrag(event) {
onDragStart_(event) {
const draggedItem =
/** @type {!Array<!Element>} */ (event.composedPath()).find(item => {
return isTabElement(item) || isTabGroupElement(item);
......@@ -183,30 +312,56 @@ export class DragManager {
return;
}
this.draggedItem_ = /** @type {!TabElement} */ (draggedItem);
event.dataTransfer.effectAllowed = 'move';
const draggedItemRect = this.draggedItem_.getBoundingClientRect();
this.draggedItem_.setDragging(true);
event.dataTransfer.setDragImage(
this.draggedItem_.getDragImage(), event.clientX - draggedItemRect.left,
event.clientY - draggedItemRect.top);
this.dragSession_ = DragSession.createFromElement(
this.delegate_,
/** @type {!TabElement|!TabGroupElement} */ (draggedItem));
this.dragSession_.start(event);
}
if (isTabElement(draggedItem)) {
event.dataTransfer.setData(
getTabIdDataType(), this.draggedItem_.tab.id.toString());
} else if (isTabGroupElement(draggedItem)) {
event.dataTransfer.setData(
getGroupIdDataType(), this.draggedItem_.dataset.groupId);
/** @param {!DragEvent} event */
onDragEnd_(event) {
if (!this.dragSession_) {
return;
}
this.dragSession_.cancel();
this.dragSession_ = null;
}
/** @param {!DragEvent} event */
stopDrag(event) {
if (!this.draggedItem_) {
/**
* @param {!DragEvent} event
*/
onDrop_(event) {
if (this.dragSession_) {
this.dragSession_.finish();
this.dragSession_ = null;
return;
}
this.draggedItem_.setDragging(false);
this.draggedItem_ = null;
// TODO(johntlee): Handle drag and drop from other windows with DragSession.
this.dropPlaceholder_.remove();
if (event.dataTransfer.types.includes(getTabIdDataType())) {
const tabId = Number(event.dataTransfer.getData(getTabIdDataType()));
if (Number.isNaN(tabId)) {
// Invalid tab ID. Return silently.
return;
}
this.tabsProxy_.moveTab(tabId, -1);
} else if (event.dataTransfer.types.includes(getGroupIdDataType())) {
const groupId = event.dataTransfer.getData(getGroupIdDataType());
this.tabsProxy_.moveGroup(groupId, -1);
}
}
startObserving() {
this.delegate_.addEventListener(
'dragstart', e => this.onDragStart_(/** @type {!DragEvent} */ (e)));
this.delegate_.addEventListener(
'dragend', e => this.onDragEnd_(/** @type {!DragEvent} */ (e)));
this.delegate_.addEventListener('dragleave', () => this.onDragLeave_());
this.delegate_.addEventListener(
'dragover', e => this.onDragOver_(/** @type {!DragEvent} */ (e)));
this.delegate_.addEventListener(
'drop', e => this.onDrop_(/** @type {!DragEvent} */ (e)));
}
}
......@@ -4,6 +4,11 @@
--tabstrip-tab-group-title-margin: var(--tabstrip-tab-spacing);
}
:host(:empty) {
/* A tab group can temporarily become empty as a tab is being dragged out. */
display: none;
}
#tabGroup {
border-radius: 8px;
box-shadow: 0 0 0 1px rgba(var(--tabstrip-tab-group-color-rgb), .24);
......
......@@ -163,17 +163,7 @@ class TabListElement extends CustomElement {
});
const dragManager = new DragManager(this);
this.addEventListener(
'dragstart', e => dragManager.startDrag(/** @type {!DragEvent} */ (e)));
this.addEventListener(
'dragend', e => dragManager.stopDrag(/** @type {!DragEvent} */ (e)));
this.addEventListener('dragleave', () => dragManager.cancelDrag());
this.addEventListener(
'dragover',
e => dragManager.continueDrag(
/** @type {!DragEvent} */ (e)));
this.addEventListener(
'drop', e => dragManager.drop(/** @type {!DragEvent} */ (e)));
dragManager.startObserving();
if (loadTimeData.getBoolean('showDemoOptions')) {
this.$('#demoOptions').style.display = 'block';
......
......@@ -10,15 +10,24 @@ import {TabsApiProxy} from 'chrome://tab-strip/tabs_api_proxy.js';
import {TestTabsApiProxy} from './test_tabs_api_proxy.js';
class MockDelegate extends HTMLElement {
constructor() {
super();
}
getIndexOfTab(tabElement) {
return Array.from(this.querySelectorAll('tabstrip-tab'))
.indexOf(tabElement);
}
placeTabElement(element, index, pinned, groupId) {
element.remove();
const parent =
groupId ? this.querySelector(`[data-group-id=${groupId}]`) : this;
parent.insertBefore(element, this.children[index]);
}
placeTabGroupElement(element, index) {
element.remove();
this.insertBefore(element, this.children[index]);
}
showDropPlaceholder(element) {
this.appendChild(element);
}
......@@ -117,11 +126,7 @@ suite('DragManager', () => {
delegate.appendChild(tabElement);
});
dragManager = new DragManager(delegate);
delegate.addEventListener('dragstart', e => dragManager.startDrag(e));
delegate.addEventListener('dragend', e => dragManager.stopDrag(e));
delegate.addEventListener('dragleave', () => dragManager.cancelDrag());
delegate.addEventListener('dragover', (e) => dragManager.continueDrag(e));
delegate.addEventListener('drop', (e) => dragManager.drop(e));
dragManager.startObserving();
});
test('DragStartSetsDragImage', () => {
......@@ -151,7 +156,7 @@ suite('DragManager', () => {
const dragOverTab = delegate.children[dragOverIndex];
const mockDataTransfer = new MockDataTransfer();
// Dispatch a dragstart event to start the drag process
// Dispatch a dragstart event to start the drag process.
const dragStartEvent = new DragEvent('dragstart', {
bubbles: true,
composed: true,
......@@ -161,7 +166,7 @@ suite('DragManager', () => {
});
draggedTab.dispatchEvent(dragStartEvent);
// Move the draggedTab over the 2nd tab
// Move the draggedTab over the 2nd tab.
const dragOverEvent = new DragEvent('dragover', {
bubbles: true,
composed: true,
......@@ -169,6 +174,12 @@ suite('DragManager', () => {
});
dragOverTab.dispatchEvent(dragOverEvent);
assertEquals(dragOverEvent.dataTransfer.dropEffect, 'move');
// Dragover tab and dragged tab have now switched places in the DOM.
assertEquals(draggedTab, delegate.children[dragOverIndex]);
assertEquals(dragOverTab, delegate.children[draggedIndex]);
draggedTab.dispatchEvent(new DragEvent('drop', {bubbles: true}));
const [tabId, newIndex] = await testTabsApiProxy.whenCalled('moveTab');
assertEquals(tabId, tabs[draggedIndex].id);
assertEquals(newIndex, dragOverIndex);
......@@ -199,6 +210,11 @@ suite('DragManager', () => {
dataTransfer: mockDataTransfer,
});
dragOverTabGroup.dispatchEvent(dragOverEvent);
// Tab is now in the group within the DOM.
assertEquals(dragOverTabGroup, draggedTab.parentElement);
draggedTab.dispatchEvent(new DragEvent('drop', {bubbles: true}));
const [tabId, groupId] = await testTabsApiProxy.whenCalled('groupTab');
assertEquals(draggedTab.tab.id, tabId);
assertEquals('group0', groupId);
......@@ -227,6 +243,11 @@ suite('DragManager', () => {
dataTransfer: mockDataTransfer,
});
delegate.dispatchEvent(dragOverEvent);
// The tab is now outside of the group in the DOM.
assertEquals(delegate, draggedTab.parentElement);
draggedTab.dispatchEvent(new DragEvent('drop', {bubbles: true}));
const [tabId] = await testTabsApiProxy.whenCalled('ungroupTab');
assertEquals(draggedTab.tab.id, tabId);
});
......@@ -235,7 +256,8 @@ suite('DragManager', () => {
const tabElements = delegate.children;
// Start dragging the group.
const draggedGroup = groupTab(tabElements[0], 'group0');
const draggedGroupIndex = 0;
const draggedGroup = groupTab(tabElements[draggedGroupIndex], 'group0');
const mockDataTransfer = new MockDataTransfer();
const dragStartEvent = new DragEvent('dragstart', {
bubbles: true,
......@@ -247,13 +269,20 @@ suite('DragManager', () => {
draggedGroup.dispatchEvent(dragStartEvent);
// Drag the group over the second tab.
const dragOverTab = tabElements[1];
const dragOverIndex = 1;
const dragOverTab = tabElements[dragOverIndex];
const dragOverEvent = new DragEvent('dragover', {
bubbles: true,
composed: true,
dataTransfer: mockDataTransfer,
});
dragOverTab.dispatchEvent(dragOverEvent);
// Group and tab have now switched places.
assertEquals(draggedGroup, delegate.children[dragOverIndex]);
assertEquals(dragOverTab, delegate.children[draggedGroupIndex]);
draggedGroup.dispatchEvent(new DragEvent('drop', {bubbles: true}));
const [groupId, index] = await testTabsApiProxy.whenCalled('moveGroup');
assertEquals('group0', groupId);
assertEquals(1, index);
......@@ -263,8 +292,10 @@ suite('DragManager', () => {
const tabElements = delegate.children;
// Group the first tab and second tab separately.
const draggedGroup = groupTab(tabElements[0], 'group0');
const dragOverGroup = groupTab(tabElements[1], 'group1');
const draggedIndex = 0;
const draggedGroup = groupTab(tabElements[draggedIndex], 'group0');
const dragOverIndex = 1;
const dragOverGroup = groupTab(tabElements[dragOverIndex], 'group1');
// Start dragging the first group.
const mockDataTransfer = new MockDataTransfer();
......@@ -284,6 +315,12 @@ suite('DragManager', () => {
dataTransfer: mockDataTransfer,
});
dragOverGroup.dispatchEvent(dragOverEvent);
// Groups have now switched places.
assertEquals(draggedGroup, delegate.children[dragOverIndex]);
assertEquals(dragOverGroup, delegate.children[draggedIndex]);
draggedGroup.dispatchEvent(new DragEvent('drop', {bubbles: true}));
const [groupId, index] = await testTabsApiProxy.whenCalled('moveGroup');
assertEquals('group0', groupId);
assertEquals(1, index);
......@@ -321,4 +358,31 @@ suite('DragManager', () => {
assertEquals(droppedTabId, tabId);
assertEquals(-1, index);
});
test('CancelDragResetsPosition', () => {
const draggedIndex = 0;
const draggedTab = delegate.children[draggedIndex];
const mockDataTransfer = new MockDataTransfer();
// Dispatch a dragstart event to start the drag process.
const dragStartEvent = new DragEvent('dragstart', {
bubbles: true,
composed: true,
clientX: 100,
clientY: 150,
dataTransfer: mockDataTransfer,
});
draggedTab.dispatchEvent(dragStartEvent);
// Move the draggedTab over the 2nd tab.
const dragOverEvent = new DragEvent('dragover', {
bubbles: true,
composed: true,
dataTransfer: mockDataTransfer,
});
delegate.children[1].dispatchEvent(dragOverEvent);
draggedTab.dispatchEvent(new DragEvent('dragend', {bubbles: true}));
assertEquals(draggedTab, delegate.children[draggedIndex]);
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment