Skip to content
Open
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
149 changes: 149 additions & 0 deletions packages/widgets/src/docklayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import { Layout, LayoutItem } from './layout';

import { TabBar } from './tabbar';

import { INTERSECTION_TOLERANCE_MULTIPLIER } from './intersectionutils';

import Utils from './utils';

import { Widget } from './widget';
Expand Down Expand Up @@ -253,6 +255,153 @@ export class DockLayout extends Layout {
}
}

/**
* Find the handle that intersects orthogonally with the given handle at
* the specified client position.
*
* @param handle - The primary handle that was pressed.
*
* @param clientX - The clientX of the pointer event.
*
* @param clientY - The clientY of the pointer event.
*
* @returns The intersecting handle, or `null` if none exists.
*
* #### Notes
* This is used to detect intersection points where two orthogonal split
* handles meet, enabling simultaneous two-axis resizing.
*/
findIntersectingHandle(
handle: HTMLDivElement,
clientX: number,
clientY: number
): HTMLDivElement | null {
if (!this._root) {
return null;
}

// Determine the primary handle's orientation.
const primaryOrientation = handle.getAttribute('data-orientation');

// Iterate all handles looking for one with the opposite orientation
// whose bounding rect (expanded by spacing) contains the click point.
//
// We expand by spacing because child handles stop exactly at the edge of
// the parent handle's bounding rect — they don't overlap. For example, in
// a 2×2 grid the horizontal child handles end at the top of the root
// vertical handle, leaving a gap equal to the spacing. Expanding the
// candidate rect closes that gap. A multiplier of 4 gives a comfortable
// hit area without accidentally activating distant handles.
const tol = this._spacing * INTERSECTION_TOLERANCE_MULTIPLIER;
for (const candidate of this.handles()) {
if (candidate === handle) {
continue;
}
if (candidate.classList.contains('lm-mod-hidden')) {
continue;
}
if (candidate.getAttribute('data-orientation') === primaryOrientation) {
continue;
}
const rect = candidate.getBoundingClientRect();

// Expand the candidate rect in the primary handle's movement axis so
// the gap between handle regions is bridged:
// 'vertical' primary (ns-resize, horizontal bar) → expand Y bounds
// 'horizontal' primary (ew-resize, vertical bar) → expand X bounds
let left = rect.left,
right = rect.right;
let top = rect.top,
bottom = rect.bottom;
if (primaryOrientation === 'vertical') {
top -= tol;
bottom += tol;
} else {
left -= tol;
right += tol;
}

if (
clientX >= left &&
clientX <= right &&
clientY >= top &&
clientY <= bottom
) {
return candidate;
}
}

return null;
}

/**
* Move two handles simultaneously to their given offset positions.
*
* @param handle1 - The first handle to move.
*
* @param offsetX1 - The desired offset X position for the first handle.
*
* @param offsetY1 - The desired offset Y position for the first handle.
*
* @param handle2 - The second handle to move.
*
* @param offsetX2 - The desired offset X position for the second handle.
*
* @param offsetY2 - The desired offset Y position for the second handle.
*
* #### Notes
* Both handles are adjusted before a single layout update is triggered,
* avoiding a double reflow. This is used for two-axis resizing at
* handle intersection points.
*/
moveHandles(
handle1: HTMLDivElement,
offsetX1: number,
offsetY1: number,
handle2: HTMLDivElement,
offsetX2: number,
offsetY2: number
): void {
if (!this._root) {
return;
}

// Adjust the first handle.
if (!handle1.classList.contains('lm-mod-hidden')) {
const data1 = this._root.findSplitNode(handle1);
if (data1) {
const delta1 =
data1.node.orientation === 'horizontal'
? offsetX1 - handle1.offsetLeft
: offsetY1 - handle1.offsetTop;
if (delta1 !== 0) {
data1.node.holdSizes();
BoxEngine.adjust(data1.node.sizers, data1.index, delta1);
}
}
}

// Adjust the second handle.
if (!handle2.classList.contains('lm-mod-hidden')) {
const data2 = this._root.findSplitNode(handle2);
if (data2) {
const delta2 =
data2.node.orientation === 'horizontal'
? offsetX2 - handle2.offsetLeft
: offsetY2 - handle2.offsetTop;
if (delta2 !== 0) {
data2.node.holdSizes();
BoxEngine.adjust(data2.node.sizers, data2.index, delta2);
}
}
}

// Trigger a single layout update for both adjustments.
if (this.parent) {
this.parent.update();
}
}

