diff --git a/app/package.json b/app/package.json index 14ea01fe..68828808 100644 --- a/app/package.json +++ b/app/package.json @@ -14,6 +14,7 @@ "watch": "webpack --progress --color --watch" }, "dependencies": { + "@angular/cdk": "^12.1.2", "@electron/remote": "1.2.0", "any-promise": "^1.3.0", "electron-config": "2.0.0", diff --git a/app/yarn.lock b/app/yarn.lock index 2cdd5cb3..a2876d4a 100644 --- a/app/yarn.lock +++ b/app/yarn.lock @@ -2,6 +2,15 @@ # yarn lockfile v1 +"@angular/cdk@^12.1.2": + version "12.1.2" + resolved "https://registry.yarnpkg.com/@angular/cdk/-/cdk-12.1.2.tgz#5c2407324d860737374d873bd4381bf7f90f8a61" + integrity sha512-ALupZejZDsVYcbNZcEH1cV8SDgVBL40FAwDnlSZxCgd0HOBHH0ZqQV+8z0uCQeMatoNM+SwmJ8Y1JXYh9Bqfiw== + dependencies: + tslib "^2.2.0" + optionalDependencies: + parse5 "^5.0.0" + "@electron/remote@1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@electron/remote/-/remote-1.2.0.tgz#772eb4c3ac17aaba5a9cf05a09092f6277f5671f" @@ -2558,6 +2567,11 @@ parse-json@^2.2.0: dependencies: error-ex "^1.2.0" +parse5@^5.0.0: + version "5.1.1" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-5.1.1.tgz#f68e4e5ba1852ac2cadc00f4555fff6c2abb6178" + integrity sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug== + path-exists@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz" @@ -3399,6 +3413,11 @@ tslib@^2.0.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.2.0.tgz#fb2c475977e35e241311ede2693cee1ec6698f5c" integrity sha512-gS9GVHRU+RGn5KQM2rllAlR3dU6m7AcpJKdtH8gFvQiC4Otgk98XnmMU+nZenHt/+VhnBPWwgrJsyrdcw6i23w== +tslib@^2.2.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.0.tgz#803b8cdab3e12ba581a4ca41c8839bbb0dacb09e" + integrity sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg== + tslib@~2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.1.0.tgz#da60860f1c2ecaa5703ab7d39bc05b6bf988b97a" diff --git a/tabby-core/src/components/appRoot.component.pug b/tabby-core/src/components/appRoot.component.pug index c4fd36d3..5fbd1679 100644 --- a/tabby-core/src/components/appRoot.component.pug +++ b/tabby-core/src/components/appRoot.component.pug @@ -14,15 +14,17 @@ title-bar( && config.store.appearance.frame == "thin" \ && (config.store.appearance.tabsLocation == "top" || config.store.appearance.tabsLocation == "left")') .tabs( - dnd-sortable-container, - [sortableData]='app.tabs', + cdkDropList, + [cdkDropListOrientation]='(config.store.appearance.tabsLocation == "top" || config.store.appearance.tabsLocation == "bottom") ? "horizontal" : "vertical"', + (cdkDropListDropped)='onTabsReordered($event)', + cdkAutoDropGroup='app-tabs' ) tab-header( *ngFor='let tab of app.tabs; let idx = index', - dnd-sortable, - [sortableIndex]='idx', - (onDragStart)='onTabDragStart()', - (onDragEnd)='onTabDragEnd()', + cdkDrag, + [cdkDragData]='tab', + (cdkDragStarted)='onTabDragStart()', + (cdkDragEnded)='onTabDragEnd()', [index]='idx', [tab]='tab', [active]='tab == app.activeTab', @@ -30,7 +32,7 @@ title-bar( [@.disabled]='hasVerticalTabs()', (click)='app.selectTab(tab)', [class.fully-draggable]='hostApp.platform != Platform.macOS', - [class.drag-region]='hostApp.platform == Platform.macOS && !tabsDragging', + [class.drag-region]='hostApp.platform == Platform.macOS && !(app.tabDragActive$|async)', ) .btn-group.background @@ -109,6 +111,7 @@ title-bar( start-page.content-tab.content-tab-active(*ngIf='ready && app.tabs.length == 0') tab-body.content-tab( + #tabBodies, *ngFor='let tab of unsortedTabs', [class.content-tab-active]='tab == app.activeTab', [active]='tab == app.activeTab', diff --git a/tabby-core/src/components/appRoot.component.scss b/tabby-core/src/components/appRoot.component.scss index 1bc79c0a..afdc4546 100644 --- a/tabby-core/src/components/appRoot.component.scss +++ b/tabby-core/src/components/appRoot.component.scss @@ -132,6 +132,14 @@ $side-tab-width: 200px; window-controls { padding-left: 10px; } + + .cdk-drag-animating { + transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); + } + + .cdk-drop-list-dragging tab-header:not(.cdk-drag-placeholder) { + transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); + } } .content { diff --git a/tabby-core/src/components/appRoot.component.ts b/tabby-core/src/components/appRoot.component.ts index ca007f4b..d39348dc 100644 --- a/tabby-core/src/components/appRoot.component.ts +++ b/tabby-core/src/components/appRoot.component.ts @@ -1,7 +1,8 @@ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ -import { Component, Inject, Input, HostListener, HostBinding } from '@angular/core' +import { Component, Inject, Input, HostListener, HostBinding, ViewChildren } from '@angular/core' import { trigger, style, animate, transition, state } from '@angular/animations' import { NgbModal } from '@ng-bootstrap/ng-bootstrap' +import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop' import { HostAppService, Platform } from '../api/hostApp' import { HotkeysService } from '../services/hotkeys.service' @@ -12,6 +13,7 @@ import { UpdaterService } from '../services/updater.service' import { BaseTabComponent } from './baseTab.component' import { SafeModeModalComponent } from './safeModeModal.component' +import { TabBodyComponent } from './tabBody.component' import { AppService, FileTransfer, HostWindowService, PlatformService, ToolbarButton, ToolbarButtonProvider } from '../api' /** @hidden */ @@ -57,7 +59,7 @@ export class AppRootComponent { @HostBinding('class.platform-darwin') platformClassMacOS = process.platform === 'darwin' @HostBinding('class.platform-linux') platformClassLinux = process.platform === 'linux' @HostBinding('class.no-tabs') noTabs = true - tabsDragging = false + @ViewChildren(TabBodyComponent) tabBodies: TabBodyComponent[] unsortedTabs: BaseTabComponent[] = [] updatesAvailable = false activeTransfers: FileTransfer[] = [] @@ -126,11 +128,18 @@ export class AppRootComponent { this.app.tabOpened$.subscribe(tab => { this.unsortedTabs.push(tab) this.noTabs = false + this.app.emitTabDragEnded() }) - this.app.tabClosed$.subscribe(tab => { + this.app.tabRemoved$.subscribe(tab => { + for (const tabBody of this.tabBodies) { + if (tabBody.tab === tab) { + tabBody.detach() + } + } this.unsortedTabs = this.unsortedTabs.filter(x => x !== tab) this.noTabs = app.tabs.length === 0 + this.app.emitTabDragEnded() }) platform.fileTransferStarted$.subscribe(transfer => { @@ -174,12 +183,12 @@ export class AppRootComponent { } onTabDragStart () { - this.tabsDragging = true + this.app.emitTabDragStarted() } onTabDragEnd () { setTimeout(() => { - this.tabsDragging = false + this.app.emitTabDragEnded() this.app.emitTabsChanged() }) } @@ -194,6 +203,11 @@ export class AppRootComponent { return submenuItems.some(x => !!x.icon) } + onTabsReordered (event: CdkDragDrop) { + moveItemInArray(this.app.tabs, event.previousIndex, event.currentIndex) + this.app.emitTabsChanged() + } + private getToolbarButtons (aboveZero: boolean): ToolbarButton[] { let buttons: ToolbarButton[] = [] this.config.enabledServices(this.toolbarButtonProviders).forEach(provider => { diff --git a/tabby-core/src/components/baseTab.component.ts b/tabby-core/src/components/baseTab.component.ts index 6360bc3f..181348db 100644 --- a/tabby-core/src/components/baseTab.component.ts +++ b/tabby-core/src/components/baseTab.component.ts @@ -1,5 +1,5 @@ import { Observable, Subject } from 'rxjs' -import { ViewRef } from '@angular/core' +import { EmbeddedViewRef, ViewContainerRef, ViewRef } from '@angular/core' import { RecoveryToken } from '../api/tabRecovery' import { BaseComponent } from './base.component' @@ -52,6 +52,10 @@ export abstract class BaseTabComponent extends BaseComponent { * your tab state to be saved sooner */ protected recoveryStateChangedHint = new Subject() + protected viewContainer?: ViewContainerRef + + /* @hidden */ + viewContainerEmbeddedRef?: EmbeddedViewRef private progressClearTimeout: number private titleChange = new Subject() @@ -61,6 +65,8 @@ export abstract class BaseTabComponent extends BaseComponent { private activity = new Subject() private destroyed = new Subject() + private _destroyCalled = false + get focused$ (): Observable { return this.focused } get blurred$ (): Observable { return this.blurred } get titleChange$ (): Observable { return this.titleChange } @@ -152,10 +158,29 @@ export abstract class BaseTabComponent extends BaseComponent { this.blurred.next() } + insertIntoContainer (container: ViewContainerRef): EmbeddedViewRef { + this.viewContainerEmbeddedRef = container.insert(this.hostView) as EmbeddedViewRef + this.viewContainer = container + return this.viewContainerEmbeddedRef + } + + removeFromContainer (): void { + if (!this.viewContainer || !this.viewContainerEmbeddedRef) { + return + } + this.viewContainer.detach(this.viewContainer.indexOf(this.viewContainerEmbeddedRef)) + this.viewContainerEmbeddedRef = undefined + this.viewContainer = undefined + } + /** * Called before the tab is closed */ destroy (skipDestroyedEvent = false): void { + if (this._destroyCalled) { + return + } + this._destroyCalled = true this.focused.complete() this.blurred.complete() this.titleChange.complete() @@ -166,6 +191,7 @@ export abstract class BaseTabComponent extends BaseComponent { this.destroyed.next() } this.destroyed.complete() + this.hostView.destroy() } /** @hidden */ diff --git a/tabby-core/src/components/selfPositioning.component.ts b/tabby-core/src/components/selfPositioning.component.ts new file mode 100644 index 00000000..f2d36d26 --- /dev/null +++ b/tabby-core/src/components/selfPositioning.component.ts @@ -0,0 +1,18 @@ +import { HostBinding, ElementRef } from '@angular/core' +import { BaseComponent } from './base.component' + +export abstract class SelfPositioningComponent extends BaseComponent { + @HostBinding('style.left') cssLeft: string + @HostBinding('style.top') cssTop: string + @HostBinding('style.width') cssWidth: string | null + @HostBinding('style.height') cssHeight: string | null + + constructor (protected element: ElementRef) { super() } + + protected setDimensions (x: number, y: number, w: number, h: number, unit = '%'): void { + this.cssLeft = `${x}${unit}` + this.cssTop = `${y}${unit}` + this.cssWidth = w ? `${w}${unit}` : null + this.cssHeight = h ? `${h}${unit}` : null + } +} diff --git a/tabby-core/src/components/splitTab.component.ts b/tabby-core/src/components/splitTab.component.ts index 63e43ae7..43aa88e0 100644 --- a/tabby-core/src/components/splitTab.component.ts +++ b/tabby-core/src/components/splitTab.component.ts @@ -123,6 +123,14 @@ export interface SplitSpannerInfo { index: number } +/** + * Represents a tab drop zone + */ +export interface SplitDropZoneInfo { + relativeToTab: BaseTabComponent + side: SplitDirection +} + /** * Split tab is a tab that contains other tabs and allows further splitting them * You'll mainly encounter it inside [[AppService]].tabs @@ -137,6 +145,12 @@ export interface SplitSpannerInfo { [index]='spanner.index' (change)='onSpannerAdjusted(spanner)' > + + `, styles: [require('./splitTab.component.scss')], }) @@ -157,6 +171,9 @@ export class SplitTabComponent extends BaseTabComponent implements AfterViewInit /** @hidden */ _spanners: SplitSpannerInfo[] = [] + /** @hidden */ + _dropZones: SplitDropZoneInfo[] = [] + /** @hidden */ _allFocusMode = false @@ -166,12 +183,19 @@ export class SplitTabComponent extends BaseTabComponent implements AfterViewInit private viewRefs: Map> = new Map() private tabAdded = new Subject() + private tabAdopted = new Subject() private tabRemoved = new Subject() private splitAdjusted = new Subject() private focusChanged = new Subject() private initialized = new Subject() get tabAdded$ (): Observable { return this.tabAdded } + + /** + * Fired when an existing top-level tab is dragged into this tab + */ + get tabAdopted$ (): Observable { return this.tabAdopted } + get tabRemoved$ (): Observable { return this.tabRemoved } /** @@ -330,11 +354,27 @@ export class SplitTabComponent extends BaseTabComponent implements AfterViewInit } } + addTab (tab: BaseTabComponent, relative: BaseTabComponent|null, side: SplitDirection): Promise { + return this.add(tab, relative, side) + } + /** * Inserts a new `tab` to the `side` of the `relative` tab */ - async addTab (tab: BaseTabComponent, relative: BaseTabComponent|null, side: SplitDirection): Promise { - tab.parent = this + async add (thing: BaseTabComponent|SplitContainer, relative: BaseTabComponent|null, side: SplitDirection): Promise { + if (thing instanceof SplitTabComponent) { + const tab = thing + thing = tab.root + tab.root = new SplitContainer() + for (const child of thing.getAllTabs()) { + child.removeFromContainer() + } + tab.destroy() + } + + if (thing instanceof BaseTabComponent) { + thing.parent = this + } let target = (relative ? this.getParentOf(relative) : null) ?? this.root let insertIndex = relative ? target.children.indexOf(relative) : -1 @@ -362,14 +402,16 @@ export class SplitTabComponent extends BaseTabComponent implements AfterViewInit target.ratios[i] *= target.children.length / (target.children.length + 1) } target.ratios.splice(insertIndex, 0, 1 / (target.children.length + 1)) - target.children.splice(insertIndex, 0, tab) + target.children.splice(insertIndex, 0, thing) this.recoveryStateChangedHint.next() await this.initialized$.toPromise() - this.attachTabView(tab) - this.onAfterTabAdded(tab) + for (const tab of thing instanceof SplitContainer ? thing.getAllTabs() : [thing]) { + this.attachTabView(tab) + this.onAfterTabAdded(tab) + } } removeTab (tab: BaseTabComponent): void { @@ -381,8 +423,9 @@ export class SplitTabComponent extends BaseTabComponent implements AfterViewInit parent.ratios.splice(index, 1) parent.children.splice(index, 1) - this.detachTabView(tab) + tab.removeFromContainer() tab.parent = null + this.viewRefs.delete(tab) this.layout() @@ -401,7 +444,7 @@ export class SplitTabComponent extends BaseTabComponent implements AfterViewInit } const position = parent.children.indexOf(tab) parent.children[position] = newTab - this.detachTabView(tab) + tab.removeFromContainer() this.attachTabView(newTab) tab.parent = null newTab.parent = this @@ -508,6 +551,16 @@ export class SplitTabComponent extends BaseTabComponent implements AfterViewInit this.splitAdjusted.next(spanner) } + /** @hidden */ + onTabDropped (tab: BaseTabComponent, zone: SplitDropZoneInfo) { // eslint-disable-line @typescript-eslint/explicit-module-boundary-types + if (tab === this) { + return + } + + this.add(tab, zone.relativeToTab, zone.side) + this.tabAdopted.next(tab) + } + destroy (): void { super.destroy() for (const x of this.getAllTabs()) { @@ -518,13 +571,13 @@ export class SplitTabComponent extends BaseTabComponent implements AfterViewInit layout (): void { this.root.normalize() this._spanners = [] + this._dropZones = [] this.layoutInternal(this.root, 0, 0, 100, 100) } private attachTabView (tab: BaseTabComponent) { - const ref = this.viewContainer.insert(tab.hostView) as EmbeddedViewRef // eslint-disable-line @typescript-eslint/no-unnecessary-type-assertion + const ref = tab.insertIntoContainer(this.viewContainer) this.viewRefs.set(tab, ref) - tab.addEventListenerUntilDestroyed(ref.rootNodes[0], 'click', () => this.focus(tab)) tab.subscribeUntilDestroyed(tab.titleChange$, t => this.setTitle(t)) @@ -541,14 +594,6 @@ export class SplitTabComponent extends BaseTabComponent implements AfterViewInit }) } - private detachTabView (tab: BaseTabComponent) { - const ref = this.viewRefs.get(tab) - if (ref) { - this.viewRefs.delete(tab) - this.viewContainer.remove(this.viewContainer.indexOf(ref)) - } - } - private onAfterTabAdded (tab: BaseTabComponent) { setImmediate(() => { this.layout() @@ -593,6 +638,13 @@ export class SplitTabComponent extends BaseTabComponent implements AfterViewInit element.style.width = '90%' element.style.height = '90%' } + + for (const side of ['t', 'r', 'b', 'l']) { + this._dropZones.push({ + relativeToTab: child, + side: side as SplitDirection, + }) + } } } offset += sizes[i] @@ -612,6 +664,9 @@ export class SplitTabComponent extends BaseTabComponent implements AfterViewInit root.ratios = state.ratios root.children = children for (const childState of state.children) { + if (!childState) { + continue + } if (childState.type === 'app:split-tab') { const child = new SplitContainer() await this.recoverContainer(child, childState, duplicate) diff --git a/tabby-core/src/components/splitTabDropZone.component.scss b/tabby-core/src/components/splitTabDropZone.component.scss new file mode 100644 index 00000000..bc5437f7 --- /dev/null +++ b/tabby-core/src/components/splitTabDropZone.component.scss @@ -0,0 +1,37 @@ +:host { + position: absolute; + display: flex; + z-index: 5; + padding: 15px; + transition: all 125ms cubic-bezier(0, 0, 0.2, 1); + + > div { + flex: 1 1 0; + width: 100%; + height: 100%; + + background: rgba(255, 255, 255, .125); + border-radius: 5px; + border: 1px solid rgba(255, 255, 255, .25); + transition: all 125ms cubic-bezier(0, 0, 0.2, 1); + } + + &.highlighted { + padding: 0px; + border-radius: 3px; + + > div { + background: rgba(255, 255, 255, .5); + } + } + + &:not(.active) { + pointer-events: none; + opacity: 0; + } + + ::ng-deep tab-header { + // placeholders + opacity: 0; + } +} diff --git a/tabby-core/src/components/splitTabDropZone.component.ts b/tabby-core/src/components/splitTabDropZone.component.ts new file mode 100644 index 00000000..f2621d0d --- /dev/null +++ b/tabby-core/src/components/splitTabDropZone.component.ts @@ -0,0 +1,63 @@ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +import { Component, Input, HostBinding, ElementRef, Output, EventEmitter } from '@angular/core' +import { AppService } from '../services/app.service' +import { BaseTabComponent } from './baseTab.component' +import { SelfPositioningComponent } from './selfPositioning.component' +import { SplitDropZoneInfo } from './splitTab.component' + +/** @hidden */ +@Component({ + selector: 'split-tab-drop-zone', + template: ` +
+
+ `, + styles: [require('./splitTabDropZone.component.scss')], +}) +export class SplitTabDropZoneComponent extends SelfPositioningComponent { + @Input() dropZone: SplitDropZoneInfo + @Output() tabDropped = new EventEmitter() + @HostBinding('class.active') isActive = false + @HostBinding('class.highlighted') isHighlighted = false + + // eslint-disable-next-line @typescript-eslint/no-useless-constructor + constructor ( + element: ElementRef, + app: AppService, + ) { + super(element) + this.subscribeUntilDestroyed(app.tabDragActive$, active => { + this.isActive = active + this.layout() + }) + } + + ngOnChanges () { + this.layout() + } + + layout () { + const tabElement: HTMLElement = this.dropZone.relativeToTab.viewContainerEmbeddedRef?.rootNodes[0] + + const args = { + t: [0, 0, tabElement.clientWidth, tabElement.clientHeight / 5], + l: [0, tabElement.clientHeight / 5, tabElement.clientWidth / 3, tabElement.clientHeight * 3 / 5], + r: [tabElement.clientWidth * 2 / 3, tabElement.clientHeight / 5, tabElement.clientWidth / 3, tabElement.clientHeight * 3 / 5], + b: [0, tabElement.clientHeight * 4 / 5, tabElement.clientWidth, tabElement.clientHeight / 5], + }[this.dropZone.side] + + this.setDimensions( + args[0] + tabElement.offsetLeft, + args[1] + tabElement.offsetTop, + args[2], + args[3], + 'px' + ) + } +} diff --git a/tabby-core/src/components/splitTabSpanner.component.ts b/tabby-core/src/components/splitTabSpanner.component.ts index b8d58302..d4273c3a 100644 --- a/tabby-core/src/components/splitTabSpanner.component.ts +++ b/tabby-core/src/components/splitTabSpanner.component.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ import { Component, Input, HostBinding, ElementRef, Output, EventEmitter } from '@angular/core' +import { SelfPositioningComponent } from './selfPositioning.component' import { SplitContainer } from './splitTab.component' /** @hidden */ @@ -8,20 +9,19 @@ import { SplitContainer } from './splitTab.component' template: '', styles: [require('./splitTabSpanner.component.scss')], }) -export class SplitTabSpannerComponent { +export class SplitTabSpannerComponent extends SelfPositioningComponent { @Input() container: SplitContainer @Input() index: number @Output() change = new EventEmitter() @HostBinding('class.active') isActive = false @HostBinding('class.h') isHorizontal = false @HostBinding('class.v') isVertical = true - @HostBinding('style.left') cssLeft: string - @HostBinding('style.top') cssTop: string - @HostBinding('style.width') cssWidth: string | null - @HostBinding('style.height') cssHeight: string | null private marginOffset = -5 - constructor (private element: ElementRef) { } + // eslint-disable-next-line @typescript-eslint/no-useless-constructor + constructor (element: ElementRef) { + super(element) + } ngAfterViewInit () { this.element.nativeElement.addEventListener('dblclick', () => { @@ -92,11 +92,4 @@ export class SplitTabSpannerComponent { this.container.ratios[this.index] = ratio this.change.emit() } - - private setDimensions (x: number, y: number, w: number, h: number) { - this.cssLeft = `${x}%` - this.cssTop = `${y}%` - this.cssWidth = w ? `${w}%` : null - this.cssHeight = h ? `${h}%` : null - } } diff --git a/tabby-core/src/components/tabBody.component.ts b/tabby-core/src/components/tabBody.component.ts index d47271e4..fe65c0e1 100644 --- a/tabby-core/src/components/tabBody.component.ts +++ b/tabby-core/src/components/tabBody.component.ts @@ -27,6 +27,10 @@ export class TabBodyComponent implements OnChanges { } } + detach () { + this.placeholder?.detach() + } + ngOnDestroy () { this.placeholder?.detach() } diff --git a/tabby-core/src/components/tabHeader.component.pug b/tabby-core/src/components/tabHeader.component.pug index 33dec67a..9bcd06ec 100644 --- a/tabby-core/src/components/tabHeader.component.pug +++ b/tabby-core/src/components/tabHeader.component.pug @@ -2,9 +2,14 @@ .progressbar([style.width]='progress + "%"', *ngIf='progress != null') .activity-indicator(*ngIf='tab.activity$|async') -.index(*ngIf='!config.store.terminal.hideTabIndex', #handle) {{index + 1}} +ng-container(*ngIf='!config.store.terminal.hideTabIndex') + .index(*ngIf='hostApp.platform === Platform.macOS', cdkDragHandle) {{index + 1}} + .index(*ngIf='hostApp.platform !== Platform.macOS') {{index + 1}} + .name( [title]='tab.customTitle || tab.title', [class.no-hover]='config.store.terminal.hideCloseButton' ) {{tab.customTitle || tab.title}} button(*ngIf='!config.store.terminal.hideCloseButton',(click)='app.closeTab(tab, true)') × + +ng-content diff --git a/tabby-core/src/components/tabHeader.component.ts b/tabby-core/src/components/tabHeader.component.ts index 89ffe23f..aa81424b 100644 --- a/tabby-core/src/components/tabHeader.component.ts +++ b/tabby-core/src/components/tabHeader.component.ts @@ -1,6 +1,5 @@ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ -import { Component, Input, Optional, Inject, HostBinding, HostListener, ViewChild, ElementRef, NgZone } from '@angular/core' -import { SortableComponent } from 'ng2-dnd' +import { Component, Input, Optional, Inject, HostBinding, HostListener, NgZone } from '@angular/core' import { NgbModal } from '@ng-bootstrap/ng-bootstrap' import { TabContextMenuItemProvider } from '../api/tabContextMenuProvider' import { BaseTabComponent } from './baseTab.component' @@ -13,11 +12,6 @@ import { BaseComponent } from './base.component' import { MenuItemOptions } from '../api/menu' import { PlatformService } from '../api/platform' -/** @hidden */ -export interface SortableComponentProxy { - setDragHandle: (_: HTMLElement) => void -} - /** @hidden */ @Component({ selector: 'tab-header', @@ -29,17 +23,16 @@ export class TabHeaderComponent extends BaseComponent { @Input() @HostBinding('class.active') active: boolean @Input() tab: BaseTabComponent @Input() progress: number|null - @ViewChild('handle') handle?: ElementRef + Platform = Platform constructor ( public app: AppService, public config: ConfigService, - private hostApp: HostAppService, + public hostApp: HostAppService, private ngbModal: NgbModal, private hotkeys: HotkeysService, private platform: PlatformService, private zone: NgZone, - @Inject(SortableComponent) private parentDraggable: SortableComponentProxy, @Optional() @Inject(TabContextMenuItemProvider) protected contextMenuProviders: TabContextMenuItemProvider[], ) { super() @@ -61,12 +54,6 @@ export class TabHeaderComponent extends BaseComponent { }) } - ngAfterViewInit () { - if (this.handle && this.hostApp.platform === Platform.macOS) { - this.parentDraggable.setDragHandle(this.handle.nativeElement) - } - } - showRenameTabModal (): void { const modal = this.ngbModal.open(RenameTabModalComponent) modal.componentInstance.value = this.tab.customTitle || this.tab.title diff --git a/tabby-core/src/directives/cdkAutoDropGroup.directive.ts b/tabby-core/src/directives/cdkAutoDropGroup.directive.ts new file mode 100644 index 00000000..11aa3fd0 --- /dev/null +++ b/tabby-core/src/directives/cdkAutoDropGroup.directive.ts @@ -0,0 +1,26 @@ +import { Directive, Input, OnInit } from '@angular/core' +import { CdkDropList } from '@angular/cdk/drag-drop' + +class FakeDropGroup { + _items: Set = new Set() +} + +/** @hidden */ +@Directive({ + selector: '[cdkAutoDropGroup]', +}) +export class CdkAutoDropGroup implements OnInit { + static groups: Record = {} + + @Input('cdkAutoDropGroup') groupName: string + + constructor ( + private cdkDropList: CdkDropList, + ) { } + + ngOnInit (): void { + CdkAutoDropGroup.groups[this.groupName] ??= new FakeDropGroup() + CdkAutoDropGroup.groups[this.groupName]._items.add(this.cdkDropList) + this.cdkDropList['_group'] = CdkAutoDropGroup.groups[this.groupName] + } +} diff --git a/tabby-core/src/index.ts b/tabby-core/src/index.ts index 65e88190..86f002aa 100644 --- a/tabby-core/src/index.ts +++ b/tabby-core/src/index.ts @@ -7,6 +7,7 @@ import { PerfectScrollbarModule, PERFECT_SCROLLBAR_CONFIG } from 'ngx-perfect-sc import { NgxFilesizeModule } from 'ngx-filesize' import { DndModule } from 'ng2-dnd' import { SortablejsModule } from 'ngx-sortablejs' +import { DragDropModule } from '@angular/cdk/drag-drop' import { AppRootComponent } from './components/appRoot.component' import { CheckboxComponent } from './components/checkbox.component' @@ -22,6 +23,7 @@ import { RenameTabModalComponent } from './components/renameTabModal.component' import { SelectorModalComponent } from './components/selectorModal.component' import { SplitTabComponent, SplitTabRecoveryProvider } from './components/splitTab.component' import { SplitTabSpannerComponent } from './components/splitTabSpanner.component' +import { SplitTabDropZoneComponent } from './components/splitTabDropZone.component' import { UnlockVaultModalComponent } from './components/unlockVaultModal.component' import { WelcomeTabComponent } from './components/welcomeTab.component' import { TransfersMenuComponent } from './components/transfersMenu.component' @@ -30,6 +32,7 @@ import { AutofocusDirective } from './directives/autofocus.directive' import { AlwaysVisibleTypeaheadDirective } from './directives/alwaysVisibleTypeahead.directive' import { FastHtmlBindDirective } from './directives/fastHtmlBind.directive' import { DropZoneDirective } from './directives/dropZone.directive' +import { CdkAutoDropGroup } from './directives/cdkAutoDropGroup.directive' import { Theme, CLIHandler, TabContextMenuItemProvider, TabRecoveryProvider, HotkeyProvider, ConfigProvider, PlatformService, FileProvider, ToolbarButtonProvider, ProfilesService, ProfileProvider } from './api' @@ -78,6 +81,7 @@ const PROVIDERS = [ NgxFilesizeModule, PerfectScrollbarModule, DndModule.forRoot(), + DragDropModule, SortablejsModule.forRoot({ animation: 150 }), ], declarations: [ @@ -98,10 +102,12 @@ const PROVIDERS = [ SelectorModalComponent, SplitTabComponent, SplitTabSpannerComponent, + SplitTabDropZoneComponent, UnlockVaultModalComponent, WelcomeTabComponent, TransfersMenuComponent, DropZoneDirective, + CdkAutoDropGroup, ], entryComponents: [ PromptModalComponent, @@ -121,6 +127,7 @@ const PROVIDERS = [ FastHtmlBindDirective, AlwaysVisibleTypeaheadDirective, SortablejsModule, + DragDropModule, ], }) export default class AppModule { // eslint-disable-line @typescript-eslint/no-extraneous-class diff --git a/tabby-core/src/services/app.service.ts b/tabby-core/src/services/app.service.ts index b39e7c0c..f959e07e 100644 --- a/tabby-core/src/services/app.service.ts +++ b/tabby-core/src/services/app.service.ts @@ -54,7 +54,9 @@ export class AppService { private activeTabChange = new Subject() private tabsChanged = new Subject() private tabOpened = new Subject() + private tabRemoved = new Subject() private tabClosed = new Subject() + private tabDragActive = new Subject() private ready = new AsyncSubject() private completionObservers = new Map() @@ -62,7 +64,9 @@ export class AppService { get activeTabChange$ (): Observable { return this.activeTabChange } get tabOpened$ (): Observable { return this.tabOpened } get tabsChanged$ (): Observable { return this.tabsChanged } + get tabRemoved$ (): Observable { return this.tabRemoved } get tabClosed$ (): Observable { return this.tabClosed } + get tabDragActive$ (): Observable { return this.tabDragActive } /** Fires once when the app is ready */ get ready$ (): Observable { return this.ready } @@ -131,21 +135,30 @@ export class AppService { }) tab.destroyed$.subscribe(() => { - const newIndex = Math.max(0, this.tabs.indexOf(tab) - 1) - this.tabs = this.tabs.filter((x) => x !== tab) - if (tab === this._activeTab) { - this.selectTab(this.tabs[newIndex]) - } - this.tabsChanged.next() + this.removeTab(tab) + this.tabRemoved.next(tab) this.tabClosed.next(tab) }) if (tab instanceof SplitTabComponent) { tab.tabAdded$.subscribe(() => this.emitTabsChanged()) tab.tabRemoved$.subscribe(() => this.emitTabsChanged()) + tab.tabAdopted$.subscribe(t => { + this.removeTab(t) + this.tabRemoved.next(t) + }) } } + removeTab (tab: BaseTabComponent): void { + const newIndex = Math.max(0, this.tabs.indexOf(tab) - 1) + this.tabs = this.tabs.filter((x) => x !== tab) + if (tab === this._activeTab) { + this.selectTab(this.tabs[newIndex]) + } + this.tabsChanged.next() + } + /** * Adds a new tab **without** wrapping it in a SplitTabComponent * @param inputs Properties to be assigned on the new tab component instance @@ -344,6 +357,16 @@ export class AppService { this.hostApp.emitReady() } + /** @hidden */ + emitTabDragStarted (): void { + this.tabDragActive.next(true) + } + + /** @hidden */ + emitTabDragEnded (): void { + this.tabDragActive.next(false) + } + /** * Returns an observable that fires once * the tab's internal "process" (see [[BaseTabProcess]]) completes diff --git a/web/polyfills.ts b/web/polyfills.ts index cdb22bed..797042b8 100644 --- a/web/polyfills.ts +++ b/web/polyfills.ts @@ -2,6 +2,7 @@ /* eslint-disable @typescript-eslint/no-empty-function */ /* eslint-disable @typescript-eslint/no-extraneous-class */ import * as angularCoreModule from '@angular/core' +import * as angularCDKModule from '@angular/cdk' import * as angularCompilerModule from '@angular/compiler' import * as angularCommonModule from '@angular/common' import * as angularFormsModule from '@angular/forms' @@ -147,6 +148,7 @@ Tabby.registerModule('readline', { }) Tabby.registerModule('@angular/core', angularCoreModule) +Tabby.registerModule('@angular/cdk', angularCDKModule) Tabby.registerModule('@angular/compiler', angularCompilerModule) Tabby.registerModule('@angular/common', angularCommonModule) Tabby.registerModule('@angular/forms', angularFormsModule)