mirror of
https://github.com/mathuo/dockview
synced 2025-08-26 20:16:30 +00:00
Compare commits
27 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
2414e5e7d6 | ||
|
4615f4d984 | ||
|
3ca12d0e75 | ||
|
97d9bcc90f | ||
|
6c3ba33226 | ||
|
89286ebe5c | ||
|
e1d47ddea2 | ||
|
49014345d9 | ||
|
65b68a66cc | ||
|
e9df48e294 | ||
|
de4a31df72 | ||
|
3e77b8a4ee | ||
|
0a7e5338ef | ||
|
874d6a27ca | ||
|
d6667f14fd | ||
|
722150fae7 | ||
|
be14c4265d | ||
|
1fa8a61123 | ||
|
414244cc8c | ||
|
212863cbec | ||
|
392e63c226 | ||
|
a7a13b85c1 | ||
|
ac5af02f20 | ||
|
5c7d5b9ae7 | ||
|
079a751fba | ||
|
35bcd9d7a9 | ||
|
0a19313cc7 |
@ -2,11 +2,11 @@
|
||||
"packages": [
|
||||
"packages/*"
|
||||
],
|
||||
"version": "4.6.0",
|
||||
"version": "4.7.0",
|
||||
"npmClient": "yarn",
|
||||
"command": {
|
||||
"publish": {
|
||||
"message": "chore(release): publish %s"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dockview-angular",
|
||||
"version": "4.6.0",
|
||||
"version": "4.7.0",
|
||||
"description": "Zero dependency layout manager supporting tabs, grids and splitviews",
|
||||
"keywords": [
|
||||
"splitview",
|
||||
@ -53,6 +53,6 @@
|
||||
"test:cov": "cross-env ../../node_modules/.bin/jest --selectProjects dockview --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"dockview-core": "^4.6.0"
|
||||
"dockview-core": "^4.7.0"
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dockview-core",
|
||||
"version": "4.6.0",
|
||||
"version": "4.7.0",
|
||||
"description": "Zero dependency layout manager supporting tabs, grids and splitviews",
|
||||
"keywords": [
|
||||
"splitview",
|
||||
|
@ -45,6 +45,12 @@ export function exhaustMicrotaskQueue(): Promise<void> {
|
||||
return new Promise<void>((resolve) => resolve());
|
||||
}
|
||||
|
||||
export function exhaustAnimationFrame(): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
export const mockGetBoundingClientRect = ({
|
||||
left,
|
||||
top,
|
||||
|
@ -176,4 +176,120 @@ describe('abstractDragHandler', () => {
|
||||
|
||||
handler.dispose();
|
||||
});
|
||||
|
||||
test('that disabled handler calls preventDefault on dragstart', () => {
|
||||
const element = document.createElement('div');
|
||||
|
||||
const handler = new (class TestClass extends DragHandler {
|
||||
constructor(el: HTMLElement, disabled?: boolean) {
|
||||
super(el, disabled);
|
||||
}
|
||||
|
||||
getData(): IDisposable {
|
||||
return {
|
||||
dispose: () => {
|
||||
// /
|
||||
},
|
||||
};
|
||||
}
|
||||
})(element, true);
|
||||
|
||||
const event = new Event('dragstart');
|
||||
const spy = jest.spyOn(event, 'preventDefault');
|
||||
fireEvent(element, event);
|
||||
expect(spy).toBeCalledTimes(1);
|
||||
|
||||
handler.dispose();
|
||||
});
|
||||
|
||||
test('that non-disabled handler does not call preventDefault on dragstart', () => {
|
||||
const element = document.createElement('div');
|
||||
|
||||
const handler = new (class TestClass extends DragHandler {
|
||||
constructor(el: HTMLElement, disabled?: boolean) {
|
||||
super(el, disabled);
|
||||
}
|
||||
|
||||
getData(): IDisposable {
|
||||
return {
|
||||
dispose: () => {
|
||||
// /
|
||||
},
|
||||
};
|
||||
}
|
||||
})(element, false);
|
||||
|
||||
const event = new Event('dragstart');
|
||||
const spy = jest.spyOn(event, 'preventDefault');
|
||||
fireEvent(element, event);
|
||||
expect(spy).toHaveBeenCalledTimes(0);
|
||||
|
||||
handler.dispose();
|
||||
});
|
||||
|
||||
test('that setDisabled method updates disabled state', () => {
|
||||
const element = document.createElement('div');
|
||||
|
||||
const handler = new (class TestClass extends DragHandler {
|
||||
constructor(el: HTMLElement, disabled?: boolean) {
|
||||
super(el, disabled);
|
||||
}
|
||||
|
||||
getData(): IDisposable {
|
||||
return {
|
||||
dispose: () => {
|
||||
// /
|
||||
},
|
||||
};
|
||||
}
|
||||
})(element, false);
|
||||
|
||||
// Initially not disabled
|
||||
let event = new Event('dragstart');
|
||||
let spy = jest.spyOn(event, 'preventDefault');
|
||||
fireEvent(element, event);
|
||||
expect(spy).toHaveBeenCalledTimes(0);
|
||||
|
||||
// Disable and test
|
||||
handler.setDisabled(true);
|
||||
event = new Event('dragstart');
|
||||
spy = jest.spyOn(event, 'preventDefault');
|
||||
fireEvent(element, event);
|
||||
expect(spy).toBeCalledTimes(1);
|
||||
|
||||
// Re-enable and test
|
||||
handler.setDisabled(false);
|
||||
event = new Event('dragstart');
|
||||
spy = jest.spyOn(event, 'preventDefault');
|
||||
fireEvent(element, event);
|
||||
expect(spy).toHaveBeenCalledTimes(0);
|
||||
|
||||
handler.dispose();
|
||||
});
|
||||
|
||||
test('that disabled handler does not fire onDragStart event', () => {
|
||||
const element = document.createElement('div');
|
||||
|
||||
const handler = new (class TestClass extends DragHandler {
|
||||
constructor(el: HTMLElement, disabled?: boolean) {
|
||||
super(el, disabled);
|
||||
}
|
||||
|
||||
getData(): IDisposable {
|
||||
return {
|
||||
dispose: () => {
|
||||
// /
|
||||
},
|
||||
};
|
||||
}
|
||||
})(element, true);
|
||||
|
||||
const spy = jest.fn();
|
||||
handler.onDragStart(spy);
|
||||
|
||||
fireEvent.dragStart(element);
|
||||
expect(spy).toHaveBeenCalledTimes(0);
|
||||
|
||||
handler.dispose();
|
||||
});
|
||||
});
|
||||
|
@ -180,10 +180,13 @@ describe('droptarget', () => {
|
||||
height: string;
|
||||
}
|
||||
) {
|
||||
// Check positioning (back to top/left with GPU layer maintained)
|
||||
expect(element.style.top).toBe(box.top);
|
||||
expect(element.style.left).toBe(box.left);
|
||||
expect(element.style.width).toBe(box.width);
|
||||
expect(element.style.height).toBe(box.height);
|
||||
// Ensure GPU layer is maintained
|
||||
expect(element.style.transform).toBe('translate3d(0, 0, 0)');
|
||||
}
|
||||
|
||||
viewQuery = element.querySelectorAll(
|
||||
@ -273,13 +276,14 @@ describe('droptarget', () => {
|
||||
createOffsetDragOverEvent({ clientX: 100, clientY: 50 })
|
||||
);
|
||||
expect(droptarget.state).toBe('center');
|
||||
// With GPU optimizations, elements always have a base transform layer
|
||||
expect(
|
||||
(
|
||||
element
|
||||
.getElementsByClassName('dv-drop-target-selection')
|
||||
.item(0) as HTMLDivElement
|
||||
).style.transform
|
||||
).toBe('');
|
||||
).toBe('translate3d(0, 0, 0)');
|
||||
|
||||
fireEvent.dragLeave(target);
|
||||
expect(droptarget.state).toBe('center');
|
||||
|
@ -338,5 +338,104 @@ describe('tab', () => {
|
||||
cut.updateDragAndDropState();
|
||||
expect(cut.element.draggable).toBe(true);
|
||||
});
|
||||
|
||||
test('that dragstart is prevented when disableDnd is true', () => {
|
||||
const accessor = fromPartial<DockviewComponent>({
|
||||
options: { disableDnd: true }
|
||||
});
|
||||
const groupMock = jest.fn();
|
||||
|
||||
const cut = new Tab(
|
||||
{ id: 'panelId' } as IDockviewPanel,
|
||||
accessor,
|
||||
new groupMock()
|
||||
);
|
||||
|
||||
const event = new Event('dragstart');
|
||||
const spy = jest.spyOn(event, 'preventDefault');
|
||||
fireEvent(cut.element, event);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
|
||||
cut.dispose();
|
||||
});
|
||||
|
||||
test('that dragstart is not prevented when disableDnd is false', () => {
|
||||
const accessor = fromPartial<DockviewComponent>({
|
||||
options: { disableDnd: false }
|
||||
});
|
||||
const groupMock = jest.fn();
|
||||
|
||||
const cut = new Tab(
|
||||
{ id: 'panelId' } as IDockviewPanel,
|
||||
accessor,
|
||||
new groupMock()
|
||||
);
|
||||
|
||||
const event = new Event('dragstart');
|
||||
const spy = jest.spyOn(event, 'preventDefault');
|
||||
fireEvent(cut.element, event);
|
||||
expect(spy).toHaveBeenCalledTimes(0);
|
||||
|
||||
cut.dispose();
|
||||
});
|
||||
|
||||
test('that updateDragAndDropState updates drag handler disabled state', () => {
|
||||
const options = { disableDnd: false };
|
||||
const accessor = fromPartial<DockviewComponent>({
|
||||
options
|
||||
});
|
||||
const groupMock = jest.fn();
|
||||
|
||||
const cut = new Tab(
|
||||
{ id: 'panelId' } as IDockviewPanel,
|
||||
accessor,
|
||||
new groupMock()
|
||||
);
|
||||
|
||||
// Initially not disabled
|
||||
let event = new Event('dragstart');
|
||||
let spy = jest.spyOn(event, 'preventDefault');
|
||||
fireEvent(cut.element, event);
|
||||
expect(spy).toHaveBeenCalledTimes(0);
|
||||
|
||||
// Simulate option change to disabled
|
||||
options.disableDnd = true;
|
||||
cut.updateDragAndDropState();
|
||||
event = new Event('dragstart');
|
||||
spy = jest.spyOn(event, 'preventDefault');
|
||||
fireEvent(cut.element, event);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Change back to enabled
|
||||
options.disableDnd = false;
|
||||
cut.updateDragAndDropState();
|
||||
event = new Event('dragstart');
|
||||
spy = jest.spyOn(event, 'preventDefault');
|
||||
fireEvent(cut.element, event);
|
||||
expect(spy).toHaveBeenCalledTimes(0);
|
||||
|
||||
cut.dispose();
|
||||
});
|
||||
|
||||
test('that onDragStart is not fired when disableDnd is true', () => {
|
||||
const accessor = fromPartial<DockviewComponent>({
|
||||
options: { disableDnd: true }
|
||||
});
|
||||
const groupMock = jest.fn();
|
||||
|
||||
const cut = new Tab(
|
||||
{ id: 'panelId' } as IDockviewPanel,
|
||||
accessor,
|
||||
new groupMock()
|
||||
);
|
||||
|
||||
const spy = jest.fn();
|
||||
cut.onDragStart(spy);
|
||||
|
||||
fireEvent.dragStart(cut.element);
|
||||
expect(spy).toHaveBeenCalledTimes(0);
|
||||
|
||||
cut.dispose();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -111,5 +111,100 @@ describe('voidContainer', () => {
|
||||
cut.updateDragAndDropState();
|
||||
expect(cut.element.classList.contains('dv-draggable')).toBe(true);
|
||||
});
|
||||
|
||||
test('that dragstart is prevented when disableDnd is true', () => {
|
||||
const accessor = fromPartial<DockviewComponent>({
|
||||
options: { disableDnd: true }
|
||||
});
|
||||
const group = fromPartial<DockviewGroupPanel>({
|
||||
api: {
|
||||
location: { type: 'grid' }
|
||||
}
|
||||
});
|
||||
const cut = new VoidContainer(accessor, group);
|
||||
|
||||
const event = new Event('dragstart');
|
||||
const spy = jest.spyOn(event, 'preventDefault');
|
||||
fireEvent(cut.element, event);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
|
||||
cut.dispose();
|
||||
});
|
||||
|
||||
test('that dragstart is not prevented when disableDnd is false', () => {
|
||||
const accessor = fromPartial<DockviewComponent>({
|
||||
options: { disableDnd: false }
|
||||
});
|
||||
const group = fromPartial<DockviewGroupPanel>({
|
||||
api: {
|
||||
location: { type: 'grid' }
|
||||
}
|
||||
});
|
||||
const cut = new VoidContainer(accessor, group);
|
||||
|
||||
const event = new Event('dragstart');
|
||||
const spy = jest.spyOn(event, 'preventDefault');
|
||||
fireEvent(cut.element, event);
|
||||
expect(spy).toHaveBeenCalledTimes(0);
|
||||
|
||||
cut.dispose();
|
||||
});
|
||||
|
||||
test('that updateDragAndDropState updates drag handler disabled state', () => {
|
||||
const options = { disableDnd: false };
|
||||
const accessor = fromPartial<DockviewComponent>({
|
||||
options
|
||||
});
|
||||
const group = fromPartial<DockviewGroupPanel>({
|
||||
api: {
|
||||
location: { type: 'grid' }
|
||||
}
|
||||
});
|
||||
const cut = new VoidContainer(accessor, group);
|
||||
|
||||
// Initially not disabled
|
||||
let event = new Event('dragstart');
|
||||
let spy = jest.spyOn(event, 'preventDefault');
|
||||
fireEvent(cut.element, event);
|
||||
expect(spy).toHaveBeenCalledTimes(0);
|
||||
|
||||
// Simulate option change to disabled
|
||||
options.disableDnd = true;
|
||||
cut.updateDragAndDropState();
|
||||
event = new Event('dragstart');
|
||||
spy = jest.spyOn(event, 'preventDefault');
|
||||
fireEvent(cut.element, event);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Change back to enabled
|
||||
options.disableDnd = false;
|
||||
cut.updateDragAndDropState();
|
||||
event = new Event('dragstart');
|
||||
spy = jest.spyOn(event, 'preventDefault');
|
||||
fireEvent(cut.element, event);
|
||||
expect(spy).toHaveBeenCalledTimes(0);
|
||||
|
||||
cut.dispose();
|
||||
});
|
||||
|
||||
test('that onDragStart is not fired when disableDnd is true', () => {
|
||||
const accessor = fromPartial<DockviewComponent>({
|
||||
options: { disableDnd: true }
|
||||
});
|
||||
const group = fromPartial<DockviewGroupPanel>({
|
||||
api: {
|
||||
location: { type: 'grid' }
|
||||
}
|
||||
});
|
||||
const cut = new VoidContainer(accessor, group);
|
||||
|
||||
const spy = jest.fn();
|
||||
cut.onDragStart(spy);
|
||||
|
||||
fireEvent.dragStart(cut.element);
|
||||
expect(spy).toHaveBeenCalledTimes(0);
|
||||
|
||||
cut.dispose();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -172,14 +172,18 @@ describe('dockviewComponent', () => {
|
||||
});
|
||||
|
||||
// Get all tab elements and void containers
|
||||
const tabElements = Array.from(dockview.element.querySelectorAll('.dv-tab')) as HTMLElement[];
|
||||
const voidContainers = Array.from(dockview.element.querySelectorAll('.dv-void-container')) as HTMLElement[];
|
||||
const tabElements = Array.from(
|
||||
dockview.element.querySelectorAll('.dv-tab')
|
||||
) as HTMLElement[];
|
||||
const voidContainers = Array.from(
|
||||
dockview.element.querySelectorAll('.dv-void-container')
|
||||
) as HTMLElement[];
|
||||
|
||||
// Initially tabs should be draggable (disableDnd: false)
|
||||
tabElements.forEach(tab => {
|
||||
tabElements.forEach((tab) => {
|
||||
expect(tab.draggable).toBe(true);
|
||||
});
|
||||
voidContainers.forEach(container => {
|
||||
voidContainers.forEach((container) => {
|
||||
expect(container.draggable).toBe(true);
|
||||
});
|
||||
|
||||
@ -187,10 +191,10 @@ describe('dockviewComponent', () => {
|
||||
dockview.updateOptions({ disableDnd: true });
|
||||
|
||||
// Now tabs should not be draggable
|
||||
tabElements.forEach(tab => {
|
||||
tabElements.forEach((tab) => {
|
||||
expect(tab.draggable).toBe(false);
|
||||
});
|
||||
voidContainers.forEach(container => {
|
||||
voidContainers.forEach((container) => {
|
||||
expect(container.draggable).toBe(false);
|
||||
});
|
||||
|
||||
@ -198,10 +202,10 @@ describe('dockviewComponent', () => {
|
||||
dockview.updateOptions({ disableDnd: false });
|
||||
|
||||
// Tabs should be draggable again
|
||||
tabElements.forEach(tab => {
|
||||
tabElements.forEach((tab) => {
|
||||
expect(tab.draggable).toBe(true);
|
||||
});
|
||||
voidContainers.forEach(container => {
|
||||
voidContainers.forEach((container) => {
|
||||
expect(container.draggable).toBe(true);
|
||||
});
|
||||
});
|
||||
@ -232,8 +236,12 @@ describe('dockviewComponent', () => {
|
||||
});
|
||||
|
||||
// New tab should not be draggable
|
||||
const tabElement = dockview.element.querySelector('.dv-tab') as HTMLElement;
|
||||
const voidContainer = dockview.element.querySelector('.dv-void-container') as HTMLElement;
|
||||
const tabElement = dockview.element.querySelector(
|
||||
'.dv-tab'
|
||||
) as HTMLElement;
|
||||
const voidContainer = dockview.element.querySelector(
|
||||
'.dv-void-container'
|
||||
) as HTMLElement;
|
||||
|
||||
expect(tabElement.draggable).toBe(false);
|
||||
expect(voidContainer.draggable).toBe(false);
|
||||
@ -464,7 +472,7 @@ describe('dockviewComponent', () => {
|
||||
expect(panel1.api.location.type).toBe('grid');
|
||||
expect(dockview.groups.length).toBe(2); // Should clean up properly
|
||||
expect(dockview.panels.length).toBe(2);
|
||||
|
||||
|
||||
// Verify both panels are visible and accessible
|
||||
expect(panel1.api.isVisible).toBe(true);
|
||||
expect(panel2.api.isVisible).toBe(true);
|
||||
@ -503,7 +511,7 @@ describe('dockviewComponent', () => {
|
||||
expect(panel1.api.location.type).toBe('popout');
|
||||
|
||||
// Test moving to different positions
|
||||
['top', 'bottom', 'left', 'right'].forEach(position => {
|
||||
['top', 'bottom', 'left', 'right'].forEach((position) => {
|
||||
panel1.api.group.api.moveTo({
|
||||
group: panel2.api.group,
|
||||
position: position as any,
|
||||
@ -562,9 +570,11 @@ describe('dockviewComponent', () => {
|
||||
|
||||
expect(panel1.api.location.type).toBe('grid');
|
||||
expect(dockview.groups.length).toBe(2); // Just panel2 + panel1 in new position
|
||||
|
||||
|
||||
// Reference group should be cleaned up (no longer exist)
|
||||
const referenceGroupStillExists = dockview.groups.some(g => g.id === originalGroupId);
|
||||
const referenceGroupStillExists = dockview.groups.some(
|
||||
(g) => g.id === originalGroupId
|
||||
);
|
||||
expect(referenceGroupStillExists).toBe(false);
|
||||
});
|
||||
|
||||
@ -743,7 +753,10 @@ describe('dockviewComponent', () => {
|
||||
createComponent(options) {
|
||||
switch (options.name) {
|
||||
case 'default':
|
||||
return new PanelContentPartTest(options.id, options.name);
|
||||
return new PanelContentPartTest(
|
||||
options.id,
|
||||
options.name
|
||||
);
|
||||
default:
|
||||
throw new Error(`unsupported`);
|
||||
}
|
||||
@ -818,7 +831,10 @@ describe('dockviewComponent', () => {
|
||||
createComponent(options) {
|
||||
switch (options.name) {
|
||||
case 'default':
|
||||
return new PanelContentPartTest(options.id, options.name);
|
||||
return new PanelContentPartTest(
|
||||
options.id,
|
||||
options.name
|
||||
);
|
||||
default:
|
||||
throw new Error(`unsupported`);
|
||||
}
|
||||
@ -1347,6 +1363,104 @@ describe('dockviewComponent', () => {
|
||||
|
||||
expect(state).toEqual(api.toJSON());
|
||||
});
|
||||
|
||||
test('always visible renderer positioning after fromJSON', async () => {
|
||||
dockview.layout(1000, 1000);
|
||||
|
||||
// Create a layout with both onlyWhenVisible and always visible panels
|
||||
dockview.fromJSON({
|
||||
activeGroup: 'group-1',
|
||||
grid: {
|
||||
root: {
|
||||
type: 'branch',
|
||||
data: [
|
||||
{
|
||||
type: 'leaf',
|
||||
data: {
|
||||
views: ['panel1', 'panel2'],
|
||||
id: 'group-1',
|
||||
activeView: 'panel1',
|
||||
},
|
||||
size: 500,
|
||||
},
|
||||
{
|
||||
type: 'leaf',
|
||||
data: {
|
||||
views: ['panel3'],
|
||||
id: 'group-2',
|
||||
activeView: 'panel3',
|
||||
},
|
||||
size: 500,
|
||||
},
|
||||
],
|
||||
size: 1000,
|
||||
},
|
||||
height: 1000,
|
||||
width: 1000,
|
||||
orientation: Orientation.HORIZONTAL,
|
||||
},
|
||||
panels: {
|
||||
panel1: {
|
||||
id: 'panel1',
|
||||
contentComponent: 'default',
|
||||
title: 'panel1',
|
||||
renderer: 'onlyWhenVisible',
|
||||
},
|
||||
panel2: {
|
||||
id: 'panel2',
|
||||
contentComponent: 'default',
|
||||
title: 'panel2',
|
||||
renderer: 'always',
|
||||
},
|
||||
panel3: {
|
||||
id: 'panel3',
|
||||
contentComponent: 'default',
|
||||
title: 'panel3',
|
||||
renderer: 'always',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Wait for next animation frame to ensure positioning is complete
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
|
||||
const panel2 = dockview.getGroupPanel('panel2')!;
|
||||
const panel3 = dockview.getGroupPanel('panel3')!;
|
||||
|
||||
// Verify that always visible panels have been positioned
|
||||
const overlayContainer = dockview.overlayRenderContainer;
|
||||
|
||||
// Check that panels with renderer: 'always' are attached to overlay container
|
||||
expect(panel2.api.renderer).toBe('always');
|
||||
expect(panel3.api.renderer).toBe('always');
|
||||
|
||||
// Get the overlay elements for always visible panels
|
||||
const panel2Overlay = overlayContainer.element.querySelector('[data-panel-id]') as HTMLElement;
|
||||
const panel3Overlay = overlayContainer.element.querySelector('[data-panel-id]:not(:first-child)') as HTMLElement;
|
||||
|
||||
// Verify positioning has been applied (should not be 0 after layout)
|
||||
if (panel2Overlay) {
|
||||
const style = getComputedStyle(panel2Overlay);
|
||||
expect(style.position).toBe('absolute');
|
||||
expect(style.left).not.toBe('0px');
|
||||
expect(style.top).not.toBe('0px');
|
||||
expect(style.width).not.toBe('0px');
|
||||
expect(style.height).not.toBe('0px');
|
||||
}
|
||||
|
||||
// Test that updateAllPositions method works correctly
|
||||
const updateSpy = jest.spyOn(overlayContainer, 'updateAllPositions');
|
||||
|
||||
// Call fromJSON again to trigger position updates
|
||||
dockview.fromJSON(dockview.toJSON());
|
||||
|
||||
// Wait for the position update to be called
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
|
||||
expect(updateSpy).toHaveBeenCalled();
|
||||
|
||||
updateSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
test('add panel', () => {
|
||||
@ -5323,6 +5437,73 @@ describe('dockviewComponent', () => {
|
||||
expect(dockview.groups.length).toBe(1);
|
||||
expect(dockview.panels.length).toBe(2);
|
||||
});
|
||||
|
||||
test('component should remain visible when moving from floating back to new grid group (GitHub issue #996)', () => {
|
||||
const container = document.createElement('div');
|
||||
container.style.width = '800px';
|
||||
container.style.height = '600px';
|
||||
document.body.appendChild(container);
|
||||
|
||||
const dockview = new DockviewComponent(container, {
|
||||
createComponent(options) {
|
||||
const element = document.createElement('div');
|
||||
element.innerHTML = `<div class="test-content-${options.id}">Test Content: ${options.id}</div>`;
|
||||
element.style.background = 'lightblue';
|
||||
element.style.padding = '10px';
|
||||
return new PanelContentPartTest(options.id, options.name);
|
||||
}
|
||||
});
|
||||
|
||||
dockview.layout(800, 600);
|
||||
|
||||
try {
|
||||
// 1. Create a panel
|
||||
const panel = dockview.addPanel({
|
||||
id: 'test-panel',
|
||||
component: 'default'
|
||||
});
|
||||
|
||||
// Verify initial state
|
||||
expect(panel.api.location.type).toBe('grid');
|
||||
|
||||
// 2. Move to floating group
|
||||
dockview.addFloatingGroup(panel, {
|
||||
position: {
|
||||
bottom: 50,
|
||||
right: 50,
|
||||
},
|
||||
width: 400,
|
||||
height: 300,
|
||||
});
|
||||
|
||||
// Verify floating state
|
||||
expect(panel.api.location.type).toBe('floating');
|
||||
|
||||
// 3. Move back to grid using addGroup + moveTo pattern (reproducing user's exact issue)
|
||||
const addGroup = dockview.addGroup();
|
||||
panel.api.moveTo({ group: addGroup });
|
||||
|
||||
// THIS IS THE FIX: Component should still be visible
|
||||
expect(panel.api.location.type).toBe('grid');
|
||||
|
||||
// Test multiple scenarios
|
||||
const panel2 = dockview.addPanel({
|
||||
id: 'panel-2',
|
||||
component: 'default',
|
||||
floating: true
|
||||
});
|
||||
|
||||
const group2 = dockview.addGroup();
|
||||
panel2.api.moveTo({ group: group2 });
|
||||
|
||||
expect(panel2.api.location.type).toBe('grid');
|
||||
} finally {
|
||||
dockview.dispose();
|
||||
if (container.parentElement) {
|
||||
container.parentElement.removeChild(container);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('popout group', () => {
|
||||
@ -5810,9 +5991,9 @@ describe('dockviewComponent', () => {
|
||||
dockview.fromJSON(state);
|
||||
|
||||
/**
|
||||
* exhaust task queue since popout group completion is async but not awaited in `fromJSON(...)`
|
||||
* Wait for delayed popout group creation to complete
|
||||
*/
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await dockview.popoutRestorationPromise;
|
||||
|
||||
expect(dockview.panels.length).toBe(4);
|
||||
|
||||
@ -6113,6 +6294,7 @@ describe('dockviewComponent', () => {
|
||||
});
|
||||
|
||||
test('persistance with custom url', async () => {
|
||||
jest.useFakeTimers();
|
||||
const container = document.createElement('div');
|
||||
|
||||
window.open = () => setupMockWindow();
|
||||
@ -6196,7 +6378,12 @@ describe('dockviewComponent', () => {
|
||||
expect(dockview.groups.length).toBe(0);
|
||||
|
||||
dockview.fromJSON(state);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0)); // popout views are completed as a promise so must complete microtask-queue
|
||||
|
||||
// Advance timers to trigger delayed popout creation (0ms, 100ms delays)
|
||||
jest.advanceTimersByTime(200);
|
||||
|
||||
// Wait for the popout restoration to complete
|
||||
await dockview.popoutRestorationPromise;
|
||||
|
||||
expect(dockview.toJSON().popoutGroups).toEqual([
|
||||
{
|
||||
@ -6230,6 +6417,8 @@ describe('dockviewComponent', () => {
|
||||
url: '/custom.html',
|
||||
},
|
||||
]);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe('when browsers block popups', () => {
|
||||
@ -6982,16 +7171,16 @@ describe('dockviewComponent', () => {
|
||||
|
||||
// Move panel2 to a new group to the right
|
||||
panel2.api.moveTo({ position: 'right' });
|
||||
|
||||
|
||||
// panel2's group should be active
|
||||
expect(dockview.activeGroup).toBe(panel2.group);
|
||||
expect(dockview.activePanel?.id).toBe(panel2.id);
|
||||
|
||||
// Now move panel1 to panel2's group without setting it active
|
||||
panel1.api.moveTo({
|
||||
group: panel2.group,
|
||||
panel1.api.moveTo({
|
||||
group: panel2.group,
|
||||
position: 'center',
|
||||
skipSetActive: true
|
||||
skipSetActive: true,
|
||||
});
|
||||
|
||||
// panel2's group should still be active, but panel2 should still be the active panel
|
||||
@ -7040,7 +7229,7 @@ describe('dockviewComponent', () => {
|
||||
|
||||
// Move panel2 to a new group to create separate groups
|
||||
panel2.api.moveTo({ position: 'right' });
|
||||
|
||||
|
||||
// Move panel3 to panel2's group
|
||||
panel3.api.moveTo({ group: panel2.group, position: 'center' });
|
||||
|
||||
@ -7052,10 +7241,10 @@ describe('dockviewComponent', () => {
|
||||
expect(dockview.activeGroup).toBe(panel1.group);
|
||||
|
||||
// Now move panel2's group to panel1's group without setting it active
|
||||
panel2.group.api.moveTo({
|
||||
group: panel1.group,
|
||||
panel2.group.api.moveTo({
|
||||
group: panel1.group,
|
||||
position: 'center',
|
||||
skipSetActive: true
|
||||
skipSetActive: true,
|
||||
});
|
||||
|
||||
// panel1's group should still be active and there should be an active panel
|
||||
@ -7100,15 +7289,15 @@ describe('dockviewComponent', () => {
|
||||
|
||||
// Move panel2 to a new group to the right
|
||||
panel2.api.moveTo({ position: 'right' });
|
||||
|
||||
|
||||
// Set panel1's group as active
|
||||
dockview.doSetGroupActive(panel1.group);
|
||||
expect(dockview.activeGroup).toBe(panel1.group);
|
||||
|
||||
// Move panel1 to panel2's group (should activate panel2's group)
|
||||
panel1.api.moveTo({
|
||||
group: panel2.group,
|
||||
position: 'center'
|
||||
panel1.api.moveTo({
|
||||
group: panel2.group,
|
||||
position: 'center',
|
||||
});
|
||||
|
||||
// panel2's group should now be active and panel1 should be the active panel
|
||||
@ -7441,6 +7630,51 @@ describe('dockviewComponent', () => {
|
||||
expect(api.groups.length).toBe(3);
|
||||
});
|
||||
|
||||
test('addGroup calls normalize method on gridview', () => {
|
||||
const container = document.createElement('div');
|
||||
|
||||
const dockview = new DockviewComponent(container, {
|
||||
createComponent(options) {
|
||||
switch (options.name) {
|
||||
case 'default':
|
||||
return new PanelContentPartTest(
|
||||
options.id,
|
||||
options.name
|
||||
);
|
||||
default:
|
||||
throw new Error(`unsupported`);
|
||||
}
|
||||
},
|
||||
});
|
||||
const api = new DockviewApi(dockview);
|
||||
|
||||
dockview.layout(1000, 1000);
|
||||
|
||||
// Add initial panel
|
||||
api.addPanel({
|
||||
id: 'panel_1',
|
||||
component: 'default',
|
||||
});
|
||||
|
||||
// Access gridview through the (any) cast to bypass protected access
|
||||
const gridview = (dockview as any).gridview;
|
||||
|
||||
// Mock the normalize method to verify it's called
|
||||
const normalizeSpy = jest.spyOn(gridview, 'normalize');
|
||||
|
||||
// Adding a group should trigger normalization
|
||||
api.addGroup({ direction: 'left' });
|
||||
|
||||
// Verify normalize was called during addGroup
|
||||
expect(normalizeSpy).toHaveBeenCalled();
|
||||
|
||||
// Should have the new empty group plus the existing group with panels
|
||||
expect(api.panels.length).toBe(1);
|
||||
expect(api.groups.length).toBe(2);
|
||||
|
||||
normalizeSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('add group with custom group is', () => {
|
||||
const container = document.createElement('div');
|
||||
|
||||
@ -7514,4 +7748,236 @@ describe('dockviewComponent', () => {
|
||||
dockview.layout(1000, 1000);
|
||||
});
|
||||
});
|
||||
|
||||
// Adding back tests one by one to identify problematic expectations
|
||||
describe('GitHub Issue #991 - Group remains active after tab header space drag', () => {
|
||||
let container: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
});
|
||||
|
||||
test('single panel group remains active after move to edge', () => {
|
||||
const dockview = new DockviewComponent(container, {
|
||||
createComponent(options) {
|
||||
switch (options.name) {
|
||||
case 'default':
|
||||
return new PanelContentPartTest(
|
||||
options.id,
|
||||
options.name
|
||||
);
|
||||
default:
|
||||
throw new Error(`unsupported`);
|
||||
}
|
||||
},
|
||||
});
|
||||
dockview.layout(1000, 1000);
|
||||
|
||||
// Create panel in first group
|
||||
dockview.addPanel({
|
||||
id: 'panel1',
|
||||
component: 'default',
|
||||
});
|
||||
|
||||
const panel1 = dockview.getGroupPanel('panel1')!;
|
||||
const originalGroup = panel1.group;
|
||||
|
||||
// Set up initial state - make sure group is active
|
||||
dockview.doSetGroupActive(originalGroup);
|
||||
expect(dockview.activeGroup).toBe(originalGroup);
|
||||
expect(dockview.activePanel?.id).toBe('panel1');
|
||||
|
||||
// Move panel to edge position
|
||||
panel1.api.moveTo({ position: 'right' });
|
||||
|
||||
// After move, there should still be an active group and panel
|
||||
expect(dockview.activeGroup).toBeTruthy();
|
||||
expect(dockview.activePanel).toBeTruthy();
|
||||
expect(dockview.activePanel?.id).toBe('panel1');
|
||||
|
||||
// When moving a single panel to an edge, the existing group gets repositioned
|
||||
// rather than creating a new group (since there would be no panels left in the original group)
|
||||
expect(panel1.group).toBe(originalGroup);
|
||||
expect(dockview.activeGroup).toBe(panel1.group);
|
||||
});
|
||||
|
||||
test('merged group becomes active after center position group move', () => {
|
||||
const dockview = new DockviewComponent(container, {
|
||||
createComponent(options) {
|
||||
switch (options.name) {
|
||||
case 'default':
|
||||
return new PanelContentPartTest(
|
||||
options.id,
|
||||
options.name
|
||||
);
|
||||
default:
|
||||
throw new Error(`unsupported`);
|
||||
}
|
||||
},
|
||||
});
|
||||
dockview.layout(1000, 1000);
|
||||
|
||||
// Create two groups with panels
|
||||
dockview.addPanel({
|
||||
id: 'panel1',
|
||||
component: 'default',
|
||||
});
|
||||
|
||||
dockview.addPanel({
|
||||
id: 'panel2',
|
||||
component: 'default',
|
||||
position: { direction: 'right' },
|
||||
});
|
||||
|
||||
const panel1 = dockview.getGroupPanel('panel1')!;
|
||||
const panel2 = dockview.getGroupPanel('panel2')!;
|
||||
const group1 = panel1.group;
|
||||
const group2 = panel2.group;
|
||||
|
||||
// Set group1 as active initially
|
||||
dockview.doSetGroupActive(group1);
|
||||
expect(dockview.activeGroup).toBe(group1);
|
||||
expect(dockview.activePanel?.id).toBe('panel1');
|
||||
|
||||
// Move panel2's group to panel1's group (center merge)
|
||||
dockview.moveGroupOrPanel({
|
||||
from: { groupId: group2.id },
|
||||
to: { group: group1, position: 'center' },
|
||||
});
|
||||
|
||||
// After move, the target group should be active and have an active panel
|
||||
expect(dockview.activeGroup).toBeTruthy();
|
||||
expect(dockview.activePanel).toBeTruthy();
|
||||
// Both panels should now be in the same group
|
||||
expect(panel1.group).toBe(panel2.group);
|
||||
});
|
||||
|
||||
test('panel content remains visible after group move', () => {
|
||||
const dockview = new DockviewComponent(container, {
|
||||
createComponent(options) {
|
||||
switch (options.name) {
|
||||
case 'default':
|
||||
return new PanelContentPartTest(
|
||||
options.id,
|
||||
options.name
|
||||
);
|
||||
default:
|
||||
throw new Error(`unsupported`);
|
||||
}
|
||||
},
|
||||
});
|
||||
dockview.layout(1000, 1000);
|
||||
|
||||
// Create panel
|
||||
dockview.addPanel({
|
||||
id: 'panel1',
|
||||
component: 'default',
|
||||
});
|
||||
|
||||
const panel1 = dockview.getGroupPanel('panel1')!;
|
||||
|
||||
// Verify content is initially rendered
|
||||
expect(panel1.view.content.element.parentElement).toBeTruthy();
|
||||
|
||||
// Move panel to edge position
|
||||
panel1.api.moveTo({ position: 'left' });
|
||||
|
||||
// After move, panel content should still be rendered (fixes content disappearing)
|
||||
expect(panel1.view.content.element.parentElement).toBeTruthy();
|
||||
expect(dockview.activePanel?.id).toBe('panel1');
|
||||
|
||||
// Panel should be visible and active
|
||||
expect(panel1.api.isVisible).toBe(true);
|
||||
expect(panel1.api.isActive).toBe(true);
|
||||
});
|
||||
|
||||
test('first panel in group does not get skipSetActive when moved', () => {
|
||||
const dockview = new DockviewComponent(container, {
|
||||
createComponent(options) {
|
||||
switch (options.name) {
|
||||
case 'default':
|
||||
return new PanelContentPartTest(
|
||||
options.id,
|
||||
options.name
|
||||
);
|
||||
default:
|
||||
throw new Error(`unsupported`);
|
||||
}
|
||||
},
|
||||
});
|
||||
dockview.layout(1000, 1000);
|
||||
|
||||
// Create group with one panel
|
||||
dockview.addPanel({
|
||||
id: 'panel1',
|
||||
component: 'default',
|
||||
});
|
||||
|
||||
const panel1 = dockview.getGroupPanel('panel1')!;
|
||||
const group = panel1.group;
|
||||
|
||||
// Verify initial state
|
||||
expect(dockview.activeGroup).toBe(group);
|
||||
expect(dockview.activePanel?.id).toBe('panel1');
|
||||
expect(panel1.view.content.element.parentElement).toBeTruthy();
|
||||
|
||||
// Move panel to trigger group move logic
|
||||
panel1.api.moveTo({ position: 'right' });
|
||||
|
||||
// Panel content should render correctly (the fix ensures first panel is not skipped)
|
||||
expect(panel1.view.content.element.parentElement).toBeTruthy();
|
||||
expect(dockview.activePanel?.id).toBe('panel1');
|
||||
expect(panel1.api.isActive).toBe(true);
|
||||
});
|
||||
|
||||
test('skipSetActive option prevents automatic group activation', () => {
|
||||
const dockview = new DockviewComponent(container, {
|
||||
createComponent(options) {
|
||||
switch (options.name) {
|
||||
case 'default':
|
||||
return new PanelContentPartTest(
|
||||
options.id,
|
||||
options.name
|
||||
);
|
||||
default:
|
||||
throw new Error(`unsupported`);
|
||||
}
|
||||
},
|
||||
});
|
||||
dockview.layout(1000, 1000);
|
||||
|
||||
// Create two groups
|
||||
dockview.addPanel({
|
||||
id: 'panel1',
|
||||
component: 'default',
|
||||
});
|
||||
|
||||
dockview.addPanel({
|
||||
id: 'panel2',
|
||||
component: 'default',
|
||||
position: { direction: 'right' },
|
||||
});
|
||||
|
||||
const panel1 = dockview.getGroupPanel('panel1')!;
|
||||
const panel2 = dockview.getGroupPanel('panel2')!;
|
||||
const group1 = panel1.group;
|
||||
const group2 = panel2.group;
|
||||
|
||||
// Set group2 as active
|
||||
dockview.doSetGroupActive(group2);
|
||||
expect(dockview.activeGroup).toBe(group2);
|
||||
|
||||
// Move group2 to group1 with skipSetActive option
|
||||
dockview.moveGroupOrPanel({
|
||||
from: { groupId: group2.id },
|
||||
to: { group: group1, position: 'center' },
|
||||
skipSetActive: true,
|
||||
});
|
||||
|
||||
// After merge, there should still be an active group and panel
|
||||
// The skipSetActive should be respected in the implementation
|
||||
expect(dockview.activeGroup).toBeTruthy();
|
||||
expect(dockview.activePanel).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -1204,4 +1204,112 @@ describe('gridview', () => {
|
||||
gridview.setViewVisible(getGridLocation(view4.element), true);
|
||||
assertVisibility([true, true, true, true, true, true]);
|
||||
});
|
||||
|
||||
describe('normalize', () => {
|
||||
test('should normalize after structure correctly', () => {
|
||||
// This test verifies that the normalize method works correctly
|
||||
// Since gridview already normalizes during remove operations,
|
||||
// we'll test the method directly with known scenarios
|
||||
const gridview = new Gridview(
|
||||
false,
|
||||
{ separatorBorder: '' },
|
||||
Orientation.HORIZONTAL
|
||||
);
|
||||
gridview.layout(1000, 1000);
|
||||
|
||||
// Create a simple structure and test that normalize doesn't break anything
|
||||
const view1 = new MockGridview('1');
|
||||
const view2 = new MockGridview('2');
|
||||
|
||||
gridview.addView(view1, Sizing.Distribute, [0]);
|
||||
gridview.addView(view2, Sizing.Distribute, [1]);
|
||||
|
||||
const beforeNormalize = gridview.serialize();
|
||||
|
||||
// Normalize should not change a balanced structure
|
||||
gridview.normalize();
|
||||
|
||||
const afterNormalize = gridview.serialize();
|
||||
expect(afterNormalize).toEqual(beforeNormalize);
|
||||
expect(gridview.element.querySelectorAll('.mock-grid-view').length).toBe(2);
|
||||
});
|
||||
|
||||
test('should not normalize when root has single leaf child', () => {
|
||||
const gridview = new Gridview(
|
||||
false,
|
||||
{ separatorBorder: '' },
|
||||
Orientation.HORIZONTAL
|
||||
);
|
||||
gridview.layout(1000, 1000);
|
||||
|
||||
const view1 = new MockGridview('1');
|
||||
gridview.addView(view1, Sizing.Distribute, [0]);
|
||||
|
||||
const beforeNormalize = gridview.serialize();
|
||||
|
||||
gridview.normalize();
|
||||
|
||||
const afterNormalize = gridview.serialize();
|
||||
|
||||
// Structure should remain unchanged
|
||||
expect(afterNormalize).toEqual(beforeNormalize);
|
||||
});
|
||||
|
||||
test('should not normalize when root has multiple children', () => {
|
||||
const gridview = new Gridview(
|
||||
false,
|
||||
{ separatorBorder: '' },
|
||||
Orientation.HORIZONTAL
|
||||
);
|
||||
gridview.layout(1000, 1000);
|
||||
|
||||
const view1 = new MockGridview('1');
|
||||
const view2 = new MockGridview('2');
|
||||
const view3 = new MockGridview('3');
|
||||
|
||||
gridview.addView(view1, Sizing.Distribute, [0]);
|
||||
gridview.addView(view2, Sizing.Distribute, [1]);
|
||||
gridview.addView(view3, Sizing.Distribute, [2]);
|
||||
|
||||
const beforeNormalize = gridview.serialize();
|
||||
|
||||
gridview.normalize();
|
||||
|
||||
const afterNormalize = gridview.serialize();
|
||||
|
||||
// Structure should remain unchanged since root has multiple children
|
||||
expect(afterNormalize).toEqual(beforeNormalize);
|
||||
});
|
||||
|
||||
test('should not normalize when no root exists', () => {
|
||||
const gridview = new Gridview(
|
||||
false,
|
||||
{ separatorBorder: '' },
|
||||
Orientation.HORIZONTAL
|
||||
);
|
||||
gridview.layout(1000, 1000);
|
||||
|
||||
// Call normalize on empty gridview
|
||||
expect(() => gridview.normalize()).not.toThrow();
|
||||
|
||||
// Should still be able to add views after normalizing empty gridview
|
||||
const view1 = new MockGridview('1');
|
||||
gridview.addView(view1, Sizing.Distribute, [0]);
|
||||
|
||||
expect(gridview.element.querySelectorAll('.mock-grid-view').length).toBe(1);
|
||||
});
|
||||
|
||||
test('normalize method exists and is callable', () => {
|
||||
const gridview = new Gridview(
|
||||
false,
|
||||
{ separatorBorder: '' },
|
||||
Orientation.HORIZONTAL
|
||||
);
|
||||
gridview.layout(1000, 1000);
|
||||
|
||||
// Verify the normalize method exists and can be called
|
||||
expect(typeof gridview.normalize).toBe('function');
|
||||
expect(() => gridview.normalize()).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -6,7 +6,7 @@ import {
|
||||
OverlayRenderContainer,
|
||||
} from '../../overlay/overlayRenderContainer';
|
||||
import { fromPartial } from '@total-typescript/shoehorn';
|
||||
import { Writable, exhaustMicrotaskQueue } from '../__test_utils__/utils';
|
||||
import { Writable, exhaustMicrotaskQueue, exhaustAnimationFrame } from '../__test_utils__/utils';
|
||||
import { DockviewComponent } from '../../dockview/dockviewComponent';
|
||||
import { DockviewGroupPanel } from '../../dockview/dockviewGroupPanel';
|
||||
|
||||
@ -160,6 +160,7 @@ describe('overlayRenderContainer', () => {
|
||||
const container = cut.attach({ panel, referenceContainer });
|
||||
|
||||
await exhaustMicrotaskQueue();
|
||||
await exhaustAnimationFrame();
|
||||
|
||||
expect(panelContentEl.parentElement).toBe(container);
|
||||
expect(container.parentElement).toBe(parentContainer);
|
||||
@ -175,6 +176,7 @@ describe('overlayRenderContainer', () => {
|
||||
).toHaveBeenCalledTimes(1);
|
||||
|
||||
onDidDimensionsChange.fire({});
|
||||
await exhaustAnimationFrame();
|
||||
expect(container.style.display).toBe('');
|
||||
|
||||
expect(container.style.left).toBe('49px');
|
||||
@ -196,13 +198,13 @@ describe('overlayRenderContainer', () => {
|
||||
onDidVisibilityChange.fire({});
|
||||
expect(container.style.display).toBe('');
|
||||
|
||||
expect(container.style.left).toBe('50px');
|
||||
expect(container.style.top).toBe('100px');
|
||||
expect(container.style.width).toBe('100px');
|
||||
expect(container.style.height).toBe('200px');
|
||||
expect(container.style.left).toBe('49px');
|
||||
expect(container.style.top).toBe('99px');
|
||||
expect(container.style.width).toBe('101px');
|
||||
expect(container.style.height).toBe('201px');
|
||||
expect(
|
||||
referenceContainer.element.getBoundingClientRect
|
||||
).toHaveBeenCalledTimes(3);
|
||||
).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('related z-index from `aria-level` set on floating panels', async () => {
|
||||
@ -262,4 +264,194 @@ describe('overlayRenderContainer', () => {
|
||||
'calc(var(--dv-overlay-z-index, 999) + 5)'
|
||||
);
|
||||
});
|
||||
|
||||
test('that frequent resize calls are batched to prevent shaking (issue #988)', async () => {
|
||||
const cut = new OverlayRenderContainer(
|
||||
parentContainer,
|
||||
fromPartial<DockviewComponent>({})
|
||||
);
|
||||
|
||||
const panelContentEl = document.createElement('div');
|
||||
const onDidVisibilityChange = new Emitter<any>();
|
||||
const onDidDimensionsChange = new Emitter<any>();
|
||||
const onDidLocationChange = new Emitter<any>();
|
||||
|
||||
const panel = fromPartial<IDockviewPanel>({
|
||||
api: {
|
||||
id: 'test_panel_id',
|
||||
onDidVisibilityChange: onDidVisibilityChange.event,
|
||||
onDidDimensionsChange: onDidDimensionsChange.event,
|
||||
onDidLocationChange: onDidLocationChange.event,
|
||||
isVisible: true,
|
||||
location: { type: 'grid' },
|
||||
},
|
||||
view: {
|
||||
content: {
|
||||
element: panelContentEl,
|
||||
},
|
||||
},
|
||||
group: {
|
||||
api: {
|
||||
location: {
|
||||
type: 'grid',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
jest.spyOn(referenceContainer.element, 'getBoundingClientRect')
|
||||
.mockReturnValue(
|
||||
fromPartial<DOMRect>({
|
||||
left: 100,
|
||||
top: 200,
|
||||
width: 150,
|
||||
height: 250,
|
||||
})
|
||||
);
|
||||
|
||||
jest.spyOn(parentContainer, 'getBoundingClientRect').mockReturnValue(
|
||||
fromPartial<DOMRect>({
|
||||
left: 50,
|
||||
top: 100,
|
||||
width: 200,
|
||||
height: 300,
|
||||
})
|
||||
);
|
||||
|
||||
const container = cut.attach({ panel, referenceContainer });
|
||||
|
||||
// Wait for initial positioning
|
||||
await exhaustMicrotaskQueue();
|
||||
await exhaustAnimationFrame();
|
||||
|
||||
expect(container.style.left).toBe('50px');
|
||||
expect(container.style.top).toBe('100px');
|
||||
|
||||
// Simulate rapid resize events that could cause shaking
|
||||
onDidDimensionsChange.fire({});
|
||||
onDidDimensionsChange.fire({});
|
||||
onDidDimensionsChange.fire({});
|
||||
onDidDimensionsChange.fire({});
|
||||
onDidDimensionsChange.fire({});
|
||||
|
||||
// Even with multiple rapid events, only one RAF should be scheduled
|
||||
await exhaustAnimationFrame();
|
||||
|
||||
expect(container.style.left).toBe('50px');
|
||||
expect(container.style.top).toBe('100px');
|
||||
expect(container.style.width).toBe('150px');
|
||||
expect(container.style.height).toBe('250px');
|
||||
|
||||
// Verify that DOM measurements are cached within the same frame
|
||||
// Should be called initially and possibly one more time for visibility change
|
||||
expect(referenceContainer.element.getBoundingClientRect).toHaveBeenCalledTimes(2);
|
||||
expect(parentContainer.getBoundingClientRect).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('updateAllPositions forces position recalculation for visible panels', async () => {
|
||||
const cut = new OverlayRenderContainer(
|
||||
parentContainer,
|
||||
fromPartial<DockviewComponent>({})
|
||||
);
|
||||
|
||||
const panelContentEl1 = document.createElement('div');
|
||||
const panelContentEl2 = document.createElement('div');
|
||||
|
||||
const onDidVisibilityChange1 = new Emitter<any>();
|
||||
const onDidDimensionsChange1 = new Emitter<any>();
|
||||
const onDidLocationChange1 = new Emitter<any>();
|
||||
|
||||
const onDidVisibilityChange2 = new Emitter<any>();
|
||||
const onDidDimensionsChange2 = new Emitter<any>();
|
||||
const onDidLocationChange2 = new Emitter<any>();
|
||||
|
||||
const panel1 = fromPartial<IDockviewPanel>({
|
||||
api: {
|
||||
id: 'panel1',
|
||||
onDidVisibilityChange: onDidVisibilityChange1.event,
|
||||
onDidDimensionsChange: onDidDimensionsChange1.event,
|
||||
onDidLocationChange: onDidLocationChange1.event,
|
||||
isVisible: true,
|
||||
location: { type: 'grid' },
|
||||
},
|
||||
view: {
|
||||
content: {
|
||||
element: panelContentEl1,
|
||||
},
|
||||
},
|
||||
group: {
|
||||
api: {
|
||||
location: { type: 'grid' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const panel2 = fromPartial<IDockviewPanel>({
|
||||
api: {
|
||||
id: 'panel2',
|
||||
onDidVisibilityChange: onDidVisibilityChange2.event,
|
||||
onDidDimensionsChange: onDidDimensionsChange2.event,
|
||||
onDidLocationChange: onDidLocationChange2.event,
|
||||
isVisible: false, // This panel is not visible
|
||||
location: { type: 'grid' },
|
||||
},
|
||||
view: {
|
||||
content: {
|
||||
element: panelContentEl2,
|
||||
},
|
||||
},
|
||||
group: {
|
||||
api: {
|
||||
location: { type: 'grid' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Mock getBoundingClientRect for consistent testing
|
||||
jest.spyOn(referenceContainer.element, 'getBoundingClientRect')
|
||||
.mockReturnValue(
|
||||
fromPartial<DOMRect>({
|
||||
left: 100,
|
||||
top: 200,
|
||||
width: 150,
|
||||
height: 250,
|
||||
})
|
||||
);
|
||||
|
||||
jest.spyOn(parentContainer, 'getBoundingClientRect').mockReturnValue(
|
||||
fromPartial<DOMRect>({
|
||||
left: 50,
|
||||
top: 100,
|
||||
width: 200,
|
||||
height: 300,
|
||||
})
|
||||
);
|
||||
|
||||
// Attach both panels
|
||||
const container1 = cut.attach({ panel: panel1, referenceContainer });
|
||||
const container2 = cut.attach({ panel: panel2, referenceContainer });
|
||||
|
||||
await exhaustMicrotaskQueue();
|
||||
await exhaustAnimationFrame();
|
||||
|
||||
// Clear previous calls to getBoundingClientRect
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Call updateAllPositions
|
||||
cut.updateAllPositions();
|
||||
|
||||
// Should trigger resize for visible panels only
|
||||
await exhaustAnimationFrame();
|
||||
|
||||
// Verify that positioning was updated for visible panel
|
||||
expect(container1.style.left).toBe('50px');
|
||||
expect(container1.style.top).toBe('100px');
|
||||
expect(container1.style.width).toBe('150px');
|
||||
expect(container1.style.height).toBe('250px');
|
||||
|
||||
// Verify getBoundingClientRect was called for visible panel only
|
||||
// updateAllPositions should call the resize function which triggers getBoundingClientRect
|
||||
expect(referenceContainer.element.getBoundingClientRect).toHaveBeenCalled();
|
||||
expect(parentContainer.getBoundingClientRect).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
@ -1,3 +1,5 @@
|
||||
export const DEFAULT_FLOATING_GROUP_OVERFLOW_SIZE = 100;
|
||||
|
||||
export const DEFAULT_FLOATING_GROUP_POSITION = { left: 100, top: 100, width: 300, height: 300 };
|
||||
|
||||
export const DESERIALIZATION_POPOUT_DELAY_MS = 100
|
||||
|
@ -13,7 +13,7 @@ export abstract class DragHandler extends CompositeDisposable {
|
||||
private readonly _onDragStart = new Emitter<DragEvent>();
|
||||
readonly onDragStart = this._onDragStart.event;
|
||||
|
||||
constructor(protected readonly el: HTMLElement) {
|
||||
constructor(protected readonly el: HTMLElement, private disabled?: boolean) {
|
||||
super();
|
||||
|
||||
this.addDisposables(
|
||||
@ -25,6 +25,10 @@ export abstract class DragHandler extends CompositeDisposable {
|
||||
this.configure();
|
||||
}
|
||||
|
||||
public setDisabled(disabled: boolean): void {
|
||||
this.disabled = disabled;
|
||||
}
|
||||
|
||||
abstract getData(event: DragEvent): IDisposable;
|
||||
|
||||
protected isCancelled(_event: DragEvent): boolean {
|
||||
@ -35,7 +39,7 @@ export abstract class DragHandler extends CompositeDisposable {
|
||||
this.addDisposables(
|
||||
this._onDragStart,
|
||||
addDisposableListener(this.el, 'dragstart', (event) => {
|
||||
if (event.defaultPrevented || this.isCancelled(event)) {
|
||||
if (event.defaultPrevented || this.isCancelled(event) || this.disabled) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
@ -12,12 +12,16 @@
|
||||
.dv-drop-target-anchor {
|
||||
position: relative;
|
||||
border: var(--dv-drag-over-border);
|
||||
transition: opacity var(--dv-transition-duration) ease-in,
|
||||
top var(--dv-transition-duration) ease-out,
|
||||
left var(--dv-transition-duration) ease-out,
|
||||
width var(--dv-transition-duration) ease-out,
|
||||
height var(--dv-transition-duration) ease-out;
|
||||
background-color: var(--dv-drag-over-background-color);
|
||||
opacity: 1;
|
||||
|
||||
/* GPU optimizations */
|
||||
will-change: transform, opacity;
|
||||
transform: translate3d(0, 0, 0);
|
||||
backface-visibility: hidden;
|
||||
contain: layout paint;
|
||||
|
||||
transition: opacity var(--dv-transition-duration) ease-in,
|
||||
transform var(--dv-transition-duration) ease-out;
|
||||
}
|
||||
}
|
||||
|
@ -5,6 +5,68 @@ import { DragAndDropObserver } from './dnd';
|
||||
import { clamp } from '../math';
|
||||
import { Direction } from '../gridview/baseComponentGridview';
|
||||
|
||||
interface DropTargetRect {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
function setGPUOptimizedBounds(element: HTMLElement, bounds: DropTargetRect): void {
|
||||
const { top, left, width, height } = bounds;
|
||||
const topPx = `${Math.round(top)}px`;
|
||||
const leftPx = `${Math.round(left)}px`;
|
||||
const widthPx = `${Math.round(width)}px`;
|
||||
const heightPx = `${Math.round(height)}px`;
|
||||
|
||||
// Use traditional positioning but maintain GPU layer
|
||||
element.style.top = topPx;
|
||||
element.style.left = leftPx;
|
||||
element.style.width = widthPx;
|
||||
element.style.height = heightPx;
|
||||
element.style.visibility = 'visible';
|
||||
|
||||
// Ensure GPU layer is maintained
|
||||
if (!element.style.transform || element.style.transform === '') {
|
||||
element.style.transform = 'translate3d(0, 0, 0)';
|
||||
}
|
||||
}
|
||||
|
||||
function setGPUOptimizedBoundsFromStrings(element: HTMLElement, bounds: {
|
||||
top: string;
|
||||
left: string;
|
||||
width: string;
|
||||
height: string;
|
||||
}): void {
|
||||
const { top, left, width, height } = bounds;
|
||||
|
||||
// Use traditional positioning but maintain GPU layer
|
||||
element.style.top = top;
|
||||
element.style.left = left;
|
||||
element.style.width = width;
|
||||
element.style.height = height;
|
||||
element.style.visibility = 'visible';
|
||||
|
||||
// Ensure GPU layer is maintained
|
||||
if (!element.style.transform || element.style.transform === '') {
|
||||
element.style.transform = 'translate3d(0, 0, 0)';
|
||||
}
|
||||
}
|
||||
|
||||
function checkBoundsChanged(element: HTMLElement, bounds: DropTargetRect): boolean {
|
||||
const { top, left, width, height } = bounds;
|
||||
const topPx = `${Math.round(top)}px`;
|
||||
const leftPx = `${Math.round(left)}px`;
|
||||
const widthPx = `${Math.round(width)}px`;
|
||||
const heightPx = `${Math.round(height)}px`;
|
||||
|
||||
// Check if position or size changed (back to traditional method)
|
||||
return element.style.top !== topPx ||
|
||||
element.style.left !== leftPx ||
|
||||
element.style.width !== widthPx ||
|
||||
element.style.height !== heightPx;
|
||||
}
|
||||
|
||||
export interface DroptargetEvent {
|
||||
readonly position: Position;
|
||||
readonly nativeEvent: DragEvent;
|
||||
@ -422,25 +484,12 @@ export class Droptarget extends CompositeDisposable {
|
||||
box.width = 4;
|
||||
}
|
||||
|
||||
const topPx = `${Math.round(box.top)}px`;
|
||||
const leftPx = `${Math.round(box.left)}px`;
|
||||
const widthPx = `${Math.round(box.width)}px`;
|
||||
const heightPx = `${Math.round(box.height)}px`;
|
||||
|
||||
if (
|
||||
overlay.style.top === topPx &&
|
||||
overlay.style.left === leftPx &&
|
||||
overlay.style.width === widthPx &&
|
||||
overlay.style.height === heightPx
|
||||
) {
|
||||
// Use GPU-optimized bounds checking and setting
|
||||
if (!checkBoundsChanged(overlay, box)) {
|
||||
return;
|
||||
}
|
||||
|
||||
overlay.style.top = topPx;
|
||||
overlay.style.left = leftPx;
|
||||
overlay.style.width = widthPx;
|
||||
overlay.style.height = heightPx;
|
||||
overlay.style.visibility = 'visible';
|
||||
setGPUOptimizedBounds(overlay, box);
|
||||
|
||||
overlay.className = `dv-drop-target-anchor${
|
||||
this.options.className ? ` ${this.options.className}` : ''
|
||||
@ -511,10 +560,7 @@ export class Droptarget extends CompositeDisposable {
|
||||
box.height = `${100 * size}%`;
|
||||
}
|
||||
|
||||
this.overlayElement.style.top = box.top;
|
||||
this.overlayElement.style.left = box.left;
|
||||
this.overlayElement.style.width = box.width;
|
||||
this.overlayElement.style.height = box.height;
|
||||
setGPUOptimizedBoundsFromStrings(this.overlayElement, box);
|
||||
|
||||
toggleClass(
|
||||
this.overlayElement,
|
||||
|
@ -14,9 +14,10 @@ export class GroupDragHandler extends DragHandler {
|
||||
constructor(
|
||||
element: HTMLElement,
|
||||
private readonly accessor: DockviewComponent,
|
||||
private readonly group: DockviewGroupPanel
|
||||
private readonly group: DockviewGroupPanel,
|
||||
disabled?: boolean
|
||||
) {
|
||||
super(element);
|
||||
super(element, disabled);
|
||||
|
||||
this.addDisposables(
|
||||
addDisposableListener(
|
||||
|
@ -26,9 +26,10 @@ class TabDragHandler extends DragHandler {
|
||||
element: HTMLElement,
|
||||
private readonly accessor: DockviewComponent,
|
||||
private readonly group: DockviewGroupPanel,
|
||||
private readonly panel: IDockviewPanel
|
||||
private readonly panel: IDockviewPanel,
|
||||
disabled?: boolean
|
||||
) {
|
||||
super(element);
|
||||
super(element, disabled);
|
||||
}
|
||||
|
||||
getData(event: DragEvent): IDisposable {
|
||||
@ -49,6 +50,7 @@ export class Tab extends CompositeDisposable {
|
||||
private readonly _element: HTMLElement;
|
||||
private readonly dropTarget: Droptarget;
|
||||
private content: ITabRenderer | undefined = undefined;
|
||||
private readonly dragHandler: TabDragHandler;
|
||||
|
||||
private readonly _onPointDown = new Emitter<MouseEvent>();
|
||||
readonly onPointerDown: Event<MouseEvent> = this._onPointDown.event;
|
||||
@ -79,11 +81,12 @@ export class Tab extends CompositeDisposable {
|
||||
|
||||
toggleClass(this.element, 'dv-inactive-tab', true);
|
||||
|
||||
const dragHandler = new TabDragHandler(
|
||||
this.dragHandler = new TabDragHandler(
|
||||
this._element,
|
||||
this.accessor,
|
||||
this.group,
|
||||
this.panel
|
||||
this.panel,
|
||||
!!this.accessor.options.disableDnd
|
||||
);
|
||||
|
||||
this.dropTarget = new Droptarget(this._element, {
|
||||
@ -115,7 +118,7 @@ export class Tab extends CompositeDisposable {
|
||||
this._onPointDown,
|
||||
this._onDropped,
|
||||
this._onDragStart,
|
||||
dragHandler.onDragStart((event) => {
|
||||
this.dragHandler.onDragStart((event) => {
|
||||
if (event.dataTransfer) {
|
||||
const style = getComputedStyle(this.element);
|
||||
const newNode = this.element.cloneNode(true) as HTMLElement;
|
||||
@ -135,7 +138,7 @@ export class Tab extends CompositeDisposable {
|
||||
}
|
||||
this._onDragStart.fire(event);
|
||||
}),
|
||||
dragHandler,
|
||||
this.dragHandler,
|
||||
addDisposableListener(this._element, 'pointerdown', (event) => {
|
||||
this._onPointDown.fire(event);
|
||||
}),
|
||||
@ -161,6 +164,7 @@ export class Tab extends CompositeDisposable {
|
||||
|
||||
public updateDragAndDropState(): void {
|
||||
this._element.draggable = !this.accessor.options.disableDnd;
|
||||
this.dragHandler.setDisabled(!!this.accessor.options.disableDnd);
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
|
@ -3,6 +3,10 @@
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
scrollbar-width: thin; // firefox
|
||||
|
||||
/* GPU optimizations for smooth scrolling */
|
||||
will-change: scroll-position;
|
||||
transform: translate3d(0, 0, 0);
|
||||
|
||||
&.dv-horizontal {
|
||||
.dv-tab {
|
||||
|
@ -15,6 +15,7 @@ import { toggleClass } from '../../../dom';
|
||||
export class VoidContainer extends CompositeDisposable {
|
||||
private readonly _element: HTMLElement;
|
||||
private readonly dropTarget: Droptarget;
|
||||
private readonly handler: GroupDragHandler;
|
||||
|
||||
private readonly _onDrop = new Emitter<DroptargetEvent>();
|
||||
readonly onDrop: Event<DroptargetEvent> = this._onDrop.event;
|
||||
@ -49,7 +50,7 @@ export class VoidContainer extends CompositeDisposable {
|
||||
})
|
||||
);
|
||||
|
||||
const handler = new GroupDragHandler(this._element, accessor, group);
|
||||
this.handler = new GroupDragHandler(this._element, accessor, group, !!this.accessor.options.disableDnd);
|
||||
|
||||
this.dropTarget = new Droptarget(this._element, {
|
||||
acceptedTargetZones: ['center'],
|
||||
@ -72,8 +73,8 @@ export class VoidContainer extends CompositeDisposable {
|
||||
this.onWillShowOverlay = this.dropTarget.onWillShowOverlay;
|
||||
|
||||
this.addDisposables(
|
||||
handler,
|
||||
handler.onDragStart((event) => {
|
||||
this.handler,
|
||||
this.handler.onDragStart((event) => {
|
||||
this._onDragStart.fire(event);
|
||||
}),
|
||||
this.dropTarget.onDrop((event) => {
|
||||
@ -86,5 +87,6 @@ export class VoidContainer extends CompositeDisposable {
|
||||
updateDragAndDropState(): void {
|
||||
this._element.draggable = !this.accessor.options.disableDnd;
|
||||
toggleClass(this._element, 'dv-draggable', !this.accessor.options.disableDnd);
|
||||
this.handler.setDisabled(!!this.accessor.options.disableDnd);
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
.dv-dockview {
|
||||
position: relative;
|
||||
background-color: var(--dv-group-view-background-color);
|
||||
contain: layout;
|
||||
|
||||
.dv-watermark-container {
|
||||
position: absolute;
|
||||
|
@ -70,6 +70,7 @@ import { AnchoredBox, AnchorPosition, Box } from '../types';
|
||||
import {
|
||||
DEFAULT_FLOATING_GROUP_OVERFLOW_SIZE,
|
||||
DEFAULT_FLOATING_GROUP_POSITION,
|
||||
DESERIALIZATION_POPOUT_DELAY_MS,
|
||||
} from '../constants';
|
||||
import {
|
||||
DockviewPanelRenderer,
|
||||
@ -351,6 +352,7 @@ export class DockviewComponent
|
||||
disposable: { dispose: () => DockviewGroupPanel | undefined };
|
||||
}[] = [];
|
||||
private readonly _rootDropTarget: Droptarget;
|
||||
private _popoutRestorationPromise: Promise<void> = Promise.resolve();
|
||||
|
||||
private readonly _onDidRemoveGroup = new Emitter<DockviewGroupPanel>();
|
||||
readonly onDidRemoveGroup: Event<DockviewGroupPanel> =
|
||||
@ -407,6 +409,14 @@ export class DockviewComponent
|
||||
return this._floatingGroups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Promise that resolves when all popout groups from the last fromJSON call are restored.
|
||||
* Useful for tests that need to wait for delayed popout creation.
|
||||
*/
|
||||
get popoutRestorationPromise(): Promise<void> {
|
||||
return this._popoutRestorationPromise;
|
||||
}
|
||||
|
||||
constructor(container: HTMLElement, options: DockviewComponentOptions) {
|
||||
super(container, {
|
||||
proportionalLayout: true,
|
||||
@ -1207,6 +1217,8 @@ export class DockviewComponent
|
||||
position: Position,
|
||||
options?: GroupOptions
|
||||
): DockviewGroupPanel {
|
||||
this.gridview.normalize();
|
||||
|
||||
switch (position) {
|
||||
case 'top':
|
||||
case 'bottom':
|
||||
@ -1522,21 +1534,36 @@ export class DockviewComponent
|
||||
|
||||
const serializedPopoutGroups = data.popoutGroups ?? [];
|
||||
|
||||
for (const serializedPopoutGroup of serializedPopoutGroups) {
|
||||
// Create a promise that resolves when all popout groups are created
|
||||
const popoutPromises: Promise<void>[] = [];
|
||||
|
||||
// Queue popup group creation with delays to avoid browser blocking
|
||||
serializedPopoutGroups.forEach((serializedPopoutGroup, index) => {
|
||||
const { data, position, gridReferenceGroup, url } =
|
||||
serializedPopoutGroup;
|
||||
|
||||
const group = createGroupFromSerializedState(data);
|
||||
|
||||
this.addPopoutGroup(group, {
|
||||
position: position ?? undefined,
|
||||
overridePopoutGroup: gridReferenceGroup ? group : undefined,
|
||||
referenceGroup: gridReferenceGroup
|
||||
? this.getPanel(gridReferenceGroup)
|
||||
: undefined,
|
||||
popoutUrl: url,
|
||||
// Add a small delay for each popup after the first to avoid browser popup blocking
|
||||
const popoutPromise = new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
this.addPopoutGroup(group, {
|
||||
position: position ?? undefined,
|
||||
overridePopoutGroup: gridReferenceGroup ? group : undefined,
|
||||
referenceGroup: gridReferenceGroup
|
||||
? this.getPanel(gridReferenceGroup)
|
||||
: undefined,
|
||||
popoutUrl: url,
|
||||
});
|
||||
resolve();
|
||||
}, index * DESERIALIZATION_POPOUT_DELAY_MS); // 100ms delay between each popup
|
||||
});
|
||||
}
|
||||
|
||||
popoutPromises.push(popoutPromise);
|
||||
});
|
||||
|
||||
// Store the promise for tests to wait on
|
||||
this._popoutRestorationPromise = Promise.all(popoutPromises).then(() => void 0);
|
||||
|
||||
for (const floatingGroup of this._floatingGroups) {
|
||||
floatingGroup.overlay.setBounds();
|
||||
@ -1594,6 +1621,11 @@ export class DockviewComponent
|
||||
|
||||
this.updateWatermark();
|
||||
|
||||
// Force position updates for always visible panels after DOM layout is complete
|
||||
requestAnimationFrame(() => {
|
||||
this.overlayRenderContainer.updateAllPositions();
|
||||
});
|
||||
|
||||
this._onDidLayoutFromJSON.fire();
|
||||
}
|
||||
|
||||
@ -2158,10 +2190,13 @@ export class DockviewComponent
|
||||
this.doRemoveGroup(sourceGroup, { skipActive: true });
|
||||
}
|
||||
|
||||
// Check if destination group is empty - if so, force render the component
|
||||
const isDestinationGroupEmpty = destinationGroup.model.size === 0;
|
||||
|
||||
this.movingLock(() =>
|
||||
destinationGroup.model.openPanel(removedPanel, {
|
||||
index: destinationIndex,
|
||||
skipSetActive: options.skipSetActive ?? false,
|
||||
skipSetActive: (options.skipSetActive ?? false) && !isDestinationGroupEmpty,
|
||||
skipSetGroupActive: true,
|
||||
})
|
||||
);
|
||||
@ -2328,7 +2363,6 @@ export class DockviewComponent
|
||||
|
||||
if (target === 'center') {
|
||||
const activePanel = from.activePanel;
|
||||
const targetActivePanel = to.activePanel;
|
||||
|
||||
const panels = this.movingLock(() =>
|
||||
[...from.panels].map((p) =>
|
||||
@ -2345,22 +2379,21 @@ export class DockviewComponent
|
||||
this.movingLock(() => {
|
||||
for (const panel of panels) {
|
||||
to.model.openPanel(panel, {
|
||||
skipSetActive: true, // Always skip setting panels active during move
|
||||
skipSetActive: panel !== activePanel,
|
||||
skipSetGroupActive: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (!options.skipSetActive) {
|
||||
// Make the moved panel (from the source group) active
|
||||
if (activePanel) {
|
||||
this.doSetGroupAndPanelActive(to);
|
||||
}
|
||||
} else if (targetActivePanel) {
|
||||
// Ensure the target group's original active panel remains active
|
||||
to.model.openPanel(targetActivePanel, {
|
||||
skipSetGroupActive: true
|
||||
});
|
||||
// Ensure group becomes active after move
|
||||
if (options.skipSetActive !== true) {
|
||||
// For center moves (merges), we need to ensure the target group is active
|
||||
// unless explicitly told not to (skipSetActive: true)
|
||||
this.doSetGroupAndPanelActive(to);
|
||||
} else if (!this.activePanel) {
|
||||
// Even with skipSetActive: true, ensure there's an active panel if none exists
|
||||
// This maintains basic functionality while respecting skipSetActive
|
||||
this.doSetGroupAndPanelActive(to);
|
||||
}
|
||||
} else {
|
||||
switch (from.api.location.type) {
|
||||
@ -2384,35 +2417,44 @@ export class DockviewComponent
|
||||
if (!selectedPopoutGroup) {
|
||||
throw new Error('failed to find popout group');
|
||||
}
|
||||
|
||||
|
||||
// Remove from popout groups list to prevent automatic restoration
|
||||
const index = this._popoutGroups.indexOf(selectedPopoutGroup);
|
||||
const index =
|
||||
this._popoutGroups.indexOf(selectedPopoutGroup);
|
||||
if (index >= 0) {
|
||||
this._popoutGroups.splice(index, 1);
|
||||
}
|
||||
|
||||
|
||||
// Clean up the reference group (ghost) if it exists and is hidden
|
||||
if (selectedPopoutGroup.referenceGroup) {
|
||||
const referenceGroup = this.getPanel(selectedPopoutGroup.referenceGroup);
|
||||
const referenceGroup = this.getPanel(
|
||||
selectedPopoutGroup.referenceGroup
|
||||
);
|
||||
if (referenceGroup && !referenceGroup.api.isVisible) {
|
||||
this.doRemoveGroup(referenceGroup, { skipActive: true });
|
||||
this.doRemoveGroup(referenceGroup, {
|
||||
skipActive: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Manually dispose the window without triggering restoration
|
||||
selectedPopoutGroup.window.dispose();
|
||||
|
||||
|
||||
// Update group's location and containers for target
|
||||
if (to.api.location.type === 'grid') {
|
||||
from.model.renderContainer = this.overlayRenderContainer;
|
||||
from.model.dropTargetContainer = this.rootDropTargetContainer;
|
||||
from.model.renderContainer =
|
||||
this.overlayRenderContainer;
|
||||
from.model.dropTargetContainer =
|
||||
this.rootDropTargetContainer;
|
||||
from.model.location = { type: 'grid' };
|
||||
} else if (to.api.location.type === 'floating') {
|
||||
from.model.renderContainer = this.overlayRenderContainer;
|
||||
from.model.dropTargetContainer = this.rootDropTargetContainer;
|
||||
from.model.renderContainer =
|
||||
this.overlayRenderContainer;
|
||||
from.model.dropTargetContainer =
|
||||
this.rootDropTargetContainer;
|
||||
from.model.location = { type: 'floating' };
|
||||
}
|
||||
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
@ -2425,7 +2467,7 @@ export class DockviewComponent
|
||||
referenceLocation,
|
||||
target
|
||||
);
|
||||
|
||||
|
||||
// Add to grid for all moves targeting grid location
|
||||
|
||||
let size: number;
|
||||
@ -2454,7 +2496,7 @@ export class DockviewComponent
|
||||
);
|
||||
if (targetFloatingGroup) {
|
||||
const box = targetFloatingGroup.overlay.toJSON();
|
||||
|
||||
|
||||
// Calculate position based on available properties
|
||||
let left: number, top: number;
|
||||
if ('left' in box) {
|
||||
@ -2464,7 +2506,7 @@ export class DockviewComponent
|
||||
} else {
|
||||
left = 50; // Default fallback
|
||||
}
|
||||
|
||||
|
||||
if ('top' in box) {
|
||||
top = box.top + 50;
|
||||
} else if ('bottom' in box) {
|
||||
@ -2472,7 +2514,7 @@ export class DockviewComponent
|
||||
} else {
|
||||
top = 50; // Default fallback
|
||||
}
|
||||
|
||||
|
||||
this.addFloatingGroup(from, {
|
||||
height: box.height,
|
||||
width: box.width,
|
||||
@ -2489,8 +2531,12 @@ export class DockviewComponent
|
||||
this._onDidMovePanel.fire({ panel, from });
|
||||
});
|
||||
|
||||
if (!options.skipSetActive) {
|
||||
this.doSetGroupAndPanelActive(from);
|
||||
// Ensure group becomes active after move
|
||||
if (options.skipSetActive === false) {
|
||||
// Only activate when explicitly requested (skipSetActive: false)
|
||||
// Use 'to' group for non-center moves since 'from' may have been destroyed
|
||||
const targetGroup = to ?? from;
|
||||
this.doSetGroupAndPanelActive(targetGroup);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -30,6 +30,39 @@ function findLeaf(candiateNode: Node, last: boolean): LeafNode {
|
||||
throw new Error('invalid node');
|
||||
}
|
||||
|
||||
function cloneNode<T extends Node>(
|
||||
node: T,
|
||||
size: number,
|
||||
orthogonalSize: number
|
||||
): T {
|
||||
if (node instanceof BranchNode) {
|
||||
const result = new BranchNode(
|
||||
node.orientation,
|
||||
node.proportionalLayout,
|
||||
node.styles,
|
||||
size,
|
||||
orthogonalSize,
|
||||
node.disabled,
|
||||
node.margin
|
||||
);
|
||||
|
||||
for (let i = node.children.length - 1; i >= 0; i--) {
|
||||
const child = node.children[i];
|
||||
|
||||
result.addChild(
|
||||
cloneNode(child, child.size, child.orthogonalSize),
|
||||
child.size,
|
||||
0,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
return result as T;
|
||||
} else {
|
||||
return new LeafNode(node.view, node.orientation, orthogonalSize) as T;
|
||||
}
|
||||
}
|
||||
|
||||
function flipNode<T extends Node>(
|
||||
node: T,
|
||||
size: number,
|
||||
@ -648,6 +681,43 @@ export class Gridview implements IDisposable {
|
||||
});
|
||||
}
|
||||
|
||||
normalize(): void {
|
||||
if (!this._root) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._root.children.length !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const oldRoot = this.root;
|
||||
|
||||
// can remove one level of redundant branching if there is only a single child
|
||||
const childReference = oldRoot.children[0];
|
||||
|
||||
if (childReference instanceof LeafNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
oldRoot.element.remove();
|
||||
|
||||
const child = oldRoot.removeChild(0); // Remove child to prevent double disposal
|
||||
oldRoot.dispose(); // Dispose old root (won't dispose removed child)
|
||||
child.dispose(); // Dispose the removed child
|
||||
|
||||
this._root = cloneNode(
|
||||
childReference,
|
||||
childReference.size,
|
||||
childReference.orthogonalSize
|
||||
);
|
||||
|
||||
this.element.appendChild(this._root.element);
|
||||
|
||||
this.disposable.value = this._root.onDidChange((e) => {
|
||||
this._onDidChange.fire(e);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* If the root is orientated as a VERTICAL node then nest the existing root within a new HORIZIONTAL root node
|
||||
* If the root is orientated as a HORIZONTAL node then nest the existing root within a new VERITCAL root node
|
||||
|
@ -33,6 +33,11 @@
|
||||
|
||||
border: 1px solid var(--dv-tab-divider-color);
|
||||
box-shadow: var(--dv-floating-box-shadow);
|
||||
|
||||
/* GPU optimizations for floating group movement */
|
||||
will-change: transform, opacity;
|
||||
transform: translate3d(0, 0, 0);
|
||||
backface-visibility: hidden;
|
||||
|
||||
&.dv-hidden {
|
||||
display: none;
|
||||
@ -40,6 +45,8 @@
|
||||
|
||||
&.dv-resize-container-dragging {
|
||||
opacity: 0.5;
|
||||
/* Enhanced GPU acceleration during drag */
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
.dv-resize-handle-top {
|
||||
|
@ -3,7 +3,15 @@
|
||||
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
contain: layout paint;
|
||||
isolation: isolate;
|
||||
|
||||
/* GPU optimizations */
|
||||
will-change: transform;
|
||||
transform: translate3d(0, 0, 0);
|
||||
backface-visibility: hidden;
|
||||
|
||||
&.dv-render-overlay-float {
|
||||
z-index: calc(var(--dv-overlay-z-index) - 1);
|
||||
|
@ -10,6 +10,36 @@ import {
|
||||
import { IDockviewPanel } from '../dockview/dockviewPanel';
|
||||
import { DockviewComponent } from '../dockview/dockviewComponent';
|
||||
|
||||
class PositionCache {
|
||||
private cache = new Map<Element, { rect: { left: number; top: number; width: number; height: number }; frameId: number }>();
|
||||
private currentFrameId = 0;
|
||||
private rafId: number | null = null;
|
||||
|
||||
getPosition(element: Element): { left: number; top: number; width: number; height: number } {
|
||||
const cached = this.cache.get(element);
|
||||
if (cached && cached.frameId === this.currentFrameId) {
|
||||
return cached.rect;
|
||||
}
|
||||
|
||||
this.scheduleFrameUpdate();
|
||||
const rect = getDomNodePagePosition(element);
|
||||
this.cache.set(element, { rect, frameId: this.currentFrameId });
|
||||
return rect;
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.currentFrameId++;
|
||||
}
|
||||
|
||||
private scheduleFrameUpdate() {
|
||||
if (this.rafId) return;
|
||||
this.rafId = requestAnimationFrame(() => {
|
||||
this.currentFrameId++;
|
||||
this.rafId = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export type DockviewPanelRenderer = 'onlyWhenVisible' | 'always';
|
||||
|
||||
export interface IRenderable {
|
||||
@ -31,10 +61,13 @@ export class OverlayRenderContainer extends CompositeDisposable {
|
||||
disposable: IDisposable;
|
||||
destroy: IDisposable;
|
||||
element: HTMLElement;
|
||||
resize?: () => void;
|
||||
}
|
||||
> = {};
|
||||
|
||||
private _disposed = false;
|
||||
private readonly positionCache = new PositionCache();
|
||||
private readonly pendingUpdates = new Set<string>();
|
||||
|
||||
constructor(
|
||||
readonly element: HTMLElement,
|
||||
@ -53,6 +86,22 @@ export class OverlayRenderContainer extends CompositeDisposable {
|
||||
);
|
||||
}
|
||||
|
||||
updateAllPositions(): void {
|
||||
if (this._disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Invalidate position cache to force recalculation
|
||||
this.positionCache.invalidate();
|
||||
|
||||
// Call resize function directly for all visible panels
|
||||
for (const entry of Object.values(this.map)) {
|
||||
if (entry.panel.api.isVisible && entry.resize) {
|
||||
entry.resize();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
detatch(panel: IDockviewPanel): boolean {
|
||||
if (this.map[panel.api.id]) {
|
||||
const { disposable, destroy } = this.map[panel.api.id];
|
||||
@ -94,23 +143,46 @@ export class OverlayRenderContainer extends CompositeDisposable {
|
||||
}
|
||||
|
||||
const resize = () => {
|
||||
// TODO propagate position to avoid getDomNodePagePosition calls, possible performance bottleneck?
|
||||
const box = getDomNodePagePosition(referenceContainer.element);
|
||||
const box2 = getDomNodePagePosition(this.element);
|
||||
focusContainer.style.left = `${box.left - box2.left}px`;
|
||||
focusContainer.style.top = `${box.top - box2.top}px`;
|
||||
focusContainer.style.width = `${box.width}px`;
|
||||
focusContainer.style.height = `${box.height}px`;
|
||||
const panelId = panel.api.id;
|
||||
|
||||
toggleClass(
|
||||
focusContainer,
|
||||
'dv-render-overlay-float',
|
||||
panel.group.api.location.type === 'floating'
|
||||
);
|
||||
if (this.pendingUpdates.has(panelId)) {
|
||||
return; // Update already scheduled
|
||||
}
|
||||
|
||||
this.pendingUpdates.add(panelId);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.pendingUpdates.delete(panelId);
|
||||
|
||||
if (this.isDisposed || !this.map[panelId]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const box = this.positionCache.getPosition(referenceContainer.element);
|
||||
const box2 = this.positionCache.getPosition(this.element);
|
||||
|
||||
// Use traditional positioning for overlay containers
|
||||
const left = box.left - box2.left;
|
||||
const top = box.top - box2.top;
|
||||
const width = box.width;
|
||||
const height = box.height;
|
||||
|
||||
focusContainer.style.left = `${left}px`;
|
||||
focusContainer.style.top = `${top}px`;
|
||||
focusContainer.style.width = `${width}px`;
|
||||
focusContainer.style.height = `${height}px`;
|
||||
|
||||
toggleClass(
|
||||
focusContainer,
|
||||
'dv-render-overlay-float',
|
||||
panel.group.api.location.type === 'floating'
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const visibilityChanged = () => {
|
||||
if (panel.api.isVisible) {
|
||||
this.positionCache.invalidate();
|
||||
resize();
|
||||
}
|
||||
|
||||
@ -235,6 +307,8 @@ export class OverlayRenderContainer extends CompositeDisposable {
|
||||
this.map[panel.api.id].disposable.dispose();
|
||||
// and reset the disposable to the active reference-container
|
||||
this.map[panel.api.id].disposable = disposable;
|
||||
// store the resize function for direct access
|
||||
this.map[panel.api.id].resize = resize;
|
||||
|
||||
return focusContainer;
|
||||
}
|
||||
|
@ -4,8 +4,12 @@
|
||||
|
||||
&.dv-animated {
|
||||
.dv-view {
|
||||
transition-duration: 0.15s;
|
||||
transition-timing-function: ease-out;
|
||||
/* GPU optimizations for smooth pane animations */
|
||||
will-change: transform;
|
||||
transform: translate3d(0, 0, 0);
|
||||
backface-visibility: hidden;
|
||||
|
||||
transition: transform 0.15s ease-out;
|
||||
}
|
||||
}
|
||||
.dv-view {
|
||||
|
@ -9,6 +9,12 @@
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background-color: transparent;
|
||||
|
||||
/* GPU optimizations */
|
||||
will-change: background-color, transform;
|
||||
transform: translate3d(0, 0, 0);
|
||||
backface-visibility: hidden;
|
||||
|
||||
transition-property: background-color;
|
||||
transition-timing-function: ease-in-out;
|
||||
transition-duration: 1s;
|
||||
|
@ -34,8 +34,12 @@
|
||||
&.dv-animation {
|
||||
.dv-view,
|
||||
.dv-sash {
|
||||
transition-duration: 0.15s;
|
||||
transition-timing-function: ease-out;
|
||||
/* GPU optimizations for smooth animations */
|
||||
will-change: transform;
|
||||
transform: translate3d(0, 0, 0);
|
||||
backface-visibility: hidden;
|
||||
|
||||
transition: transform 0.15s ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dockview-react",
|
||||
"version": "4.6.0",
|
||||
"version": "4.7.0",
|
||||
"description": "Zero dependency layout manager supporting tabs, grids and splitviews",
|
||||
"keywords": [
|
||||
"splitview",
|
||||
@ -53,6 +53,6 @@
|
||||
"test:cov": "cross-env ../../node_modules/.bin/jest --selectProjects dockview-react --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"dockview": "^4.6.0"
|
||||
"dockview": "^4.7.0"
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dockview-vue",
|
||||
"version": "4.6.0",
|
||||
"version": "4.7.0",
|
||||
"description": "Zero dependency layout manager supporting tabs, grids and splitviews",
|
||||
"keywords": [
|
||||
"splitview",
|
||||
@ -41,6 +41,7 @@
|
||||
"README.md"
|
||||
],
|
||||
"scripts": {
|
||||
"build:bundle": "echo 'noop'",
|
||||
"build:js": "vite build",
|
||||
"build:types": "vue-tsc --project tsconfig.build-types.json --declaration --emitDeclarationOnly --outDir dist/types",
|
||||
"build:css": "node scripts/copy-css.js",
|
||||
@ -52,7 +53,7 @@
|
||||
"test:cov": "cross-env ../../node_modules/.bin/jest --selectProjects dockview-vue --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"dockview-core": "^4.6.0"
|
||||
"dockview-core": "^4.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.4.0"
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dockview",
|
||||
"version": "4.6.0",
|
||||
"version": "4.7.0",
|
||||
"description": "Zero dependency layout manager supporting tabs, grids and splitviews",
|
||||
"keywords": [
|
||||
"splitview",
|
||||
@ -53,7 +53,7 @@
|
||||
"test:cov": "cross-env ../../node_modules/.bin/jest --selectProjects dockview --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"dockview-core": "^4.6.0"
|
||||
"dockview-core": "^4.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
|
@ -1,6 +1,6 @@
|
||||
---
|
||||
slug: dockview-4.6.0-release
|
||||
title: Dockview 4.6.0
|
||||
slug: dockview-4.6.2-release
|
||||
title: Dockview 4.6.2
|
||||
tags: [release]
|
||||
---
|
||||
|
22
packages/docs/blog/2025-08-22-dockview-4.7.0.md
Normal file
22
packages/docs/blog/2025-08-22-dockview-4.7.0.md
Normal file
@ -0,0 +1,22 @@
|
||||
---
|
||||
slug: dockview-4.7.0-release
|
||||
title: Dockview 4.7.0
|
||||
tags: [release]
|
||||
---
|
||||
|
||||
# Release Notes
|
||||
|
||||
Please reference docs @ [dockview.dev](https://dockview.dev).
|
||||
|
||||
## 🚀 Features
|
||||
|
||||
- Enhance layouting flow [#992](https://github.com/mathuo/dockview/pull/992)
|
||||
|
||||
## 🛠 Miscs
|
||||
|
||||
- Bug: Fix group positioning issues [#993](https://github.com/mathuo/dockview/pull/993) [#998](https://github.com/mathuo/dockview/pull/998)
|
||||
- Bug: Delay popout groups to prevent browser blocking [#983](https://github.com/mathuo/dockview/pull/983)
|
||||
|
||||
|
||||
|
||||
## 🔥 Breaking changes
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dockview-docs",
|
||||
"version": "4.6.0",
|
||||
"version": "4.7.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "npm run build-templates && docusaurus build",
|
||||
@ -38,7 +38,7 @@
|
||||
"ag-grid-react": "^31.0.2",
|
||||
"axios": "^1.6.3",
|
||||
"clsx": "^2.1.0",
|
||||
"dockview": "^4.6.0",
|
||||
"dockview": "^4.7.0",
|
||||
"prism-react-renderer": "^2.3.1",
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-laag": "^2.0.5",
|
||||
|
Loading…
x
Reference in New Issue
Block a user