/**
* Save the current configuration of the dock layout.
*
Expand Down
121 changes: 101 additions & 20 deletions packages/widgets/src/dockpanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import { ISignal, Signal } from '@lumino/signaling';

import { DockLayout } from './docklayout';

import { IntersectionHoverStyler } from './intersectionutils';

import { TabBar } from './tabbar';

import { Widget } from './widget';
Expand Down Expand Up @@ -455,6 +457,9 @@ export class DockPanel extends Widget {
case 'pointermove':
this._evtPointerMove(event as PointerEvent);
break;
case 'pointerleave':
this._setIntersectionHoverHandle(null);
break;
case 'pointerup':
this._evtPointerUp(event as PointerEvent);
break;
Expand All @@ -477,6 +482,8 @@ export class DockPanel extends Widget {
this.node.addEventListener('lm-dragover', this);
this.node.addEventListener('lm-drop', this);
this.node.addEventListener('pointerdown', this);
this.node.addEventListener('pointermove', this);
this.node.addEventListener('pointerleave', this);
}

/**
Expand All @@ -488,6 +495,9 @@ export class DockPanel extends Widget {
this.node.removeEventListener('lm-dragover', this);
this.node.removeEventListener('lm-drop', this);
this.node.removeEventListener('pointerdown', this);
this.node.removeEventListener('pointermove', this);
this.node.removeEventListener('pointerleave', this);
this._setIntersectionHoverHandle(null, null);
this._releaseMouse();
}

Expand Down Expand Up @@ -704,9 +714,9 @@ export class DockPanel extends Widget {
}

// Find the handle which contains the mouse target, if any.
let layout = this.layout as DockLayout;
let target = event.target as HTMLElement;
let handle = find(layout.handles(), handle => handle.contains(target));
const layout = this.layout as DockLayout;
const target = event.target as HTMLElement;
const handle = find(layout.handles(), h => h.contains(target));
if (!handle) {
return;
}
Expand All @@ -722,22 +732,52 @@ export class DockPanel extends Widget {
this._document.addEventListener('contextmenu', this, true);

// Compute the offset deltas for the handle press.
let rect = handle.getBoundingClientRect();
let deltaX = event.clientX - rect.left;
let deltaY = event.clientY - rect.top;

// Override the cursor and store the press data.
let style = window.getComputedStyle(handle);
let override = Drag.overrideCursor(style.cursor!, this._document);
this._pressData = { handle, deltaX, deltaY, override };
const rect = handle.getBoundingClientRect();
const deltaX = event.clientX - rect.left;
const deltaY = event.clientY - rect.top;

// Check for an orthogonal handle at the same intersection point.
const intersectHandle = layout.findIntersectingHandle(
handle,
event.clientX,
event.clientY
);

// Use 'move' at intersections; otherwise use the handle's default cursor.
const cursor = intersectHandle
? 'move'
: window.getComputedStyle(handle).cursor!;
const override = Drag.overrideCursor(cursor, this._document);

let intersect: Private.IPressData['intersect'];
if (intersectHandle) {
const iRect = intersectHandle.getBoundingClientRect();
intersect = {
handle: intersectHandle,
deltaX: event.clientX - iRect.left,
deltaY: event.clientY - iRect.top
};
}

this._pressData = { handle, deltaX, deltaY, override, intersect };
}

/**
* Handle the `'pointermove'` event for the dock panel.
*/
private _evtPointerMove(event: PointerEvent): void {
// Bail early if no drag is in progress.
// Update hover state when no drag is in progress.
if (!this._pressData) {
const layout = this.layout as DockLayout;
const target = event.target as HTMLElement;
const handle = find(layout.handles(), h => h.contains(target)) ?? null;
const intersectHandle = handle
? layout.findIntersectingHandle(handle, event.clientX, event.clientY)
: null;
this._setIntersectionHoverHandle(
intersectHandle ? handle : null,
intersectHandle
);
return;
}

Expand All @@ -746,13 +786,27 @@ export class DockPanel extends Widget {
event.stopPropagation();

// Compute the desired offset position for the handle.
let rect = this.node.getBoundingClientRect();
let xPos = event.clientX - rect.left - this._pressData.deltaX;
let yPos = event.clientY - rect.top - this._pressData.deltaY;

// Set the handle as close to the desired position as possible.
let layout = this.layout as DockLayout;
layout.moveHandle(this._pressData.handle, xPos, yPos);
const rect = this.node.getBoundingClientRect();
const xPos = event.clientX - rect.left - this._pressData.deltaX;
const yPos = event.clientY - rect.top - this._pressData.deltaY;

// Set the handle(s) as close to the desired position as possible.
const layout = this.layout as DockLayout;
const { intersect } = this._pressData;
if (intersect) {
const xPos2 = event.clientX - rect.left - intersect.deltaX;
const yPos2 = event.clientY - rect.top - intersect.deltaY;
layout.moveHandles(
this._pressData.handle,
xPos,
yPos,
intersect.handle,
xPos2,
yPos2
);
} else {
layout.moveHandle(this._pressData.handle, xPos, yPos);
}
}

/**
Expand Down Expand Up @@ -795,6 +849,16 @@ export class DockPanel extends Widget {
this._document.removeEventListener('contextmenu', this, true);
}

/**
* Set the handle pair which should render as an intersection hover.
*/
private _setIntersectionHoverHandle(
handle: HTMLDivElement | null,
peer: HTMLDivElement | null = null
): void {
this._intersectionHoverStyler.set(handle, peer);
}

/**
* Show the overlay indicator at the given client position.
*
Expand Down Expand Up @@ -1072,6 +1136,7 @@ export class DockPanel extends Widget {
private _tabsConstrained: boolean = false;
private _addButtonEnabled: boolean = false;
private _pressData: Private.IPressData | null = null;
private _intersectionHoverStyler = new IntersectionHoverStyler();
private _layoutModified = new Signal<this, void>(this);

private _addRequested = new Signal<this, TabBar<Widget>>(this);
Expand Down Expand Up @@ -1466,6 +1531,22 @@ namespace Private {
* The disposable which will clear the override cursor.
*/
override: IDisposable;

/**
* Data for two-axis resizing when the pressed handle intersects an
* orthogonal handle, or `undefined` if not applicable.
*
* When set, both `handle` and `intersect.handle` are moved together
* during pointermove, enabling simultaneous two-axis resizing.
*/
intersect?: {
/** The orthogonally-intersecting handle. */
handle: HTMLDivElement;
/** The X offset of the press within the intersecting handle. */
deltaX: number;
/** The Y offset of the press within the intersecting handle. */
deltaY: number;
};
}

/**
Expand Down Expand Up @@ -1559,7 +1640,7 @@ namespace Private {
});

/**
* Create a single document config for the widgets in a dock panel.
as * Create a single document config for the widgets in a dock panel.
*/
export function createSingleDocumentConfig(
panel: DockPanel
Expand Down
Loading
Loading