Commit 3b309d59 authored by John Lee's avatar John Lee Committed by Commit Bot

Tab Strip WebUI: Add ability to drag and drop to reorder tabs

The interactions and visual polish of drag and drop is still in flux
but this CL adds basic functionality such that TabElements can be
dragged to be re-ordered within the tab strip. This CL also only adds
support for reordering tabs within the same pinned status.

Bug: 989131
Change-Id: I93cd72ea527f53e2973c98ad1739abb11561e285
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/1800914Reviewed-by: default avatarEsmael Elmoslimany <aee@chromium.org>
Commit-Queue: John Lee <johntlee@chromium.org>
Cr-Commit-Position: refs/heads/master@{#697713}
parent 3c2dfa5c
<style> <style>
:host { :host {
border-radius: var(--tabstrip-card-border-radius);
box-shadow: var(--tabstrip-elevation-box-shadow);
cursor: pointer; cursor: pointer;
display: flex;
flex-direction: column;
height: 230px; height: 230px;
overflow: hidden; position: relative;
width: 280px; width: 280px;
} }
:host([active]) { :host([active]) #dragImage {
box-shadow: 0 0 0 2px var(--tabstrip-focus-color); box-shadow: 0 0 0 2px var(--tabstrip-focus-color);
outline: none; outline: none;
} }
#dragImage {
border-radius: var(--tabstrip-card-border-radius);
box-shadow: var(--tabstrip-elevation-box-shadow);
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
width: 100%;
}
#title { #title {
align-items: center; align-items: center;
background: var(--tabstrip-card-background-color); background: var(--tabstrip-card-background-color);
...@@ -75,6 +81,7 @@ ...@@ -75,6 +81,7 @@
#thumbnailImg { #thumbnailImg {
height: 100%; height: 100%;
object-fit: contain; object-fit: contain;
pointer-events: none;
width: 100%; width: 100%;
} }
...@@ -94,16 +101,39 @@ ...@@ -94,16 +101,39 @@
:host([pinned]) #thumbnail { :host([pinned]) #thumbnail {
display: none; display: none;
} }
:host([dragging]) #dragPlaceholder {
background: rgba(var(--google-grey-200-rgb));
border-radius: var(--tabstrip-card-border-radius);
height: 100%;
width: 100%;
}
:host([dragging][active]) #dragPlaceholder {
box-shadow: 0 0 0 2px var(--tabstrip-focus-color);
}
/* When being dragged, the contents of the drag image needs to be off-screen
* with nothing else on top or below obscuring it. */
:host([dragging]) #dragImage {
box-shadow: none;
position: absolute;
top: -999px;
}
</style> </style>
<header id="title"> <div id="dragPlaceholder"></div>
<span id="favicon"></span>
<h2 id="titleText"></h2> <div id="dragImage">
<button id="close"> <header id="title">
<span id="closeIcon"></span> <span id="favicon"></span>
</button> <h2 id="titleText"></h2>
</header> <button id="close">
<span id="closeIcon"></span>
</button>
</header>
<div id="thumbnail"> <div id="thumbnail">
<img id="thumbnailImg"> <img id="thumbnailImg">
</div>
</div> </div>
...@@ -21,6 +21,11 @@ export class TabElement extends CustomElement { ...@@ -21,6 +21,11 @@ export class TabElement extends CustomElement {
this.closeButtonEl_ = this.closeButtonEl_ =
/** @type {!HTMLElement} */ (this.shadowRoot.querySelector('#close')); /** @type {!HTMLElement} */ (this.shadowRoot.querySelector('#close'));
/** @private {!HTMLElement} */
this.dragImage_ =
/** @type {!HTMLElement} */ (
this.shadowRoot.querySelector('#dragImage'));
/** @private {!HTMLElement} */ /** @private {!HTMLElement} */
this.faviconEl_ = this.faviconEl_ =
/** @type {!HTMLElement} */ (this.shadowRoot.querySelector('#favicon')); /** @type {!HTMLElement} */ (this.shadowRoot.querySelector('#favicon'));
...@@ -48,6 +53,10 @@ export class TabElement extends CustomElement { ...@@ -48,6 +53,10 @@ export class TabElement extends CustomElement {
this.closeButtonEl_.addEventListener('click', this.onClose_.bind(this)); this.closeButtonEl_.addEventListener('click', this.onClose_.bind(this));
} }
connectedCallback() {
this.setAttribute('draggable', 'true');
}
/** @return {!Tab} */ /** @return {!Tab} */
get tab() { get tab() {
return this.tab_; return this.tab_;
...@@ -81,6 +90,13 @@ export class TabElement extends CustomElement { ...@@ -81,6 +90,13 @@ export class TabElement extends CustomElement {
this.tab_ = Object.freeze(tab); this.tab_ = Object.freeze(tab);
} }
/**
* @return {!HTMLElement}
*/
getDragImage() {
return this.dragImage_;
}
/** /**
* @param {string} imgData * @param {string} imgData
*/ */
...@@ -110,6 +126,13 @@ export class TabElement extends CustomElement { ...@@ -110,6 +126,13 @@ export class TabElement extends CustomElement {
this.tabsApi_.closeTab(this.tab_.id); this.tabsApi_.closeTab(this.tab_.id);
} }
/**
* @param {boolean} dragging
*/
setDragging(dragging) {
this.toggleAttribute('dragging', dragging);
}
/** /**
* @return {!Promise} * @return {!Promise}
*/ */
......
...@@ -9,15 +9,25 @@ import {CustomElement} from './custom_element.js'; ...@@ -9,15 +9,25 @@ import {CustomElement} from './custom_element.js';
import {TabElement} from './tab.js'; import {TabElement} from './tab.js';
import {TabsApiProxy} from './tabs_api_proxy.js'; import {TabsApiProxy} from './tabs_api_proxy.js';
/** @const {number} */
const GHOST_PINNED_TAB_COUNT = 3; const GHOST_PINNED_TAB_COUNT = 3;
/** /**
* The amount of padding to leave between the edge of the screen and the active * The amount of padding to leave between the edge of the screen and the active
* tab when auto-scrolling. This should leave some room to show the previous or * tab when auto-scrolling. This should leave some room to show the previous or
* next tab to afford to users that there more tabs if the user scrolls. * next tab to afford to users that there more tabs if the user scrolls.
* @const {number}
*/ */
const SCROLL_PADDING = 32; const SCROLL_PADDING = 32;
/**
* @param {!Element} element
* @return {boolean}
*/
function isTabElement(element) {
return element.tagName === 'TABSTRIP-TAB';
}
class TabListElement extends CustomElement { class TabListElement extends CustomElement {
static get template() { static get template() {
return `{__html_template__}`; return `{__html_template__}`;
...@@ -35,6 +45,12 @@ class TabListElement extends CustomElement { ...@@ -35,6 +45,12 @@ class TabListElement extends CustomElement {
*/ */
this.animationPromises = Promise.resolve(); this.animationPromises = Promise.resolve();
/**
* The TabElement that is currently being dragged.
* @private {!TabElement|undefined}
*/
this.draggedItem_;
/** @private {!Element} */ /** @private {!Element} */
this.pinnedTabsContainerElement_ = this.pinnedTabsContainerElement_ =
/** @type {!Element} */ ( /** @type {!Element} */ (
...@@ -59,6 +75,13 @@ class TabListElement extends CustomElement { ...@@ -59,6 +75,13 @@ class TabListElement extends CustomElement {
addWebUIListener( addWebUIListener(
'tab-thumbnail-updated', this.tabThumbnailUpdated_.bind(this)); 'tab-thumbnail-updated', this.tabThumbnailUpdated_.bind(this));
this.addEventListener(
'dragstart', (e) => this.onDragStart_(/** @type {!DragEvent} */ (e)));
this.addEventListener(
'dragend', (e) => this.onDragEnd_(/** @type {!DragEvent} */ (e)));
this.addEventListener(
'dragover', (e) => this.onDragOver_(/** @type {!DragEvent} */ (e)));
} }
/** /**
...@@ -130,6 +153,15 @@ class TabListElement extends CustomElement { ...@@ -130,6 +153,15 @@ class TabListElement extends CustomElement {
this.shadowRoot.querySelector('tabstrip-tab[active]')); this.shadowRoot.querySelector('tabstrip-tab[active]'));
} }
/**
* @return {number}
* @private
*/
getPinnedTabsCount_() {
return this.pinnedTabsContainerElement_.childElementCount -
GHOST_PINNED_TAB_COUNT;
}
/** /**
* @param {!TabElement} tabElement * @param {!TabElement} tabElement
* @param {number} index * @param {number} index
...@@ -145,9 +177,7 @@ class TabListElement extends CustomElement { ...@@ -145,9 +177,7 @@ class TabListElement extends CustomElement {
} else { } else {
// Pinned tabs are in their own container, so the index of non-pinned // Pinned tabs are in their own container, so the index of non-pinned
// tabs need to be offset by the number of pinned tabs // tabs need to be offset by the number of pinned tabs
const offsetIndex = index - const offsetIndex = index - this.getPinnedTabsCount_();
(this.pinnedTabsContainerElement_.childElementCount -
GHOST_PINNED_TAB_COUNT);
this.tabsContainerElement_.insertBefore( this.tabsContainerElement_.insertBefore(
tabElement, this.tabsContainerElement_.childNodes[offsetIndex]); tabElement, this.tabsContainerElement_.childNodes[offsetIndex]);
} }
...@@ -155,6 +185,64 @@ class TabListElement extends CustomElement { ...@@ -155,6 +185,64 @@ class TabListElement extends CustomElement {
this.updatePinnedTabsState_(); this.updatePinnedTabsState_();
} }
/**
* @param {!DragEvent} event
* @private
*/
onDragEnd_(event) {
if (!this.draggedItem_) {
return;
}
this.draggedItem_.setDragging(false);
this.draggedItem_ = undefined;
}
/**
* @param {!DragEvent} event
* @private
*/
onDragOver_(event) {
event.preventDefault();
const dragOverItem = event.path.find((pathItem) => {
return pathItem !== this.draggedItem_ && isTabElement(pathItem);
});
if (!dragOverItem ||
dragOverItem.tab.pinned !== this.draggedItem_.tab.pinned) {
// TODO(johntlee): Support dragging between different pinned states.
return;
}
let dragOverIndex =
Array.from(dragOverItem.parentNode.children).indexOf(dragOverItem);
event.dataTransfer.dropEffect = 'move';
if (!dragOverItem.tab.pinned) {
dragOverIndex += this.getPinnedTabsCount_();
}
this.tabsApi_.moveTab(this.draggedItem_.tab.id, dragOverIndex);
}
/**
* @param {!DragEvent} event
* @private
*/
onDragStart_(event) {
const draggedItem = event.path[0];
if (!isTabElement(draggedItem)) {
return;
}
this.draggedItem_ = /** @type {!TabElement} */ (draggedItem);
this.draggedItem_.setDragging(true);
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setDragImage(
this.draggedItem_.getDragImage(),
event.pageX - this.draggedItem_.offsetLeft,
event.pageY - this.draggedItem_.offsetTop);
}
/** /**
* @param {!TabActivatedInfo} activeInfo * @param {!TabActivatedInfo} activeInfo
* @private * @private
......
...@@ -6,6 +6,43 @@ import 'chrome://tab-strip/tab_list.js'; ...@@ -6,6 +6,43 @@ import 'chrome://tab-strip/tab_list.js';
import {TabsApiProxy} from 'chrome://tab-strip/tabs_api_proxy.js'; import {TabsApiProxy} from 'chrome://tab-strip/tabs_api_proxy.js';
import {TestTabsApiProxy} from './test_tabs_api_proxy.js'; import {TestTabsApiProxy} from './test_tabs_api_proxy.js';
class MockDataTransfer extends DataTransfer {
constructor() {
super();
this.dragImageData = {
image: undefined,
offsetX: undefined,
offsetY: undefined,
};
this.dropEffect_ = 'none';
this.effectAllowed_ = 'none';
}
get dropEffect() {
return this.dropEffect_;
}
set dropEffect(effect) {
this.dropEffect_ = effect;
}
get effectAllowed() {
return this.effectAllowed_;
}
set effectAllowed(effect) {
this.effectAllowed_ = effect;
}
setDragImage(image, offsetX, offsetY) {
this.dragImageData.image = image;
this.dragImageData.offsetX = offsetX;
this.dragImageData.offsetY = offsetY;
}
}
suite('TabList', () => { suite('TabList', () => {
let callbackRouter; let callbackRouter;
let optionsCalled; let optionsCalled;
...@@ -294,4 +331,54 @@ suite('TabList', () => { ...@@ -294,4 +331,54 @@ suite('TabList', () => {
await tabList.animationPromises; await tabList.animationPromises;
assertEquals(fakeScroller.scrollLeft, activeTab.offsetLeft - scrollPadding); assertEquals(fakeScroller.scrollLeft, activeTab.offsetLeft - scrollPadding);
}); });
test('dragstart sets a drag image offset by the event coordinates', () => {
const draggedTab = getUnpinnedTabs()[0];
const mockDataTransfer = new MockDataTransfer();
const dragStartEvent = new DragEvent('dragstart', {
bubbles: true,
composed: true,
clientX: 100,
clientY: 150,
dataTransfer: mockDataTransfer,
});
draggedTab.dispatchEvent(dragStartEvent);
assertEquals(dragStartEvent.dataTransfer.effectAllowed, 'move');
assertEquals(
mockDataTransfer.dragImageData.image, draggedTab.getDragImage());
assertEquals(
mockDataTransfer.dragImageData.offsetX, 100 - draggedTab.offsetLeft);
assertEquals(
mockDataTransfer.dragImageData.offsetY, 150 - draggedTab.offsetTop);
});
test('dragover moves tabs', async () => {
const draggedIndex = 0;
const dragOverIndex = 1;
const draggedTab = getUnpinnedTabs()[draggedIndex];
const dragOverTab = getUnpinnedTabs()[dragOverIndex];
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,
});
dragOverTab.dispatchEvent(dragOverEvent);
assertEquals(dragOverEvent.dataTransfer.dropEffect, 'move');
const [tabId, newIndex] = await testTabsApiProxy.whenCalled('moveTab');
assertEquals(tabId, currentWindow.tabs[draggedIndex].id);
assertEquals(newIndex, dragOverIndex);
});
}); });
...@@ -2,6 +2,8 @@ ...@@ -2,6 +2,8 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
GEN('#include "services/network/public/cpp/features.h"');
var TabStripBrowserTest = class extends testing.Test { var TabStripBrowserTest = class extends testing.Test {
get isAsync() { get isAsync() {
return true; return true;
...@@ -21,6 +23,11 @@ var TabStripBrowserTest = class extends testing.Test { ...@@ -21,6 +23,11 @@ var TabStripBrowserTest = class extends testing.Test {
get runAccessibilityChecks() { get runAccessibilityChecks() {
return false; return false;
} }
/** @override */
get featureList() {
return {enabled: ['network::features::kOutOfBlinkCors']};
}
}; };
var TabStripTabListTest = class extends TabStripBrowserTest { var TabStripTabListTest = class extends TabStripBrowserTest {
......
...@@ -103,4 +103,17 @@ suite('Tab', function() { ...@@ -103,4 +103,17 @@ suite('Tab', function() {
faviconElement.style.backgroundImage, faviconElement.style.backgroundImage,
getFaviconForPageURL(expectedPageUrl, false)); getFaviconForPageURL(expectedPageUrl, false));
}); });
test('setting dragging state toggles an attribute', () => {
tabElement.setDragging(true);
assertTrue(tabElement.hasAttribute('dragging'));
tabElement.setDragging(false);
assertFalse(tabElement.hasAttribute('dragging'));
});
test('getting the drag image grabs the contents', () => {
assertEquals(
tabElement.getDragImage(),
tabElement.shadowRoot.querySelector('#dragImage'));
});
}); });
...@@ -26,6 +26,7 @@ export class TestTabsApiProxy extends TestBrowserProxy { ...@@ -26,6 +26,7 @@ export class TestTabsApiProxy extends TestBrowserProxy {
'activateTab', 'activateTab',
'closeTab', 'closeTab',
'getCurrentWindow', 'getCurrentWindow',
'moveTab',
]); ]);
this.callbackRouter = { this.callbackRouter = {
...@@ -54,6 +55,11 @@ export class TestTabsApiProxy extends TestBrowserProxy { ...@@ -54,6 +55,11 @@ export class TestTabsApiProxy extends TestBrowserProxy {
return Promise.resolve(this.currentWindow_); return Promise.resolve(this.currentWindow_);
} }
moveTab(tabId, newIndex) {
this.methodCalled('moveTab', [tabId, newIndex]);
return Promise.resolve();
}
setCurrentWindow(currentWindow) { setCurrentWindow(currentWindow) {
this.currentWindow_ = currentWindow; this.currentWindow_ = currentWindow;
} }
......
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