diff --git a/packages/widgets/src/docklayout.ts b/packages/widgets/src/docklayout.ts index 7128405ad..62d2f341f 100644 --- a/packages/widgets/src/docklayout.ts +++ b/packages/widgets/src/docklayout.ts @@ -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'; @@ -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. * diff --git a/packages/widgets/src/dockpanel.ts b/packages/widgets/src/dockpanel.ts index 575ff1c05..549212560 100644 --- a/packages/widgets/src/dockpanel.ts +++ b/packages/widgets/src/dockpanel.ts @@ -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'; @@ -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; @@ -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); } /** @@ -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(); } @@ -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; } @@ -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; } @@ -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); + } } /** @@ -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. * @@ -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); private _addRequested = new Signal>(this); @@ -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; + }; } /** @@ -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 diff --git a/packages/widgets/src/intersectionutils.ts b/packages/widgets/src/intersectionutils.ts new file mode 100644 index 000000000..82bfdfa95 --- /dev/null +++ b/packages/widgets/src/intersectionutils.ts @@ -0,0 +1,60 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +/** + * Shared multiplier for intersection hit testing tolerance. + */ +export const INTERSECTION_TOLERANCE_MULTIPLIER = 4; + +/** + * Manages an intersection hover class on up to two handles. + */ +export class IntersectionHoverStyler { + /** + * Set the handles which should render with intersection hover styling. + */ + set( + primary: HTMLDivElement | null, + secondary: HTMLDivElement | null = null + ): void { + if (this._primary === primary && this._secondary === secondary) { + return; + } + + this._applyClass(this._primary, this._secondary, false); + this._primary = primary; + this._secondary = secondary; + this._applyClass(this._primary, this._secondary, true); + } + + /** + * Clear intersection hover styling. + */ + clear(): void { + this.set(null, null); + } + + /** + * Apply or remove the managed class from up to two distinct handles. + */ + private _applyClass( + first: HTMLDivElement | null, + second: HTMLDivElement | null, + add: boolean + ): void { + const action = add ? 'add' : 'remove'; + const seen = new Set(); + + for (const handle of [first, second]) { + if (!handle || seen.has(handle)) { + continue; + } + handle.classList[action](this._className); + seen.add(handle); + } + } + + private readonly _className = 'lm-mod-intersection'; + private _primary: HTMLDivElement | null = null; + private _secondary: HTMLDivElement | null = null; +} diff --git a/packages/widgets/src/splitpanel.ts b/packages/widgets/src/splitpanel.ts index 8c9a81df5..4f97065aa 100644 --- a/packages/widgets/src/splitpanel.ts +++ b/packages/widgets/src/splitpanel.ts @@ -17,6 +17,11 @@ import { Message } from '@lumino/messaging'; import { ISignal, Signal } from '@lumino/signaling'; +import { + INTERSECTION_TOLERANCE_MULTIPLIER, + IntersectionHoverStyler +} from './intersectionutils'; + import { Panel } from './panel'; import { SplitLayout } from './splitlayout'; @@ -172,6 +177,9 @@ export class SplitPanel extends Panel { case 'pointermove': this._evtPointerMove(event as PointerEvent); break; + case 'pointerleave': + this._setIntersectionHoverHandle(null, null); + break; case 'pointerup': this._evtPointerUp(event as PointerEvent); break; @@ -190,6 +198,8 @@ export class SplitPanel extends Panel { */ protected onBeforeAttach(msg: Message): void { this.node.addEventListener('pointerdown', this); + this.node.addEventListener('pointermove', this); + this.node.addEventListener('pointerleave', this); } /** @@ -197,6 +207,9 @@ export class SplitPanel extends Panel { */ protected onAfterDetach(msg: Message): void { this.node.removeEventListener('pointerdown', this); + this.node.removeEventListener('pointermove', this); + this.node.removeEventListener('pointerleave', this); + this._setIntersectionHoverHandle(null, null); this._releaseMouse(); } @@ -242,8 +255,8 @@ export class SplitPanel extends Panel { } // Find the handle which contains the target, if any. - let layout = this.layout as SplitLayout; - let index = ArrayExt.findFirstIndex(layout.handles, handle => { + const layout = this.layout as SplitLayout; + const index = ArrayExt.findFirstIndex(layout.handles, handle => { return handle.contains(event.target as HTMLElement); }); @@ -263,41 +276,68 @@ export class SplitPanel extends Panel { document.addEventListener('contextmenu', this, true); // Compute the offset delta for the handle press. - let delta: number; - let handle = layout.handles[index]; - let rect = handle.getBoundingClientRect(); - if (layout.orientation === 'horizontal') { - delta = event.clientX - rect.left; - } else { - delta = event.clientY - rect.top; - } - - // Override the cursor and store the press data. - let style = window.getComputedStyle(handle); - let override = Drag.overrideCursor(style.cursor!); - this._pressData = { index, delta, override }; + const handle = layout.handles[index]; + const rect = handle.getBoundingClientRect(); + const delta = + layout.orientation === 'horizontal' + ? event.clientX - rect.left + : event.clientY - rect.top; + + // Check whether an adjacent widget is an orthogonal SplitPanel whose + // handle intersects the cursor position in the cross-axis. + const crossPos = + layout.orientation === 'horizontal' ? event.clientY : event.clientX; + const found = this._findInnerIntersect(index, crossPos); + + // Use 'move' at intersections; otherwise use the handle's cursor. + const style = window.getComputedStyle(handle); + const cursor = found ? 'move' : style.cursor!; + const override = Drag.overrideCursor(cursor); + + this._pressData = { + index, + delta, + override, + innerIntersect: found ?? undefined + }; } /** * Handle the `'pointermove'` event for the split panel. */ private _evtPointerMove(event: PointerEvent): void { + // Update hover state when no drag is in progress. + if (!this._pressData) { + this._updateIntersectionHover(event); + return; + } + // Stop the event when dragging a split handle. event.preventDefault(); event.stopPropagation(); // Compute the desired offset position for the handle. - let pos: number; - let layout = this.layout as SplitLayout; - let rect = this.node.getBoundingClientRect(); - if (layout.orientation === 'horizontal') { - pos = event.clientX - rect.left - this._pressData!.delta; - } else { - pos = event.clientY - rect.top - this._pressData!.delta; - } + const layout = this.layout as SplitLayout; + const rect = this.node.getBoundingClientRect(); + const { index, delta, innerIntersect } = this._pressData!; + const pos = + layout.orientation === 'horizontal' + ? event.clientX - rect.left - delta + : event.clientY - rect.top - delta; // Move the handle as close to the desired position as possible. - layout.moveHandle(this._pressData!.index, pos); + layout.moveHandle(index, pos); + + // Also move the inner panel's intersecting handle in the cross-axis. + if (innerIntersect) { + const innerLayout = innerIntersect.panel.layout as SplitLayout; + const innerRect = innerIntersect.panel.node.getBoundingClientRect(); + const innerPos = + layout.orientation === 'horizontal' + ? event.clientY - innerRect.top - innerIntersect.delta + : event.clientX - innerRect.left - innerIntersect.delta; + innerLayout.moveHandle(innerIntersect.index, innerPos); + } } /** @@ -340,8 +380,90 @@ export class SplitPanel extends Panel { document.removeEventListener('contextmenu', this, true); } + /** + * Find an orthogonal inner SplitPanel handle that intersects with the + * given outer handle index at the specified cross-axis position. + * + * The search is tolerant by `layout.spacing * 4` pixels to account for the + * gap that separates child handles from the parent handle's boundary. + * + * @returns The intersecting handle descriptor, or `null` if none is found. + */ + private _findInnerIntersect( + handleIndex: number, + crossPos: number + ): { panel: SplitPanel; index: number; delta: number } | null { + const layout = this.layout as SplitLayout; + const tolerance = layout.spacing * INTERSECTION_TOLERANCE_MULTIPLIER; + for (const candidate of [ + this.widgets[handleIndex], + this.widgets[handleIndex + 1] + ]) { + if (!(candidate instanceof SplitPanel)) { + continue; + } + if (candidate.orientation === layout.orientation) { + continue; + } + const innerLayout = candidate.layout as SplitLayout; + for (let i = 0; i < innerLayout.handles.length; i++) { + const h = innerLayout.handles[i]; + if (h.classList.contains('lm-mod-hidden')) { + continue; + } + const r = h.getBoundingClientRect(); + const lo = layout.orientation === 'horizontal' ? r.top : r.left; + const hi = layout.orientation === 'horizontal' ? r.bottom : r.right; + if (crossPos >= lo - tolerance && crossPos <= hi + tolerance) { + return { + panel: candidate, + index: i, + delta: Math.max(0, crossPos - lo) + }; + } + } + } + return null; + } + + /** + * Update the intersection hover style based on pointer position. + */ + private _updateIntersectionHover(event: PointerEvent): void { + const layout = this.layout as SplitLayout; + const target = event.target as HTMLElement; + const index = ArrayExt.findFirstIndex(layout.handles, handle => { + return handle.contains(target); + }); + + if (index === -1) { + this._setIntersectionHoverHandle(null, null); + return; + } + + const handle = layout.handles[index]; + const crossPos = + layout.orientation === 'horizontal' ? event.clientY : event.clientX; + const intersect = this._findInnerIntersect(index, crossPos); + const peer = intersect + ? (intersect.panel.layout as SplitLayout).handles[intersect.index] ?? null + : null; + this._setIntersectionHoverHandle(intersect ? handle : null, peer); + } + + /** + * 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); + } + private _handleMoved = new Signal(this); private _pressData: Private.IPressData | null = null; + private _intersectionHoverStyler = new IntersectionHoverStyler(); } /** @@ -471,6 +593,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 child panel's handle, or `undefined` if not applicable. + * + * When set, the inner handle is moved alongside the outer handle during + * pointermove, enabling simultaneous two-axis resizing. + */ + innerIntersect?: { + /** The orthogonally-oriented child SplitPanel. */ + panel: SplitPanel; + /** The handle index within the inner panel's layout. */ + index: number; + /** The press offset within the inner handle's cross-axis coordinate. */ + delta: number; + }; } /** diff --git a/packages/widgets/style/dockpanel.css b/packages/widgets/style/dockpanel.css index f2ecf9174..0b08e8b92 100644 --- a/packages/widgets/style/dockpanel.css +++ b/packages/widgets/style/dockpanel.css @@ -49,6 +49,10 @@ cursor: ns-resize; } +.lm-DockPanel-handle.lm-mod-intersection { + cursor: move; +} + .lm-DockPanel-handle[data-orientation='horizontal']:after { left: 50%; min-width: 8px; diff --git a/packages/widgets/style/splitpanel.css b/packages/widgets/style/splitpanel.css index 640a85a65..b6bbc1b5d 100644 --- a/packages/widgets/style/splitpanel.css +++ b/packages/widgets/style/splitpanel.css @@ -41,6 +41,10 @@ cursor: ns-resize; } +.lm-SplitPanel-handle.lm-mod-intersection { + cursor: move; +} + .lm-SplitPanel[data-orientation='horizontal'] > .lm-SplitPanel-handle:after { left: 50%; min-width: 8px;