diff --git a/packages/dockview-core/src/__tests__/api/dockviewPanelApi.spec.ts b/packages/dockview-core/src/__tests__/api/dockviewPanelApi.spec.ts index 1226b6cb2..475146b5f 100644 --- a/packages/dockview-core/src/__tests__/api/dockviewPanelApi.spec.ts +++ b/packages/dockview-core/src/__tests__/api/dockviewPanelApi.spec.ts @@ -5,6 +5,12 @@ import { DockviewGroupPanel } from '../../dockview/dockviewGroupPanel'; describe('groupPanelApi', () => { test('title', () => { + const accessor: Partial = { + onDidAddPanel: jest.fn(), + onDidRemovePanel: jest.fn(), + options: {}, + }; + const panelMock = jest.fn(() => { return { update: jest.fn(), @@ -18,7 +24,11 @@ describe('groupPanelApi', () => { const panel = new panelMock(); const group = new groupMock(); - const cut = new DockviewPanelApiImpl(panel, group); + const cut = new DockviewPanelApiImpl( + panel, + group, + accessor + ); cut.setTitle('test_title'); expect(panel.setTitle).toBeCalledTimes(1); @@ -44,7 +54,8 @@ describe('groupPanelApi', () => { const cut = new DockviewPanelApiImpl( groupPanel, - groupViewPanel + groupViewPanel, + accessor ); cut.updateParameters({ keyA: 'valueA' }); @@ -73,7 +84,8 @@ describe('groupPanelApi', () => { const cut = new DockviewPanelApiImpl( groupPanel, - groupViewPanel + groupViewPanel, + accessor ); let events = 0; diff --git a/packages/dockview-core/src/__tests__/array.spec.ts b/packages/dockview-core/src/__tests__/array.spec.ts index 9e3f0055a..3b48b83ed 100644 --- a/packages/dockview-core/src/__tests__/array.spec.ts +++ b/packages/dockview-core/src/__tests__/array.spec.ts @@ -3,6 +3,7 @@ import { last, pushToEnd, pushToStart, + remove, sequenceEquals, tail, } from '../array'; @@ -47,4 +48,22 @@ describe('array', () => { expect(sequenceEquals([1, 2, 3, 4], [1, 2, 3])).toBeFalsy(); expect(sequenceEquals([1, 2, 3, 4], [1, 2, 3, 4, 5])).toBeFalsy(); }); + + test('remove', () => { + const arr1 = [1, 2, 3, 4]; + remove(arr1, 2); + expect(arr1).toEqual([1, 3, 4]); + + const arr2 = [1, 2, 2, 3, 4]; + remove(arr2, 2); + expect(arr2).toEqual([1, 2, 3, 4]); + + const arr3 = [1]; + remove(arr3, 2); + expect(arr3).toEqual([1]); + remove(arr3, 1); + expect(arr3).toEqual([]); + remove(arr3, 1); + expect(arr3).toEqual([]); + }); }); diff --git a/packages/dockview-core/src/__tests__/dnd/abstractDragHandler.spec.ts b/packages/dockview-core/src/__tests__/dnd/abstractDragHandler.spec.ts index 312916ae5..df57caa66 100644 --- a/packages/dockview-core/src/__tests__/dnd/abstractDragHandler.spec.ts +++ b/packages/dockview-core/src/__tests__/dnd/abstractDragHandler.spec.ts @@ -118,4 +118,62 @@ describe('abstractDragHandler', () => { expect(webview.style.pointerEvents).toBe('auto'); expect(span.style.pointerEvents).toBeFalsy(); }); + + test('that .preventDefault() is called for cancelled events', () => { + const element = document.createElement('div'); + + const handler = new (class TestClass extends DragHandler { + constructor(el: HTMLElement) { + super(el); + } + + protected isCancelled(_event: DragEvent): boolean { + return true; + } + + getData(): IDisposable { + return { + dispose: () => { + // / + }, + }; + } + })(element); + + const event = new Event('dragstart'); + const spy = jest.spyOn(event, 'preventDefault'); + fireEvent(element, event); + expect(spy).toBeCalledTimes(1); + + handler.dispose(); + }); + + test('that .preventDefault() is not called for non-cancelled events', () => { + const element = document.createElement('div'); + + const handler = new (class TestClass extends DragHandler { + constructor(el: HTMLElement) { + super(el); + } + + protected isCancelled(_event: DragEvent): boolean { + return false; + } + + getData(): IDisposable { + return { + dispose: () => { + // / + }, + }; + } + })(element); + + const event = new Event('dragstart'); + const spy = jest.spyOn(event, 'preventDefault'); + fireEvent(element, event); + expect(spy).toBeCalledTimes(0); + + handler.dispose(); + }); }); diff --git a/packages/dockview-core/src/__tests__/dnd/droptarget.spec.ts b/packages/dockview-core/src/__tests__/dnd/droptarget.spec.ts index 5875637ec..32de9729e 100644 --- a/packages/dockview-core/src/__tests__/dnd/droptarget.spec.ts +++ b/packages/dockview-core/src/__tests__/dnd/droptarget.spec.ts @@ -34,6 +34,48 @@ describe('droptarget', () => { jest.spyOn(element, 'clientWidth', 'get').mockImplementation(() => 200); }); + test('that dragover events are marked', () => { + droptarget = new Droptarget(element, { + canDisplayOverlay: () => true, + acceptedTargetZones: ['center'], + }); + + fireEvent.dragEnter(element); + const event = new Event('dragover'); + fireEvent(element, event); + + expect( + (event as any)['__dockview_droptarget_event_is_used__'] + ).toBeTruthy(); + }); + + test('that the drop target is removed when receiving a marked dragover event', () => { + let position: Position | undefined = undefined; + + droptarget = new Droptarget(element, { + canDisplayOverlay: () => true, + acceptedTargetZones: ['center'], + }); + + droptarget.onDrop((event) => { + position = event.position; + }); + + fireEvent.dragEnter(element); + fireEvent.dragOver(element); + + const target = element.querySelector( + '.drop-target-dropzone' + ) as HTMLElement; + fireEvent.drop(target); + expect(position).toBe('center'); + + const event = new Event('dragover'); + (event as any)['__dockview_droptarget_event_is_used__'] = true; + fireEvent(element, event); + expect(element.querySelector('.drop-target-dropzone')).toBeNull(); + }); + test('directionToPosition', () => { expect(directionToPosition('above')).toBe('top'); expect(directionToPosition('below')).toBe('bottom'); diff --git a/packages/dockview-core/src/__tests__/dnd/groupDragHandler.spec.ts b/packages/dockview-core/src/__tests__/dnd/groupDragHandler.spec.ts new file mode 100644 index 000000000..b597f2fa5 --- /dev/null +++ b/packages/dockview-core/src/__tests__/dnd/groupDragHandler.spec.ts @@ -0,0 +1,101 @@ +import { fireEvent } from '@testing-library/dom'; +import { GroupDragHandler } from '../../dnd/groupDragHandler'; +import { DockviewGroupPanel } from '../../dockview/dockviewGroupPanel'; +import { LocalSelectionTransfer, PanelTransfer } from '../../dnd/dataTransfer'; + +describe('groupDragHandler', () => { + test('that the dnd transfer object is setup and torndown', () => { + const element = document.createElement('div'); + + const groupMock = jest.fn(() => { + const partial: Partial = { + id: 'test_group_id', + api: { isFloating: false } as any, + }; + return partial as DockviewGroupPanel; + }); + const group = new groupMock(); + + const cut = new GroupDragHandler(element, 'test_accessor_id', group); + + fireEvent.dragStart(element, new Event('dragstart')); + + expect( + LocalSelectionTransfer.getInstance().hasData( + PanelTransfer.prototype + ) + ).toBeTruthy(); + const transferObject = + LocalSelectionTransfer.getInstance().getData( + PanelTransfer.prototype + )![0]; + expect(transferObject).toBeTruthy(); + expect(transferObject.viewId).toBe('test_accessor_id'); + expect(transferObject.groupId).toBe('test_group_id'); + expect(transferObject.panelId).toBeNull(); + + fireEvent.dragStart(element, new Event('dragend')); + expect( + LocalSelectionTransfer.getInstance().hasData( + PanelTransfer.prototype + ) + ).toBeFalsy(); + + cut.dispose(); + }); + test('that the event is cancelled when isFloating and shiftKey=true', () => { + const element = document.createElement('div'); + + const groupMock = jest.fn(() => { + const partial: Partial = { + api: { isFloating: true } as any, + }; + return partial as DockviewGroupPanel; + }); + const group = new groupMock(); + + const cut = new GroupDragHandler(element, 'accessor_id', group); + + const event = new KeyboardEvent('dragstart', { shiftKey: false }); + + const spy = jest.spyOn(event, 'preventDefault'); + fireEvent(element, event); + expect(spy).toBeCalledTimes(1); + + const event2 = new KeyboardEvent('dragstart', { shiftKey: true }); + + const spy2 = jest.spyOn(event2, 'preventDefault'); + fireEvent(element, event); + expect(spy2).toBeCalledTimes(0); + + cut.dispose(); + }); + + test('that the event is never cancelled when the group is not floating', () => { + const element = document.createElement('div'); + + const groupMock = jest.fn(() => { + const partial: Partial = { + api: { isFloating: false } as any, + }; + return partial as DockviewGroupPanel; + }); + const group = new groupMock(); + + const cut = new GroupDragHandler(element, 'accessor_id', group); + + const event = new KeyboardEvent('dragstart', { shiftKey: false }); + + const spy = jest.spyOn(event, 'preventDefault'); + fireEvent(element, event); + expect(spy).toBeCalledTimes(0); + + const event2 = new KeyboardEvent('dragstart', { shiftKey: true }); + + const spy2 = jest.spyOn(event2, 'preventDefault'); + fireEvent(element, event); + expect(spy2).toBeCalledTimes(0); + + cut.dispose(); + }); +}); diff --git a/packages/dockview-core/src/__tests__/dnd/overlay.spec.ts b/packages/dockview-core/src/__tests__/dnd/overlay.spec.ts new file mode 100644 index 000000000..edc661a12 --- /dev/null +++ b/packages/dockview-core/src/__tests__/dnd/overlay.spec.ts @@ -0,0 +1,156 @@ +import { Overlay } from '../../dnd/overlay'; + +describe('overlay', () => { + test('toJSON', () => { + const container = document.createElement('div'); + const content = document.createElement('div'); + + document.body.appendChild(container); + container.appendChild(content); + + const cut = new Overlay({ + height: 200, + width: 100, + left: 10, + top: 20, + minimumInViewportWidth: 0, + minimumInViewportHeight: 0, + container, + content, + }); + + jest.spyOn( + container.childNodes.item(0) as HTMLElement, + 'getBoundingClientRect' + ).mockImplementation(() => { + return { left: 80, top: 100, width: 40, height: 50 } as any; + }); + jest.spyOn(container, 'getBoundingClientRect').mockImplementation( + () => { + return { left: 20, top: 30, width: 100, height: 100 } as any; + } + ); + + expect(cut.toJSON()).toEqual({ + top: 70, + left: 60, + width: 40, + height: 50, + }); + }); + + test('that out-of-bounds dimensions are fixed', () => { + const container = document.createElement('div'); + const content = document.createElement('div'); + + document.body.appendChild(container); + container.appendChild(content); + + const cut = new Overlay({ + height: 200, + width: 100, + left: -1000, + top: -1000, + minimumInViewportWidth: 0, + minimumInViewportHeight: 0, + container, + content, + }); + + jest.spyOn( + container.childNodes.item(0) as HTMLElement, + 'getBoundingClientRect' + ).mockImplementation(() => { + return { left: 80, top: 100, width: 40, height: 50 } as any; + }); + jest.spyOn(container, 'getBoundingClientRect').mockImplementation( + () => { + return { left: 20, top: 30, width: 100, height: 100 } as any; + } + ); + + expect(cut.toJSON()).toEqual({ + top: 70, + left: 60, + width: 40, + height: 50, + }); + }); + + test('setBounds', () => { + const container = document.createElement('div'); + const content = document.createElement('div'); + + document.body.appendChild(container); + container.appendChild(content); + + const cut = new Overlay({ + height: 1000, + width: 1000, + left: 0, + top: 0, + minimumInViewportWidth: 0, + minimumInViewportHeight: 0, + container, + content, + }); + + const element: HTMLElement = container.querySelector( + '.dv-resize-container' + )!; + expect(element).toBeTruthy(); + + jest.spyOn(element, 'getBoundingClientRect').mockImplementation(() => { + return { left: 300, top: 400, width: 1000, height: 1000 } as any; + }); + jest.spyOn(container, 'getBoundingClientRect').mockImplementation( + () => { + return { left: 0, top: 0, width: 1000, height: 1000 } as any; + } + ); + + cut.setBounds({ height: 100, width: 200, left: 300, top: 400 }); + + expect(element.style.height).toBe('100px'); + expect(element.style.width).toBe('200px'); + expect(element.style.left).toBe('300px'); + expect(element.style.top).toBe('400px'); + }); + + test('that the resize handles are added', () => { + const container = document.createElement('div'); + const content = document.createElement('div'); + + const cut = new Overlay({ + height: 500, + width: 500, + left: 100, + top: 200, + minimumInViewportWidth: 0, + minimumInViewportHeight: 0, + container, + content, + }); + + expect(container.querySelector('.dv-resize-handle-top')).toBeTruthy(); + expect( + container.querySelector('.dv-resize-handle-bottom') + ).toBeTruthy(); + expect(container.querySelector('.dv-resize-handle-left')).toBeTruthy(); + expect(container.querySelector('.dv-resize-handle-right')).toBeTruthy(); + expect( + container.querySelector('.dv-resize-handle-topleft') + ).toBeTruthy(); + expect( + container.querySelector('.dv-resize-handle-topright') + ).toBeTruthy(); + expect( + container.querySelector('.dv-resize-handle-bottomleft') + ).toBeTruthy(); + expect( + container.querySelector('.dv-resize-handle-bottomright') + ).toBeTruthy(); + + cut.dispose(); + }); +}); diff --git a/packages/dockview-core/src/__tests__/dockview/components/titlebar/tabsContainer.spec.ts b/packages/dockview-core/src/__tests__/dockview/components/titlebar/tabsContainer.spec.ts index 77efaca4c..5514500b3 100644 --- a/packages/dockview-core/src/__tests__/dockview/components/titlebar/tabsContainer.spec.ts +++ b/packages/dockview-core/src/__tests__/dockview/components/titlebar/tabsContainer.spec.ts @@ -8,6 +8,7 @@ import { DockviewGroupPanel } from '../../../../dockview/dockviewGroupPanel'; import { DockviewGroupPanelModel } from '../../../../dockview/dockviewGroupPanelModel'; import { fireEvent } from '@testing-library/dom'; import { TestPanel } from '../../dockviewGroupPanelModel.spec'; +import { IDockviewPanel } from '../../../../dockview/dockviewPanel'; describe('tabsContainer', () => { test('that an external event does not render a drop target and calls through to the group mode', () => { @@ -463,4 +464,169 @@ describe('tabsContainer', () => { expect(query.length).toBe(1); expect(query[0].children.length).toBe(0); }); + + test('that a tab will become floating when clicked if not floating and shift is selected', () => { + const accessorMock = jest.fn(() => { + return (>{ + options: {}, + onDidAddPanel: jest.fn(), + onDidRemovePanel: jest.fn(), + element: document.createElement('div'), + addFloatingGroup: jest.fn(), + }) as DockviewComponent; + }); + + const groupPanelMock = jest.fn(() => { + return (>{ + api: { isFloating: false } as any, + }) as DockviewGroupPanel; + }); + + const accessor = new accessorMock(); + const groupPanel = new groupPanelMock(); + + const cut = new TabsContainer(accessor, groupPanel); + + const container = cut.element.querySelector('.void-container')!; + expect(container).toBeTruthy(); + + jest.spyOn(cut.element, 'getBoundingClientRect').mockImplementation( + () => { + return { top: 50, left: 100, width: 0, height: 0 } as any; + } + ); + jest.spyOn( + accessor.element, + 'getBoundingClientRect' + ).mockImplementation(() => { + return { top: 10, left: 20, width: 0, height: 0 } as any; + }); + + const event = new KeyboardEvent('mousedown', { shiftKey: true }); + const eventPreventDefaultSpy = jest.spyOn(event, 'preventDefault'); + fireEvent(container, event); + + expect(accessor.addFloatingGroup).toBeCalledWith( + groupPanel, + { + x: 100, + y: 60, + }, + { inDragMode: true } + ); + expect(accessor.addFloatingGroup).toBeCalledTimes(1); + expect(eventPreventDefaultSpy).toBeCalledTimes(1); + + const event2 = new KeyboardEvent('mousedown', { shiftKey: false }); + const eventPreventDefaultSpy2 = jest.spyOn(event2, 'preventDefault'); + fireEvent(container, event2); + + expect(accessor.addFloatingGroup).toBeCalledTimes(1); + expect(eventPreventDefaultSpy2).toBeCalledTimes(0); + }); + + test('that a tab that is already floating cannot be floated again', () => { + const accessorMock = jest.fn(() => { + return (>{ + options: {}, + onDidAddPanel: jest.fn(), + onDidRemovePanel: jest.fn(), + element: document.createElement('div'), + addFloatingGroup: jest.fn(), + }) as DockviewComponent; + }); + + const groupPanelMock = jest.fn(() => { + return (>{ + api: { isFloating: true } as any, + }) as DockviewGroupPanel; + }); + + const accessor = new accessorMock(); + const groupPanel = new groupPanelMock(); + + const cut = new TabsContainer(accessor, groupPanel); + + const container = cut.element.querySelector('.void-container')!; + expect(container).toBeTruthy(); + + jest.spyOn(cut.element, 'getBoundingClientRect').mockImplementation( + () => { + return { top: 50, left: 100, width: 0, height: 0 } as any; + } + ); + jest.spyOn( + accessor.element, + 'getBoundingClientRect' + ).mockImplementation(() => { + return { top: 10, left: 20, width: 0, height: 0 } as any; + }); + + const event = new KeyboardEvent('mousedown', { shiftKey: true }); + const eventPreventDefaultSpy = jest.spyOn(event, 'preventDefault'); + fireEvent(container, event); + + expect(accessor.addFloatingGroup).toBeCalledTimes(0); + expect(eventPreventDefaultSpy).toBeCalledTimes(0); + + const event2 = new KeyboardEvent('mousedown', { shiftKey: false }); + const eventPreventDefaultSpy2 = jest.spyOn(event2, 'preventDefault'); + fireEvent(container, event2); + + expect(accessor.addFloatingGroup).toBeCalledTimes(0); + expect(eventPreventDefaultSpy2).toBeCalledTimes(0); + }); + + test('that selecting a tab which shift down will move that tab into a new floating group', () => { + const accessorMock = jest.fn(() => { + return (>{ + options: {}, + onDidAddPanel: jest.fn(), + onDidRemovePanel: jest.fn(), + element: document.createElement('div'), + addFloatingGroup: jest.fn(), + getGroupPanel: jest.fn(), + }) as DockviewComponent; + }); + + const groupPanelMock = jest.fn(() => { + return (>{ + api: { isFloating: true } as any, + }) as DockviewGroupPanel; + }); + + const accessor = new accessorMock(); + const groupPanel = new groupPanelMock(); + + const cut = new TabsContainer(accessor, groupPanel); + + const panelMock = jest.fn(() => { + const partial: Partial = { + id: 'test_id', + + view: { + tab: { + element: document.createElement('div'), + } as any, + content: { + element: document.createElement('div'), + } as any, + } as any, + }; + return partial as IDockviewPanel; + }); + const panel = new panelMock(); + + cut.openPanel(panel); + + const el = cut.element.querySelector('.tab')!; + expect(el).toBeTruthy(); + + const event = new KeyboardEvent('mousedown', { shiftKey: true }); + const preventDefaultSpy = jest.spyOn(event, 'preventDefault'); + fireEvent(el, event); + + expect(preventDefaultSpy).toBeCalledTimes(1); + expect(accessor.addFloatingGroup).toBeCalledTimes(1); + }); }); diff --git a/packages/dockview-core/src/__tests__/dockview/dockviewComponent.spec.ts b/packages/dockview-core/src/__tests__/dockview/dockviewComponent.spec.ts index 8091ff768..778df49d6 100644 --- a/packages/dockview-core/src/__tests__/dockview/dockviewComponent.spec.ts +++ b/packages/dockview-core/src/__tests__/dockview/dockviewComponent.spec.ts @@ -2287,6 +2287,126 @@ describe('dockviewComponent', () => { }); }); + test('orthogonal realigment #4', () => { + const container = document.createElement('div'); + + const dockview = new DockviewComponent({ + parentElement: container, + components: { + default: PanelContentPartTest, + }, + tabComponents: { + test_tab_id: PanelTabPartTest, + }, + orientation: Orientation.HORIZONTAL, + }); + + dockview.layout(1000, 1000); + + expect(dockview.orientation).toBe(Orientation.HORIZONTAL); + + dockview.addPanel({ + id: 'panel1', + component: 'default', + position: { + direction: 'above', + }, + }); + + expect(dockview.orientation).toBe(Orientation.VERTICAL); + + expect(JSON.parse(JSON.stringify(dockview.toJSON()))).toEqual({ + activeGroup: '1', + grid: { + root: { + type: 'branch', + data: [ + { + type: 'leaf', + data: { + views: ['panel1'], + id: '1', + activeView: 'panel1', + }, + size: 1000, + }, + ], + size: 1000, + }, + height: 1000, + width: 1000, + orientation: Orientation.VERTICAL, + }, + panels: { + panel1: { + id: 'panel1', + contentComponent: 'default', + title: 'panel1', + }, + }, + }); + }); + + test('orthogonal realigment #5', () => { + const container = document.createElement('div'); + + const dockview = new DockviewComponent({ + parentElement: container, + components: { + default: PanelContentPartTest, + }, + tabComponents: { + test_tab_id: PanelTabPartTest, + }, + orientation: Orientation.VERTICAL, + }); + + dockview.layout(1000, 1000); + + expect(dockview.orientation).toBe(Orientation.VERTICAL); + + dockview.addPanel({ + id: 'panel1', + component: 'default', + position: { + direction: 'left', + }, + }); + + expect(dockview.orientation).toBe(Orientation.HORIZONTAL); + + expect(JSON.parse(JSON.stringify(dockview.toJSON()))).toEqual({ + activeGroup: '1', + grid: { + root: { + type: 'branch', + data: [ + { + type: 'leaf', + data: { + views: ['panel1'], + id: '1', + activeView: 'panel1', + }, + size: 1000, + }, + ], + size: 1000, + }, + height: 1000, + width: 1000, + orientation: Orientation.HORIZONTAL, + }, + panels: { + panel1: { + id: 'panel1', + contentComponent: 'default', + title: 'panel1', + }, + }, + }); + }); + test('that a empty component has no groups', () => { const container = document.createElement('div'); @@ -2619,4 +2739,1052 @@ describe('dockviewComponent', () => { }, }); }); + + test('floating: group is removed', async () => { + const container = document.createElement('div'); + + const dockview = new DockviewComponent({ + parentElement: container, + components: { + default: PanelContentPartTest, + }, + tabComponents: { + test_tab_id: PanelTabPartTest, + }, + orientation: Orientation.HORIZONTAL, + }); + + dockview.layout(1000, 500); + + expect(dockview.groups.length).toBe(0); + const panel = dockview.addPanel({ + id: 'panel_1', + component: 'default', + floating: true, + }); + expect(dockview.groups.length).toBe(1); + + dockview.removePanel(panel); + expect(dockview.groups.length).toBe(0); + }); + + test('floating: move a floating group of one tab to a new fixed group', () => { + const container = document.createElement('div'); + + const dockview = new DockviewComponent({ + parentElement: container, + components: { + default: PanelContentPartTest, + }, + tabComponents: { + test_tab_id: PanelTabPartTest, + }, + orientation: Orientation.HORIZONTAL, + }); + + dockview.layout(1000, 500); + + const panel1 = dockview.addPanel({ + id: 'panel_1', + component: 'default', + }); + + const panel2 = dockview.addPanel({ + id: 'panel_2', + component: 'default', + floating: true, + }); + + expect(panel1.group.api.isFloating).toBeFalsy(); + expect(panel2.group.api.isFloating).toBeTruthy(); + expect(dockview.groups.length).toBe(2); + expect(dockview.panels.length).toBe(2); + + dockview.moveGroupOrPanel( + panel1.group, + panel2.group.id, + undefined, + 'right' + ); + + expect(panel1.group.api.isFloating).toBeFalsy(); + expect(panel2.group.api.isFloating).toBeFalsy(); + expect(dockview.groups.length).toBe(2); + expect(dockview.panels.length).toBe(2); + }); + + test('floating: move a floating group of one tab to an existing fixed group', () => { + const container = document.createElement('div'); + + const dockview = new DockviewComponent({ + parentElement: container, + components: { + default: PanelContentPartTest, + }, + tabComponents: { + test_tab_id: PanelTabPartTest, + }, + orientation: Orientation.HORIZONTAL, + }); + + dockview.layout(1000, 500); + + const panel1 = dockview.addPanel({ + id: 'panel_1', + component: 'default', + }); + + const panel2 = dockview.addPanel({ + id: 'panel_2', + component: 'default', + floating: true, + }); + + expect(panel1.group.api.isFloating).toBeFalsy(); + expect(panel2.group.api.isFloating).toBeTruthy(); + expect(dockview.groups.length).toBe(2); + expect(dockview.panels.length).toBe(2); + + dockview.moveGroupOrPanel( + panel1.group, + panel2.group.id, + undefined, + 'center' + ); + + expect(panel1.group.api.isFloating).toBeFalsy(); + expect(panel2.group.api.isFloating).toBeFalsy(); + expect(dockview.groups.length).toBe(1); + expect(dockview.panels.length).toBe(2); + }); + + test('floating: move a floating group of one tab to an existing floating group', () => { + const container = document.createElement('div'); + + const dockview = new DockviewComponent({ + parentElement: container, + components: { + default: PanelContentPartTest, + }, + tabComponents: { + test_tab_id: PanelTabPartTest, + }, + orientation: Orientation.HORIZONTAL, + }); + + dockview.layout(1000, 500); + + const panel1 = dockview.addPanel({ + id: 'panel_1', + component: 'default', + }); + + const panel2 = dockview.addPanel({ + id: 'panel_2', + component: 'default', + floating: true, + }); + + const panel3 = dockview.addPanel({ + id: 'panel_3', + component: 'default', + floating: true, + }); + + expect(panel1.group.api.isFloating).toBeFalsy(); + expect(panel2.group.api.isFloating).toBeTruthy(); + expect(panel3.group.api.isFloating).toBeTruthy(); + expect(dockview.groups.length).toBe(3); + expect(dockview.panels.length).toBe(3); + + dockview.moveGroupOrPanel( + panel2.group, + panel3.group.id, + undefined, + 'center' + ); + + expect(panel1.group.api.isFloating).toBeFalsy(); + expect(panel2.group.api.isFloating).toBeTruthy(); + expect(panel3.group.api.isFloating).toBeTruthy(); + expect(dockview.groups.length).toBe(2); + expect(dockview.panels.length).toBe(3); + }); + + test('floating: move a floating group of many tabs to a new fixed group', () => { + const container = document.createElement('div'); + + const dockview = new DockviewComponent({ + parentElement: container, + components: { + default: PanelContentPartTest, + }, + tabComponents: { + test_tab_id: PanelTabPartTest, + }, + orientation: Orientation.HORIZONTAL, + }); + + dockview.layout(1000, 500); + + const panel1 = dockview.addPanel({ + id: 'panel_1', + component: 'default', + }); + + const panel2 = dockview.addPanel({ + id: 'panel_2', + component: 'default', + floating: true, + }); + + const panel3 = dockview.addPanel({ + id: 'panel_3', + component: 'default', + position: { referencePanel: panel2 }, + }); + + expect(panel1.group.api.isFloating).toBeFalsy(); + expect(panel2.group.api.isFloating).toBeTruthy(); + expect(panel3.group.api.isFloating).toBeTruthy(); + expect(dockview.groups.length).toBe(2); + expect(dockview.panels.length).toBe(3); + + dockview.moveGroupOrPanel( + panel1.group, + panel2.group.id, + undefined, + 'right' + ); + + expect(panel1.group.api.isFloating).toBeFalsy(); + expect(panel2.group.api.isFloating).toBeFalsy(); + expect(panel3.group.api.isFloating).toBeFalsy(); + expect(dockview.groups.length).toBe(2); + expect(dockview.panels.length).toBe(3); + }); + + test('floating: move a floating group of many tabs to an existing fixed group', () => { + const container = document.createElement('div'); + + const dockview = new DockviewComponent({ + parentElement: container, + components: { + default: PanelContentPartTest, + }, + tabComponents: { + test_tab_id: PanelTabPartTest, + }, + orientation: Orientation.HORIZONTAL, + }); + + dockview.layout(1000, 500); + + const panel1 = dockview.addPanel({ + id: 'panel_1', + component: 'default', + }); + + const panel2 = dockview.addPanel({ + id: 'panel_2', + component: 'default', + floating: true, + }); + + const panel3 = dockview.addPanel({ + id: 'panel_3', + component: 'default', + position: { referencePanel: panel2 }, + }); + + expect(panel1.group.api.isFloating).toBeFalsy(); + expect(panel2.group.api.isFloating).toBeTruthy(); + expect(panel3.group.api.isFloating).toBeTruthy(); + expect(dockview.groups.length).toBe(2); + expect(dockview.panels.length).toBe(3); + + dockview.moveGroupOrPanel( + panel1.group, + panel2.group.id, + undefined, + 'center' + ); + + expect(panel1.group.api.isFloating).toBeFalsy(); + expect(panel2.group.api.isFloating).toBeFalsy(); + expect(panel3.group.api.isFloating).toBeFalsy(); + expect(dockview.groups.length).toBe(1); + expect(dockview.panels.length).toBe(3); + }); + + test('floating: move a floating group of many tabs to an existing floating group', () => { + const container = document.createElement('div'); + + const dockview = new DockviewComponent({ + parentElement: container, + components: { + default: PanelContentPartTest, + }, + tabComponents: { + test_tab_id: PanelTabPartTest, + }, + orientation: Orientation.HORIZONTAL, + }); + + dockview.layout(1000, 500); + + const panel1 = dockview.addPanel({ + id: 'panel_1', + component: 'default', + }); + + const panel2 = dockview.addPanel({ + id: 'panel_2', + component: 'default', + floating: true, + }); + + const panel3 = dockview.addPanel({ + id: 'panel_3', + component: 'default', + position: { referencePanel: panel2 }, + }); + + const panel4 = dockview.addPanel({ + id: 'panel_4', + component: 'default', + floating: true, + }); + + expect(panel1.group.api.isFloating).toBeFalsy(); + expect(panel2.group.api.isFloating).toBeTruthy(); + expect(panel3.group.api.isFloating).toBeTruthy(); + expect(panel4.group.api.isFloating).toBeTruthy(); + expect(dockview.groups.length).toBe(3); + expect(dockview.panels.length).toBe(4); + + dockview.moveGroupOrPanel( + panel4.group, + panel2.group.id, + undefined, + 'center' + ); + + expect(panel1.group.api.isFloating).toBeFalsy(); + expect(panel2.group.api.isFloating).toBeTruthy(); + expect(panel3.group.api.isFloating).toBeTruthy(); + expect(panel4.group.api.isFloating).toBeTruthy(); + expect(dockview.groups.length).toBe(2); + expect(dockview.panels.length).toBe(4); + }); + + test('floating: move a floating tab of one tab to a new fixed group', () => { + const container = document.createElement('div'); + + const dockview = new DockviewComponent({ + parentElement: container, + components: { + default: PanelContentPartTest, + }, + tabComponents: { + test_tab_id: PanelTabPartTest, + }, + orientation: Orientation.HORIZONTAL, + }); + + dockview.layout(1000, 500); + + const panel1 = dockview.addPanel({ + id: 'panel_1', + component: 'default', + }); + + const panel2 = dockview.addPanel({ + id: 'panel_2', + component: 'default', + floating: true, + }); + + expect(panel1.group.api.isFloating).toBeFalsy(); + expect(panel2.group.api.isFloating).toBeTruthy(); + expect(dockview.groups.length).toBe(2); + expect(dockview.panels.length).toBe(2); + + dockview.moveGroupOrPanel( + panel1.group, + panel2.group.id, + panel2.id, + 'right' + ); + + expect(panel1.group.api.isFloating).toBeFalsy(); + expect(panel2.group.api.isFloating).toBeFalsy(); + expect(dockview.groups.length).toBe(2); + expect(dockview.panels.length).toBe(2); + }); + + test('floating: move a floating tab of one tab to an existing fixed group', () => { + const container = document.createElement('div'); + + const dockview = new DockviewComponent({ + parentElement: container, + components: { + default: PanelContentPartTest, + }, + tabComponents: { + test_tab_id: PanelTabPartTest, + }, + orientation: Orientation.HORIZONTAL, + }); + + dockview.layout(1000, 500); + + const panel1 = dockview.addPanel({ + id: 'panel_1', + component: 'default', + }); + + const panel2 = dockview.addPanel({ + id: 'panel_2', + component: 'default', + floating: true, + }); + + expect(panel1.group.api.isFloating).toBeFalsy(); + expect(panel2.group.api.isFloating).toBeTruthy(); + expect(dockview.groups.length).toBe(2); + expect(dockview.panels.length).toBe(2); + + dockview.moveGroupOrPanel( + panel1.group, + panel2.group.id, + panel2.id, + 'center' + ); + + expect(panel1.group.api.isFloating).toBeFalsy(); + expect(panel2.group.api.isFloating).toBeFalsy(); + expect(dockview.groups.length).toBe(1); + expect(dockview.panels.length).toBe(2); + }); + + test('floating: move a floating tab of one tab to an existing floating group', () => { + const container = document.createElement('div'); + + const dockview = new DockviewComponent({ + parentElement: container, + components: { + default: PanelContentPartTest, + }, + tabComponents: { + test_tab_id: PanelTabPartTest, + }, + orientation: Orientation.HORIZONTAL, + }); + + dockview.layout(1000, 500); + + const panel1 = dockview.addPanel({ + id: 'panel_1', + component: 'default', + }); + + const panel2 = dockview.addPanel({ + id: 'panel_2', + component: 'default', + floating: true, + }); + + const panel3 = dockview.addPanel({ + id: 'panel_3', + component: 'default', + floating: true, + }); + + expect(panel1.group.api.isFloating).toBeFalsy(); + expect(panel2.group.api.isFloating).toBeTruthy(); + expect(panel3.group.api.isFloating).toBeTruthy(); + expect(dockview.groups.length).toBe(3); + expect(dockview.panels.length).toBe(3); + + dockview.moveGroupOrPanel( + panel2.group, + panel3.group.id, + panel3.id, + 'center' + ); + + expect(panel1.group.api.isFloating).toBeFalsy(); + expect(panel2.group.api.isFloating).toBeTruthy(); + expect(panel3.group.api.isFloating).toBeTruthy(); + expect(dockview.groups.length).toBe(2); + expect(dockview.panels.length).toBe(3); + }); + + test('floating: move a floating tab of many tabs to a new fixed group', () => { + const container = document.createElement('div'); + + const dockview = new DockviewComponent({ + parentElement: container, + components: { + default: PanelContentPartTest, + }, + tabComponents: { + test_tab_id: PanelTabPartTest, + }, + orientation: Orientation.HORIZONTAL, + }); + + dockview.layout(1000, 500); + + const panel1 = dockview.addPanel({ + id: 'panel_1', + component: 'default', + }); + + const panel2 = dockview.addPanel({ + id: 'panel_2', + component: 'default', + floating: true, + }); + + const panel3 = dockview.addPanel({ + id: 'panel_3', + component: 'default', + position: { referencePanel: panel2 }, + }); + + expect(panel1.group.api.isFloating).toBeFalsy(); + expect(panel2.group.api.isFloating).toBeTruthy(); + expect(panel3.group.api.isFloating).toBeTruthy(); + expect(dockview.groups.length).toBe(2); + expect(dockview.panels.length).toBe(3); + + dockview.moveGroupOrPanel( + panel1.group, + panel2.group.id, + panel2.id, + 'right' + ); + + expect(panel1.group.api.isFloating).toBeFalsy(); + expect(panel2.group.api.isFloating).toBeFalsy(); + expect(panel3.group.api.isFloating).toBeTruthy(); + expect(dockview.groups.length).toBe(3); + expect(dockview.panels.length).toBe(3); + }); + + test('floating: move a floating tab of many tabs to an existing fixed group', () => { + const container = document.createElement('div'); + + const dockview = new DockviewComponent({ + parentElement: container, + components: { + default: PanelContentPartTest, + }, + tabComponents: { + test_tab_id: PanelTabPartTest, + }, + orientation: Orientation.HORIZONTAL, + }); + + dockview.layout(1000, 500); + + const panel1 = dockview.addPanel({ + id: 'panel_1', + component: 'default', + }); + + const panel2 = dockview.addPanel({ + id: 'panel_2', + component: 'default', + floating: true, + }); + + const panel3 = dockview.addPanel({ + id: 'panel_3', + component: 'default', + position: { referencePanel: panel2 }, + }); + + expect(panel1.group.api.isFloating).toBeFalsy(); + expect(panel2.group.api.isFloating).toBeTruthy(); + expect(panel3.group.api.isFloating).toBeTruthy(); + expect(dockview.groups.length).toBe(2); + expect(dockview.panels.length).toBe(3); + + dockview.moveGroupOrPanel( + panel1.group, + panel2.group.id, + panel2.id, + 'center' + ); + + expect(panel1.group.api.isFloating).toBeFalsy(); + expect(panel2.group.api.isFloating).toBeFalsy(); + expect(panel3.group.api.isFloating).toBeTruthy(); + expect(dockview.groups.length).toBe(2); + expect(dockview.panels.length).toBe(3); + }); + + test('floating: move a floating tab of many tabs to an existing floating group', () => { + const container = document.createElement('div'); + + const dockview = new DockviewComponent({ + parentElement: container, + components: { + default: PanelContentPartTest, + }, + tabComponents: { + test_tab_id: PanelTabPartTest, + }, + orientation: Orientation.HORIZONTAL, + }); + + dockview.layout(1000, 500); + + const panel1 = dockview.addPanel({ + id: 'panel_1', + component: 'default', + }); + + const panel2 = dockview.addPanel({ + id: 'panel_2', + component: 'default', + floating: true, + }); + + const panel3 = dockview.addPanel({ + id: 'panel_3', + component: 'default', + position: { referencePanel: panel2 }, + }); + + const panel4 = dockview.addPanel({ + id: 'panel_4', + component: 'default', + floating: true, + }); + + expect(panel1.group.api.isFloating).toBeFalsy(); + expect(panel2.group.api.isFloating).toBeTruthy(); + expect(panel3.group.api.isFloating).toBeTruthy(); + expect(panel4.group.api.isFloating).toBeTruthy(); + expect(dockview.groups.length).toBe(3); + expect(dockview.panels.length).toBe(4); + + dockview.moveGroupOrPanel( + panel4.group, + panel2.group.id, + panel2.id, + 'center' + ); + + expect(panel1.group.api.isFloating).toBeFalsy(); + expect(panel2.group.api.isFloating).toBeTruthy(); + expect(panel3.group.api.isFloating).toBeTruthy(); + expect(panel4.group.api.isFloating).toBeTruthy(); + expect(dockview.groups.length).toBe(3); + expect(dockview.panels.length).toBe(4); + }); + + test('floating: move a fixed tab of one tab to an existing floating group', () => { + const container = document.createElement('div'); + + const dockview = new DockviewComponent({ + parentElement: container, + components: { + default: PanelContentPartTest, + }, + tabComponents: { + test_tab_id: PanelTabPartTest, + }, + orientation: Orientation.HORIZONTAL, + }); + + dockview.layout(1000, 500); + + const panel1 = dockview.addPanel({ + id: 'panel_1', + component: 'default', + }); + + const panel2 = dockview.addPanel({ + id: 'panel_2', + component: 'default', + position: { direction: 'right' }, + }); + + const panel3 = dockview.addPanel({ + id: 'panel_3', + component: 'default', + floating: true, + }); + + expect(panel1.group.api.isFloating).toBeFalsy(); + expect(panel2.group.api.isFloating).toBeFalsy(); + expect(panel3.group.api.isFloating).toBeTruthy(); + expect(dockview.groups.length).toBe(3); + expect(dockview.panels.length).toBe(3); + + dockview.moveGroupOrPanel( + panel3.group, + panel1.group.id, + panel1.id, + 'center' + ); + + expect(panel1.group.api.isFloating).toBeTruthy(); + expect(panel2.group.api.isFloating).toBeFalsy(); + expect(panel3.group.api.isFloating).toBeTruthy(); + expect(dockview.groups.length).toBe(2); + expect(dockview.panels.length).toBe(3); + }); + + test('floating: move a fixed tab of many tabs to an existing floating group', () => { + const container = document.createElement('div'); + + const dockview = new DockviewComponent({ + parentElement: container, + components: { + default: PanelContentPartTest, + }, + tabComponents: { + test_tab_id: PanelTabPartTest, + }, + orientation: Orientation.HORIZONTAL, + }); + + dockview.layout(1000, 500); + + const panel1 = dockview.addPanel({ + id: 'panel_1', + component: 'default', + }); + + const panel2 = dockview.addPanel({ + id: 'panel_2', + component: 'default', + }); + + const panel3 = dockview.addPanel({ + id: 'panel_3', + component: 'default', + floating: true, + }); + + expect(panel1.group.api.isFloating).toBeFalsy(); + expect(panel2.group.api.isFloating).toBeFalsy(); + expect(panel3.group.api.isFloating).toBeTruthy(); + expect(dockview.groups.length).toBe(2); + expect(dockview.panels.length).toBe(3); + + dockview.moveGroupOrPanel( + panel3.group, + panel1.group.id, + panel1.id, + 'center' + ); + + expect(panel1.group.api.isFloating).toBeTruthy(); + expect(panel2.group.api.isFloating).toBeFalsy(); + expect(panel3.group.api.isFloating).toBeTruthy(); + expect(dockview.groups.length).toBe(2); + expect(dockview.panels.length).toBe(3); + }); + + test('floating: move a fixed group of one tab to an existing floating group', () => { + const container = document.createElement('div'); + + const dockview = new DockviewComponent({ + parentElement: container, + components: { + default: PanelContentPartTest, + }, + tabComponents: { + test_tab_id: PanelTabPartTest, + }, + orientation: Orientation.HORIZONTAL, + }); + + dockview.layout(1000, 500); + + const panel1 = dockview.addPanel({ + id: 'panel_1', + component: 'default', + }); + + const panel2 = dockview.addPanel({ + id: 'panel_2', + component: 'default', + position: { direction: 'right' }, + }); + + const panel3 = dockview.addPanel({ + id: 'panel_3', + component: 'default', + floating: true, + }); + + expect(panel1.group.api.isFloating).toBeFalsy(); + expect(panel2.group.api.isFloating).toBeFalsy(); + expect(panel3.group.api.isFloating).toBeTruthy(); + expect(dockview.groups.length).toBe(3); + expect(dockview.panels.length).toBe(3); + + dockview.moveGroupOrPanel( + panel3.group, + panel1.group.id, + undefined, + 'center' + ); + + expect(panel1.group.api.isFloating).toBeTruthy(); + expect(panel2.group.api.isFloating).toBeFalsy(); + expect(panel3.group.api.isFloating).toBeTruthy(); + expect(dockview.groups.length).toBe(2); + expect(dockview.panels.length).toBe(3); + }); + + test('floating: move a fixed group of many tabs to an existing floating group', () => { + const container = document.createElement('div'); + + const dockview = new DockviewComponent({ + parentElement: container, + components: { + default: PanelContentPartTest, + }, + tabComponents: { + test_tab_id: PanelTabPartTest, + }, + orientation: Orientation.HORIZONTAL, + }); + + dockview.layout(1000, 500); + + const panel1 = dockview.addPanel({ + id: 'panel_1', + component: 'default', + }); + + const panel2 = dockview.addPanel({ + id: 'panel_2', + component: 'default', + }); + + const panel3 = dockview.addPanel({ + id: 'panel_3', + component: 'default', + floating: true, + }); + + expect(panel1.group.api.isFloating).toBeFalsy(); + expect(panel2.group.api.isFloating).toBeFalsy(); + expect(panel3.group.api.isFloating).toBeTruthy(); + expect(dockview.groups.length).toBe(2); + expect(dockview.panels.length).toBe(3); + + dockview.moveGroupOrPanel( + panel3.group, + panel1.group.id, + undefined, + 'center' + ); + + expect(panel1.group.api.isFloating).toBeTruthy(); + expect(panel2.group.api.isFloating).toBeTruthy(); + expect(panel3.group.api.isFloating).toBeTruthy(); + expect(dockview.groups.length).toBe(1); + expect(dockview.panels.length).toBe(3); + }); + + test('floating: move a fixed tab of one tab to a new floating group', () => { + const container = document.createElement('div'); + + const dockview = new DockviewComponent({ + parentElement: container, + components: { + default: PanelContentPartTest, + }, + tabComponents: { + test_tab_id: PanelTabPartTest, + }, + orientation: Orientation.HORIZONTAL, + }); + + dockview.layout(1000, 500); + + const panel1 = dockview.addPanel({ + id: 'panel_1', + component: 'default', + }); + + const panel2 = dockview.addPanel({ + id: 'panel_2', + component: 'default', + position: { direction: 'right' }, + }); + + expect(panel1.group.api.isFloating).toBeFalsy(); + expect(panel2.group.api.isFloating).toBeFalsy(); + expect(dockview.groups.length).toBe(2); + expect(dockview.panels.length).toBe(2); + + dockview.addFloatingGroup(panel2); + + expect(panel1.group.api.isFloating).toBeFalsy(); + expect(panel2.group.api.isFloating).toBeTruthy(); + expect(dockview.groups.length).toBe(2); + expect(dockview.panels.length).toBe(2); + }); + + test('floating: move a fixed tab of many tabs to a new floating group', () => { + const container = document.createElement('div'); + + const dockview = new DockviewComponent({ + parentElement: container, + components: { + default: PanelContentPartTest, + }, + tabComponents: { + test_tab_id: PanelTabPartTest, + }, + orientation: Orientation.HORIZONTAL, + }); + + dockview.layout(1000, 500); + + const panel1 = dockview.addPanel({ + id: 'panel_1', + component: 'default', + }); + + const panel2 = dockview.addPanel({ + id: 'panel_2', + component: 'default', + }); + + expect(panel1.group.api.isFloating).toBeFalsy(); + expect(panel2.group.api.isFloating).toBeFalsy(); + expect(dockview.groups.length).toBe(1); + expect(dockview.panels.length).toBe(2); + + dockview.addFloatingGroup(panel2); + + expect(panel1.group.api.isFloating).toBeFalsy(); + expect(panel2.group.api.isFloating).toBeTruthy(); + expect(dockview.groups.length).toBe(2); + expect(dockview.panels.length).toBe(2); + }); + + test('floating: move a fixed group of one tab to a new floating group', () => { + const container = document.createElement('div'); + + const dockview = new DockviewComponent({ + parentElement: container, + components: { + default: PanelContentPartTest, + }, + tabComponents: { + test_tab_id: PanelTabPartTest, + }, + orientation: Orientation.HORIZONTAL, + }); + + dockview.layout(1000, 500); + + const panel1 = dockview.addPanel({ + id: 'panel_1', + component: 'default', + }); + + const panel2 = dockview.addPanel({ + id: 'panel_2', + component: 'default', + position: { direction: 'right' }, + }); + + expect(panel1.group.api.isFloating).toBeFalsy(); + expect(panel2.group.api.isFloating).toBeFalsy(); + expect(dockview.groups.length).toBe(2); + expect(dockview.panels.length).toBe(2); + + dockview.addFloatingGroup(panel2.group); + + expect(panel1.group.api.isFloating).toBeFalsy(); + expect(panel2.group.api.isFloating).toBeTruthy(); + expect(dockview.groups.length).toBe(2); + expect(dockview.panels.length).toBe(2); + }); + + test('floating: move a fixed group of many tabs to a new floating group', () => { + const container = document.createElement('div'); + + const dockview = new DockviewComponent({ + parentElement: container, + components: { + default: PanelContentPartTest, + }, + tabComponents: { + test_tab_id: PanelTabPartTest, + }, + orientation: Orientation.HORIZONTAL, + }); + + dockview.layout(1000, 500); + + const panel1 = dockview.addPanel({ + id: 'panel_1', + component: 'default', + }); + + const panel2 = dockview.addPanel({ + id: 'panel_2', + component: 'default', + }); + + expect(panel1.group.api.isFloating).toBeFalsy(); + expect(panel2.group.api.isFloating).toBeFalsy(); + expect(dockview.groups.length).toBe(1); + expect(dockview.panels.length).toBe(2); + + dockview.addFloatingGroup(panel2.group); + + expect(panel1.group.api.isFloating).toBeTruthy(); + expect(panel2.group.api.isFloating).toBeTruthy(); + expect(dockview.groups.length).toBe(1); + expect(dockview.panels.length).toBe(2); + }); + + test('that moving the last panel to be floating should leave an empty gridview', () => { + const container = document.createElement('div'); + + const dockview = new DockviewComponent({ + parentElement: container, + components: { + default: PanelContentPartTest, + }, + tabComponents: { + test_tab_id: PanelTabPartTest, + }, + orientation: Orientation.HORIZONTAL, + }); + + dockview.layout(1000, 500); + + const panel1 = dockview.addPanel({ + id: 'panel_1', + component: 'default', + }); + + expect( + dockview.element.querySelectorAll('.view-container > .view').length + ).toBe(1); + + dockview.addFloatingGroup(panel1); + + expect( + dockview.element.querySelectorAll('.view-container > .view').length + ).toBe(0); + }); }); diff --git a/packages/dockview-core/src/__tests__/dockview/dockviewGroupPanelModel.spec.ts b/packages/dockview-core/src/__tests__/dockview/dockviewGroupPanelModel.spec.ts index 249167341..5d5657b80 100644 --- a/packages/dockview-core/src/__tests__/dockview/dockviewGroupPanelModel.spec.ts +++ b/packages/dockview-core/src/__tests__/dockview/dockviewGroupPanelModel.spec.ts @@ -247,8 +247,8 @@ describe('groupview', () => { id: 'dockview-1', removePanel: removePanelMock, removeGroup: removeGroupMock, - onDidAddPanel: jest.fn(), - onDidRemovePanel: jest.fn(), + onDidAddPanel: () => ({ dispose: jest.fn() }), + onDidRemovePanel: () => ({ dispose: jest.fn() }), }) as DockviewComponent; groupview = new DockviewGroupPanel(dockview, 'groupview-1', options); @@ -858,6 +858,47 @@ describe('groupview', () => { ).toBe(0); }); + test('that the watermark is removed when dispose is called', () => { + const groupviewMock = jest.fn, []>( + () => { + return { + canDisplayOverlay: jest.fn(), + }; + } + ); + + const groupView = new groupviewMock() as DockviewGroupPanelModel; + + const groupPanelMock = jest.fn, []>(() => { + return { + id: 'testgroupid', + model: groupView, + }; + }); + + const container = document.createElement('div'); + + const cut = new DockviewGroupPanelModel( + container, + dockview, + 'groupviewid', + {}, + new groupPanelMock() as DockviewGroupPanel + ); + + cut.initialize(); + + expect( + container.getElementsByClassName('watermark-test-container').length + ).toBe(1); + + cut.dispose(); + + expect( + container.getElementsByClassName('watermark-test-container').length + ).toBe(0); + }); + test('that watermark is added', () => { const groupviewMock = jest.fn, []>( () => { diff --git a/packages/dockview-core/src/__tests__/dom.spec.ts b/packages/dockview-core/src/__tests__/dom.spec.ts new file mode 100644 index 000000000..f628d5904 --- /dev/null +++ b/packages/dockview-core/src/__tests__/dom.spec.ts @@ -0,0 +1,21 @@ +import { quasiDefaultPrevented, quasiPreventDefault } from '../dom'; + +describe('dom', () => { + test('quasiPreventDefault', () => { + const event = new Event('myevent'); + expect((event as any)['dv-quasiPreventDefault']).toBeUndefined(); + quasiPreventDefault(event); + expect((event as any)['dv-quasiPreventDefault']).toBe(true); + }); + + test('quasiDefaultPrevented', () => { + const event = new Event('myevent'); + expect(quasiDefaultPrevented(event)).toBeFalsy(); + + (event as any)['dv-quasiPreventDefault'] = false; + expect(quasiDefaultPrevented(event)).toBeFalsy(); + + (event as any)['dv-quasiPreventDefault'] = true; + expect(quasiDefaultPrevented(event)).toBeTruthy(); + }); +}); diff --git a/packages/dockview-core/src/__tests__/gridview/gridview.spec.ts b/packages/dockview-core/src/__tests__/gridview/gridview.spec.ts index bb67ad2ea..a34b71702 100644 --- a/packages/dockview-core/src/__tests__/gridview/gridview.spec.ts +++ b/packages/dockview-core/src/__tests__/gridview/gridview.spec.ts @@ -690,4 +690,37 @@ describe('gridview', () => { gridview.element.querySelectorAll('.mock-grid-view').length ).toBe(4); }); + + test('that calling insertOrthogonalSplitviewAtRoot() for an empty view doesnt add any nodes', () => { + const gridview = new Gridview( + false, + { separatorBorder: '' }, + Orientation.HORIZONTAL + ); + gridview.layout(1000, 1000); + + expect(gridview.serialize()).toEqual({ + height: 1000, + orientation: 'HORIZONTAL', + root: { + data: [], + size: 1000, + type: 'branch', + }, + width: 1000, + }); + + gridview.insertOrthogonalSplitviewAtRoot(); + + expect(gridview.serialize()).toEqual({ + height: 1000, + orientation: 'VERTICAL', + root: { + data: [], + size: 1000, + type: 'branch', + }, + width: 1000, + }); + }); }); diff --git a/packages/dockview-core/src/__tests__/gridview/gridviewComponent.spec.ts b/packages/dockview-core/src/__tests__/gridview/gridviewComponent.spec.ts index 267600ffb..99e237a6f 100644 --- a/packages/dockview-core/src/__tests__/gridview/gridviewComponent.spec.ts +++ b/packages/dockview-core/src/__tests__/gridview/gridviewComponent.spec.ts @@ -2046,7 +2046,7 @@ describe('gridview', () => { }); }); - test('that a deep layout with fromJSON dimensions identical to the current dimensions loads', async () => { + test('that a deep HORIZONTAL layout with fromJSON dimensions identical to the current dimensions loads', async () => { const container = document.createElement('div'); const gridview = new GridviewComponent({ @@ -2056,12 +2056,12 @@ describe('gridview', () => { components: { default: TestGridview }, }); - gridview.layout(5000, 5000); + gridview.layout(6000, 5000); gridview.fromJSON({ grid: { height: 5000, - width: 5000, + width: 6000, orientation: Orientation.HORIZONTAL, root: { type: 'branch', @@ -2069,7 +2069,7 @@ describe('gridview', () => { data: [ { type: 'leaf', - size: 1000, + size: 2000, data: { id: 'panel_1', component: 'default', @@ -2078,7 +2078,7 @@ describe('gridview', () => { }, { type: 'branch', - size: 2000, + size: 3000, data: [ { type: 'branch', @@ -2095,7 +2095,7 @@ describe('gridview', () => { }, { type: 'branch', - size: 1000, + size: 2000, data: [ { type: 'leaf', @@ -2132,7 +2132,7 @@ describe('gridview', () => { }, { type: 'leaf', - size: 2000, + size: 1000, data: { id: 'panel_6', component: 'default', @@ -2148,7 +2148,7 @@ describe('gridview', () => { expect(JSON.parse(JSON.stringify(gridview.toJSON()))).toEqual({ grid: { height: 5000, - width: 5000, + width: 6000, orientation: Orientation.HORIZONTAL, root: { type: 'branch', @@ -2156,7 +2156,7 @@ describe('gridview', () => { data: [ { type: 'leaf', - size: 1000, + size: 2000, data: { id: 'panel_1', component: 'default', @@ -2165,7 +2165,7 @@ describe('gridview', () => { }, { type: 'branch', - size: 2000, + size: 3000, data: [ { type: 'branch', @@ -2182,7 +2182,7 @@ describe('gridview', () => { }, { type: 'branch', - size: 1000, + size: 2000, data: [ { type: 'leaf', @@ -2217,9 +2217,374 @@ describe('gridview', () => { }, ], }, + { + type: 'leaf', + size: 1000, + data: { + id: 'panel_6', + component: 'default', + snap: false, + }, + }, + ], + }, + }, + activePanel: 'panel_1', + }); + + gridview.layout(6000, 5000, true); + + expect(JSON.parse(JSON.stringify(gridview.toJSON()))).toEqual({ + grid: { + height: 5000, + width: 6000, + orientation: Orientation.HORIZONTAL, + root: { + type: 'branch', + size: 5000, + data: [ { type: 'leaf', size: 2000, + data: { + id: 'panel_1', + component: 'default', + snap: false, + }, + }, + { + type: 'branch', + size: 3000, + data: [ + { + type: 'branch', + size: 4000, + data: [ + { + type: 'leaf', + size: 1000, + data: { + id: 'panel_2', + component: 'default', + snap: false, + }, + }, + { + type: 'branch', + size: 2000, + data: [ + { + type: 'leaf', + size: 2000, + data: { + id: 'panel_3', + component: 'default', + snap: false, + }, + }, + { + type: 'leaf', + size: 2000, + data: { + id: 'panel_4', + component: 'default', + snap: false, + }, + }, + ], + }, + ], + }, + { + type: 'leaf', + size: 1000, + data: { + id: 'panel_5', + component: 'default', + snap: false, + }, + }, + ], + }, + { + type: 'leaf', + size: 1000, + data: { + id: 'panel_6', + component: 'default', + snap: false, + }, + }, + ], + }, + }, + activePanel: 'panel_1', + }); + }); + + test('that a deep VERTICAL layout with fromJSON dimensions identical to the current dimensions loads', async () => { + const container = document.createElement('div'); + + const gridview = new GridviewComponent({ + parentElement: container, + proportionalLayout: true, + orientation: Orientation.VERTICAL, + components: { default: TestGridview }, + }); + + gridview.layout(5000, 6000); + + gridview.fromJSON({ + grid: { + height: 6000, + width: 5000, + orientation: Orientation.VERTICAL, + root: { + type: 'branch', + size: 5000, + data: [ + { + type: 'leaf', + size: 2000, + data: { + id: 'panel_1', + component: 'default', + snap: false, + }, + }, + { + type: 'branch', + size: 3000, + data: [ + { + type: 'branch', + size: 4000, + data: [ + { + type: 'leaf', + size: 1000, + data: { + id: 'panel_2', + component: 'default', + snap: false, + }, + }, + { + type: 'branch', + size: 2000, + data: [ + { + type: 'leaf', + size: 2000, + data: { + id: 'panel_3', + component: 'default', + snap: false, + }, + }, + { + type: 'leaf', + size: 2000, + data: { + id: 'panel_4', + component: 'default', + snap: false, + }, + }, + ], + }, + ], + }, + { + type: 'leaf', + size: 1000, + data: { + id: 'panel_5', + component: 'default', + snap: false, + }, + }, + ], + }, + { + type: 'leaf', + size: 1000, + data: { + id: 'panel_6', + component: 'default', + snap: false, + }, + }, + ], + }, + }, + activePanel: 'panel_1', + }); + + expect(JSON.parse(JSON.stringify(gridview.toJSON()))).toEqual({ + grid: { + height: 6000, + width: 5000, + orientation: Orientation.VERTICAL, + root: { + type: 'branch', + size: 5000, + data: [ + { + type: 'leaf', + size: 2000, + data: { + id: 'panel_1', + component: 'default', + snap: false, + }, + }, + { + type: 'branch', + size: 3000, + data: [ + { + type: 'branch', + size: 4000, + data: [ + { + type: 'leaf', + size: 1000, + data: { + id: 'panel_2', + component: 'default', + snap: false, + }, + }, + { + type: 'branch', + size: 2000, + data: [ + { + type: 'leaf', + size: 2000, + data: { + id: 'panel_3', + component: 'default', + snap: false, + }, + }, + { + type: 'leaf', + size: 2000, + data: { + id: 'panel_4', + component: 'default', + snap: false, + }, + }, + ], + }, + ], + }, + { + type: 'leaf', + size: 1000, + data: { + id: 'panel_5', + component: 'default', + snap: false, + }, + }, + ], + }, + { + type: 'leaf', + size: 1000, + data: { + id: 'panel_6', + component: 'default', + snap: false, + }, + }, + ], + }, + }, + activePanel: 'panel_1', + }); + + gridview.layout(5000, 6000, true); + + expect(JSON.parse(JSON.stringify(gridview.toJSON()))).toEqual({ + grid: { + height: 6000, + width: 5000, + orientation: Orientation.VERTICAL, + root: { + type: 'branch', + size: 5000, + data: [ + { + type: 'leaf', + size: 2000, + data: { + id: 'panel_1', + component: 'default', + snap: false, + }, + }, + { + type: 'branch', + size: 3000, + data: [ + { + type: 'branch', + size: 4000, + data: [ + { + type: 'leaf', + size: 1000, + data: { + id: 'panel_2', + component: 'default', + snap: false, + }, + }, + { + type: 'branch', + size: 2000, + data: [ + { + type: 'leaf', + size: 2000, + data: { + id: 'panel_3', + component: 'default', + snap: false, + }, + }, + { + type: 'leaf', + size: 2000, + data: { + id: 'panel_4', + component: 'default', + snap: false, + }, + }, + ], + }, + ], + }, + { + type: 'leaf', + size: 1000, + data: { + id: 'panel_5', + component: 'default', + snap: false, + }, + }, + ], + }, + { + type: 'leaf', + size: 1000, data: { id: 'panel_6', component: 'default', diff --git a/packages/dockview-core/src/api/component.api.ts b/packages/dockview-core/src/api/component.api.ts index d527ef6af..e67fc1b62 100644 --- a/packages/dockview-core/src/api/component.api.ts +++ b/packages/dockview-core/src/api/component.api.ts @@ -435,7 +435,7 @@ export class DockviewApi implements CommonApi { return this.component.addPanel(options); } - addGroup(options?: AddGroupOptions): IDockviewGroupPanel { + addGroup(options?: AddGroupOptions): DockviewGroupPanel { return this.component.addGroup(options); } @@ -459,6 +459,13 @@ export class DockviewApi implements CommonApi { return this.component.getPanel(id); } + addFloatingGroup( + item: IDockviewPanel | DockviewGroupPanel, + coord?: { x: number; y: number } + ): void { + return this.component.addFloatingGroup(item, coord); + } + fromJSON(data: SerializedDockview): void { this.component.fromJSON(data); } diff --git a/packages/dockview-core/src/api/dockviewGroupPanelApi.ts b/packages/dockview-core/src/api/dockviewGroupPanelApi.ts new file mode 100644 index 000000000..65154b961 --- /dev/null +++ b/packages/dockview-core/src/api/dockviewGroupPanelApi.ts @@ -0,0 +1,54 @@ +import { Position } from '../dnd/droptarget'; +import { DockviewComponent } from '../dockview/dockviewComponent'; +import { DockviewGroupPanel } from '../dockview/dockviewGroupPanel'; +import { Emitter, Event } from '../events'; +import { GridviewPanelApi, GridviewPanelApiImpl } from './gridviewPanelApi'; + +export interface DockviewGroupPanelApi extends GridviewPanelApi { + readonly onDidFloatingStateChange: Event; + readonly isFloating: boolean; + moveTo(options: { group: DockviewGroupPanel; position?: Position }): void; +} + +export interface DockviewGroupPanelFloatingChangeEvent { + readonly isFloating: boolean; +} + +export class DockviewGroupPanelApiImpl extends GridviewPanelApiImpl { + private _group: DockviewGroupPanel | undefined; + + readonly _onDidFloatingStateChange = + new Emitter(); + readonly onDidFloatingStateChange: Event = + this._onDidFloatingStateChange.event; + + get isFloating() { + if (!this._group) { + throw new Error(`DockviewGroupPanelApiImpl not initialized`); + } + return this._group.model.isFloating; + } + + constructor(id: string, private readonly accessor: DockviewComponent) { + super(id); + + this.addDisposables(this._onDidFloatingStateChange); + } + + moveTo(options: { group: DockviewGroupPanel; position?: Position }): void { + if (!this._group) { + throw new Error(`DockviewGroupPanelApiImpl not initialized`); + } + + this.accessor.moveGroupOrPanel( + options.group, + this._group.id, + undefined, + options.position ?? 'center' + ); + } + + initialize(group: DockviewGroupPanel): void { + this._group = group; + } +} diff --git a/packages/dockview-core/src/api/dockviewPanelApi.ts b/packages/dockview-core/src/api/dockviewPanelApi.ts index 5c6222071..e724c251d 100644 --- a/packages/dockview-core/src/api/dockviewPanelApi.ts +++ b/packages/dockview-core/src/api/dockviewPanelApi.ts @@ -3,6 +3,8 @@ import { GridviewPanelApiImpl, GridviewPanelApi } from './gridviewPanelApi'; import { DockviewGroupPanel } from '../dockview/dockviewGroupPanel'; import { MutableDisposable } from '../lifecycle'; import { IDockviewPanel } from '../dockview/dockviewPanel'; +import { DockviewComponent } from '../dockview/dockviewComponent'; +import { Position } from '../dnd/droptarget'; export interface TitleEvent { readonly title: string; @@ -24,6 +26,11 @@ export interface DockviewPanelApi readonly onDidGroupChange: Event; close(): void; setTitle(title: string): void; + moveTo(options: { + group: DockviewGroupPanel; + position?: Position; + index?: number; + }): void; } export class DockviewPanelApiImpl @@ -73,7 +80,11 @@ export class DockviewPanelApiImpl return this._group; } - constructor(private panel: IDockviewPanel, group: DockviewGroupPanel) { + constructor( + private panel: IDockviewPanel, + group: DockviewGroupPanel, + private readonly accessor: DockviewComponent + ) { super(panel.id); this.initialize(panel); @@ -88,11 +99,25 @@ export class DockviewPanelApiImpl ); } - public setTitle(title: string): void { + moveTo(options: { + group: DockviewGroupPanel; + position?: Position; + index?: number; + }): void { + this.accessor.moveGroupOrPanel( + options.group, + this._group.id, + this.panel.id, + options.position ?? 'center', + options.index + ); + } + + setTitle(title: string): void { this.panel.setTitle(title); } - public close(): void { + close(): void { this.group.model.closePanel(this.panel); } } diff --git a/packages/dockview-core/src/array.ts b/packages/dockview-core/src/array.ts index 227744927..44b1003f3 100644 --- a/packages/dockview-core/src/array.ts +++ b/packages/dockview-core/src/array.ts @@ -61,3 +61,13 @@ export function firstIndex( return -1; } + +export function remove(array: T[], value: T): boolean { + const index = array.findIndex((t) => t === value); + + if (index > -1) { + array.splice(index, 1); + return true; + } + return false; +} diff --git a/packages/dockview-core/src/dnd/abstractDragHandler.ts b/packages/dockview-core/src/dnd/abstractDragHandler.ts index 55e03fa42..0e452dcf3 100644 --- a/packages/dockview-core/src/dnd/abstractDragHandler.ts +++ b/packages/dockview-core/src/dnd/abstractDragHandler.ts @@ -27,10 +27,19 @@ export abstract class DragHandler extends CompositeDisposable { abstract getData(dataTransfer?: DataTransfer | null): IDisposable; + protected isCancelled(_event: DragEvent): boolean { + return false; + } + private configure(): void { this.addDisposables( this._onDragStart, addDisposableListener(this.el, 'dragstart', (event) => { + if (this.isCancelled(event)) { + event.preventDefault(); + return; + } + const iframes = [ ...getElementsByTagName('iframe'), ...getElementsByTagName('webview'), diff --git a/packages/dockview-core/src/dnd/droptarget.scss b/packages/dockview-core/src/dnd/droptarget.scss index fbb800615..2767c2c24 100644 --- a/packages/dockview-core/src/dnd/droptarget.scss +++ b/packages/dockview-core/src/dnd/droptarget.scss @@ -7,7 +7,8 @@ top: 0px; height: 100%; width: 100%; - z-index: 10000; + z-index: 1000; + pointer-events: none; > .drop-target-selection { position: relative; @@ -15,7 +16,9 @@ height: 100%; width: 100%; background-color: var(--dv-drag-over-background-color); - transition: top 70ms ease-out,left 70ms ease-out,width 70ms ease-out,height 70ms ease-out,opacity .15s ease-out; + transition: top 70ms ease-out, left 70ms ease-out, + width 70ms ease-out, height 70ms ease-out, + opacity 0.15s ease-out; will-change: transform; pointer-events: none; diff --git a/packages/dockview-core/src/dnd/droptarget.ts b/packages/dockview-core/src/dnd/droptarget.ts index 12fcb0ea0..003f8b045 100644 --- a/packages/dockview-core/src/dnd/droptarget.ts +++ b/packages/dockview-core/src/dnd/droptarget.ts @@ -58,10 +58,13 @@ export class Droptarget extends CompositeDisposable { private targetElement: HTMLElement | undefined; private overlayElement: HTMLElement | undefined; private _state: Position | undefined; + private _acceptedTargetZonesSet: Set; private readonly _onDrop = new Emitter(); readonly onDrop: Event = this._onDrop.event; + private static USED_EVENT_ID = '__dockview_droptarget_event_is_used__'; + get state(): Position | undefined { return this._state; } @@ -83,7 +86,7 @@ export class Droptarget extends CompositeDisposable { super(); // use a set to take advantage of #.has - const acceptedTargetZonesSet = new Set( + this._acceptedTargetZonesSet = new Set( this.options.acceptedTargetZones ); @@ -92,6 +95,11 @@ export class Droptarget extends CompositeDisposable { new DragAndDropObserver(this.element, { onDragEnter: () => undefined, onDragOver: (e) => { + if (this._acceptedTargetZonesSet.size === 0) { + this.removeDropTarget(); + return; + } + const width = this.element.clientWidth; const height = this.element.clientHeight; @@ -106,14 +114,19 @@ export class Droptarget extends CompositeDisposable { const y = e.clientY - rect.top; const quadrant = this.calculateQuadrant( - acceptedTargetZonesSet, + this._acceptedTargetZonesSet, x, y, width, height ); - if (quadrant === null) { + /** + * If the event has already been used by another DropTarget instance + * then don't show a second drop target, only one target should be + * active at any one time + */ + if (this.isAlreadyUsed(e) || quadrant === null) { // no drop target should be displayed this.removeDropTarget(); return; @@ -121,12 +134,16 @@ export class Droptarget extends CompositeDisposable { if (typeof this.options.canDisplayOverlay === 'boolean') { if (!this.options.canDisplayOverlay) { + this.removeDropTarget(); return; } } else if (!this.options.canDisplayOverlay(e, quadrant)) { + this.removeDropTarget(); return; } + this.markAsUsed(e); + if (!this.targetElement) { this.targetElement = document.createElement('div'); this.targetElement.className = 'drop-target-dropzone'; @@ -139,14 +156,6 @@ export class Droptarget extends CompositeDisposable { this.element.append(this.targetElement); } - if (this.options.acceptedTargetZones.length === 0) { - return; - } - - if (!this.targetElement || !this.overlayElement) { - return; - } - this.toggleClasses(quadrant, width, height); this.setState(quadrant); @@ -175,11 +184,30 @@ export class Droptarget extends CompositeDisposable { ); } - public dispose(): void { + setTargetZones(acceptedTargetZones: Position[]): void { + this._acceptedTargetZonesSet = new Set(acceptedTargetZones); + } + + dispose(): void { this.removeDropTarget(); super.dispose(); } + /** + * Add a property to the event object for other potential listeners to check + */ + private markAsUsed(event: DragEvent): void { + (event as any)[Droptarget.USED_EVENT_ID] = true; + } + + /** + * Check is the event has already been used by another instance od DropTarget + */ + private isAlreadyUsed(event: DragEvent): boolean { + const value = (event as any)[Droptarget.USED_EVENT_ID]; + return typeof value === 'boolean' && value; + } + private toggleClasses( quadrant: Position, width: number, diff --git a/packages/dockview-core/src/dnd/groupDragHandler.ts b/packages/dockview-core/src/dnd/groupDragHandler.ts index bdd183182..0a6f55008 100644 --- a/packages/dockview-core/src/dnd/groupDragHandler.ts +++ b/packages/dockview-core/src/dnd/groupDragHandler.ts @@ -1,4 +1,6 @@ import { DockviewGroupPanel } from '../dockview/dockviewGroupPanel'; +import { quasiPreventDefault } from '../dom'; +import { addDisposableListener } from '../events'; import { IDisposable } from '../lifecycle'; import { DragHandler } from './abstractDragHandler'; import { LocalSelectionTransfer, PanelTransfer } from './dataTransfer'; @@ -14,6 +16,31 @@ export class GroupDragHandler extends DragHandler { private readonly group: DockviewGroupPanel ) { super(element); + + this.addDisposables( + addDisposableListener( + element, + 'mousedown', + (e) => { + if (e.shiftKey) { + /** + * You cannot call e.preventDefault() because that will prevent drag events from firing + * but we also need to stop any group overlay drag events from occuring + * Use a custom event marker that can be checked by the overlay drag events + */ + quasiPreventDefault(e); + } + }, + true + ) + ); + } + + override isCancelled(_event: DragEvent): boolean { + if (this.group.api.isFloating && !_event.shiftKey) { + return true; + } + return false; } getData(dataTransfer: DataTransfer | null): IDisposable { diff --git a/packages/dockview-core/src/dnd/overlay.scss b/packages/dockview-core/src/dnd/overlay.scss new file mode 100644 index 000000000..5f95b379a --- /dev/null +++ b/packages/dockview-core/src/dnd/overlay.scss @@ -0,0 +1,122 @@ +.dv-debug { + .dv-resize-container { + .dv-resize-handle-top { + background-color: red; + } + + .dv-resize-handle-bottom { + background-color: green; + } + + .dv-resize-handle-left { + background-color: yellow; + } + + .dv-resize-handle-right { + background-color: blue; + } + + .dv-resize-handle-topleft, + .dv-resize-handle-topright, + .dv-resize-handle-bottomleft, + .dv-resize-handle-bottomright { + background-color: cyan; + } + } +} + +.dv-resize-container { + position: absolute; + z-index: 997; + + &.dv-bring-to-front { + z-index: 998; + } + + border: 1px solid var(--dv-tab-divider-color); + box-shadow: var(--dv-floating-box-shadow); + + &.dv-resize-container-dragging { + opacity: 0.5; + } + + .dv-resize-handle-top { + height: 4px; + width: calc(100% - 8px); + left: 4px; + top: -2px; + z-index: 999; + position: absolute; + cursor: ns-resize; + } + + .dv-resize-handle-bottom { + height: 4px; + width: calc(100% - 8px); + left: 4px; + bottom: -2px; + z-index: 999; + position: absolute; + cursor: ns-resize; + } + + .dv-resize-handle-left { + height: calc(100% - 8px); + width: 4px; + left: -2px; + top: 4px; + z-index: 999; + position: absolute; + cursor: ew-resize; + } + + .dv-resize-handle-right { + height: calc(100% - 8px); + width: 4px; + right: -2px; + top: 4px; + z-index: 999; + position: absolute; + cursor: ew-resize; + } + + .dv-resize-handle-topleft { + height: 4px; + width: 4px; + top: -2px; + left: -2px; + z-index: 999; + position: absolute; + cursor: nw-resize; + } + + .dv-resize-handle-topright { + height: 4px; + width: 4px; + right: -2px; + top: -2px; + z-index: 999; + position: absolute; + cursor: ne-resize; + } + + .dv-resize-handle-bottomleft { + height: 4px; + width: 4px; + left: -2px; + bottom: -2px; + z-index: 999; + position: absolute; + cursor: sw-resize; + } + + .dv-resize-handle-bottomright { + height: 4px; + width: 4px; + right: -2px; + bottom: -2px; + z-index: 999; + position: absolute; + cursor: se-resize; + } +} diff --git a/packages/dockview-core/src/dnd/overlay.ts b/packages/dockview-core/src/dnd/overlay.ts new file mode 100644 index 000000000..6d818725a --- /dev/null +++ b/packages/dockview-core/src/dnd/overlay.ts @@ -0,0 +1,449 @@ +import { quasiDefaultPrevented, toggleClass } from '../dom'; +import { + Emitter, + Event, + addDisposableListener, + addDisposableWindowListener, +} from '../events'; +import { CompositeDisposable, MutableDisposable } from '../lifecycle'; +import { clamp } from '../math'; + +const bringElementToFront = (() => { + let previous: HTMLElement | null = null; + + function pushToTop(element: HTMLElement) { + if (previous !== element && previous !== null) { + toggleClass(previous, 'dv-bring-to-front', false); + } + + toggleClass(element, 'dv-bring-to-front', true); + previous = element; + } + + return pushToTop; +})(); + +export class Overlay extends CompositeDisposable { + private _element: HTMLElement = document.createElement('div'); + + private readonly _onDidChange = new Emitter(); + readonly onDidChange: Event = this._onDidChange.event; + + private static MINIMUM_HEIGHT = 20; + private static MINIMUM_WIDTH = 20; + + constructor( + private readonly options: { + height: number; + width: number; + left: number; + top: number; + container: HTMLElement; + content: HTMLElement; + minimumInViewportWidth: number; + minimumInViewportHeight: number; + } + ) { + super(); + + this.addDisposables(this._onDidChange); + + this.setupOverlay(); + this.setupResize('top'); + this.setupResize('bottom'); + this.setupResize('left'); + this.setupResize('right'); + this.setupResize('topleft'); + this.setupResize('topright'); + this.setupResize('bottomleft'); + this.setupResize('bottomright'); + + this._element.appendChild(this.options.content); + this.options.container.appendChild(this._element); + + // if input bad resize within acceptable boundaries + this.renderWithinBoundaryConditions(); + } + + setBounds( + bounds: Partial<{ + height: number; + width: number; + top: number; + left: number; + }> + ): void { + if (typeof bounds.height === 'number') { + this._element.style.height = `${bounds.height}px`; + } + if (typeof bounds.width === 'number') { + this._element.style.width = `${bounds.width}px`; + } + if (typeof bounds.top === 'number') { + this._element.style.top = `${bounds.top}px`; + } + if (typeof bounds.left === 'number') { + this._element.style.left = `${bounds.left}px`; + } + + this.renderWithinBoundaryConditions(); + } + + toJSON(): { top: number; left: number; height: number; width: number } { + const container = this.options.container.getBoundingClientRect(); + const element = this._element.getBoundingClientRect(); + + return { + top: element.top - container.top, + left: element.left - container.left, + width: element.width, + height: element.height, + }; + } + + renderWithinBoundaryConditions(): void { + const containerRect = this.options.container.getBoundingClientRect(); + const overlayRect = this._element.getBoundingClientRect(); + + // a minimum width of minimumViewportWidth must be inside the viewport + const xOffset = Math.max( + 0, + overlayRect.width - this.options.minimumInViewportWidth + ); + + // a minimum height of minimumViewportHeight must be inside the viewport + const yOffset = Math.max( + 0, + overlayRect.height - this.options.minimumInViewportHeight + ); + + const left = clamp( + overlayRect.left - containerRect.left, + -xOffset, + Math.max(0, containerRect.width - overlayRect.width + xOffset) + ); + + const top = clamp( + overlayRect.top - containerRect.top, + -yOffset, + Math.max(0, containerRect.height - overlayRect.height + yOffset) + ); + + this._element.style.left = `${left}px`; + this._element.style.top = `${top}px`; + } + + setupDrag( + dragTarget: HTMLElement, + options: { inDragMode: boolean } = { inDragMode: false } + ): void { + const move = new MutableDisposable(); + + const track = () => { + let offset: { x: number; y: number } | null = null; + + move.value = new CompositeDisposable( + addDisposableWindowListener(window, 'mousemove', (e) => { + const containerRect = + this.options.container.getBoundingClientRect(); + const x = e.clientX - containerRect.left; + const y = e.clientY - containerRect.top; + + toggleClass( + this._element, + 'dv-resize-container-dragging', + true + ); + + const overlayRect = this._element.getBoundingClientRect(); + if (offset === null) { + offset = { + x: e.clientX - overlayRect.left, + y: e.clientY - overlayRect.top, + }; + } + + const xOffset = Math.max( + 0, + overlayRect.width - this.options.minimumInViewportWidth + ); + const yOffset = Math.max( + 0, + overlayRect.height - + this.options.minimumInViewportHeight + ); + + const left = clamp( + x - offset.x, + -xOffset, + Math.max( + 0, + containerRect.width - overlayRect.width + xOffset + ) + ); + + const top = clamp( + y - offset.y, + -yOffset, + Math.max( + 0, + containerRect.height - overlayRect.height + yOffset + ) + ); + + this._element.style.left = `${left}px`; + this._element.style.top = `${top}px`; + }), + addDisposableWindowListener(window, 'mouseup', () => { + toggleClass( + this._element, + 'dv-resize-container-dragging', + false + ); + + move.dispose(); + this._onDidChange.fire(); + }) + ); + }; + + this.addDisposables( + move, + addDisposableListener(dragTarget, 'mousedown', (event) => { + if (event.defaultPrevented) { + event.preventDefault(); + return; + } + + // if somebody has marked this event then treat as a defaultPrevented + // without actually calling event.preventDefault() + if (quasiDefaultPrevented(event)) { + return; + } + + track(); + }), + addDisposableListener( + this.options.content, + 'mousedown', + (event) => { + if (event.defaultPrevented) { + return; + } + + // if somebody has marked this event then treat as a defaultPrevented + // without actually calling event.preventDefault() + if (quasiDefaultPrevented(event)) { + return; + } + + if (event.shiftKey) { + track(); + } + } + ), + addDisposableListener( + this.options.content, + 'mousedown', + () => { + bringElementToFront(this._element); + }, + true + ) + ); + + bringElementToFront(this._element); + + if (options.inDragMode) { + track(); + } + } + + private setupOverlay(): void { + this._element.style.height = `${this.options.height}px`; + this._element.style.width = `${this.options.width}px`; + this._element.style.left = `${this.options.left}px`; + this._element.style.top = `${this.options.top}px`; + + this._element.className = 'dv-resize-container'; + } + + private setupResize( + direction: + | 'top' + | 'bottom' + | 'left' + | 'right' + | 'topleft' + | 'topright' + | 'bottomleft' + | 'bottomright' + ): void { + const resizeHandleElement = document.createElement('div'); + resizeHandleElement.className = `dv-resize-handle-${direction}`; + this._element.appendChild(resizeHandleElement); + + const move = new MutableDisposable(); + + this.addDisposables( + move, + addDisposableListener(resizeHandleElement, 'mousedown', (e) => { + e.preventDefault(); + + let startPosition: { + originalY: number; + originalHeight: number; + originalX: number; + originalWidth: number; + } | null = null; + + move.value = new CompositeDisposable( + addDisposableWindowListener(window, 'mousemove', (e) => { + const containerRect = + this.options.container.getBoundingClientRect(); + const overlayRect = + this._element.getBoundingClientRect(); + + const y = e.clientY - containerRect.top; + const x = e.clientX - containerRect.left; + + if (startPosition === null) { + // record the initial dimensions since as all subsequence moves are relative to this + startPosition = { + originalY: y, + originalHeight: overlayRect.height, + originalX: x, + originalWidth: overlayRect.width, + }; + } + + let top: number | null = null; + let height: number | null = null; + let left: number | null = null; + let width: number | null = null; + + function moveTop() { + top = clamp( + y, + 0, + Math.max( + 0, + startPosition!.originalY + + startPosition!.originalHeight - + Overlay.MINIMUM_HEIGHT + ) + ); + height = + startPosition!.originalY + + startPosition!.originalHeight - + top; + } + + function moveBottom() { + top = + startPosition!.originalY - + startPosition!.originalHeight; + + height = clamp( + y - top, + Overlay.MINIMUM_HEIGHT, + Math.max( + 0, + containerRect.height - + startPosition!.originalY + + startPosition!.originalHeight + ) + ); + } + + function moveLeft() { + left = clamp( + x, + 0, + Math.max( + 0, + startPosition!.originalX + + startPosition!.originalWidth - + Overlay.MINIMUM_WIDTH + ) + ); + width = + startPosition!.originalX + + startPosition!.originalWidth - + left; + } + + function moveRight() { + left = + startPosition!.originalX - + startPosition!.originalWidth; + width = clamp( + x - left, + Overlay.MINIMUM_WIDTH, + Math.max( + 0, + containerRect.width - + startPosition!.originalX + + startPosition!.originalWidth + ) + ); + } + + switch (direction) { + case 'top': + moveTop(); + break; + case 'bottom': + moveBottom(); + break; + case 'left': + moveLeft(); + break; + case 'right': + moveRight(); + break; + case 'topleft': + moveTop(); + moveLeft(); + break; + case 'topright': + moveTop(); + moveRight(); + break; + case 'bottomleft': + moveBottom(); + moveLeft(); + break; + case 'bottomright': + moveBottom(); + moveRight(); + break; + } + + if (height !== null) { + this._element.style.height = `${height}px`; + } + if (top !== null) { + this._element.style.top = `${top}px`; + } + if (left !== null) { + this._element.style.left = `${left}px`; + } + if (width !== null) { + this._element.style.width = `${width}px`; + } + }), + addDisposableWindowListener(window, 'mouseup', () => { + move.dispose(); + this._onDidChange.fire(); + }) + ); + }) + ); + } + + override dispose(): void { + this._element.remove(); + super.dispose(); + } +} diff --git a/packages/dockview-core/src/dockview/components/tab/tab.ts b/packages/dockview-core/src/dockview/components/tab/tab.ts index 15c81206b..b57d08b26 100644 --- a/packages/dockview-core/src/dockview/components/tab/tab.ts +++ b/packages/dockview-core/src/dockview/components/tab/tab.ts @@ -6,7 +6,7 @@ import { PanelTransfer, } from '../../../dnd/dataTransfer'; import { toggleClass } from '../../../dom'; -import { IDockviewComponent } from '../../dockviewComponent'; +import { DockviewComponent } from '../../dockviewComponent'; import { DockviewDropTargets, ITabRenderer } from '../../types'; import { DockviewGroupPanel } from '../../dockviewGroupPanel'; import { DroptargetEvent, Droptarget } from '../../../dnd/droptarget'; @@ -38,7 +38,7 @@ export class Tab extends CompositeDisposable implements ITab { constructor( public readonly panelId: string, - private readonly accessor: IDockviewComponent, + private readonly accessor: DockviewComponent, private readonly group: DockviewGroupPanel ) { super(); @@ -79,6 +79,7 @@ export class Tab extends CompositeDisposable implements ITab { if (event.defaultPrevented) { return; } + /** * TODO: alternative to stopPropagation * diff --git a/packages/dockview-core/src/dockview/components/titlebar/tabsContainer.ts b/packages/dockview-core/src/dockview/components/titlebar/tabsContainer.ts index b0ce14fc8..b857dc00e 100644 --- a/packages/dockview-core/src/dockview/components/titlebar/tabsContainer.ts +++ b/packages/dockview-core/src/dockview/components/titlebar/tabsContainer.ts @@ -9,7 +9,7 @@ import { DockviewComponent } from '../../dockviewComponent'; import { DockviewGroupPanel } from '../../dockviewGroupPanel'; import { VoidContainer } from './voidContainer'; import { toggleClass } from '../../../dom'; -import { IDockviewPanel } from '../../dockviewPanel'; +import { DockviewPanel, IDockviewPanel } from '../../dockviewPanel'; export interface TabDropIndexEvent { readonly event: DragEvent; @@ -187,6 +187,36 @@ export class TabsContainer index: this.tabs.length, }); }), + addDisposableListener( + this.voidContainer.element, + 'mousedown', + (event) => { + const isFloatingGroupsEnabled = + !this.accessor.options.disableFloatingGroups; + + if ( + isFloatingGroupsEnabled && + event.shiftKey && + !this.group.api.isFloating + ) { + 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, 'mousedown', (event) => { if (event.defaultPrevented) { return; @@ -263,6 +293,30 @@ export class TabsContainer const disposable = CompositeDisposable.from( tabToAdd.onChanged((event) => { + const isFloatingGroupsEnabled = + !this.accessor.options.disableFloatingGroups; + + if (isFloatingGroupsEnabled && event.shiftKey) { + event.preventDefault(); + + const panel = this.accessor.getGroupPanel(tabToAdd.panelId); + + const { top, left } = + tabToAdd.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 alreadyFocused = panel.id === this.group.model.activePanel?.id && this.group.model.isContentFocused; diff --git a/packages/dockview-core/src/dockview/components/titlebar/voidContainer.ts b/packages/dockview-core/src/dockview/components/titlebar/voidContainer.ts index 1737c87aa..5aef855c6 100644 --- a/packages/dockview-core/src/dockview/components/titlebar/voidContainer.ts +++ b/packages/dockview-core/src/dockview/components/titlebar/voidContainer.ts @@ -28,6 +28,7 @@ export class VoidContainer extends CompositeDisposable { this._element = document.createElement('div'); this._element.className = 'void-container'; + this._element.id = 'dv-group-float-drag-handle'; this._element.tabIndex = 0; this._element.draggable = true; diff --git a/packages/dockview-core/src/dockview/deserializer.ts b/packages/dockview-core/src/dockview/deserializer.ts index 82daa7381..3f3490b14 100644 --- a/packages/dockview-core/src/dockview/deserializer.ts +++ b/packages/dockview-core/src/dockview/deserializer.ts @@ -1,7 +1,7 @@ import { GroupviewPanelState } from './types'; import { DockviewGroupPanel } from './dockviewGroupPanel'; import { DockviewPanel, IDockviewPanel } from './dockviewPanel'; -import { IDockviewComponent } from './dockviewComponent'; +import { DockviewComponent } from './dockviewComponent'; import { DockviewPanelModel } from './dockviewPanelModel'; import { DockviewApi } from '../api/component.api'; @@ -21,7 +21,7 @@ interface LegacyState extends GroupviewPanelState { } export class DefaultDockviewDeserialzier implements IPanelDeserializer { - constructor(private readonly layout: IDockviewComponent) {} + constructor(private readonly layout: DockviewComponent) {} public fromJSON( panelData: GroupviewPanelState, diff --git a/packages/dockview-core/src/dockview/dockviewComponent.scss b/packages/dockview-core/src/dockview/dockviewComponent.scss index db272fc48..1aa878539 100644 --- a/packages/dockview-core/src/dockview/dockviewComponent.scss +++ b/packages/dockview-core/src/dockview/dockviewComponent.scss @@ -1,14 +1,15 @@ .dv-dockview { - position: relative; - background-color: var(--dv-group-view-background-color); + position: relative; + background-color: var(--dv-group-view-background-color); - .dv-watermark-container { - position: absolute; - top: 0px; - left: 0px; - height: 100%; - width: 100%; - } + .dv-watermark-container { + position: absolute; + top: 0px; + left: 0px; + height: 100%; + width: 100%; + z-index: 1; + } } .groupview { diff --git a/packages/dockview-core/src/dockview/dockviewComponent.ts b/packages/dockview-core/src/dockview/dockviewComponent.ts index cce14c23d..2ef2cdf5a 100644 --- a/packages/dockview-core/src/dockview/dockviewComponent.ts +++ b/packages/dockview-core/src/dockview/dockviewComponent.ts @@ -5,7 +5,7 @@ import { ISerializedLeafNode, } from '../gridview/gridview'; import { directionToPosition, Droptarget, Position } from '../dnd/droptarget'; -import { tail, sequenceEquals } from '../array'; +import { tail, sequenceEquals, remove } from '../array'; import { DockviewPanel, IDockviewPanel } from './dockviewPanel'; import { CompositeDisposable } from '../lifecycle'; import { Event, Emitter } from '../events'; @@ -41,15 +41,26 @@ import { GroupPanelViewState, GroupviewDropEvent, } from './dockviewGroupPanelModel'; -import { DockviewGroupPanel, IDockviewGroupPanel } from './dockviewGroupPanel'; +import { DockviewGroupPanel } from './dockviewGroupPanel'; import { DockviewPanelModel } from './dockviewPanelModel'; import { getPanelData } from '../dnd/dataTransfer'; +import { Overlay } from '../dnd/overlay'; +import { toggleClass } from '../dom'; +import { + DockviewFloatingGroupPanel, + IDockviewFloatingGroupPanel, +} from './dockviewFloatingGroupPanel'; export interface PanelReference { update: (event: { params: { [key: string]: any } }) => void; remove: () => void; } +export interface SerializedFloatingGroup { + data: GroupPanelViewState; + position: { height: number; width: number; left: number; top: number }; +} + export interface SerializedDockview { grid: { root: SerializedGridObject; @@ -57,8 +68,9 @@ export interface SerializedDockview { width: number; orientation: Orientation; }; - panels: { [key: string]: GroupviewPanelState }; + panels: Record; activeGroup?: string; + floatingGroups?: SerializedFloatingGroup[]; } export type DockviewComponentUpdateOptions = Pick< @@ -84,6 +96,7 @@ export interface IDockviewComponent extends IBaseGrid { readonly activePanel: IDockviewPanel | undefined; readonly totalPanels: number; readonly panels: IDockviewPanel[]; + readonly floatingGroups: IDockviewFloatingGroupPanel[]; readonly onDidDrop: Event; readonly orientation: Orientation; updateOptions(options: DockviewComponentUpdateOptions): void; @@ -102,7 +115,7 @@ export interface IDockviewComponent extends IBaseGrid { getGroupPanel: (id: string) => IDockviewPanel | undefined; createWatermarkComponent(): IWatermarkRenderer; // lifecycle - addGroup(options?: AddGroupOptions): IDockviewGroupPanel; + addGroup(options?: AddGroupOptions): DockviewGroupPanel; closeAllGroups(): void; // events moveToNext(options?: MovementOptions): void; @@ -116,6 +129,10 @@ export interface IDockviewComponent extends IBaseGrid { readonly onDidAddPanel: Event; readonly onDidLayoutFromJSON: Event; readonly onDidActivePanelChange: Event; + addFloatingGroup( + item: IDockviewPanel | DockviewGroupPanel, + coord?: { x: number; y: number } + ): void; } export class DockviewComponent @@ -147,6 +164,8 @@ export class DockviewComponent readonly onDidActivePanelChange: Event = this._onDidActivePanelChange.event; + readonly floatingGroups: DockviewFloatingGroupPanel[] = []; + get orientation(): Orientation { return this.gridview.orientation; } @@ -181,7 +200,7 @@ export class DockviewComponent parentElement: options.parentElement, }); - this.element.classList.add('dv-dockview'); + toggleClass(this.gridview.element, 'dv-dockview', true); this.addDisposables( this._onDidDrop, @@ -229,6 +248,13 @@ export class DockviewComponent if (data.viewId !== this.id) { return false; } + + if (position === 'center') { + // center drop target is only allowed if there are no panels in the grid + // floating panels are allowed + return this.gridview.length === 0; + } + return true; } @@ -243,7 +269,7 @@ export class DockviewComponent return false; }, - acceptedTargetZones: ['top', 'bottom', 'left', 'right'], + acceptedTargetZones: ['top', 'bottom', 'left', 'right', 'center'], overlayModel: { activationSize: { type: 'pixels', value: 10 }, size: { type: 'pixels', value: 20 }, @@ -278,6 +304,85 @@ export class DockviewComponent this.updateWatermark(); } + addFloatingGroup( + item: DockviewPanel | DockviewGroupPanel, + coord?: { x?: number; y?: number; height?: number; width?: number }, + options?: { skipRemoveGroup?: boolean; inDragMode: boolean } + ): void { + let group: DockviewGroupPanel; + + if (item instanceof DockviewPanel) { + group = this.createGroup(); + + this.removePanel(item, { + removeEmptyGroup: true, + skipDispose: true, + }); + + group.model.openPanel(item); + } else { + group = item; + + const skip = + typeof options?.skipRemoveGroup === 'boolean' && + options.skipRemoveGroup; + + if (!skip) { + this.doRemoveGroup(item, { skipDispose: true }); + } + } + + group.model.isFloating = true; + + const overlayLeft = + typeof coord?.x === 'number' ? Math.max(coord.x, 0) : 100; + const overlayTop = + typeof coord?.y === 'number' ? Math.max(coord.y, 0) : 100; + + const overlay = new Overlay({ + container: this.gridview.element, + content: group.element, + height: coord?.height ?? 300, + width: coord?.width ?? 300, + left: overlayLeft, + top: overlayTop, + minimumInViewportWidth: 100, + minimumInViewportHeight: 100, + }); + + const el = group.element.querySelector('#dv-group-float-drag-handle'); + + if (el) { + overlay.setupDrag(el as HTMLElement, { + inDragMode: + typeof options?.inDragMode === 'boolean' + ? options.inDragMode + : false, + }); + } + + const floatingGroupPanel = new DockviewFloatingGroupPanel( + group, + overlay + ); + + floatingGroupPanel.addDisposables( + overlay.onDidChange(() => { + this._bufferOnDidLayoutChange.fire(); + }), + { + dispose: () => { + group.model.isFloating = false; + remove(this.floatingGroups, floatingGroupPanel); + this.updateWatermark(); + }, + } + ); + + this.floatingGroups.push(floatingGroupPanel); + this.updateWatermark(); + } + private orthogonalize(position: Position): DockviewGroupPanel { switch (position) { case 'top': @@ -303,6 +408,7 @@ export class DockviewComponent switch (position) { case 'top': case 'left': + case 'center': return this.createGroupAtLocation([0]); // insert into first position case 'bottom': case 'right': @@ -326,6 +432,21 @@ export class DockviewComponent this.layout(this.gridview.width, this.gridview.height, true); } + override layout( + width: number, + height: number, + forceResize?: boolean | undefined + ): void { + super.layout(width, height, forceResize); + + if (this.floatingGroups) { + for (const floating of this.floatingGroups) { + // ensure floting groups stay within visible boundaries + floating.overlay.renderWithinBoundaryConditions(); + } + } + } + focus(): void { this.activeGroup?.focus(); } @@ -397,11 +518,26 @@ export class DockviewComponent return collection; }, {} as { [key: string]: GroupviewPanelState }); - return { + const floats: SerializedFloatingGroup[] = this.floatingGroups.map( + (floatingGroup) => { + return { + data: floatingGroup.group.toJSON() as GroupPanelViewState, + position: floatingGroup.overlay.toJSON(), + }; + } + ); + + const result: SerializedDockview = { grid: data, panels, activeGroup: this.activeGroup?.id, }; + + if (floats.length > 0) { + result.floatingGroups = floats; + } + + return result; } fromJSON(data: SerializedDockview): void { @@ -417,48 +553,67 @@ export class DockviewComponent const width = this.width; const height = this.height; + const createGroupFromSerializedState = (data: GroupPanelViewState) => { + const { id, locked, hideHeader, views, activeView } = data; + + const group = this.createGroup({ + id, + locked: !!locked, + hideHeader: !!hideHeader, + }); + + this._onDidAddGroup.fire(group); + + for (const child of views) { + const panel = this._deserializer.fromJSON(panels[child], group); + + const isActive = + typeof activeView === 'string' && activeView === panel.id; + + group.model.openPanel(panel, { + skipSetPanelActive: !isActive, + skipSetGroupActive: true, + }); + } + + if (!group.activePanel && group.panels.length > 0) { + group.model.openPanel(group.panels[group.panels.length - 1], { + skipSetGroupActive: true, + }); + } + + return group; + }; + this.gridview.deserialize(grid, { fromJSON: (node: ISerializedLeafNode) => { - const { id, locked, hideHeader, views, activeView } = node.data; - - const group = this.createGroup({ - id, - locked: !!locked, - hideHeader: !!hideHeader, - }); - - this._onDidAddGroup.fire(group); - - for (const child of views) { - const panel = this._deserializer.fromJSON( - panels[child], - group - ); - - const isActive = - typeof activeView === 'string' && - activeView === panel.id; - - group.model.openPanel(panel, { - skipSetPanelActive: !isActive, - skipSetGroupActive: true, - }); - } - - if (!group.activePanel && group.panels.length > 0) { - group.model.openPanel( - group.panels[group.panels.length - 1], - { - skipSetGroupActive: true, - } - ); - } - - return group; + return createGroupFromSerializedState(node.data); }, }); - this.layout(width, height); + this.layout(width, height, true); + + const serializedFloatingGroups = data.floatingGroups ?? []; + + for (const serializedFloatingGroup of serializedFloatingGroups) { + const { data, position } = serializedFloatingGroup; + const group = createGroupFromSerializedState(data); + + this.addFloatingGroup( + group, + { + x: position.left, + y: position.top, + height: position.height, + width: position.width, + }, + { skipRemoveGroup: true, inDragMode: false } + ); + } + + for (const floatingGroup of this.floatingGroups) { + floatingGroup.overlay.renderWithinBoundaryConditions(); + } if (typeof activeGroup === 'string') { const panel = this.getPanel(activeGroup); @@ -478,7 +633,7 @@ export class DockviewComponent for (const group of groups) { // remove the group will automatically remove the panels - this.removeGroup(group, true); + this.removeGroup(group, { skipActive: true }); } if (hasActiveGroup) { @@ -500,13 +655,19 @@ export class DockviewComponent } } - addPanel(options: AddPanelOptions): IDockviewPanel { + addPanel(options: AddPanelOptions): DockviewPanel { if (this.panels.find((_) => _.id === options.id)) { throw new Error(`panel with id ${options.id} already exists`); } let referenceGroup: DockviewGroupPanel | undefined; + if (options.position && options.floating) { + throw new Error( + 'you can only provide one of: position, floating as arguments to .addPanel(...)' + ); + } + if (options.position) { if (isPanelOptionsWithPanel(options.position)) { const referencePanel = @@ -545,13 +706,29 @@ export class DockviewComponent referenceGroup = this.activeGroup; } - let panel: IDockviewPanel; + let panel: DockviewPanel; if (referenceGroup) { const target = toTarget( options.position?.direction || 'within' ); - if (target === 'center') { + + if (options.floating) { + const group = this.createGroup(); + panel = this.createPanel(options, group); + group.model.openPanel(panel); + + const o = + typeof options.floating === 'object' && + options.floating !== null + ? options.floating + : {}; + + this.addFloatingGroup(group, o, { + inDragMode: false, + skipRemoveGroup: true, + }); + } else if (referenceGroup.api.isFloating || target === 'center') { panel = this.createPanel(options, referenceGroup); referenceGroup.model.openPanel(panel); } else { @@ -565,10 +742,26 @@ export class DockviewComponent panel = this.createPanel(options, group); group.model.openPanel(panel); } + } else if (options.floating) { + const group = this.createGroup(); + panel = this.createPanel(options, group); + group.model.openPanel(panel); + + const o = + typeof options.floating === 'object' && + options.floating !== null + ? options.floating + : {}; + + this.addFloatingGroup(group, o, { + inDragMode: false, + skipRemoveGroup: true, + }); } else { const group = this.createGroupAtLocation(); panel = this.createPanel(options, group); + group.model.openPanel(panel); } @@ -592,7 +785,9 @@ export class DockviewComponent group.model.removePanel(panel); - panel.dispose(); + if (!options.skipDispose) { + panel.dispose(); + } if (group.size === 0 && options.removeEmptyGroup) { this.removeGroup(group); @@ -614,7 +809,7 @@ export class DockviewComponent } private updateWatermark(): void { - if (this.groups.length === 0) { + if (this.groups.filter((x) => !x.api.isFloating).length === 0) { if (!this.watermark) { this.watermark = this.createWatermarkComponent(); @@ -626,7 +821,7 @@ export class DockviewComponent watermarkContainer.className = 'dv-watermark-container'; watermarkContainer.appendChild(this.watermark.element); - this.element.appendChild(watermarkContainer); + this.gridview.element.appendChild(watermarkContainer); } } else if (this.watermark) { this.watermark.element.parentElement!.remove(); @@ -696,17 +891,51 @@ export class DockviewComponent } } - removeGroup(group: DockviewGroupPanel, skipActive = false): void { + removeGroup( + group: DockviewGroupPanel, + options?: + | { + skipActive?: boolean; + skipDispose?: boolean; + } + | undefined + ): void { const panels = [...group.panels]; // reassign since group panels will mutate for (const panel of panels) { this.removePanel(panel, { removeEmptyGroup: false, - skipDispose: false, + skipDispose: options?.skipDispose ?? false, }); } - super.doRemoveGroup(group, { skipActive }); + this.doRemoveGroup(group, options); + } + + protected override doRemoveGroup( + group: DockviewGroupPanel, + options?: + | { + skipActive?: boolean; + skipDispose?: boolean; + } + | undefined + ): DockviewGroupPanel { + const floatingGroup = this.floatingGroups.find( + (_) => _.group === group + ); + + if (floatingGroup) { + if (!options?.skipDispose) { + floatingGroup.group.dispose(); + this._groups.delete(group.id); + } + floatingGroup.dispose(); + + return floatingGroup.group; + } + + return super.doRemoveGroup(group, options); } moveGroupOrPanel( @@ -757,34 +986,44 @@ export class DockviewComponent if (sourceGroup && sourceGroup.size < 2) { const [targetParentLocation, to] = tail(targetLocation); - const sourceLocation = getGridLocation(sourceGroup.element); - const [sourceParentLocation, from] = tail(sourceLocation); - if ( - sequenceEquals(sourceParentLocation, targetParentLocation) - ) { - // special case when 'swapping' two views within same grid location - // if a group has one tab - we are essentially moving the 'group' - // which is equivalent to swapping two views in this case - this.gridview.moveView(sourceParentLocation, from, to); - } else { - // source group will become empty so delete the group - const targetGroup = this.doRemoveGroup(sourceGroup, { - skipActive: true, - skipDispose: true, - }); + const isFloating = this.floatingGroups.find( + (x) => x.group === sourceGroup + ); - // after deleting the group we need to re-evaulate the ref location - const updatedReferenceLocation = getGridLocation( - destinationGroup.element - ); - const location = getRelativeLocation( - this.gridview.orientation, - updatedReferenceLocation, - destinationTarget - ); - this.doAddGroup(targetGroup, location); + if (!isFloating) { + const sourceLocation = getGridLocation(sourceGroup.element); + const [sourceParentLocation, from] = tail(sourceLocation); + + if ( + sequenceEquals( + sourceParentLocation, + targetParentLocation + ) + ) { + // special case when 'swapping' two views within same grid location + // if a group has one tab - we are essentially moving the 'group' + // which is equivalent to swapping two views in this case + this.gridview.moveView(sourceParentLocation, from, to); + } } + + // source group will become empty so delete the group + const targetGroup = this.doRemoveGroup(sourceGroup, { + skipActive: true, + skipDispose: true, + }); + + // after deleting the group we need to re-evaulate the ref location + const updatedReferenceLocation = getGridLocation( + destinationGroup.element + ); + const location = getRelativeLocation( + this.gridview.orientation, + updatedReferenceLocation, + destinationTarget + ); + this.doAddGroup(targetGroup, location); } else { const groupItem: IDockviewPanel | undefined = sourceGroup?.model.removePanel(sourceItemId) || @@ -828,7 +1067,17 @@ export class DockviewComponent }); } } else { - this.gridview.removeView(getGridLocation(sourceGroup.element)); + const floatingGroup = this.floatingGroups.find( + (x) => x.group === sourceGroup + ); + + if (floatingGroup) { + floatingGroup.dispose(); + } else { + this.gridview.removeView( + getGridLocation(sourceGroup.element) + ); + } const referenceLocation = getGridLocation( referenceGroup.element @@ -921,7 +1170,7 @@ export class DockviewComponent private createPanel( options: AddPanelOptions, group: DockviewGroupPanel - ): IDockviewPanel { + ): DockviewPanel { const contentComponent = options.component; const tabComponent = options.tabComponent || this.options.defaultTabComponent; diff --git a/packages/dockview-core/src/dockview/dockviewFloatingGroupPanel.ts b/packages/dockview-core/src/dockview/dockviewFloatingGroupPanel.ts new file mode 100644 index 000000000..65d768c94 --- /dev/null +++ b/packages/dockview-core/src/dockview/dockviewFloatingGroupPanel.ts @@ -0,0 +1,37 @@ +import { Overlay } from '../dnd/overlay'; +import { CompositeDisposable } from '../lifecycle'; +import { DockviewGroupPanel, IDockviewGroupPanel } from './dockviewGroupPanel'; + +export interface IDockviewFloatingGroupPanel { + readonly group: IDockviewGroupPanel; + position( + bounds: Partial<{ + top: number; + left: number; + height: number; + width: number; + }> + ): void; +} + +export class DockviewFloatingGroupPanel + extends CompositeDisposable + implements IDockviewFloatingGroupPanel +{ + constructor(readonly group: DockviewGroupPanel, readonly overlay: Overlay) { + super(); + + this.addDisposables(overlay); + } + + position( + bounds: Partial<{ + top: number; + left: number; + height: number; + width: number; + }> + ): void { + this.overlay.setBounds(bounds); + } +} diff --git a/packages/dockview-core/src/dockview/dockviewGroupPanel.ts b/packages/dockview-core/src/dockview/dockviewGroupPanel.ts index b8391306c..0a1ccd52f 100644 --- a/packages/dockview-core/src/dockview/dockviewGroupPanel.ts +++ b/packages/dockview-core/src/dockview/dockviewGroupPanel.ts @@ -1,6 +1,5 @@ import { IFrameworkPart } from '../panel/types'; import { DockviewComponent } from '../dockview/dockviewComponent'; -import { GridviewPanelApi } from '../api/gridviewPanelApi'; import { DockviewGroupPanelModel, GroupOptions, @@ -9,8 +8,13 @@ import { } from './dockviewGroupPanelModel'; import { GridviewPanel, IGridviewPanel } from '../gridview/gridviewPanel'; import { IDockviewPanel } from '../dockview/dockviewPanel'; +import { + DockviewGroupPanelApi, + DockviewGroupPanelApiImpl, +} from '../api/dockviewGroupPanelApi'; -export interface IDockviewGroupPanel extends IGridviewPanel { +export interface IDockviewGroupPanel + extends IGridviewPanel { model: IDockviewGroupPanelModel; locked: boolean; readonly size: number; @@ -20,13 +24,11 @@ export interface IDockviewGroupPanel extends IGridviewPanel { export type IDockviewGroupPanelPublic = IDockviewGroupPanel; -export type DockviewGroupPanelApi = GridviewPanelApi; - export class DockviewGroupPanel - extends GridviewPanel + extends GridviewPanel implements IDockviewGroupPanel { - private readonly _model: IDockviewGroupPanelModel; + private readonly _model: DockviewGroupPanelModel; get panels(): IDockviewPanel[] { return this._model.panels; @@ -40,7 +42,7 @@ export class DockviewGroupPanel return this._model.size; } - get model(): IDockviewGroupPanelModel { + get model(): DockviewGroupPanelModel { return this._model; } @@ -61,10 +63,17 @@ export class DockviewGroupPanel id: string, options: GroupOptions ) { - super(id, 'groupview_default', { - minimumHeight: 100, - minimumWidth: 100, - }); + super( + id, + 'groupview_default', + { + minimumHeight: 100, + minimumWidth: 100, + }, + new DockviewGroupPanelApiImpl(id, accessor) + ); + + this.api.initialize(this); // cannot use 'this' after after 'super' call this._model = new DockviewGroupPanelModel( this.element, @@ -94,7 +103,6 @@ export class DockviewGroupPanel } toJSON(): any { - // TODO fix typing return this.model.toJSON(); } } diff --git a/packages/dockview-core/src/dockview/dockviewGroupPanelModel.ts b/packages/dockview-core/src/dockview/dockviewGroupPanelModel.ts index 8db0ac1b2..fb15a8895 100644 --- a/packages/dockview-core/src/dockview/dockviewGroupPanelModel.ts +++ b/packages/dockview-core/src/dockview/dockviewGroupPanelModel.ts @@ -137,6 +137,7 @@ export class DockviewGroupPanelModel private watermark?: IWatermarkRenderer; private _isGroupActive = false; private _locked = false; + private _isFloating = false; private _rightHeaderActions: IHeaderActionsRenderer | undefined; private _leftHeaderActions: IHeaderActionsRenderer | undefined; @@ -224,6 +225,24 @@ export class DockviewGroupPanelModel ); } + get isFloating(): boolean { + return this._isFloating; + } + + set isFloating(value: boolean) { + this._isFloating = value; + + this.dropTarget.setTargetZones( + value ? ['center'] : ['top', 'bottom', 'left', 'right', 'center'] + ); + + toggleClass(this.container, 'dv-groupview-floating', value); + + this.groupPanel.api._onDidFloatingStateChange.fire({ + isFloating: this.isFloating, + }); + } + constructor( private readonly container: HTMLElement, private accessor: DockviewComponent, @@ -233,7 +252,7 @@ export class DockviewGroupPanelModel ) { super(); - this.container.classList.add('groupview'); + toggleClass(this.container, 'groupview', true); this.tabsContainer = new TabsContainer(this.accessor, this.groupPanel); @@ -248,6 +267,10 @@ export class DockviewGroupPanelModel const data = getPanelData(); + if (!data && event.shiftKey && !this.isFloating) { + return false; + } + if (data && data.viewId === this.accessor.id) { if (data.groupId === this.id) { if (position === 'center') { @@ -773,6 +796,7 @@ export class DockviewGroupPanelModel public dispose(): void { super.dispose(); + this.watermark?.element.remove(); this.watermark?.dispose?.(); for (const panel of this.panels) { diff --git a/packages/dockview-core/src/dockview/dockviewPanel.ts b/packages/dockview-core/src/dockview/dockviewPanel.ts index 4f67933fc..c2d1b08dd 100644 --- a/packages/dockview-core/src/dockview/dockviewPanel.ts +++ b/packages/dockview-core/src/dockview/dockviewPanel.ts @@ -8,7 +8,7 @@ import { DockviewGroupPanel } from './dockviewGroupPanel'; import { CompositeDisposable, IDisposable } from '../lifecycle'; import { IPanel, PanelUpdateEvent, Parameters } from '../panel/types'; import { IDockviewPanelModel } from './dockviewPanelModel'; -import { IDockviewComponent } from './dockviewComponent'; +import { DockviewComponent } from './dockviewComponent'; export interface IDockviewPanel extends IDisposable, IPanel { readonly view: IDockviewPanelModel; @@ -47,7 +47,7 @@ export class DockviewPanel constructor( public readonly id: string, - accessor: IDockviewComponent, + accessor: DockviewComponent, private readonly containerApi: DockviewApi, group: DockviewGroupPanel, readonly view: IDockviewPanelModel @@ -55,7 +55,7 @@ export class DockviewPanel super(); this._group = group; - this.api = new DockviewPanelApiImpl(this, this._group); + this.api = new DockviewPanelApiImpl(this, this._group, accessor); this.addDisposables( this.api.onActiveChange(() => { diff --git a/packages/dockview-core/src/dockview/options.ts b/packages/dockview-core/src/dockview/options.ts index 147cc5e48..e7249d6c9 100644 --- a/packages/dockview-core/src/dockview/options.ts +++ b/packages/dockview-core/src/dockview/options.ts @@ -8,16 +8,14 @@ import { IWatermarkRenderer, DockviewDropTargets, } from './types'; -import { - DockviewGroupPanel, - DockviewGroupPanelApi, -} from './dockviewGroupPanel'; +import { DockviewGroupPanel } from './dockviewGroupPanel'; import { ISplitviewStyles, Orientation } from '../splitview/splitview'; import { PanelTransfer } from '../dnd/dataTransfer'; import { IDisposable } from '../lifecycle'; import { Position } from '../dnd/droptarget'; import { IDockviewPanel } from './dockviewPanel'; import { FrameworkFactory } from '../panel/componentFactory'; +import { DockviewGroupPanelApi } from '../api/dockviewGroupPanelApi'; export interface IHeaderActionsRenderer extends IDisposable { readonly element: HTMLElement; @@ -87,6 +85,7 @@ export interface DockviewComponentOptions extends DockviewRenderFunctions { ) => IHeaderActionsRenderer; singleTabMode?: 'fullwidth' | 'default'; parentElement?: HTMLElement; + disableFloatingGroups?: boolean; } export interface PanelOptions { @@ -134,12 +133,32 @@ export function isPanelOptionsWithGroup( return false; } -export interface AddPanelOptions - extends Omit { +type AddPanelFloatingGroupUnion = { + floating: + | { + height?: number; + width?: number; + x?: number; + y?: number; + } + | true; + position: never; +}; + +type AddPanelPositionUnion = { + floating: false | never; + position: AddPanelPositionOptions; +}; + +type AddPanelOptionsUnion = AddPanelFloatingGroupUnion | AddPanelPositionUnion; + +export type AddPanelOptions = Omit< + PanelOptions, + 'component' | 'tabComponent' +> & { component: string; tabComponent?: string; - position?: AddPanelPositionOptions; -} +} & Partial; type AddGroupOptionsWithPanel = { referencePanel: string | IDockviewPanel; diff --git a/packages/dockview-core/src/dom.ts b/packages/dockview-core/src/dom.ts index 4a36f4bde..a12b50742 100644 --- a/packages/dockview-core/src/dom.ts +++ b/packages/dockview-core/src/dom.ts @@ -1,5 +1,5 @@ import { - Event, + Event as DockviewEvent, Emitter, addDisposableListener, addDisposableWindowListener, @@ -87,8 +87,8 @@ export function getElementsByTagName(tag: string): HTMLElement[] { } export interface IFocusTracker extends IDisposable { - readonly onDidFocus: Event; - readonly onDidBlur: Event; + readonly onDidFocus: DockviewEvent; + readonly onDidBlur: DockviewEvent; refreshState?(): void; } @@ -101,10 +101,10 @@ export function trackFocus(element: HTMLElement | Window): IFocusTracker { */ class FocusTracker extends CompositeDisposable implements IFocusTracker { private readonly _onDidFocus = new Emitter(); - public readonly onDidFocus: Event = this._onDidFocus.event; + public readonly onDidFocus: DockviewEvent = this._onDidFocus.event; private readonly _onDidBlur = new Emitter(); - public readonly onDidBlur: Event = this._onDidBlur.event; + public readonly onDidBlur: DockviewEvent = this._onDidBlur.event; private _refreshStateHandler: () => void; @@ -172,3 +172,16 @@ class FocusTracker extends CompositeDisposable implements IFocusTracker { this._refreshStateHandler(); } } + +// quasi: apparently, but not really; seemingly +const QUASI_PREVENT_DEFAULT_KEY = 'dv-quasiPreventDefault'; + +// mark an event directly for other listeners to check +export function quasiPreventDefault(event: Event): void { + (event as any)[QUASI_PREVENT_DEFAULT_KEY] = true; +} + +// check if this event has been marked +export function quasiDefaultPrevented(event: Event): boolean { + return (event as any)[QUASI_PREVENT_DEFAULT_KEY]; +} diff --git a/packages/dockview-core/src/events.ts b/packages/dockview-core/src/events.ts index cb8d95930..9474ad317 100644 --- a/packages/dockview-core/src/events.ts +++ b/packages/dockview-core/src/events.ts @@ -74,7 +74,7 @@ export class Emitter implements IDisposable { static ENABLE_TRACKING = false; static readonly MEMORY_LEAK_WATCHER = new LeakageMonitor(); - static setLeakageMonitorEnabled(isEnabled: boolean) { + static setLeakageMonitorEnabled(isEnabled: boolean): void { if (isEnabled !== Emitter.ENABLE_TRACKING) { Emitter.MEMORY_LEAK_WATCHER.clear(); } diff --git a/packages/dockview-core/src/gridview/branchNode.ts b/packages/dockview-core/src/gridview/branchNode.ts index 6295b3f88..ab5507128 100644 --- a/packages/dockview-core/src/gridview/branchNode.ts +++ b/packages/dockview-core/src/gridview/branchNode.ts @@ -149,7 +149,7 @@ export class BranchNode extends CompositeDisposable implements IView { : true, }; }), - size: this.size, + size: this.orthogonalSize, }; this.children = childDescriptors.map((c) => c.node); @@ -235,7 +235,7 @@ export class BranchNode extends CompositeDisposable implements IView { this._size = orthogonalSize; this._orthogonalSize = size; - this.splitview.layout(this.size, this.orthogonalSize); + this.splitview.layout(orthogonalSize, size); } public addChild( diff --git a/packages/dockview-core/src/gridview/gridview.ts b/packages/dockview-core/src/gridview/gridview.ts index cd49a5624..f4f1d35eb 100644 --- a/packages/dockview-core/src/gridview/gridview.ts +++ b/packages/dockview-core/src/gridview/gridview.ts @@ -400,8 +400,9 @@ export class Gridview implements IDisposable { orientation, this.proportionalLayout, this.styles, - orthogonalSize, // <- size - flips at each depth node.size, // <- orthogonal size - flips at each depth + orthogonalSize, // <- size - flips at each depth + children ); } else { @@ -455,7 +456,9 @@ export class Gridview implements IDisposable { this.root.size ); - if (oldRoot.children.length === 1) { + if (oldRoot.children.length === 0) { + // no data so no need to add anything back in + } else if (oldRoot.children.length === 1) { // can remove one level of redundant branching if there is only a single child const childReference = oldRoot.children[0]; const child = oldRoot.removeChild(0); // remove to prevent disposal when disposing of unwanted root diff --git a/packages/dockview-core/src/gridview/gridviewComponent.ts b/packages/dockview-core/src/gridview/gridviewComponent.ts index 40dea53aa..12729b599 100644 --- a/packages/dockview-core/src/gridview/gridviewComponent.ts +++ b/packages/dockview-core/src/gridview/gridviewComponent.ts @@ -219,7 +219,7 @@ export class GridviewComponent }, }); - this.layout(width, height); + this.layout(width, height, true); queue.forEach((f) => f()); diff --git a/packages/dockview-core/src/gridview/gridviewPanel.ts b/packages/dockview-core/src/gridview/gridviewPanel.ts index 9562bb92b..b35758287 100644 --- a/packages/dockview-core/src/gridview/gridviewPanel.ts +++ b/packages/dockview-core/src/gridview/gridviewPanel.ts @@ -28,8 +28,8 @@ export interface GridviewInitParameters extends PanelInitParameters { isVisible?: boolean; } -export interface IGridviewPanel - extends BasePanelViewExported { +export interface IGridviewPanel + extends BasePanelViewExported { readonly minimumWidth: number; readonly maximumWidth: number; readonly minimumHeight: number; @@ -38,8 +38,10 @@ export interface IGridviewPanel readonly snap: boolean; } -export abstract class GridviewPanel - extends BasePanelView +export abstract class GridviewPanel< + T extends GridviewPanelApiImpl = GridviewPanelApiImpl + > + extends BasePanelView implements IGridPanelComponentView, IGridviewPanel { private _evaluatedMinimumWidth = 0; @@ -134,9 +136,10 @@ export abstract class GridviewPanel maximumWidth?: number; minimumHeight?: number; maximumHeight?: number; - } + }, + api?: T ) { - super(id, component, new GridviewPanelApiImpl(id)); + super(id, component, api ?? new GridviewPanelApiImpl(id)); if (typeof options?.minimumWidth === 'number') { this._minimumWidth = options.minimumWidth; diff --git a/packages/dockview-core/src/index.ts b/packages/dockview-core/src/index.ts index 35aec243e..be2f51877 100644 --- a/packages/dockview-core/src/index.ts +++ b/packages/dockview-core/src/index.ts @@ -1,7 +1,5 @@ export * from './dnd/dataTransfer'; -export { watchElementResize } from './dom'; - /** * Events, Emitters and Disposables are very common concepts that most codebases will contain. * We export them with a 'Dockview' prefix here to prevent accidental use by others. @@ -71,6 +69,10 @@ export { SplitviewPanelApi, } from './api/splitviewPanelApi'; export { ExpansionEvent, PaneviewPanelApi } from './api/paneviewPanelApi'; +export { + DockviewGroupPanelApi, + DockviewGroupPanelFloatingChangeEvent, +} from './api/dockviewGroupPanelApi'; export { CommonApi, SplitviewApi, diff --git a/packages/dockview-core/src/math.ts b/packages/dockview-core/src/math.ts index 4ee363902..1e4f7e1e9 100644 --- a/packages/dockview-core/src/math.ts +++ b/packages/dockview-core/src/math.ts @@ -5,7 +5,7 @@ export const clamp = (value: number, min: number, max: number): number => { return Math.min(max, Math.max(value, min)); }; -export const sequentialNumberGenerator = () => { +export const sequentialNumberGenerator = (): { next: () => string } => { let value = 1; return { next: () => (value++).toString() }; }; diff --git a/packages/dockview-core/src/splitview/splitview.scss b/packages/dockview-core/src/splitview/splitview.scss index 1f0df7ca9..df3380a89 100644 --- a/packages/dockview-core/src/splitview/splitview.scss +++ b/packages/dockview-core/src/splitview/splitview.scss @@ -1,3 +1,24 @@ +.dv-debug { + .split-view-container { + .sash-container { + .sash { + &.enabled { + background-color: black; + } + &.disabled { + background-color: orange; + } + &.maximum { + background-color: green; + } + &.minimum { + background-color: red; + } + } + } + } +} + .split-view-container { position: relative; overflow: hidden; @@ -12,22 +33,6 @@ } } - // debug - // .sash { - // &.enabled { - // background-color: black; - // } - // &.disabled { - // background-color: orange; - // } - // &.maximum { - // background-color: green; - // } - // &.minimum { - // background-color: red; - // } - // } - &.horizontal { height: 100%; diff --git a/packages/dockview-core/src/theme.scss b/packages/dockview-core/src/theme.scss index daa0ffaef..6a10a1a35 100644 --- a/packages/dockview-core/src/theme.scss +++ b/packages/dockview-core/src/theme.scss @@ -7,6 +7,7 @@ --dv-drag-over-border-color: white; --dv-tabs-container-scrollbar-color: #888; --dv-icon-hover-background-color: rgba(90, 93, 94, 0.31); + --dv-floating-box-shadow: 8px 8px 8px 0px rgba(83, 89, 93, 0.5); } @mixin dockview-theme-dark-mixin { @@ -225,3 +226,124 @@ .dockview-theme-dracula { @include dockview-theme-dracula-mixin(); } + +@mixin dockview-design-replit-mixin { + &.dv-dockview { + padding: 3px; + } + + .view:has(> .groupview) { + padding: 3px; + } + + .dv-resize-container:has(> .groupview) { + border-radius: 8px; + } + + .groupview { + overflow: hidden; + border-radius: 10px; + + .tabs-and-actions-container { + .tab { + margin: 4px; + border-radius: 8px; + + .dockview-svg { + height: 8px; + width: 8px; + } + + &:hover { + background-color: #e4e5e6 !important; + } + } + border-bottom: 1px solid rgba(128, 128, 128, 0.35); + } + + .content-container { + background-color: #fcfcfc; + } + + &.active-group { + border: 1px solid rgba(128, 128, 128, 0.35); + } + + &.inactive-group { + border: 1px solid transparent; + } + } + + .vertical > .sash-container > .sash { + &::after { + content: ''; + height: 4px; + width: 40px; + border-radius: 2px; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background-color: var(--dv-separator-handle-background-color); + position: absolute; + } + + &:hover { + &::after { + background-color: var( + --dv-separator-handle-hover-background-color + ); + } + } + } + + .horizontal > .sash-container > .sash { + &::after { + content: ''; + height: 40px; + width: 4px; + border-radius: 2px; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background-color: var(--dv-separator-handle-background-color); + position: absolute; + } + + &:hover { + &::after { + background-color: var( + --dv-separator-handle-hover-background-color + ); + } + } + } +} + +.dockview-theme-replit { + @include dockview-theme-core-mixin(); + @include dockview-design-replit-mixin(); + // + --dv-group-view-background-color: #ebeced; + // + --dv-tabs-and-actions-container-background-color: #fcfcfc; + // + --dv-activegroup-visiblepanel-tab-background-color: #f0f1f2; + --dv-activegroup-hiddenpanel-tab-background-color: ##fcfcfc; + --dv-inactivegroup-visiblepanel-tab-background-color: #f0f1f2; + --dv-inactivegroup-hiddenpanel-tab-background-color: #fcfcfc; + --dv-tab-divider-color: transparent; + // + --dv-activegroup-visiblepanel-tab-color: rgb(51, 51, 51); + --dv-activegroup-hiddenpanel-tab-color: rgb(51, 51, 51); + --dv-inactivegroup-visiblepanel-tab-color: rgb(51, 51, 51); + --dv-inactivegroup-hiddenpanel-tab-color: rgb(51, 51, 51); + // + --dv-separator-border: transparent; + --dv-paneview-header-border-color: rgb(51, 51, 51); + + --dv-background-color: #ebeced; + + ///// + --dv-separator-handle-background-color: #cfd1d3; + --dv-separator-handle-hover-background-color: #babbbb; +} diff --git a/packages/docs/docs/components/dockview.mdx b/packages/docs/docs/components/dockview.mdx index d2215db7f..3f2f0f51c 100644 --- a/packages/docs/docs/components/dockview.mdx +++ b/packages/docs/docs/components/dockview.mdx @@ -28,6 +28,7 @@ import DockviewExternalDnd from '@site/sandboxes/externaldnd-dockview/src/app'; import DockviewResizeContainer from '@site/sandboxes/resizecontainer-dockview/src/app'; import DockviewTabheight from '@site/sandboxes/tabheight-dockview/src/app'; import DockviewWithIFrames from '@site/sandboxes/iframe-dockview/src/app'; +import DockviewFloating from '@site/sandboxes/floatinggroup-dockview/src/app'; import { attach as attachDockviewVanilla } from '@site/sandboxes/javascript/vanilla-dockview/src/app'; import { attach as attachSimpleDockview } from '@site/sandboxes/javascript/simple-dockview/src/app'; @@ -361,6 +362,33 @@ any drag and drop logic for other controls. +## Floating Groups + +Dockview has built-in support for floating groups. Each floating container can contain a single group with many panels +and you can have as many floating containers as needed. You cannot dock multiple groups together in the same floating container. + +Floating groups can be interacted with whilst holding the `shift` key activating the `event.shiftKey` boolean property on `KeyboardEvent` events. + +> Float an existing tab by holding `shift` whilst interacting with the tab + + + +> Move a floating tab by holding `shift` whilst moving the cursor or dragging the empty +> header space + + + +> Move an entire floating group by holding `shift` whilst dragging the empty header space + + + +Floating groups can be programatically added through the dockview `api` method `api.addFloatingGroup(...)` and you can check whether +a group is floating via the `group.api.isFloating` property. See examples for full code. + + + + + ## Panels ### Add Panel @@ -471,6 +499,21 @@ panel.api.updateParameters({ }); ``` +### Move panel + +You can programatically move a panel using the panel `api`. + +```ts +panel.api.moveTo({ group, position, index }); +``` + +An equivalent method for moving groups is avaliable on the group `api`. + +```ts +const group = panel.api.group; +group.api.moveTo({ group, position }); +``` + ### Panel Rendering By default `DockviewReact` only adds to the DOM those panels that are visible, diff --git a/packages/docs/docusaurus.config.js b/packages/docs/docusaurus.config.js index fc5450b1b..4c770d792 100644 --- a/packages/docs/docusaurus.config.js +++ b/packages/docs/docusaurus.config.js @@ -39,13 +39,15 @@ const config = { 'docusaurus-plugin-sass', (context, options) => { return { - name: 'webpack', + name: 'custom-webpack', configureWebpack: (config, isServer, utils) => { return { // externals: ['react', 'react-dom'], devtool: 'source-map', resolve: { + ...config.resolve, alias: { + ...config.resolve.alias, react: path.join( __dirname, '../../node_modules', @@ -57,9 +59,6 @@ const config = { 'react-dom' ), }, - fallback: { - timers: false, - }, }, }; }, diff --git a/packages/docs/sandboxes/demo-dockview/src/app.tsx b/packages/docs/sandboxes/demo-dockview/src/app.tsx index 5ed911053..7461fe1a1 100644 --- a/packages/docs/sandboxes/demo-dockview/src/app.tsx +++ b/packages/docs/sandboxes/demo-dockview/src/app.tsx @@ -191,7 +191,7 @@ const LeftControls = (props: IDockviewHeaderActionsProps) => { ); }; -const DockviewDemo = () => { +const DockviewDemo = (props: { theme?: string }) => { const onReady = (event: DockviewReadyEvent) => { event.api.addPanel({ id: 'panel_1', @@ -249,7 +249,7 @@ const DockviewDemo = () => { rightHeaderActionsComponent={RightControls} leftHeaderActionsComponent={LeftControls} onReady={onReady} - className="dockview-theme-abyss" + className={props.theme || 'dockview-theme-abyss'} /> ); }; diff --git a/packages/docs/sandboxes/floatinggroup-dockview/package.json b/packages/docs/sandboxes/floatinggroup-dockview/package.json new file mode 100644 index 000000000..9ce09597c --- /dev/null +++ b/packages/docs/sandboxes/floatinggroup-dockview/package.json @@ -0,0 +1,32 @@ +{ + "name": "floatinggroup-dockview", + "description": "", + "keywords": [ + "dockview" + ], + "version": "1.0.0", + "main": "src/index.tsx", + "dependencies": { + "dockview": "*", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@types/react": "^18.0.28", + "@types/react-dom": "^18.0.11", + "typescript": "^4.9.5", + "react-scripts": "*" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test --env=jsdom", + "eject": "react-scripts eject" + }, + "browserslist": [ + ">0.2%", + "not dead", + "not ie <= 11", + "not op_mini all" + ] +} diff --git a/packages/docs/sandboxes/floatinggroup-dockview/public/index.html b/packages/docs/sandboxes/floatinggroup-dockview/public/index.html new file mode 100644 index 000000000..1f8a52426 --- /dev/null +++ b/packages/docs/sandboxes/floatinggroup-dockview/public/index.html @@ -0,0 +1,44 @@ + + + + + + + + + + + + + React App + + + + +
+ + + + diff --git a/packages/docs/sandboxes/floatinggroup-dockview/src/app.tsx b/packages/docs/sandboxes/floatinggroup-dockview/src/app.tsx new file mode 100644 index 000000000..6dc1c553a --- /dev/null +++ b/packages/docs/sandboxes/floatinggroup-dockview/src/app.tsx @@ -0,0 +1,266 @@ +import { + DockviewApi, + DockviewGroupPanel, + DockviewReact, + DockviewReadyEvent, + IDockviewHeaderActionsProps, + IDockviewPanelProps, + SerializedDockview, +} from 'dockview'; +import * as React from 'react'; +import { Icon } from './utils'; + +const components = { + default: (props: IDockviewPanelProps<{ title: string }>) => { + return ( +
+ {props.params.title} +
+ ); + }, +}; + +const counter = (() => { + let i = 0; + + return { + next: () => ++i, + }; +})(); + +function loadDefaultLayout(api: DockviewApi) { + api.addPanel({ + id: 'panel_1', + component: 'default', + }); + + api.addPanel({ + id: 'panel_2', + component: 'default', + }); + + api.addPanel({ + id: 'panel_3', + component: 'default', + }); + + const panel4 = api.addPanel({ + id: 'panel_4', + component: 'default', + floating: true, + }); + + api.addPanel({ + id: 'panel_5', + component: 'default', + floating: false, + position: { referencePanel: panel4 }, + }); + + api.addPanel({ + id: 'panel_6', + component: 'default', + }); +} + +let panelCount = 0; + +function addPanel(api: DockviewApi) { + api.addPanel({ + id: (++panelCount).toString(), + title: `Tab ${panelCount}`, + component: 'default', + }); +} + +function addFloatingPanel2(api: DockviewApi) { + api.addPanel({ + id: (++panelCount).toString(), + title: `Tab ${panelCount}`, + component: 'default', + floating: { width: 250, height: 150, x: 50, y: 50 }, + }); +} + +function safeParse(value: any): T | null { + try { + return JSON.parse(value) as T; + } catch (err) { + return null; + } +} + +const useLocalStorage = ( + key: string +): [T | null, (setter: T | null) => void] => { + const [state, setState] = React.useState( + safeParse(localStorage.getItem(key)) + ); + + React.useEffect(() => { + const _state = localStorage.getItem('key'); + try { + if (_state !== null) { + setState(JSON.parse(_state)); + } + } catch (err) { + // + } + }, [key]); + + return [ + state, + (_state: T | null) => { + if (_state === null) { + localStorage.removeItem(key); + } else { + localStorage.setItem(key, JSON.stringify(_state)); + setState(_state); + } + }, + ]; +}; + +export const DockviewPersistance = () => { + const [api, setApi] = React.useState(); + const [layout, setLayout] = + useLocalStorage('floating.layout'); + + const load = (api: DockviewApi) => { + api.clear(); + if (layout) { + try { + api.fromJSON(layout); + } catch (err) { + console.error(err); + api.clear(); + loadDefaultLayout(api); + } + } else { + loadDefaultLayout(api); + } + }; + + const onReady = (event: DockviewReadyEvent) => { + load(event.api); + setApi(event.api); + }; + + return ( +
+
+ + + + +
+
+ +
+
+ ); +}; + +const LeftComponent = (props: IDockviewHeaderActionsProps) => { + const onClick = () => { + addPanel(props.containerApi); + }; + return ( +
+ +
+ ); +}; + +const RightComponent = (props: IDockviewHeaderActionsProps) => { + const [floating, setFloating] = React.useState( + props.api.isFloating + ); + + React.useEffect(() => { + const disposable = props.group.api.onDidFloatingStateChange((event) => [ + setFloating(event.isFloating), + ]); + + return () => { + disposable.dispose(); + }; + }, [props.group.api]); + + const onClick = () => { + if (floating) { + const group = props.containerApi.addGroup(); + props.group.api.moveTo({ group }); + } else { + props.containerApi.addFloatingGroup(props.group); + } + }; + + return ( +
+ +
+ ); +}; + +export default DockviewPersistance; + +const Watermark = () => { + return
watermark
; +}; diff --git a/packages/docs/sandboxes/floatinggroup-dockview/src/index.tsx b/packages/docs/sandboxes/floatinggroup-dockview/src/index.tsx new file mode 100644 index 000000000..2fe1be232 --- /dev/null +++ b/packages/docs/sandboxes/floatinggroup-dockview/src/index.tsx @@ -0,0 +1,20 @@ +import { StrictMode } from 'react'; +import * as ReactDOMClient from 'react-dom/client'; +import './styles.css'; +import 'dockview/dist/styles/dockview.css'; + +import App from './app'; + +const rootElement = document.getElementById('root'); + +if (rootElement) { + const root = ReactDOMClient.createRoot(rootElement); + + root.render( + +
+ +
+
+ ); +} diff --git a/packages/docs/sandboxes/floatinggroup-dockview/src/styles.css b/packages/docs/sandboxes/floatinggroup-dockview/src/styles.css new file mode 100644 index 000000000..92b6a1b36 --- /dev/null +++ b/packages/docs/sandboxes/floatinggroup-dockview/src/styles.css @@ -0,0 +1,16 @@ +body { + margin: 0px; + color: white; + font-family: sans-serif; + text-align: center; +} + +#root { + height: 100vh; + width: 100vw; +} + +.app { + height: 100%; + +} diff --git a/packages/docs/sandboxes/floatinggroup-dockview/src/utils.tsx b/packages/docs/sandboxes/floatinggroup-dockview/src/utils.tsx new file mode 100644 index 000000000..457128d67 --- /dev/null +++ b/packages/docs/sandboxes/floatinggroup-dockview/src/utils.tsx @@ -0,0 +1,30 @@ +import * as React from 'react'; + +export const Icon = (props: { + icon: string; + title?: string; + onClick?: (event: React.MouseEvent) => void; +}) => { + return ( +
+ + {props.icon} + +
+ ); +}; diff --git a/packages/docs/sandboxes/floatinggroup-dockview/tsconfig.json b/packages/docs/sandboxes/floatinggroup-dockview/tsconfig.json new file mode 100644 index 000000000..cdc4fb5f5 --- /dev/null +++ b/packages/docs/sandboxes/floatinggroup-dockview/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "outDir": "build/dist", + "module": "esnext", + "target": "es5", + "lib": ["es6", "dom"], + "sourceMap": true, + "allowJs": true, + "jsx": "react-jsx", + "moduleResolution": "node", + "rootDir": "src", + "forceConsistentCasingInFileNames": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noImplicitAny": true, + "strictNullChecks": true + } +} diff --git a/packages/docs/sandboxes/layout-dockview/src/app.tsx b/packages/docs/sandboxes/layout-dockview/src/app.tsx index 33f1d6368..f0796befa 100644 --- a/packages/docs/sandboxes/layout-dockview/src/app.tsx +++ b/packages/docs/sandboxes/layout-dockview/src/app.tsx @@ -71,7 +71,7 @@ export const DockviewPersistance = () => { event.api.fromJSON(layout); success = true; } catch (err) { - // + console.error(err); } } diff --git a/packages/docs/sandboxes/simple-dockview/src/app.tsx b/packages/docs/sandboxes/simple-dockview/src/app.tsx index 4d1f066da..258e0a114 100644 --- a/packages/docs/sandboxes/simple-dockview/src/app.tsx +++ b/packages/docs/sandboxes/simple-dockview/src/app.tsx @@ -15,7 +15,7 @@ const components = { }, }; -export const App: React.FC = () => { +export const App: React.FC = (props: { theme?: string }) => { const onReady = (event: DockviewReadyEvent) => { const panel = event.api.addPanel({ id: 'panel_1', @@ -88,7 +88,7 @@ export const App: React.FC = () => { ); }; diff --git a/packages/docs/src/components/gridview/events.tsx b/packages/docs/src/components/gridview/events.tsx index 6917df907..9f2b0546d 100644 --- a/packages/docs/src/components/gridview/events.tsx +++ b/packages/docs/src/components/gridview/events.tsx @@ -251,8 +251,6 @@ export const EventsGridview = () => { }, }); - console.log('sdf'); - api.addPanel({ id: 'panel_4', component: 'default', diff --git a/packages/docs/src/components/ui/codeSandboxButton.tsx b/packages/docs/src/components/ui/codeSandboxButton.tsx index d536d0ad0..f17a3dec9 100644 --- a/packages/docs/src/components/ui/codeSandboxButton.tsx +++ b/packages/docs/src/components/ui/codeSandboxButton.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import './codeSandboxButton.scss'; +import { ThemePicker } from './container'; const BASE_SANDBOX_URL = 'https://codesandbox.io/s/github/mathuo/dockview/tree/master/packages/docs/sandboxes'; @@ -40,26 +41,29 @@ export const CodeSandboxButton = (props: { id: string }) => { }, [props.id]); return ( - - {`Open in `} - + + - {`Open in `} + - CodeSandbox - - - - + + CodeSandbox + + + + + ); }; diff --git a/packages/docs/src/components/ui/container.tsx b/packages/docs/src/components/ui/container.tsx index 5ad78067e..b5321c570 100644 --- a/packages/docs/src/components/ui/container.tsx +++ b/packages/docs/src/components/ui/container.tsx @@ -69,6 +69,71 @@ const JavascriptIcon = (props: { height: number; width: number }) => { ); }; +const themes = [ + 'dockview-theme-dark', + 'dockview-theme-light', + 'dockview-theme-vs', + 'dockview-theme-dracula', + 'dockview-theme-replit', +]; + +function useLocalStorageItem(key: string, defaultValue: string): string { + const [item, setItem] = React.useState( + localStorage.getItem(key) + ); + + React.useEffect(() => { + const listener = (event: StorageEvent) => { + setItem(localStorage.getItem(key)); + }; + + window.addEventListener('storage', listener); + + setItem(localStorage.getItem(key)); + + return () => { + window.removeEventListener('storage', listener); + }; + }, [key]); + + return item === null ? defaultValue : item; +} + +export const ThemePicker = () => { + const [theme, setTheme] = React.useState( + localStorage.getItem('dv-theme-class-name') || themes[0] + ); + + React.useEffect(() => { + localStorage.setItem('dv-theme-class-name', theme); + window.dispatchEvent(new StorageEvent('storage')); + }, [theme]); + + return ( +
+ {'Theme: '} + +
+ ); +}; + export const MultiFrameworkContainer = (props: { react: React.FC; typescript: (parent: HTMLElement) => { dispose: () => void }; @@ -81,6 +146,11 @@ export const MultiFrameworkContainer = (props: { const [animation, setAnimation] = React.useState(false); + const theme = useLocalStorageItem( + 'dv-theme-class-name', + 'dockview-theme-abyss' + ); + React.useEffect(() => { setAnimation(true); @@ -139,7 +209,7 @@ export const MultiFrameworkContainer = (props: { )} - {framework === 'React' && } + {framework === 'React' && }
+ + + + + + + + + + + + + + + + + + + diff --git a/packages/docs/static/img/float_group.svg b/packages/docs/static/img/float_group.svg new file mode 100644 index 000000000..fcb06299a --- /dev/null +++ b/packages/docs/static/img/float_group.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/docs/static/img/float_move.svg b/packages/docs/static/img/float_move.svg new file mode 100644 index 000000000..b8f5ccfcb --- /dev/null +++ b/packages/docs/static/img/float_move.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +