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
3 changes: 3 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.

68 changes: 68 additions & 0 deletions packages/dockview-core/src/__tests__/dnd/droptarget.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,74 @@ describe('droptarget', () => {
expect(element.querySelector('.dv-drop-target-dropzone')).toBeNull();
});

describe('positionResolver', () => {
const ALL: Position[] = ['left', 'right', 'top', 'bottom', 'center'];

test('overrides the default quadrant and receives the pointer args', () => {
const calls: any[] = [];
droptarget = new Droptarget(element, {
canDisplayOverlay: () => true,
acceptedTargetZones: ALL,
getPositionResolver: () => ({
resolve: (args) => {
calls.push(args);
return { position: 'right' };
},
}),
});

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

// 100,50 within 200x100 is the centre by default — the resolver wins.
expect(droptarget.state).toBe('right');
expect(calls[0]).toMatchObject({
x: 100,
y: 50,
width: 200,
height: 100,
});
expect(calls[0].zones.has('center')).toBe(true);
});

test('a null result shows no drop target', () => {
droptarget = new Droptarget(element, {
canDisplayOverlay: () => true,
acceptedTargetZones: ['center'],
getPositionResolver: () => ({ resolve: () => null }),
});

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

expect(
element.querySelector('.dv-drop-target-dropzone')
).toBeNull();
expect(droptarget.state).toBeUndefined();
});

test('absent → the default quadrant is unchanged', () => {
droptarget = new Droptarget(element, {
canDisplayOverlay: () => true,
acceptedTargetZones: ['left', 'center'],
});

fireEvent.dragEnter(element);
fireEvent(
element,
createOffsetDragOverEvent({ clientX: 2, clientY: 50 })
);
// x=2 of width 200 is inside the 20% left band.
expect(droptarget.state).toBe('left');
});
});

test('directionToPosition', () => {
expect(directionToPosition('above')).toBe('top');
expect(directionToPosition('below')).toBe('bottom');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,4 +211,59 @@
target.dispose();
document.body.removeChild(element);
});

function mountedElement(): HTMLElement {
const element = document.createElement('div');
document.body.appendChild(element);
jest.spyOn(element, 'offsetWidth', 'get').mockReturnValue(100);
jest.spyOn(element, 'offsetHeight', 'get').mockReturnValue(40);
jest.spyOn(element, 'getBoundingClientRect').mockReturnValue({
top: 0,
left: 0,
right: 100,
bottom: 40,
width: 100,
height: 40,
x: 0,
y: 0,
toJSON: () => ({}),
});
return element;
}

test('positionResolver overrides the default quadrant', () => {
const element = mountedElement();
const target = new PointerDropTarget(element, {
acceptedTargetZones: ['left', 'right', 'center'],
canDisplayOverlay: () => true,
// 10,20 would be 'left' by default — the resolver forces 'right'.
getPositionResolver: () => ({
resolve: () => ({ position: 'right' }),
}),
});

(target as any)._onDragOver(makeDragEvent(10, 20));
expect(target.state).toBe('right');

target.dispose();
document.body.removeChild(element);
});

