tabs overflow menu

This commit is contained in:
mathuo 2025-01-06 20:18:18 +00:00
parent 148a05a201
commit 04e4ea1a70
No known key found for this signature in database
GPG Key ID: C6EEDEFD6CA07281
8 changed files with 532 additions and 313 deletions

View File

@ -0,0 +1,82 @@
import { addDisposableWindowListener } from '../../events';
import {
CompositeDisposable,
Disposable,
MutableDisposable,
} from '../../lifecycle';
export class PopupService extends CompositeDisposable {
private readonly _element: HTMLElement;
private _active: HTMLElement | null = null;
private _activeDisposable = new MutableDisposable();
constructor(private readonly root: HTMLElement) {
super();
this._element = document.createElement('div');
this._element.className = 'dv-popover-anchor';
this._element.style.position = 'relative';
this.root.prepend(this._element);
this.addDisposables(
Disposable.from(() => {
this.close();
}),
this._activeDisposable
);
}
openPopover(
element: HTMLElement,
position: { x: number; y: number }
): void {
this.close();
const wrapper = document.createElement('div');
wrapper.style.position = 'absolute';
wrapper.style.zIndex = '99';
wrapper.appendChild(element);
const anchorBox = this._element.getBoundingClientRect();
const offsetX = anchorBox.left;
const offsetY = anchorBox.top;
wrapper.style.top = `${position.y - offsetY}px`;
wrapper.style.left = `${position.x - offsetX}px`;
this._element.appendChild(wrapper);
this._active = wrapper;
this._activeDisposable.value = new CompositeDisposable(
addDisposableWindowListener(window, 'pointerdown', (event) => {
const target = event.target;
if (!(target instanceof HTMLElement)) {
return;
}
let el: HTMLElement | null = target;
while (el && el !== wrapper) {
el = el?.parentElement ?? null;
}
if (el) {
return; // clicked within popover
}
this.close();
})
);
}
close(): void {
if (this._active) {
this._active.remove();
this._activeDisposable.dispose();
this._active = null;
}
}
}

View File

@ -0,0 +1,42 @@
.dv-tabs-container {
display: flex;
overflow-x: overlay;
overflow-y: hidden;
scrollbar-width: thin; // firefox
&::-webkit-scrollbar {
height: 3px;
}
/* Track */
&::-webkit-scrollbar-track {
background: transparent;
}
/* Handle */
&::-webkit-scrollbar-thumb {
background: var(--dv-tabs-container-scrollbar-color);
}
.dv-tab {
-webkit-user-drag: element;
outline: none;
min-width: 75px;
cursor: pointer;
position: relative;
box-sizing: border-box;
&:not(:first-child)::before {
content: ' ';
position: absolute;
top: 0;
left: 0;
z-index: 5;
pointer-events: none;
background-color: var(--dv-tab-divider-color);
width: 1px;
height: 100%;
}
}
}

View File

