Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions __generated__/dockview-core-exports.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 19 additions & 0 deletions e2e/fixtures/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@
// A served (not about:blank) target avoids a 404 when the
// popout window navigates before dockview injects content.
popoutUrl: '/e2e/fixtures/popout.html',
// Drop Guide ("compass") + pointer DnD so a panel drag is
// drivable in the harness. Inert unless a drag occurs.
dndGuide: true,
dndStrategy: 'pointer',
createComponent: (options) => {
const element = document.createElement('div');
element.className = 'dv-test-panel';
Expand Down Expand Up @@ -119,6 +123,21 @@
redo: () => dockview.redo(),
canUndo: () => dockview.canUndo,
awaitPopoutRestore: () => dockview.popoutRestorationPromise,
// Two side-by-side groups for Drop Guide: dragging the left
// group's tab over the right group shows the compass.
setupDropGuide: () => {
dockview.addPanel({
id: 'left',
component: 'default',
title: 'left',
});
dockview.addPanel({
id: 'right',
component: 'default',
title: 'right',
position: { direction: 'right' },
});
},
floatingCount: () => dockview.floatingGroups.length,
peekEdge: (position, on) =>
dockview.api.peekEdgeGroup(position, on),
Expand Down
181 changes: 181 additions & 0 deletions e2e/tests/drop-guide.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { test, expect } from '@playwright/test';

/**
* Drop Guide ("compass") — the aim-at-a-cell drop overlay. Real-browser only:
* it reads the live drop-target geometry the drag loop works in, which jsdom
* (no layout) can't produce. The harness uses the pointer DnD backend so a
* panel drag is drivable.
*/
test.describe('drop guide (compass)', () => {
const setup = async (page) => {
await page.goto('/e2e/fixtures/index.html');
await page.waitForFunction(() => (window as any).__ready === true);
await page.evaluate(() => (window as any).__dv.setupDropGuide());
};

const rightContent = (page) =>
page.locator('.dv-content-container', {
has: page.locator('.dv-test-panel', { hasText: 'right' }),
});

test('dragging a tab over a group shows the compass and a cell-drop docks', async ({
page,
}) => {
await setup(page);
expect(
await page.evaluate(() => (window as any).__dv.groupCount())
).toBe(2);

const leftTab = page.locator('.dv-tab', { hasText: 'left' });
const tab = (await leftTab.boundingBox())!;
const content = (await rightContent(page).boundingBox())!;
const cx = content.x + content.width / 2;
const cy = content.y + content.height / 2;

await page.mouse.move(tab.x + tab.width / 2, tab.y + tab.height / 2);
await page.mouse.down();
// nudge to start the drag, then move onto the right group's centre
await page.mouse.move(
tab.x + tab.width / 2 + 6,
tab.y + tab.height / 2
);
await page.mouse.move(cx, cy, { steps: 20 });

// the compass cross is painted over the hovered group (5 inner + 4 outer)
await expect(page.locator('.dv-drop-guide')).toBeVisible();
expect(await page.locator('.dv-drop-guide-cell').count()).toBe(9);
expect(await page.locator('.dv-drop-guide-cell-edge').count()).toBe(4);

// drop on the centre cell → merge into the right group
await page.mouse.up();

// the two groups collapsed into one (left panel tabbed into right)
await expect
.poll(() => page.evaluate(() => (window as any).__dv.groupCount()))
.toBe(1);
// ...and the compass is gone
await expect(page.locator('.dv-drop-guide')).toHaveCount(0);
});

test('the compass holds position from an inner to an outer cell', async ({
page,
}) => {
await setup(page);
const tab = (await page
.locator('.dv-tab', { hasText: 'left' })
.boundingBox())!;
const content = (await rightContent(page).boundingBox())!;
const cx = content.x + content.width / 2;
const cy = content.y + content.height / 2;

await page.mouse.move(tab.x + tab.width / 2, tab.y + tab.height / 2);
await page.mouse.down();
await page.mouse.move(
tab.x + tab.width / 2 + 6,
tab.y + tab.height / 2
);

const centre = rightContent(page).locator('.dv-drop-guide-cell-center');

// hover the centre (inner) cell, record where the cross sits
await page.mouse.move(cx, cy, { steps: 20 });
await expect(centre).toBeVisible();
const atInner = (await centre.boundingBox())!;

// sweep out to the far outer cell — the cross must not move (the drop
// target removing its `.dv-drop-target` class once made it re-anchor)
await page.mouse.move(cx + 84, cy, { steps: 10 });
// guard the regression is actually exercised: confirm we reached an
// outer (edge) cell, the only path that removes `.dv-drop-target`
await expect(
page.locator('.dv-drop-guide-cell-edge.dv-drop-guide-cell-active')
).toBeVisible();
const atOuter = (await centre.boundingBox())!;

expect(atOuter.x).toBeCloseTo(atInner.x, 0);
expect(atOuter.y).toBeCloseTo(atInner.y, 0);

await page.mouse.up();
});

test('feedback clears when the cursor moves into a dead zone', async ({
page,
}) => {
await setup(page);
const tab = (await page
.locator('.dv-tab', { hasText: 'left' })
.boundingBox())!;
const content = (await rightContent(page).boundingBox())!;
const cx = content.x + content.width / 2;
const cy = content.y + content.height / 2;
const band = page.locator('.dv-drop-guide-edge-preview');

await page.mouse.move(tab.x + tab.width / 2, tab.y + tab.height / 2);
await page.mouse.down();
await page.mouse.move(
tab.x + tab.width / 2 + 6,
tab.y + tab.height / 2
);

await page.mouse.move(cx + 84, cy, { steps: 10 }); // outer-right cell
await expect(band).toBeVisible();

// move far off the cross (still inside the group) — feedback must clear
await page.mouse.move(cx + 250, cy + 200, { steps: 10 });
await expect(band).toHaveCount(0);
await expect(page.locator('.dv-drop-guide-cell-active')).toHaveCount(0);

// ...and return when back on the cell
await page.mouse.move(cx + 84, cy, { steps: 10 });
await expect(band).toBeVisible();

await page.mouse.up();
});

test('dropping on an outer cell docks against the whole layout (not a merge)', async ({
page,
}) => {
await setup(page);

const leftTab = page.locator('.dv-tab', { hasText: 'left' });
const tab = (await leftTab.boundingBox())!;
const content = (await rightContent(page).boundingBox())!;
const cx = content.x + content.width / 2;
const cy = content.y + content.height / 2;
// the outer ring sits ~2 cells (CELL 38 + GAP 4 = 42 → 84px) out
const outerRightX = cx + 84;

await page.mouse.move(tab.x + tab.width / 2, tab.y + tab.height / 2);
await page.mouse.down();
await page.mouse.move(
tab.x + tab.width / 2 + 6,
tab.y + tab.height / 2
);
await page.mouse.move(cx, cy, { steps: 15 });
await expect(page.locator('.dv-drop-guide')).toBeVisible();
// aim at the outer-right cell → it lights up + the layout-edge region
// is previewed
await page.mouse.move(outerRightX, cy, { steps: 6 });
await expect(
page.locator('.dv-drop-guide-cell-edge.dv-drop-guide-cell-active')
).toBeVisible();
await expect(page.locator('.dv-drop-guide-edge-preview')).toBeVisible();
await page.mouse.up();

// the left panel docked to the layout's right edge as its OWN group —
// still two groups (a centre-cell drop would have merged to one).
await expect
.poll(() => page.evaluate(() => (window as any).__dv.groupCount()))
.toBe(2);
// ...and it actually moved to that edge: the 'left' tab, which started
// left of 'right', is now to its right (a no-op dock would leave it left).
const leftAfter = (await page
.locator('.dv-tab', { hasText: 'left' })
.boundingBox())!;
const rightAfter = (await page
.locator('.dv-tab', { hasText: 'right' })
.boundingBox())!;
expect(leftAfter.x).toBeGreaterThan(rightAfter.x);
await expect(page.locator('.dv-drop-guide')).toHaveCount(0);
});
});
31 changes: 31 additions & 0 deletions packages/dockview-core/src/__tests__/dnd/droptarget.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,37 @@ describe('droptarget', () => {
expect(calls[0].zones.has('center')).toBe(true);
});

test('an edge cell reports edge + renders no overlay', () => {
let dropped: { position: Position; edge?: boolean } | undefined;
droptarget = new Droptarget(element, {
canDisplayOverlay: () => true,
acceptedTargetZones: ALL,
getPositionResolver: () => ({
resolve: () => ({ position: 'top', edge: true }),
}),
});
droptarget.onDrop((e) => {
dropped = e;
});

fireEvent.dragEnter(element);
fireEvent(
element,
createOffsetDragOverEvent({ clientX: 100, clientY: 50 })
);

// no group overlay is drawn for an edge cell...
expect(
element.querySelector('.dv-drop-target-dropzone')
).toBeNull();
// ...but the position is still reported on drop, flagged edge
expect(droptarget.state).toBe('top');
fireEvent.drop(element);
expect(dropped).toEqual(
expect.objectContaining({ position: 'top', edge: true })
);
});

test('a null result shows no drop target', () => {
droptarget = new Droptarget(element, {
canDisplayOverlay: () => true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -254,16 +254,17 @@ describe('contentContainer', () => {
expect(cut.element.childNodes.length).toBe(1);
});

test('the dropPositionResolver option drives the content drop target', () => {
test('the resolved drop-position resolver drives the content drop target', () => {
const resolver = { resolve: () => ({ position: 'right' as const }) };

const cut = new ContentContainer(
fromPartial<DockviewComponent>({
options: { dropPositionResolver: resolver },
getDropPositionResolver: () => resolver,
options: {},
resolveDropOverlayModel: () => undefined,
}),
fromPartial<DockviewGroupPanelModel>({
canDisplayOverlay: () => true,
canDisplayContentOverlay: () => true,
})
);

Expand Down
43 changes: 43 additions & 0 deletions packages/dockview-core/src/dnd/droptarget.scss
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,46 @@
}
}
}

// Drop Guide ("compass") — the aim-at-a-cell cross painted over a group's
// content while dragging. The module positions + sizes the cells inline; only
// the theming lives here (override the CSS vars to restyle).
.dv-drop-guide {
z-index: 1001;
}

.dv-drop-guide-cell {
box-sizing: border-box;
border-radius: 2px;
border: 1px solid var(--dv-drop-guide-color, #1f9cf0);
background-color: var(--dv-drop-guide-cell-color, rgba(31, 156, 240, 0.25));
}

.dv-drop-guide-cell-edge {
// Outer "dock to the whole layout" cells read distinct from the inner ring.
border-style: dashed;
background-color: var(
--dv-drop-guide-edge-cell-color,
rgba(31, 156, 240, 0.12)
);
}

.dv-drop-guide-cell-active {
// The cell the cursor is aiming at — the hover feedback, especially for the
// outer cells (the drop target draws no overlay for them).
background-color: var(
--dv-drop-guide-active-cell-color,
rgba(31, 156, 240, 0.5)
);
border-style: solid;
}

.dv-drop-guide-edge-preview {
// The whole-layout-edge region an outer cell docks into. Reuses the real
// drop-overlay theme variables so it reads identically to any other drop
// preview; sits beneath the compass cross (z below `.dv-drop-guide`).
z-index: 1000;
box-sizing: border-box;
background-color: var(--dv-drag-over-background-color);
border: var(--dv-drag-over-border);
}
Loading
Loading