test('a null positionResolver result shows no overlay', () => {
const element = mountedElement();
const target = new PointerDropTarget(element, {
acceptedTargetZones: ['left', 'right', 'center'],
canDisplayOverlay: () => true,
getPositionResolver: () => ({ resolve: () => null }),
});

(target as any)._onDragOver(makeDragEvent(10, 20));
expect(
element.getElementsByClassName('dv-drop-target-dropzone').length
).toBe(0);

Check warning on line 263 in packages/dockview-core/src/__tests__/dnd/pointer/pointerDropTarget.spec.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer a more specific assertion instead of this generic one, e.g. "expect(element.getElementsByClassName('dv-drop-target-dropzone')).toHaveLength(0)".

See more on https://sonarcloud.io/project/issues?id=mathuo_dockview&issues=AZ8Kz7RvOTjgnHXEGSWg&open=AZ8Kz7RvOTjgnHXEGSWg&pullRequest=1382
expect(target.state).toBeUndefined();

target.dispose();
document.body.removeChild(element);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { IDockviewPanelModel } from '../../../../dockview/dockviewPanelModel';
import { DockviewComponent } from '../../../../dockview/dockviewComponent';
import { fromPartial } from '@total-typescript/shoehorn';
import { DockviewGroupPanelModel } from '../../../../dockview/dockviewGroupPanelModel';
import { createOffsetDragOverEvent } from '../../../__test_utils__/utils';
import { OverlayRenderContainer } from '../../../../overlay/overlayRenderContainer';

class TestContentRenderer
Expand Down Expand Up @@ -252,4 +253,32 @@ describe('contentContainer', () => {
expect(panel2.view.content.element.parentElement).toBe(cut.element);
expect(cut.element.childNodes.length).toBe(1);
});

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

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

jest.spyOn(cut.element, 'offsetWidth', 'get').mockReturnValue(200);
jest.spyOn(cut.element, 'offsetHeight', 'get').mockReturnValue(100);

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

// 100,50 in 200x100 is the centre by default; the option forces right.
expect(cut.dropTarget.state).toBe('right');

cut.dispose();
});
});
83 changes: 76 additions & 7 deletions packages/dockview-core/src/dnd/droptarget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,40 @@ export function positionToDirection(position: Position): Direction {

export type Position = 'top' | 'bottom' | 'left' | 'right' | 'center';

/** The pointer location within a drop target, handed to a {@link PositionResolver}. */
export interface PositionResolverArgs {
/** Pointer X within the target element (px from its left edge). */
readonly x: number;
/** Pointer Y within the target element (px from its top edge). */
readonly y: number;
readonly width: number;
readonly height: number;
/** The drop zones this target currently accepts. */
readonly zones: ReadonlySet<Position>;
/** The originating drag event (HTML5 or pointer backend). */
readonly event: DragEvent | PointerEvent;
}

export interface PositionResolverResult {
readonly position: Position;
/**
* Marks an outer / whole-layout-edge cell. The built-in drop overlay only
* reads `position`; consumers that route edge cells differently can read this.
*/
readonly edge?: boolean;
}

/**
* Pluggable replacement for the built-in cursor-quadrant drop resolution.
* Supplied via {@link DroptargetOptions.positionResolver}, it maps a pointer
* location within a target to a drop {@link Position} — or `null` for no drop —
* instead of the default threshold-band quadrant. Both DnD backends consult the
* same resolver. Unset ⇒ the default quadrant behaviour, byte-for-byte unchanged.
*/
export interface PositionResolver {
resolve(args: PositionResolverArgs): PositionResolverResult | null;
}

export type CanDisplayOverlay = (
dragEvent: DragEvent | PointerEvent,
state: Position
Expand Down Expand Up @@ -121,6 +155,13 @@ export interface DroptargetOptions {
getOverrideTarget?: () => DropTargetTargetModel | undefined;
className?: string;
getOverlayOutline?: () => HTMLElement | null;
/**
* Supply a {@link PositionResolver} that overrides how a pointer location
* resolves to a drop {@link Position}. A lazy getter (like
* {@link getOverrideTarget}) so the source can change at runtime; returning
* `undefined` — the default — uses the built-in cursor-quadrant logic.
*/
getPositionResolver?: () => PositionResolver | undefined;
}

/**
Expand Down Expand Up @@ -216,13 +257,7 @@ export class Droptarget extends CompositeDisposable implements IDropTarget {
const x = (e.clientX ?? 0) - rect.left;
const y = (e.clientY ?? 0) - rect.top;

const quadrant = this.calculateQuadrant(
this._acceptedTargetZonesSet,
x,
y,
width,
height
);
const quadrant = this.resolvePosition(x, y, width, height, e);

/**
* If the event has already been used by another DropTarget instance
Expand Down Expand Up @@ -389,6 +424,40 @@ export class Droptarget extends CompositeDisposable implements IDropTarget {
);
}

/**
* Resolve the drop {@link Position} for a pointer location: defer to an
* injected {@link PositionResolver} when present, otherwise the built-in
* cursor-quadrant logic (unchanged).
*/
private resolvePosition(
x: number,
y: number,
width: number,
height: number,
event: DragEvent | PointerEvent
): Position | null {
const resolver = this.options.getPositionResolver?.();
if (resolver) {
return (
resolver.resolve({
x,
y,
width,
height,
zones: this._acceptedTargetZonesSet,
event,
})?.position ?? null
);
}
return this.calculateQuadrant(
this._acceptedTargetZonesSet,
x,
y,
width,
height
);
}

private calculateQuadrant(
overlayType: Set<Position>,
x: number,
Expand Down
44 changes: 43 additions & 1 deletion packages/dockview-core/src/dnd/pointer/pointerDropTarget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
IDropTarget,
MeasuredValue,
Position,
PositionResolver,
WillShowOverlayEvent,
} from '../droptarget';
import { PointerDragController } from './pointerDragController';
Expand All @@ -34,6 +35,12 @@ export interface PointerDropTargetOptions {
/** Outline element for positioning; falls back to the drop element. */
getOverlayOutline?: () => HTMLElement | null;
className?: string;
/**
* Supply a {@link PositionResolver} that overrides how a pointer location
* resolves to a drop {@link Position}. A lazy getter so the source can
* change at runtime; `undefined` (default) uses the cursor-quadrant logic.
*/
getPositionResolver?: () => PositionResolver | undefined;
}

/** Pointer-driven counterpart to `Droptarget` with identical visual output. */
Expand Down Expand Up @@ -133,7 +140,13 @@ export class PointerDropTarget
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;

const quadrant = this._calculateQuadrant(x, y, width, height);
const quadrant = this._resolvePosition(
x,
y,
width,
height,
event.pointerEvent
);

if (quadrant === null) {
this._removeOverlay();
Expand Down Expand Up @@ -218,6 +231,35 @@ export class PointerDropTarget
}
}

/**
* Resolve the drop {@link Position}: defer to an injected
* {@link PositionResolver} when present, otherwise the built-in
* cursor-quadrant logic (unchanged). Shares the resolver with the HTML5
* backend.
*/
private _resolvePosition(
x: number,
y: number,
width: number,
height: number,
event: DragEvent | PointerEvent
): Position | null {
const resolver = this.options.getPositionResolver?.();
if (resolver) {
return (
resolver.resolve({
x,
y,
width,
height,
zones: this._acceptedTargetZonesSet,
event,
})?.position ?? null
);
}
return this._calculateQuadrant(x, y, width, height);
}

private _calculateQuadrant(
x: number,
y: number,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export class ContentContainer
canDisplayOverlay,
getOverrideTarget,
overlayModel: this.accessor.resolveDropOverlayModel?.('content'),
getPositionResolver: () => accessor.options.dropPositionResolver,
});

this.pointerDropTarget = pointerBackend.createDropTarget(this.element, {
Expand All @@ -135,6 +136,7 @@ export class ContentContainer
className: 'dv-drop-target-content',
getOverrideTarget,
overlayModel: this.accessor.resolveDropOverlayModel?.('content'),
getPositionResolver: () => accessor.options.dropPositionResolver,
});

this.addDisposables(
Expand Down
Loading
Loading