@ -0,0 +1,263 @@
import { getPanelData } from '../../../dnd/dataTransfer';
import { OverflowObserver } from '../../../dom';
import { addDisposableListener, Emitter, Event } from '../../../events';
import {
CompositeDisposable,
Disposable,
IValueDisposable,
} from '../../../lifecycle';
import { DockviewComponent } from '../../dockviewComponent';
import { DockviewGroupPanel } from '../../dockviewGroupPanel';
import { WillShowOverlayLocationEvent } from '../../dockviewGroupPanelModel';
import { DockviewPanel, IDockviewPanel } from '../../dockviewPanel';
import { Tab } from '../tab/tab';
import { TabDragEvent, TabDropIndexEvent } from './tabsContainer';
export class Tabs extends CompositeDisposable {
private readonly _element: HTMLElement;
private readonly _tabsList: HTMLElement;
private tabs: IValueDisposable<Tab>[] = [];
private selectedIndex = -1;
private _hasOverflow = false;
private _dropdownAnchor: HTMLElement | null = null;
private readonly _onTabDragStart = new Emitter<TabDragEvent>();
readonly onTabDragStart: Event<TabDragEvent> = this._onTabDragStart.event;
private readonly _onDrop = new Emitter<TabDropIndexEvent>();
readonly onDrop: Event<TabDropIndexEvent> = this._onDrop.event;
private readonly _onWillShowOverlay =
new Emitter<WillShowOverlayLocationEvent>();
readonly onWillShowOverlay: Event<WillShowOverlayLocationEvent> =
this._onWillShowOverlay.event;
get element(): HTMLElement {
return this._element;
}
get panels(): string[] {
return this.tabs.map((_) => _.value.panel.id);
}
get size(): number {
return this.tabs.length;
}
constructor(
private readonly group: DockviewGroupPanel,
private readonly accessor: DockviewComponent
) {
super();
this._element = document.createElement('div');
this._element.className = 'dv-tabs-panel';
this._element.style.display = 'flex';
this._element.style.overflow = 'auto';
this._tabsList = document.createElement('div');
this._tabsList.className = 'dv-tabs-container';
this._element.appendChild(this._tabsList);
const observer = new OverflowObserver(this._tabsList);
this.addDisposables(
observer,
observer.onDidChange((event) => {
const hasOverflow = event.hasScrollX || event.hasScrollY;
if (this._hasOverflow !== hasOverflow) {
this.toggleDropdown(hasOverflow);
}
}),
addDisposableListener(this.element, 'pointerdown', (event) => {
if (event.defaultPrevented) {
return;
}
const isLeftClick = event.button === 0;
if (isLeftClick) {
this.accessor.doSetGroupActive(this.group);
}
}),
Disposable.from(() => {
for (const { value, disposable } of this.tabs) {
disposable.dispose();
value.dispose();
}
this.tabs = [];
})
);
}
indexOf(id: string): number {
return this.tabs.findIndex((tab) => tab.value.panel.id === id);
}
isActive(tab: Tab): boolean {
return (
this.selectedIndex > -1 &&
this.tabs[this.selectedIndex].value === tab
);
}
setActivePanel(panel: IDockviewPanel): void {
this.tabs.forEach((tab) => {
const isActivePanel = panel.id === tab.value.panel.id;
tab.value.setActive(isActivePanel);
});
}
openPanel(panel: IDockviewPanel, index: number = this.tabs.length): void {
if (this.tabs.find((tab) => tab.value.panel.id === panel.id)) {
return;
}
const tab = new Tab(panel, this.accessor, this.group);
tab.setContent(panel.view.tab);
const disposable = new CompositeDisposable(
tab.onDragStart((event) => {
this._onTabDragStart.fire({ nativeEvent: event, panel });
}),
tab.onChanged((event) => {
const isFloatingGroupsEnabled =
!this.accessor.options.disableFloatingGroups;
const isFloatingWithOnePanel =
this.group.api.location.type === 'floating' &&
this.size === 1;
if (
isFloatingGroupsEnabled &&
!isFloatingWithOnePanel &&
event.shiftKey
) {
event.preventDefault();
const panel = this.accessor.getGroupPanel(tab.panel.id);
const { top, left } = tab.element.getBoundingClientRect();
const { top: rootTop, left: rootLeft } =
this.accessor.element.getBoundingClientRect();
this.accessor.addFloatingGroup(panel as DockviewPanel, {
x: left - rootLeft,
y: top - rootTop,
inDragMode: true,
});
return;
}
const isLeftClick = event.button === 0;
if (!isLeftClick || event.defaultPrevented) {
return;
}
if (this.group.activePanel !== panel) {
this.group.model.openPanel(panel);
}
}),
tab.onDrop((event) => {
this._onDrop.fire({
event: event.nativeEvent,
index: this.tabs.findIndex((x) => x.value === tab),
});
}),
tab.onWillShowOverlay((event) => {
this._onWillShowOverlay.fire(
new WillShowOverlayLocationEvent(event, {
kind: 'tab',
panel: this.group.activePanel,
api: this.accessor.api,
group: this.group,
getData: getPanelData,
})
);
})
);
const value: IValueDisposable<Tab> = { value: tab, disposable };
this.addTab(value, index);
}
delete(id: string): void {
const index = this.indexOf(id);
const tabToRemove = this.tabs.splice(index, 1)[0];
const { value, disposable } = tabToRemove;
disposable.dispose();
value.dispose();
value.element.remove();
}
private addTab(
tab: IValueDisposable<Tab>,
index: number = this.tabs.length
): void {
if (index < 0 || index > this.tabs.length) {
throw new Error('invalid location');
}
this._tabsList.insertBefore(
tab.value.element,
this._tabsList.children[index]
);
this.tabs = [
...this.tabs.slice(0, index),
tab,
...this.tabs.slice(index),
];
if (this.selectedIndex < 0) {
this.selectedIndex = index;
}
}
private toggleDropdown(show: boolean): void {
this._hasOverflow = show;
if (this._dropdownAnchor) {
this._dropdownAnchor.remove();
this._dropdownAnchor = null;
}
if (!show) {
return;
}
this._dropdownAnchor = document.createElement('div');
this._dropdownAnchor.style.width = '10px';
this._dropdownAnchor.style.height = '100%';
this._dropdownAnchor.style.flexShrink = '0';
this._dropdownAnchor.style.backgroundColor = 'red';
this.element.appendChild(this._dropdownAnchor);
addDisposableListener(this._dropdownAnchor, 'click', (event) => {
const el = document.createElement('div');
el.style.width = '200px';
el.style.maxHeight = '600px';
el.style.overflow = 'auto';
el.style.backgroundColor = 'lightgreen';
this.tabs.map((tab) => {
const tab2 = new Tab(
tab.value.panel,
this.accessor,
this.group
);
tab2.setContent(tab.value.panel.view.newTab);
el.appendChild(tab2.element);
});
this.accessor.popupService.openPopover(el, {
x: event.clientX,
y: event.clientY,
});
});
}
}

View File

@ -25,47 +25,4 @@
flex-grow: 1;
cursor: grab;
}
.dv-tabs-container {
display: flex;
overflow-x: overlay;
overflow-y: hidden;
scrollbar-width: thin; // firefox
&::-webkit-scrollbar {
height: 3px;
}
/* Track */
&::-webkit-scrollbar-track {
background: transparent;
}
/* Handle */
&::-webkit-scrollbar-thumb {
background: var(--dv-tabs-container-scrollbar-color);
}
.dv-tab {
-webkit-user-drag: element;
outline: none;
min-width: 75px;
cursor: pointer;
position: relative;
box-sizing: border-box;
&:not(:first-child)::before {
content: ' ';
position: absolute;
top: 0;
left: 0;
z-index: 5;
pointer-events: none;
background-color: var(--dv-tab-divider-color);
width: 1px;
height: 100%;
}
}
}
}

View File

@ -1,17 +1,14 @@
import {
IDisposable,
CompositeDisposable,
IValueDisposable,
} from '../../../lifecycle';
import { IDisposable, CompositeDisposable } from '../../../lifecycle';
import { addDisposableListener, Emitter, Event } from '../../../events';
import { Tab } from '../tab/tab';
import { DockviewGroupPanel } from '../../dockviewGroupPanel';
import { VoidContainer } from './voidContainer';
import { toggleClass } from '../../../dom';
import { DockviewPanel, IDockviewPanel } from '../../dockviewPanel';
import { IDockviewPanel } from '../../dockviewPanel';
import { DockviewComponent } from '../../dockviewComponent';
import { WillShowOverlayLocationEvent } from '../../dockviewGroupPanelModel';
import { getPanelData } from '../../../dnd/dataTransfer';
import { Tabs } from './tabs';
export interface TabDropIndexEvent {
readonly event: DragEvent;
@ -56,14 +53,12 @@ export class TabsContainer
implements ITabsContainer
{
private readonly _element: HTMLElement;
private readonly tabContainer: HTMLElement;
private readonly tabs: Tabs;
private readonly rightActionsContainer: HTMLElement;
private readonly leftActionsContainer: HTMLElement;
private readonly preActionsContainer: HTMLElement;
private readonly voidContainer: VoidContainer;
private tabs: IValueDisposable<Tab>[] = [];
private selectedIndex = -1;
private rightActions: HTMLElement | undefined;
private leftActions: HTMLElement | undefined;
private preActions: HTMLElement | undefined;
@ -73,8 +68,9 @@ export class TabsContainer
private readonly _onDrop = new Emitter<TabDropIndexEvent>();
readonly onDrop: Event<TabDropIndexEvent> = this._onDrop.event;
private readonly _onTabDragStart = new Emitter<TabDragEvent>();
readonly onTabDragStart: Event<TabDragEvent> = this._onTabDragStart.event;
get onTabDragStart(): Event<TabDragEvent> {
return this.tabs.onTabDragStart;
}
private readonly _onGroupDragStart = new Emitter<GroupDragEvent>();
readonly onGroupDragStart: Event<GroupDragEvent> =
@ -86,11 +82,11 @@ export class TabsContainer
this._onWillShowOverlay.event;
get panels(): string[] {
return this.tabs.map((_) => _.value.panel.id);
return this.tabs.panels;
}
get size(): number {
return this.tabs.length;
return this.tabs.size;
}
get hidden(): boolean {
@ -102,6 +98,102 @@ export class TabsContainer
this.element.style.display = value ? 'none' : '';
}
get element(): HTMLElement {
return this._element;
}
constructor(
private readonly accessor: DockviewComponent,
private readonly group: DockviewGroupPanel
) {
super();
this._element = document.createElement('div');
this._element.className = 'dv-tabs-and-actions-container';
toggleClass(
this._element,
'dv-full-width-single-tab',
this.accessor.options.singleTabMode === 'fullwidth'
);
this.rightActionsContainer = document.createElement('div');
this.rightActionsContainer.className = 'dv-right-actions-container';
this.leftActionsContainer = document.createElement('div');
this.leftActionsContainer.className = 'dv-left-actions-container';
this.preActionsContainer = document.createElement('div');
this.preActionsContainer.className = 'dv-pre-actions-container';
this.tabs = new Tabs(group, accessor);
this.voidContainer = new VoidContainer(this.accessor, this.group);
this._element.appendChild(this.preActionsContainer);
this._element.appendChild(this.tabs.element);
this._element.appendChild(this.leftActionsContainer);
this._element.appendChild(this.voidContainer.element);
this._element.appendChild(this.rightActionsContainer);
this.addDisposables(
this._onWillShowOverlay,
this._onDrop,
this._onGroupDragStart,
this.voidContainer,
this.voidContainer.onDragStart((event) => {
this._onGroupDragStart.fire({
nativeEvent: event,
group: this.group,
});
}),
this.voidContainer.onDrop((event) => {
this._onDrop.fire({
event: event.nativeEvent,
index: this.tabs.size,
});
}),
this.voidContainer.onWillShowOverlay((event) => {
this._onWillShowOverlay.fire(
new WillShowOverlayLocationEvent(event, {
kind: 'header_space',
panel: this.group.activePanel,
api: this.accessor.api,
group: this.group,
getData: getPanelData,
})
);
}),
addDisposableListener(
this.voidContainer.element,
'pointerdown',
(event) => {
const isFloatingGroupsEnabled =
!this.accessor.options.disableFloatingGroups;
if (
isFloatingGroupsEnabled &&
event.shiftKey &&
this.group.api.location.type !== 'floating'
) {
event.preventDefault();
const { top, left } =
this.element.getBoundingClientRect();
const { top: rootTop, left: rootLeft } =
this.accessor.element.getBoundingClientRect();
this.accessor.addFloatingGroup(this.group, {
x: left - rootLeft + 20,
y: top - rootTop + 20,
inDragMode: true,
});
}
}
)
);
}
show(): void {
if (!this.hidden) {
this.element.style.display = '';
@ -154,269 +246,36 @@ export class TabsContainer
}
}
get element(): HTMLElement {
return this._element;
isActive(tab: Tab): boolean {
return this.tabs.isActive(tab);
}
public isActive(tab: Tab): boolean {
return (
this.selectedIndex > -1 &&
this.tabs[this.selectedIndex].value === tab
);
indexOf(id: string): number {
return this.tabs.indexOf(id);
}
public indexOf(id: string): number {
return this.tabs.findIndex((tab) => tab.value.panel.id === id);
}
constructor(
private readonly accessor: DockviewComponent,
private readonly group: DockviewGroupPanel
) {
super();
this._element = document.createElement('div');
this._element.className = 'dv-tabs-and-actions-container';
toggleClass(
this._element,
'dv-full-width-single-tab',
this.accessor.options.singleTabMode === 'fullwidth'
);
this.rightActionsContainer = document.createElement('div');
this.rightActionsContainer.className = 'dv-right-actions-container';
this.leftActionsContainer = document.createElement('div');
this.leftActionsContainer.className = 'dv-left-actions-container';
this.preActionsContainer = document.createElement('div');
this.preActionsContainer.className = 'dv-pre-actions-container';
this.tabContainer = document.createElement('div');
this.tabContainer.className = 'dv-tabs-container';
this.voidContainer = new VoidContainer(this.accessor, this.group);
this._element.appendChild(this.preActionsContainer);
this._element.appendChild(this.tabContainer);
this._element.appendChild(this.leftActionsContainer);
this._element.appendChild(this.voidContainer.element);
this._element.appendChild(this.rightActionsContainer);
this.addDisposables(
this._onWillShowOverlay,
this._onDrop,
this._onTabDragStart,
this._onGroupDragStart,
this.voidContainer,
this.voidContainer.onDragStart((event) => {
this._onGroupDragStart.fire({
nativeEvent: event,
group: this.group,
});
}),
this.voidContainer.onDrop((event) => {
this._onDrop.fire({
event: event.nativeEvent,
index: this.tabs.length,
});
}),
this.voidContainer.onWillShowOverlay((event) => {
this._onWillShowOverlay.fire(
new WillShowOverlayLocationEvent(event, {
kind: 'header_space',
panel: this.group.activePanel,
api: this.accessor.api,
group: this.group,
getData: getPanelData,
})
);
}),
addDisposableListener(
this.voidContainer.element,
'pointerdown',
(event) => {
const isFloatingGroupsEnabled =
!this.accessor.options.disableFloatingGroups;
if (
isFloatingGroupsEnabled &&
event.shiftKey &&
this.group.api.location.type !== 'floating'
) {
event.preventDefault();
const { top, left } =
this.element.getBoundingClientRect();
const { top: rootTop, left: rootLeft } =
this.accessor.element.getBoundingClientRect();
this.accessor.addFloatingGroup(this.group, {
x: left - rootLeft + 20,
y: top - rootTop + 20,
inDragMode: true,
});
}
}
),
addDisposableListener(this.tabContainer, 'pointerdown', (event) => {
if (event.defaultPrevented) {
return;
}
const isLeftClick = event.button === 0;
if (isLeftClick) {
this.accessor.doSetGroupActive(this.group);
}
})
);
}
public setActive(_isGroupActive: boolean) {
setActive(_isGroupActive: boolean) {
// noop
}
public delete(id: string): void {
const index = this.tabs.findIndex((tab) => tab.value.panel.id === id);
const tabToRemove = this.tabs.splice(index, 1)[0];
const { value, disposable } = tabToRemove;
disposable.dispose();
value.dispose();
value.element.remove();
delete(id: string): void {
this.tabs.delete(id);
this.updateClassnames();
}
public setActivePanel(panel: IDockviewPanel): void {
this.tabs.forEach((tab) => {
const isActivePanel = panel.id === tab.value.panel.id;
tab.value.setActive(isActivePanel);
});
setActivePanel(panel: IDockviewPanel): void {
this.tabs.setActivePanel(panel);
}
public openPanel(
panel: IDockviewPanel,
index: number = this.tabs.length
): void {
if (this.tabs.find((tab) => tab.value.panel.id === panel.id)) {
return;
}
const tab = new Tab(panel, this.accessor, this.group);
tab.setContent(panel.view.tab);
const disposable = new CompositeDisposable(
tab.onDragStart((event) => {
this._onTabDragStart.fire({ nativeEvent: event, panel });
}),
tab.onChanged((event) => {
const isFloatingGroupsEnabled =
!this.accessor.options.disableFloatingGroups;
const isFloatingWithOnePanel =
this.group.api.location.type === 'floating' &&
this.size === 1;
if (
isFloatingGroupsEnabled &&
!isFloatingWithOnePanel &&
event.shiftKey
) {
event.preventDefault();
const panel = this.accessor.getGroupPanel(tab.panel.id);
const { top, left } = tab.element.getBoundingClientRect();
const { top: rootTop, left: rootLeft } =
this.accessor.element.getBoundingClientRect();
this.accessor.addFloatingGroup(panel as DockviewPanel, {
x: left - rootLeft,
y: top - rootTop,
inDragMode: true,
});
return;
openPanel(panel: IDockviewPanel, index: number = this.tabs.size): void {
this.tabs.openPanel(panel, index);
this.updateClassnames();
}
const isLeftClick = event.button === 0;
if (!isLeftClick || event.defaultPrevented) {
return;
}
if (this.group.activePanel !== panel) {
this.group.model.openPanel(panel);
}
}),
tab.onDrop((event) => {
this._onDrop.fire({
event: event.nativeEvent,
index: this.tabs.findIndex((x) => x.value === tab),
});
}),
tab.onWillShowOverlay((event) => {
this._onWillShowOverlay.fire(
new WillShowOverlayLocationEvent(event, {
kind: 'tab',
panel: this.group.activePanel,
api: this.accessor.api,
group: this.group,
getData: getPanelData,
})
);
})
);
const value: IValueDisposable<Tab> = { value: tab, disposable };
this.addTab(value, index);
}
public closePanel(panel: IDockviewPanel): void {
closePanel(panel: IDockviewPanel): void {
this.delete(panel.id);
}
public dispose(): void {
super.dispose();
for (const { value, disposable } of this.tabs) {
disposable.dispose();
value.dispose();
}
this.tabs = [];
}
private addTab(
tab: IValueDisposable<Tab>,
index: number = this.tabs.length
): void {
if (index < 0 || index > this.tabs.length) {
throw new Error('invalid location');
}
this.tabContainer.insertBefore(
tab.value.element,
this.tabContainer.children[index]
);
this.tabs = [
...this.tabs.slice(0, index),
tab,
...this.tabs.slice(index),
];
if (this.selectedIndex < 0) {
this.selectedIndex = index;
}
this.updateClassnames();
}
private updateClassnames(): void {
toggleClass(this._element, 'dv-single-tab', this.size === 1);
}

View File

@ -18,7 +18,10 @@
.dv-groupview {
&.dv-active-group {
> .dv-tabs-and-actions-container > .dv-tabs-container > .dv-tab {
> .dv-tabs-and-actions-container
> .dv-tabs-panel
> .dv-tabs-container
> .dv-tab {
&.dv-active-tab {
background-color: var(
--dv-activegroup-visiblepanel-tab-background-color
@ -34,7 +37,10 @@
}
}
&.dv-inactive-group {
> .dv-tabs-and-actions-container > .dv-tabs-container > .dv-tab {
> .dv-tabs-and-actions-container
> .dv-tabs-panel
> .dv-tabs-container
> .dv-tab {
&.dv-active-tab {
background-color: var(
--dv-inactivegroup-visiblepanel-tab-background-color

View File

@ -74,6 +74,7 @@ import {
} from '../overlay/overlayRenderContainer';
import { PopoutWindow } from '../popoutWindow';
import { StrictEventsSequencing } from './strictEventsSequencing';
import { PopupService } from './components/popupService';
const DEFAULT_ROOT_OVERLAY_MODEL: DroptargetOverlayModel = {
activationSize: { type: 'pixels', value: 10 },
@ -256,6 +257,7 @@ export class DockviewComponent
private watermark: IWatermarkRenderer | null = null;
readonly overlayRenderContainer: OverlayRenderContainer;
readonly popupService: PopupService;
private readonly _onWillDragPanel = new Emitter<TabDragEvent>();
readonly onWillDragPanel: Event<TabDragEvent> = this._onWillDragPanel.event;
@ -381,6 +383,8 @@ export class DockviewComponent
className: options.className,
});
this.popupService = new PopupService(this.element);
this.overlayRenderContainer = new OverlayRenderContainer(
this.gridview.element,
this

View File

@ -14,6 +14,7 @@ export interface IDockviewPanelModel extends IDisposable {
readonly tabComponent?: string;
readonly content: IContentRenderer;
readonly tab: ITabRenderer;
readonly newTab: ITabRenderer;
update(event: PanelUpdateEvent): void;
layout(width: number, height: number): void;
init(params: GroupPanelPartInitParameters): void;
@ -42,6 +43,11 @@ export class DockviewPanelModel implements IDockviewPanelModel {
this._tab = this.createTabComponent(this.id, tabComponent);
}
get newTab() {
const cmp = this.createTabComponent(this.id, this.tabComponent);
return cmp;
}
init(params: GroupPanelPartInitParameters): void {
this.content.init(params);
this.tab.init(params);