From bf5092db9112caa8ecda1e529244379139d59d1c Mon Sep 17 00:00:00 2001 From: Simon Date: Mon, 15 Jun 2026 16:57:20 -0400 Subject: [PATCH 1/2] feat: DH-22800: Support Intersection 2D Window Resizing --- .../golden-layout/scss/goldenlayout-base.scss | 23 + .../scss/goldenlayout-dark-theme.scss | 7 + .../src/__tests__/intersection-drag.test.ts | 603 ++++++++++++++++++ .../src/controls/IntersectionSplitter.ts | 85 +++ packages/golden-layout/src/controls/index.ts | 1 + .../golden-layout/src/items/RowOrColumn.ts | 486 +++++++++++++- 6 files changed, 1204 insertions(+), 1 deletion(-) create mode 100644 packages/golden-layout/src/__tests__/intersection-drag.test.ts create mode 100644 packages/golden-layout/src/controls/IntersectionSplitter.ts diff --git a/packages/golden-layout/scss/goldenlayout-base.scss b/packages/golden-layout/scss/goldenlayout-base.scss index 0538a8a0b9..4972642c9c 100644 --- a/packages/golden-layout/scss/goldenlayout-base.scss +++ b/packages/golden-layout/scss/goldenlayout-base.scss @@ -39,6 +39,11 @@ $height6: 15px; // Appears 1 time user-select: none; } +.lm_intersection_dragging, +.lm_intersection_dragging * { + cursor: move !important; +} + // If a specific Pane is maximized .lm_maximised { position: absolute; @@ -81,6 +86,24 @@ $height6: 15px; // Appears 1 time } } +// Intersection splitter: an invisible grab area at grid crossing points that +// enables simultaneous (2D) resizing of rows and columns. The visible drag +// affordance is the two perpendicular splitter lines themselves, which are +// highlighted (via .lm_dragging) on hover and while dragging. +.lm_intersection_splitter { + position: absolute; + z-index: 60; + pointer-events: auto; + cursor: move; + + .lm_drag_handle { + position: absolute; + width: 100%; + height: 100%; + cursor: move; + } +} + // Pane Header (container of Tabs for each pane) .lm_header { display: flex; diff --git a/packages/golden-layout/scss/goldenlayout-dark-theme.scss b/packages/golden-layout/scss/goldenlayout-dark-theme.scss index f191b4fc19..621897446b 100644 --- a/packages/golden-layout/scss/goldenlayout-dark-theme.scss +++ b/packages/golden-layout/scss/goldenlayout-dark-theme.scss @@ -133,6 +133,13 @@ body:not(.lm_dragging) .lm_header .lm_tab:hover .lm_close_tab { } } +// Intersection splitter (2D grid resize). The grab area is invisible; the drag +// affordance is the two perpendicular splitter lines, which reuse the standard +// .lm_splitter.lm_dragging highlight above. +.lm_intersection_splitter { + background: transparent; +} + // Pane Header (container of Tabs for each pane) .lm_header { box-sizing: content-box; // golden-layout sets a js height using a content box model diff --git a/packages/golden-layout/src/__tests__/intersection-drag.test.ts b/packages/golden-layout/src/__tests__/intersection-drag.test.ts new file mode 100644 index 0000000000..7a313fbf15 --- /dev/null +++ b/packages/golden-layout/src/__tests__/intersection-drag.test.ts @@ -0,0 +1,603 @@ +import $ from 'jquery'; +import LayoutManager from '../LayoutManager'; +import { + createLayout, + cleanupLayout, + verifyPath, +} from '../test-utils/testUtils'; + +describe('intersection splitter drag', () => { + let layout: LayoutManager | null = null; + + const dragElement = async ( + element: JQuery, + startX: number, + startY: number, + deltaX: number, + deltaY: number + ) => { + const mousedown = $.Event('mousedown') as JQuery.TriggeredEvent; + mousedown.pageX = startX; + mousedown.pageY = startY; + mousedown.button = 0; + element.trigger(mousedown); + + const mousemove = $.Event('mousemove') as JQuery.TriggeredEvent; + mousemove.pageX = startX + deltaX; + mousemove.pageY = startY + deltaY; + $(document).trigger(mousemove); + + $(document).trigger('mouseup'); + + await new Promise(resolve => { + window.requestAnimationFrame(() => resolve()); + }); + }; + + const setupDimensionMocks = () => { + const originalOffset = $.fn.offset; + const originalWidth = $.fn.width; + const originalHeight = $.fn.height; + const originalOuterWidth = $.fn.outerWidth; + const originalOuterHeight = $.fn.outerHeight; + + const getDimension = ( + el: JQuery, + dimension: 'width' | 'height', + defaultSize: number + ): number => { + if (el.length === 0) return 0; + const element = el[0]; + if (element instanceof HTMLElement) { + const value = parseInt(element.style[dimension], 10); + if (!isNaN(value)) return value; + } + return defaultSize; + }; + + $.fn.offset = function (this: JQuery) { + if (this.length === 0) return undefined; + return { left: 0, top: 0 }; + } as typeof $.fn.offset; + + $.fn.width = function (this: JQuery, value?: number | string) { + if (value !== undefined) { + this.each(function () { + if (this instanceof HTMLElement) { + this.style.width = typeof value === 'number' ? `${value}px` : value; + } + }); + return this; + } + return getDimension(this, 'width', 800); + } as typeof $.fn.width; + + $.fn.height = function (this: JQuery, value?: number | string) { + if (value !== undefined) { + this.each(function () { + if (this instanceof HTMLElement) { + this.style.height = + typeof value === 'number' ? `${value}px` : value; + } + }); + return this; + } + return getDimension(this, 'height', 600); + } as typeof $.fn.height; + + $.fn.outerWidth = function (this: JQuery) { + return getDimension(this, 'width', 800); + } as typeof $.fn.outerWidth; + + $.fn.outerHeight = function (this: JQuery) { + return getDimension(this, 'height', 600); + } as typeof $.fn.outerHeight; + + return () => { + $.fn.offset = originalOffset; + $.fn.width = originalWidth; + $.fn.height = originalHeight; + $.fn.outerWidth = originalOuterWidth; + $.fn.outerHeight = originalOuterHeight; + }; + }; + + afterEach(() => { + cleanupLayout(layout); + layout = null; + }); + + it('supports diagonal drag at a T-junction intersection', async () => { + const restoreMocks = setupDimensionMocks(); + + try { + layout = await createLayout({ + content: [ + { + type: 'column', + content: [ + { + type: 'component', + componentName: 'testComponent', + }, + { + type: 'row', + content: [ + { + type: 'column', + content: [ + { + type: 'component', + componentName: 'testComponent', + }, + { + type: 'component', + componentName: 'testComponent', + }, + ], + }, + { + type: 'component', + componentName: 'testComponent', + }, + ], + }, + ], + }, + ], + }); + + const bottomRow = verifyPath('column.1.row', layout) as any; + const leftColumn = verifyPath('column.1.row.0.column', layout) as any; + + expect(bottomRow).toBeDefined(); + expect(leftColumn).toBeDefined(); + + const intersectionHandle = bottomRow.element + .find('.lm_intersection_splitter') + .first(); + expect(intersectionHandle.length).toBe(1); + + const initialLeftWidth = bottomRow.contentItems[0].config.width; + const initialLeftTopHeight = leftColumn.contentItems[0].config.height; + + const startX = 100; + const startY = 100; + + const mousedown = $.Event('mousedown') as JQuery.TriggeredEvent; + mousedown.pageX = startX; + mousedown.pageY = startY; + mousedown.button = 0; + intersectionHandle.trigger(mousedown); + + const mousemove = $.Event('mousemove') as JQuery.TriggeredEvent; + mousemove.pageX = startX - 60; + mousemove.pageY = startY - 60; + $(document).trigger(mousemove); + + $(document).trigger('mouseup'); + + await new Promise(resolve => { + window.requestAnimationFrame(() => resolve()); + }); + + expect(bottomRow.contentItems[0].config.width).not.toBe(initialLeftWidth); + expect(leftColumn.contentItems[0].config.height).not.toBe( + initialLeftTopHeight + ); + } finally { + restoreMocks(); + } + }); + + it('supports diagonal drag on the second intersection in a full grid', async () => { + const restoreMocks = setupDimensionMocks(); + + try { + layout = await createLayout({ + content: [ + { + type: 'row', + content: [ + { + type: 'column', + content: [ + { + type: 'component', + componentName: 'testComponent', + }, + { + type: 'component', + componentName: 'testComponent', + }, + { + type: 'component', + componentName: 'testComponent', + }, + ], + }, + { + type: 'column', + content: [ + { + type: 'component', + componentName: 'testComponent', + }, + { + type: 'component', + componentName: 'testComponent', + }, + { + type: 'component', + componentName: 'testComponent', + }, + ], + }, + ], + }, + ], + }); + + const rootRow = verifyPath('row', layout) as any; + const leftColumn = verifyPath('row.0.column', layout) as any; + + expect(rootRow).toBeDefined(); + expect(leftColumn).toBeDefined(); + + await new Promise(resolve => { + window.requestAnimationFrame(() => resolve()); + }); + + const intersections = rootRow.element.find('.lm_intersection_splitter'); + expect(intersections.length).toBeGreaterThanOrEqual(1); + + const secondIntersection = intersections.eq(1); + + const initialLeftWidth = rootRow.contentItems[0].config.width; + const initialMiddleHeight = leftColumn.contentItems[1].config.height; + + const startX = 180; + const startY = 180; + + const mousedown = $.Event('mousedown') as JQuery.TriggeredEvent; + mousedown.pageX = startX; + mousedown.pageY = startY; + mousedown.button = 0; + secondIntersection.trigger(mousedown); + + const mousemove = $.Event('mousemove') as JQuery.TriggeredEvent; + mousemove.pageX = startX - 50; + mousemove.pageY = startY - 50; + $(document).trigger(mousemove); + + $(document).trigger('mouseup'); + + await new Promise(resolve => { + window.requestAnimationFrame(() => resolve()); + }); + + expect(rootRow.contentItems[0].config.width).not.toBe(initialLeftWidth); + expect(leftColumn.contentItems[1].config.height).not.toBe( + initialMiddleHeight + ); + } finally { + restoreMocks(); + } + }); + + it('keeps all intersection handles after repeated size refreshes', async () => { + const restoreMocks = setupDimensionMocks(); + + try { + layout = await createLayout({ + content: [ + { + type: 'row', + content: [ + { + type: 'column', + content: [ + { + type: 'component', + componentName: 'testComponent', + }, + { + type: 'component', + componentName: 'testComponent', + }, + { + type: 'component', + componentName: 'testComponent', + }, + ], + }, + { + type: 'column', + content: [ + { + type: 'component', + componentName: 'testComponent', + }, + { + type: 'component', + componentName: 'testComponent', + }, + { + type: 'component', + componentName: 'testComponent', + }, + ], + }, + ], + }, + ], + }); + + const rootRow = verifyPath('row', layout) as any; + expect(rootRow).toBeDefined(); + + await new Promise(resolve => { + window.requestAnimationFrame(() => resolve()); + }); + + const beforeRefreshCount = rootRow.element.find( + '.lm_intersection_splitter' + ).length; + expect(beforeRefreshCount).toBeGreaterThanOrEqual(1); + + rootRow.element.width(900); + rootRow.element.height(650); + rootRow.setSize(); + rootRow.setSize(); + + const afterRefreshCount = rootRow.element.find( + '.lm_intersection_splitter' + ).length; + expect(afterRefreshCount).toBeGreaterThanOrEqual(beforeRefreshCount); + } finally { + restoreMocks(); + } + }); + + it('creates intersection handles from both sides of a parent splitter', async () => { + const restoreMocks = setupDimensionMocks(); + + try { + layout = await createLayout({ + content: [ + { + type: 'row', + content: [ + { + type: 'column', + content: [ + { + type: 'component', + componentName: 'testComponent', + }, + { + type: 'component', + componentName: 'testComponent', + }, + ], + }, + { + type: 'column', + content: [ + { + type: 'component', + componentName: 'testComponent', + }, + { + type: 'component', + componentName: 'testComponent', + }, + { + type: 'component', + componentName: 'testComponent', + }, + ], + }, + ], + }, + ], + }); + + const rootRow = verifyPath('row', layout) as any; + expect(rootRow).toBeDefined(); + + await new Promise(resolve => { + window.requestAnimationFrame(() => resolve()); + }); + + const intersections = rootRow.element.find('.lm_intersection_splitter'); + expect(intersections.length).toBeGreaterThanOrEqual(2); + } finally { + restoreMocks(); + } + }); + + it('preserves all intersection handles after normal vertical and horizontal splitter drags', async () => { + const restoreMocks = setupDimensionMocks(); + + try { + layout = await createLayout({ + content: [ + { + type: 'row', + content: [ + { + type: 'column', + content: [ + { type: 'component', componentName: 'testComponent' }, + { type: 'component', componentName: 'testComponent' }, + { type: 'component', componentName: 'testComponent' }, + ], + }, + { + type: 'column', + content: [ + { type: 'component', componentName: 'testComponent' }, + { type: 'component', componentName: 'testComponent' }, + { type: 'component', componentName: 'testComponent' }, + ], + }, + { + type: 'column', + content: [ + { type: 'component', componentName: 'testComponent' }, + { type: 'component', componentName: 'testComponent' }, + { type: 'component', componentName: 'testComponent' }, + ], + }, + ], + }, + ], + }); + + const rootRow = verifyPath('row', layout) as any; + const leftColumn = verifyPath('row.0.column', layout) as any; + + expect(rootRow).toBeDefined(); + expect(leftColumn).toBeDefined(); + + await new Promise(resolve => { + window.requestAnimationFrame(() => resolve()); + }); + + const initialCount = rootRow.element.find( + '.lm_intersection_splitter' + ).length; + expect(initialCount).toBeGreaterThanOrEqual(2); + + const verticalSplitter = rootRow.element + .find('.lm_splitter.lm_vertical') + .first(); + expect(verticalSplitter.length).toBe(1); + await dragElement(verticalSplitter, 200, 200, 40, 0); + + const horizontalSplitter = leftColumn.element + .find('.lm_splitter') + .first(); + expect(horizontalSplitter.length).toBe(1); + await dragElement(horizontalSplitter, 200, 200, 0, 40); + + const finalCount = rootRow.element.find( + '.lm_intersection_splitter' + ).length; + expect(finalCount).toBeGreaterThanOrEqual(initialCount); + } finally { + restoreMocks(); + } + }); + + it('highlights both splitter lines while dragging and clears them on stop', async () => { + const restoreMocks = setupDimensionMocks(); + + try { + layout = await createLayout({ + content: [ + { + type: 'column', + content: [ + { type: 'component', componentName: 'testComponent' }, + { + type: 'row', + content: [ + { + type: 'column', + content: [ + { type: 'component', componentName: 'testComponent' }, + { type: 'component', componentName: 'testComponent' }, + ], + }, + { type: 'component', componentName: 'testComponent' }, + ], + }, + ], + }, + ], + }); + + const bottomRow = verifyPath('column.1.row', layout) as any; + expect(bottomRow).toBeDefined(); + + const intersectionHandle = bottomRow.element + .find('.lm_intersection_splitter') + .first(); + expect(intersectionHandle.length).toBe(1); + + const startX = 100; + const startY = 100; + const mousedown = $.Event('mousedown') as JQuery.TriggeredEvent; + mousedown.pageX = startX; + mousedown.pageY = startY; + mousedown.button = 0; + intersectionHandle.trigger(mousedown); + + const mousemove = $.Event('mousemove') as JQuery.TriggeredEvent; + mousemove.pageX = startX - 40; + mousemove.pageY = startY - 40; + $(document).trigger(mousemove); + + // While dragging, both perpendicular splitter lines are highlighted. + expect(bottomRow.element.find('.lm_splitter.lm_dragging').length).toBe(2); + + $(document).trigger('mouseup'); + await new Promise(resolve => { + window.requestAnimationFrame(() => resolve()); + }); + + // Once the drag stops (pointer not over the handle), highlight is cleared. + expect(bottomRow.element.find('.lm_splitter.lm_dragging').length).toBe(0); + } finally { + restoreMocks(); + } + }); + + it('highlights both splitter lines on hover and clears them on leave', async () => { + const restoreMocks = setupDimensionMocks(); + + try { + layout = await createLayout({ + content: [ + { + type: 'column', + content: [ + { type: 'component', componentName: 'testComponent' }, + { + type: 'row', + content: [ + { + type: 'column', + content: [ + { type: 'component', componentName: 'testComponent' }, + { type: 'component', componentName: 'testComponent' }, + ], + }, + { type: 'component', componentName: 'testComponent' }, + ], + }, + ], + }, + ], + }); + + const bottomRow = verifyPath('column.1.row', layout) as any; + expect(bottomRow).toBeDefined(); + + const intersectionHandle = bottomRow.element + .find('.lm_intersection_splitter') + .first(); + expect(intersectionHandle.length).toBe(1); + + intersectionHandle.trigger('mouseenter'); + expect(bottomRow.element.find('.lm_splitter.lm_dragging').length).toBe(2); + + intersectionHandle.trigger('mouseleave'); + expect(bottomRow.element.find('.lm_splitter.lm_dragging').length).toBe(0); + } finally { + restoreMocks(); + } + }); +}); diff --git a/packages/golden-layout/src/controls/IntersectionSplitter.ts b/packages/golden-layout/src/controls/IntersectionSplitter.ts new file mode 100644 index 0000000000..a0172a87d5 --- /dev/null +++ b/packages/golden-layout/src/controls/IntersectionSplitter.ts @@ -0,0 +1,85 @@ +import $ from 'jquery'; +import { DragListener } from '../utils'; + +/** + * IntersectionSplitter is a 2D drag handle that appears at the intersection of + * two perpendicular splitters (e.g., where a horizontal splitter crosses a vertical splitter). + * + * This allows users to drag in both X and Y dimensions simultaneously to resize both + * rows and columns at the same time, similar to VS Code's window management. + * + * Key differences from standard Splitter: + * - Supports 2D mouse movement (both X and Y offsets) + * - Uses a `move` (4-way) cursor + * - Positioned at the intersection point + * - Emits drag events with both offsetX and offsetY + */ +export default class IntersectionSplitter { + private _size: number; + private _grabSize: number; + private _hitAreaSize: number; + private _dragListener: DragListener | null; + + element: JQuery; + + /** + * Creates a new IntersectionSplitter. + * + * @param size The size of the splitter in pixels (usually matches border width) + * @param grabSize The size of the grab area (can be larger for usability) + */ + constructor(size: number, grabSize: number) { + this._size = size; + this._grabSize = grabSize < size ? size : grabSize; + this._hitAreaSize = Math.max(this._grabSize, 14); + this.element = this._createElement(); + this._dragListener = new DragListener(this.element); + } + + /** + * Listen to events on this intersection splitter. + * + * @param event The event name ('drag', 'dragStart', 'dragStop') + * @param callback The callback function + * @param context The context to bind to + */ + on(event: string, callback: Function, context?: unknown) { + this._dragListener?.on(event, callback, context); + } + + /** + * Clean up and remove this intersection splitter from the DOM. + */ + _$destroy() { + this._dragListener?.destroy(); + this._dragListener = null; + this.element.remove(); + } + + /** + * Create the DOM element for the intersection splitter. + * + * The element is an invisible square grab area positioned at the crossing + * point of two perpendicular splitters. It allows dragging in both dimensions. + * + * @returns The created jQuery element + */ + private _createElement() { + const dragHandle = $('
'); + const element = $('
'); + element.append(dragHandle); + + element.css({ + width: this._hitAreaSize, + height: this._hitAreaSize, + }); + + // Prevent the mousedown from bubbling to the parent splitter's DragListener, + // which would otherwise start a 1D drag in parallel. + element.on('mousedown touchstart', event => { + event.stopPropagation(); + }); + + return element; + } +} diff --git a/packages/golden-layout/src/controls/index.ts b/packages/golden-layout/src/controls/index.ts index d7e8df13c1..d3b591335c 100644 --- a/packages/golden-layout/src/controls/index.ts +++ b/packages/golden-layout/src/controls/index.ts @@ -5,5 +5,6 @@ export { default as DragSourceFromEvent } from './DragSourceFromEvent'; export { default as DropTargetIndicator } from './DropTargetIndicator'; export { default as Header } from './Header'; export { default as HeaderButton } from './HeaderButton'; +export { default as IntersectionSplitter } from './IntersectionSplitter'; export { default as Splitter } from './Splitter'; export { default as Tab } from './Tab'; diff --git a/packages/golden-layout/src/items/RowOrColumn.ts b/packages/golden-layout/src/items/RowOrColumn.ts index 42ea935a36..8e11ab059f 100644 --- a/packages/golden-layout/src/items/RowOrColumn.ts +++ b/packages/golden-layout/src/items/RowOrColumn.ts @@ -1,7 +1,7 @@ import $ from 'jquery'; import AbstractContentItem from './AbstractContentItem'; import { animFrame } from '../utils'; -import { Splitter } from '../controls'; +import { IntersectionSplitter, Splitter } from '../controls'; import type LayoutManager from '../LayoutManager'; import type { ColumnItemConfig, ItemConfig, RowItemConfig } from '../config'; @@ -12,6 +12,13 @@ export default class RowOrColumn extends AbstractContentItem { parent: AbstractContentItem | null; private _splitter: Splitter[] = []; + private _intersectionSplitter: { + splitter: IntersectionSplitter; + key: string; + parentSplitterIndex: number; + childItemIndex: number; + childSplitterIndex: number; + }[] = []; private _splitterSize: number; private _splitterGrabSize: number; private _isColumn: boolean; @@ -19,6 +26,8 @@ export default class RowOrColumn extends AbstractContentItem { private _splitterPosition: number | null = null; private _splitterMinPosition: number | null = null; private _splitterMaxPosition: number | null = null; + private _isIntersectionDragging = false; + private _isIntersectionHovered = false; constructor( isColumn: true, @@ -202,6 +211,7 @@ export default class RowOrColumn extends AbstractContentItem { if (this.contentItems.length > 0) { this._calculateRelativeSizes(); this._setAbsoluteSizes(); + this._scheduleIntersectionRefresh(); } this.emitBubblingEvent('stateChanged'); this.emit('resize'); @@ -222,6 +232,17 @@ export default class RowOrColumn extends AbstractContentItem { for (i = 0; i < this.contentItems.length - 1; i++) { this.contentItems[i].element.after(this._createSplitter(i).element); } + + // Initialise children eagerly so their splitters exist before we attach + // intersection handles to them. _$init is idempotent so the outer + // callDownwards('_$init') traversal will skip them as no-ops. + for (i = 0; i < this.contentItems.length; i++) { + if (this.contentItems[i].isInitialised !== true) { + this.contentItems[i]._$init(); + } + } + + this._refreshIntersectionSplitters(); } /** @@ -586,6 +607,15 @@ export default class RowOrColumn extends AbstractContentItem { * @param {lm.controls.Splitter} splitter */ _onSplitterDragStop(splitter: Splitter) { + this._applySplitterDragStop(splitter); + + this._scheduleSetSize(); + } + + /** + * Applies drag-stop updates for one splitter without scheduling layout. + */ + private _applySplitterDragStop(splitter: Splitter) { const items = this._getItemsForSplitter(splitter); const sizeBefore = items.before.element[this._dimension]() ?? 0; const sizeAfter = items.after.element[this._dimension]() ?? 0; @@ -604,9 +634,463 @@ export default class RowOrColumn extends AbstractContentItem { top: 0, left: 0, }); + } + /** + * Schedules a full descendant size update on the next animation frame. + */ + private _scheduleSetSize() { animFrame( this.callDownwards.bind(this, 'setSize', undefined, undefined, undefined) ); + // setSize only propagates downwards, so it repositions intersection handles + // owned by this item and its descendants. A handle that sits at a crossing + // is owned by the parent RowOrColumn (it depends on a child splitter's + // position), so ancestors must be refreshed too or their handles drift out + // of sync with the lines after a drag. + this._scheduleAncestorIntersectionRefresh(); + } + + /** + * Schedule intersection handle refresh after layout and browser positioning settle. + */ + private _scheduleIntersectionRefresh() { + animFrame(() => { + animFrame(this._refreshIntersectionSplitters.bind(this)); + }); + } + + /** + * Schedule an intersection handle refresh on every RowOrColumn ancestor so + * crossing handles stay aligned after a drag that only resized descendants. + */ + private _scheduleAncestorIntersectionRefresh() { + let ancestor = this.parent; + while (ancestor != null) { + if (ancestor instanceof RowOrColumn) { + ancestor._scheduleIntersectionRefresh(); + } + ancestor = ancestor.parent; + } + } + + // ============================================================================ + // Intersection Splitter Methods - Support for 2D grid resizing + // ============================================================================ + + /** + * Create intersection splitters at the crossing points between this + * RowOrColumn's splitters and the splitters of any perpendicular child. + * + * Each handle is appended into this RowOrColumn's container and positioned + * with JS (via `_positionIntersectionSplitter`) during refresh so it stays + * aligned as the layout changes. Handles are keyed by their splitter indices + * so existing ones are reused rather than recreated. + */ + private _createIntersectionSplitters() { + this.childElementContainer.css('position', 'relative'); + + const addIntersectionsForChild = ( + parentSplitterIndex: number, + childItemIndex: number, + childItem: RowOrColumn + ) => { + for ( + let childSplitterIndex = 0; + childSplitterIndex < childItem._splitter.length; + childSplitterIndex++ + ) { + const intersectionPosition = this._getIntersectionPosition( + parentSplitterIndex, + childItemIndex, + childSplitterIndex + ); + + if (intersectionPosition == null) { + continue; + } + + const key = + parentSplitterIndex + ':' + childItemIndex + ':' + childSplitterIndex; + this._ensureIntersectionSplitter( + key, + parentSplitterIndex, + childItemIndex, + childSplitterIndex + ); + } + }; + + for ( + let parentSplitterIndex = 0; + parentSplitterIndex < this._splitter.length; + parentSplitterIndex++ + ) { + const beforeItem = this.contentItems[parentSplitterIndex]; + const afterItem = this.contentItems[parentSplitterIndex + 1]; + + const beforeChild = this._asPerpendicularRowOrColumn(beforeItem); + const afterChild = this._asPerpendicularRowOrColumn(afterItem); + + if (beforeChild != null) { + addIntersectionsForChild( + parentSplitterIndex, + parentSplitterIndex, + beforeChild + ); + } + + if (afterChild != null) { + addIntersectionsForChild( + parentSplitterIndex, + parentSplitterIndex + 1, + afterChild + ); + } + } + } + + /** + * Recreate intersection splitters based on current splitter topology. + * This keeps handles aligned and present after layout tree mutations. + */ + private _refreshIntersectionSplitters() { + const previousCount = this._intersectionSplitter.length; + this._intersectionSplitter = this._intersectionSplitter.filter( + intersection => { + const keep = this._isIntersectionTopologyValid( + intersection.parentSplitterIndex, + intersection.childItemIndex, + intersection.childSplitterIndex + ); + if (!keep) { + intersection.splitter._$destroy(); + } + return keep; + } + ); + + this._createIntersectionSplitters(); + + for (let i = 0; i < this._intersectionSplitter.length; i++) { + const intersection = this._intersectionSplitter[i]; + this._positionIntersectionSplitter(intersection); + } + + // If handles were added/removed due to topology change, run one more pass + // on the next frame to settle post-layout positions. + if (this._intersectionSplitter.length !== previousCount) { + animFrame(() => { + for (let i = 0; i < this._intersectionSplitter.length; i++) { + this._positionIntersectionSplitter(this._intersectionSplitter[i]); + } + }); + } + } + + /** + * Destroy all previously created intersection splitters. + */ + private _destroyIntersectionSplitters() { + for (let i = 0; i < this._intersectionSplitter.length; i++) { + this._intersectionSplitter[i].splitter._$destroy(); + } + this._intersectionSplitter = []; + } + + /** + * Tear down splitters (including intersection handles and their document-level + * drag listeners) before delegating to the base destroy logic. + */ + _$destroy() { + this._destroyIntersectionSplitters(); + for (let i = 0; i < this._splitter.length; i++) { + this._splitter[i]._$destroy(); + } + this._splitter = []; + AbstractContentItem.prototype._$destroy.call(this); + } + + private _isIntersectionTopologyValid( + parentSplitterIndex: number, + childItemIndex: number, + childSplitterIndex: number + ) { + const parentSplitter = this._splitter[parentSplitterIndex]; + const childItem = this.contentItems[childItemIndex] as RowOrColumn; + const childSplitter = childItem?._splitter[childSplitterIndex]; + + return parentSplitter != null && childItem != null && childSplitter != null; + } + + /** + * Returns the item cast to RowOrColumn iff it is a perpendicular child of + * this RowOrColumn (i.e. a row inside a column or vice versa), otherwise null. + */ + private _asPerpendicularRowOrColumn( + item: AbstractContentItem | undefined + ): RowOrColumn | null { + if (!(item instanceof RowOrColumn)) { + return null; + } + return item._isColumn !== this._isColumn ? item : null; + } + + /** + * Create a single intersection splitter anchored in this row/column overlay + * at the given coordinates. + */ + private _ensureIntersectionSplitter( + key: string, + parentSplitterIndex: number, + childItemIndex: number, + childSplitterIndex: number + ) { + const existing = this._intersectionSplitter.find(item => item.key === key); + if (existing != null) { + existing.parentSplitterIndex = parentSplitterIndex; + existing.childItemIndex = childItemIndex; + existing.childSplitterIndex = childSplitterIndex; + return; + } + + const intersectionSplitter = new IntersectionSplitter( + this._splitterSize, + this._splitterGrabSize + ); + + intersectionSplitter.on( + 'dragStart', + this._onIntersectionSplitterDragStart.bind( + this, + intersectionSplitter, + parentSplitterIndex, + childItemIndex, + childSplitterIndex + ), + this + ); + intersectionSplitter.on( + 'drag', + this._onIntersectionSplitterDrag.bind( + this, + intersectionSplitter, + parentSplitterIndex, + childItemIndex, + childSplitterIndex + ), + this + ); + intersectionSplitter.on( + 'dragStop', + this._onIntersectionSplitterDragStop.bind( + this, + intersectionSplitter, + parentSplitterIndex, + childItemIndex, + childSplitterIndex + ), + this + ); + + // Highlight both perpendicular lines while hovering the grab area, mirroring + // the active line affordance used for 1D splitter drags. + intersectionSplitter.element.on('mouseenter', () => { + this._isIntersectionHovered = true; + this._setIntersectionHighlight( + parentSplitterIndex, + childItemIndex, + childSplitterIndex, + true + ); + }); + intersectionSplitter.element.on('mouseleave', () => { + this._isIntersectionHovered = false; + if (!this._isIntersectionDragging) { + this._setIntersectionHighlight( + parentSplitterIndex, + childItemIndex, + childSplitterIndex, + false + ); + } + }); + + intersectionSplitter.element.css({ + position: 'absolute', + left: 0, + top: 0, + transform: 'translate(-50%, -50%)', + zIndex: 60, + }); + + this.childElementContainer.append(intersectionSplitter.element); + this._intersectionSplitter.push({ + splitter: intersectionSplitter, + key, + parentSplitterIndex, + childItemIndex, + childSplitterIndex, + }); + } + + private _positionIntersectionSplitter(intersection: { + splitter: IntersectionSplitter; + parentSplitterIndex: number; + childItemIndex: number; + childSplitterIndex: number; + }) { + const position = this._getIntersectionPosition( + intersection.parentSplitterIndex, + intersection.childItemIndex, + intersection.childSplitterIndex + ); + + if (position == null) { + return; + } + + intersection.splitter.element.css({ + left: position.left, + top: position.top, + }); + } + + /** + * Compute intersection coordinates relative to this row/column container. + */ + private _getIntersectionPosition( + parentSplitterIndex: number, + childItemIndex: number, + childSplitterIndex: number + ): { left: number; top: number } | null { + const parentSplitter = this._splitter[parentSplitterIndex]; + const childItem = this.contentItems[childItemIndex] as RowOrColumn; + const childSplitter = childItem?._splitter[childSplitterIndex]; + + if (parentSplitter == null || childItem == null || childSplitter == null) { + return null; + } + + const parentPos = parentSplitter.element.position(); + const childItemPos = childItem.element.position(); + const childSplitterPos = childSplitter.element.position(); + + if (parentPos == null || childItemPos == null || childSplitterPos == null) { + return null; + } + + if (this._isColumn) { + return { + left: (childItemPos.left ?? 0) + (childSplitterPos.left ?? 0), + top: parentPos.top ?? 0, + }; + } + + return { + left: parentPos.left ?? 0, + top: (childItemPos.top ?? 0) + (childSplitterPos.top ?? 0), + }; + } + + /** + * Toggle the active-line highlight on both splitters that meet at an + * intersection. Reuses the standard `.lm_dragging` line style so the 2D + * affordance is visually identical to the existing 1D drag affordance. + */ + private _setIntersectionHighlight( + parentSplitterIndex: number, + childItemIndex: number, + childSplitterIndex: number, + highlighted: boolean + ) { + const parentSplitter = this._splitter[parentSplitterIndex]; + const childItem = this.contentItems[childItemIndex] as RowOrColumn; + const childSplitter = childItem?._splitter[childSplitterIndex]; + + parentSplitter?.element.toggleClass('lm_dragging', highlighted); + childSplitter?.element.toggleClass('lm_dragging', highlighted); + } + + /** + * Invoked when an intersection splitter's DragListener fires dragStart. + * Calculates movement bounds for both axes (via the existing 1D logic) so the + * drag stays within valid ranges, and highlights both perpendicular lines. + */ + private _onIntersectionSplitterDragStart( + intersectionSplitter: IntersectionSplitter, + parentSplitterIndex: number, + childItemIndex: number, + childSplitterIndex: number + ) { + const parentSplitter = this._splitter[parentSplitterIndex]; + const childBefore = this.contentItems[childItemIndex] as RowOrColumn; + const childSplitter = childBefore._splitter[childSplitterIndex]; + + // Reuse the existing 1D splitter drag logic to compute bounds for each axis. + this._onSplitterDragStart(parentSplitter); + childBefore._onSplitterDragStart(childSplitter); + + this._isIntersectionDragging = true; + this._setIntersectionHighlight( + parentSplitterIndex, + childItemIndex, + childSplitterIndex, + true + ); + $(document.body).addClass('lm_intersection_dragging'); + } + + /** + * Invoked when an intersection splitter's DragListener fires drag. Moves both + * splitter lines by delegating to the existing 1D logic, which clamps each + * axis to its own valid range. The lines moving form the 2D drag affordance. + */ + private _onIntersectionSplitterDrag( + intersectionSplitter: IntersectionSplitter, + parentSplitterIndex: number, + childItemIndex: number, + childSplitterIndex: number, + offsetX: number, + offsetY: number + ) { + const parentSplitter = this._splitter[parentSplitterIndex]; + const childBefore = this.contentItems[childItemIndex] as RowOrColumn; + const childSplitter = childBefore._splitter[childSplitterIndex]; + + this._onSplitterDrag(parentSplitter, offsetX, offsetY); + childBefore._onSplitterDrag(childSplitter, offsetX, offsetY); + } + + /** + * Invoked when an intersection splitter's DragListener fires dragStop. + * Applies both axis updates atomically (via the existing 1D logic), clears the + * highlight unless the pointer is still over the handle, then relayouts once. + */ + private _onIntersectionSplitterDragStop( + intersectionSplitter: IntersectionSplitter, + parentSplitterIndex: number, + childItemIndex: number, + childSplitterIndex: number + ) { + const parentSplitter = this._splitter[parentSplitterIndex]; + const childBefore = this.contentItems[childItemIndex] as RowOrColumn; + const childSplitter = childBefore._splitter[childSplitterIndex]; + + this._applySplitterDragStop(parentSplitter); + childBefore._applySplitterDragStop(childSplitter); + + this._isIntersectionDragging = false; + if (!this._isIntersectionHovered) { + this._setIntersectionHighlight( + parentSplitterIndex, + childItemIndex, + childSplitterIndex, + false + ); + } + $(document.body).removeClass('lm_intersection_dragging'); + + this._scheduleSetSize(); } } From 5cb5501e39e479b62d6a858dc2dd6cfbff49b2bc Mon Sep 17 00:00:00 2001 From: Simon Date: Wed, 24 Jun 2026 09:30:15 -0400 Subject: [PATCH 2/2] fix(golden-layout): support nested intersection drag handles Replaced the one-level-deep loop with a small recursive walk --- .../golden-layout/scss/goldenlayout-base.scss | 8 + .../src/__tests__/intersection-drag.test.ts | 128 +++++ .../golden-layout/src/items/RowOrColumn.ts | 457 +++++++++--------- 3 files changed, 367 insertions(+), 226 deletions(-) diff --git a/packages/golden-layout/scss/goldenlayout-base.scss b/packages/golden-layout/scss/goldenlayout-base.scss index 4972642c9c..583c4bba43 100644 --- a/packages/golden-layout/scss/goldenlayout-base.scss +++ b/packages/golden-layout/scss/goldenlayout-base.scss @@ -84,6 +84,14 @@ $height6: 15px; // Appears 1 time cursor: ew-resize; } } + + // While part of an active 2D intersection (hover or drag), lift the line above + // pane content so an offset T/cross junction renders cleanly instead of being + // clipped by the neighbouring pane. Stays below the intersection handle + // (z-index 60) so the handle always wins pointer priority for 2D dragging. + &.lm_intersection_line { + z-index: 59; + } } // Intersection splitter: an invisible grab area at grid crossing points that diff --git a/packages/golden-layout/src/__tests__/intersection-drag.test.ts b/packages/golden-layout/src/__tests__/intersection-drag.test.ts index 7a313fbf15..67e79a1fa2 100644 --- a/packages/golden-layout/src/__tests__/intersection-drag.test.ts +++ b/packages/golden-layout/src/__tests__/intersection-drag.test.ts @@ -417,6 +417,63 @@ describe('intersection splitter drag', () => { } }); + it('creates a handle for a perpendicular splitter nested deeper than one level', async () => { + const restoreMocks = setupDimensionMocks(); + + try { + // The bottom row holds a left column whose first child is itself a row. + // That inner row's vertical splitter is two levels below the root column's + // top/bottom bar yet its top still touches it, so the bar owner (the root + // column) must create a crossing handle for it. + layout = await createLayout({ + content: [ + { + type: 'column', + content: [ + { type: 'component', componentName: 'testComponent' }, + { + type: 'row', + content: [ + { + type: 'column', + content: [ + { + type: 'row', + content: [ + { type: 'component', componentName: 'testComponent' }, + { type: 'component', componentName: 'testComponent' }, + ], + }, + { type: 'component', componentName: 'testComponent' }, + ], + }, + { type: 'component', componentName: 'testComponent' }, + ], + }, + ], + }, + ], + }); + + const rootColumn = verifyPath('column', layout) as any; + expect(rootColumn).toBeDefined(); + + await new Promise(resolve => { + window.requestAnimationFrame(() => resolve()); + }); + + // Handles owned by the root column are appended directly into its element. + // It owns one for the bottom row's own vertical splitter (left column | + // right component) and one for the deeply nested inner-row splitter. + const rootOwnedHandles = rootColumn.element.children( + '.lm_intersection_splitter' + ); + expect(rootOwnedHandles.length).toBe(2); + } finally { + restoreMocks(); + } + }); + it('preserves all intersection handles after normal vertical and horizontal splitter drags', async () => { const restoreMocks = setupDimensionMocks(); @@ -600,4 +657,75 @@ describe('intersection splitter drag', () => { restoreMocks(); } }); + + it('stretches the stem line with a transform (not box size) and clears it on stop', async () => { + const restoreMocks = setupDimensionMocks(); + + try { + layout = await createLayout({ + content: [ + { + type: 'column', + content: [ + { type: 'component', componentName: 'testComponent' }, + { + type: 'row', + content: [ + { + type: 'column', + content: [ + { type: 'component', componentName: 'testComponent' }, + { type: 'component', componentName: 'testComponent' }, + ], + }, + { type: 'component', componentName: 'testComponent' }, + ], + }, + ], + }, + ], + }); + + const bottomRow = verifyPath('column.1.row', layout) as any; + expect(bottomRow).toBeDefined(); + + const intersectionHandle = bottomRow.element + .find('.lm_intersection_splitter') + .first(); + expect(intersectionHandle.length).toBe(1); + + // The stem line is the vertical splitter inside the bottom row. + const stemLine = bottomRow.element.find('.lm_splitter.lm_vertical'); + expect(stemLine.length).toBe(1); + const stemEl = stemLine[0] as HTMLElement; + + const startX = 100; + const startY = 100; + const mousedown = $.Event('mousedown') as JQuery.TriggeredEvent; + mousedown.pageX = startX; + mousedown.pageY = startY; + mousedown.button = 0; + intersectionHandle.trigger(mousedown); + + const mousemove = $.Event('mousemove') as JQuery.TriggeredEvent; + mousemove.pageX = startX - 40; + mousemove.pageY = startY - 40; + $(document).trigger(mousemove); + + // While dragging, the stem is stretched via a scale transform - never by + // mutating its box size, which would reflow sibling panes and headers. + expect(stemEl.style.transform).toContain('scale'); + expect(stemEl.style.width).toBe(''); + + $(document).trigger('mouseup'); + await new Promise(resolve => { + window.requestAnimationFrame(() => resolve()); + }); + + // The transform is cleared once the drag stops. + expect(stemEl.style.transform).toBe(''); + } finally { + restoreMocks(); + } + }); }); diff --git a/packages/golden-layout/src/items/RowOrColumn.ts b/packages/golden-layout/src/items/RowOrColumn.ts index 8e11ab059f..02501995e5 100644 --- a/packages/golden-layout/src/items/RowOrColumn.ts +++ b/packages/golden-layout/src/items/RowOrColumn.ts @@ -5,6 +5,22 @@ import { IntersectionSplitter, Splitter } from '../controls'; import type LayoutManager from '../LayoutManager'; import type { ColumnItemConfig, ItemConfig, RowItemConfig } from '../config'; +/** + * A single 2D intersection handle. `parentSplitterIndex` is the "bar" splitter + * owned by this RowOrColumn; `stemOwner`/`stemSplitterIndex` identify the + * perpendicular "stem" splitter that crosses it, which may live arbitrarily + * deep in this item's subtree. `junctionAtNearEdge` records which end of the + * stem meets the bar. + */ +type IntersectionRecord = { + splitter: IntersectionSplitter; + key: string; + parentSplitterIndex: number; + stemOwner: RowOrColumn; + stemSplitterIndex: number; + junctionAtNearEdge: boolean; +}; + export default class RowOrColumn extends AbstractContentItem { isRow: boolean; isColumn: boolean; @@ -12,13 +28,7 @@ export default class RowOrColumn extends AbstractContentItem { parent: AbstractContentItem | null; private _splitter: Splitter[] = []; - private _intersectionSplitter: { - splitter: IntersectionSplitter; - key: string; - parentSplitterIndex: number; - childItemIndex: number; - childSplitterIndex: number; - }[] = []; + private _intersectionSplitter: IntersectionRecord[] = []; private _splitterSize: number; private _splitterGrabSize: number; private _isColumn: boolean; @@ -687,39 +697,10 @@ export default class RowOrColumn extends AbstractContentItem { * aligned as the layout changes. Handles are keyed by their splitter indices * so existing ones are reused rather than recreated. */ - private _createIntersectionSplitters() { + private _createIntersectionSplitters(): Set { this.childElementContainer.css('position', 'relative'); - const addIntersectionsForChild = ( - parentSplitterIndex: number, - childItemIndex: number, - childItem: RowOrColumn - ) => { - for ( - let childSplitterIndex = 0; - childSplitterIndex < childItem._splitter.length; - childSplitterIndex++ - ) { - const intersectionPosition = this._getIntersectionPosition( - parentSplitterIndex, - childItemIndex, - childSplitterIndex - ); - - if (intersectionPosition == null) { - continue; - } - - const key = - parentSplitterIndex + ':' + childItemIndex + ':' + childSplitterIndex; - this._ensureIntersectionSplitter( - key, - parentSplitterIndex, - childItemIndex, - childSplitterIndex - ); - } - }; + const ensuredKeys = new Set(); for ( let parentSplitterIndex = 0; @@ -729,24 +710,91 @@ export default class RowOrColumn extends AbstractContentItem { const beforeItem = this.contentItems[parentSplitterIndex]; const afterItem = this.contentItems[parentSplitterIndex + 1]; - const beforeChild = this._asPerpendicularRowOrColumn(beforeItem); - const afterChild = this._asPerpendicularRowOrColumn(afterItem); - - if (beforeChild != null) { - addIntersectionsForChild( - parentSplitterIndex, + const stems: { + stemOwner: RowOrColumn; + stemSplitterIndex: number; + junctionAtNearEdge: boolean; + path: string; + }[] = []; + + // A splitter "bar" is crossed by perpendicular splitter lines reaching its + // shared edge from either side. Those lines can be nested arbitrarily deep + // (e.g. a row inside a column inside the adjacent row), so walk each + // adjacent subtree down to the touching edge. The before item meets the + // bar at its far edge, the after item at its near edge. + this._collectEdgeStemSplitters(beforeItem, false, 'b', stems); + this._collectEdgeStemSplitters(afterItem, true, 'a', stems); + + for (let i = 0; i < stems.length; i++) { + const stem = stems[i]; + const key = parentSplitterIndex + ':' + stem.path; + ensuredKeys.add(key); + this._ensureIntersectionSplitter( + key, parentSplitterIndex, - beforeChild + stem.stemOwner, + stem.stemSplitterIndex, + stem.junctionAtNearEdge ); } + } - if (afterChild != null) { - addIntersectionsForChild( - parentSplitterIndex, - parentSplitterIndex + 1, - afterChild + return ensuredKeys; + } + + /** + * Collect every perpendicular splitter line within `item`'s subtree that + * reaches the shared edge with one of this row/column's splitter bars, so a + * crossing handle can be created for it. Lines can be nested arbitrarily deep, + * so descend until the edge is no longer shared. + * + * @param nearEdge true when the bar sits at the start of `item` along the bar + * main axis (junction at the near end), false when at the end. + */ + private _collectEdgeStemSplitters( + item: AbstractContentItem | undefined, + nearEdge: boolean, + path: string, + out: { + stemOwner: RowOrColumn; + stemSplitterIndex: number; + junctionAtNearEdge: boolean; + path: string; + }[] + ) { + if (!(item instanceof RowOrColumn)) { + return; + } + + if (item._isColumn !== this._isColumn) { + // Perpendicular to the bar: every splitter here crosses it, and every + // child spans the full cross extent so all share the edge - recurse into + // each to pick up deeper crossings. + for (let i = 0; i < item._splitter.length; i++) { + out.push({ + stemOwner: item, + stemSplitterIndex: i, + junctionAtNearEdge: nearEdge, + path: path + ':' + i, + }); + } + for (let i = 0; i < item.contentItems.length; i++) { + this._collectEdgeStemSplitters( + item.contentItems[i], + nearEdge, + path + '.' + i, + out ); } + } else { + // Parallel to the bar: only the child at the shared edge can reach it. + const edgeIndex = nearEdge ? 0 : item.contentItems.length - 1; + this._collectEdgeStemSplitters( + item.contentItems[edgeIndex], + nearEdge, + path + '.' + edgeIndex, + out + ); } } @@ -756,25 +804,19 @@ export default class RowOrColumn extends AbstractContentItem { */ private _refreshIntersectionSplitters() { const previousCount = this._intersectionSplitter.length; - this._intersectionSplitter = this._intersectionSplitter.filter( - intersection => { - const keep = this._isIntersectionTopologyValid( - intersection.parentSplitterIndex, - intersection.childItemIndex, - intersection.childSplitterIndex - ); - if (!keep) { - intersection.splitter._$destroy(); - } - return keep; - } - ); + const ensuredKeys = this._createIntersectionSplitters(); - this._createIntersectionSplitters(); + // Sweep handles whose crossing no longer exists after a topology change. + this._intersectionSplitter = this._intersectionSplitter.filter(record => { + if (ensuredKeys.has(record.key)) { + return true; + } + record.splitter._$destroy(); + return false; + }); for (let i = 0; i < this._intersectionSplitter.length; i++) { - const intersection = this._intersectionSplitter[i]; - this._positionIntersectionSplitter(intersection); + this._positionIntersectionSplitter(this._intersectionSplitter[i]); } // If handles were added/removed due to topology change, run one more pass @@ -811,31 +853,6 @@ export default class RowOrColumn extends AbstractContentItem { AbstractContentItem.prototype._$destroy.call(this); } - private _isIntersectionTopologyValid( - parentSplitterIndex: number, - childItemIndex: number, - childSplitterIndex: number - ) { - const parentSplitter = this._splitter[parentSplitterIndex]; - const childItem = this.contentItems[childItemIndex] as RowOrColumn; - const childSplitter = childItem?._splitter[childSplitterIndex]; - - return parentSplitter != null && childItem != null && childSplitter != null; - } - - /** - * Returns the item cast to RowOrColumn iff it is a perpendicular child of - * this RowOrColumn (i.e. a row inside a column or vice versa), otherwise null. - */ - private _asPerpendicularRowOrColumn( - item: AbstractContentItem | undefined - ): RowOrColumn | null { - if (!(item instanceof RowOrColumn)) { - return null; - } - return item._isColumn !== this._isColumn ? item : null; - } - /** * Create a single intersection splitter anchored in this row/column overlay * at the given coordinates. @@ -843,14 +860,16 @@ export default class RowOrColumn extends AbstractContentItem { private _ensureIntersectionSplitter( key: string, parentSplitterIndex: number, - childItemIndex: number, - childSplitterIndex: number + stemOwner: RowOrColumn, + stemSplitterIndex: number, + junctionAtNearEdge: boolean ) { const existing = this._intersectionSplitter.find(item => item.key === key); if (existing != null) { existing.parentSplitterIndex = parentSplitterIndex; - existing.childItemIndex = childItemIndex; - existing.childSplitterIndex = childSplitterIndex; + existing.stemOwner = stemOwner; + existing.stemSplitterIndex = stemSplitterIndex; + existing.junctionAtNearEdge = junctionAtNearEdge; return; } @@ -859,60 +878,37 @@ export default class RowOrColumn extends AbstractContentItem { this._splitterGrabSize ); - intersectionSplitter.on( - 'dragStart', - this._onIntersectionSplitterDragStart.bind( - this, - intersectionSplitter, - parentSplitterIndex, - childItemIndex, - childSplitterIndex - ), - this + // Handlers close over the record so reuse (which mutates the record in + // place) keeps them pointed at the current crossing. + const record: IntersectionRecord = { + splitter: intersectionSplitter, + key, + parentSplitterIndex, + stemOwner, + stemSplitterIndex, + junctionAtNearEdge, + }; + + intersectionSplitter.on('dragStart', () => + this._onIntersectionSplitterDragStart(record) ); - intersectionSplitter.on( - 'drag', - this._onIntersectionSplitterDrag.bind( - this, - intersectionSplitter, - parentSplitterIndex, - childItemIndex, - childSplitterIndex - ), - this + intersectionSplitter.on('drag', (offsetX: number, offsetY: number) => + this._onIntersectionSplitterDrag(record, offsetX, offsetY) ); - intersectionSplitter.on( - 'dragStop', - this._onIntersectionSplitterDragStop.bind( - this, - intersectionSplitter, - parentSplitterIndex, - childItemIndex, - childSplitterIndex - ), - this + intersectionSplitter.on('dragStop', () => + this._onIntersectionSplitterDragStop(record) ); // Highlight both perpendicular lines while hovering the grab area, mirroring // the active line affordance used for 1D splitter drags. intersectionSplitter.element.on('mouseenter', () => { this._isIntersectionHovered = true; - this._setIntersectionHighlight( - parentSplitterIndex, - childItemIndex, - childSplitterIndex, - true - ); + this._setIntersectionHighlight(record, true); }); intersectionSplitter.element.on('mouseleave', () => { this._isIntersectionHovered = false; if (!this._isIntersectionDragging) { - this._setIntersectionHighlight( - parentSplitterIndex, - childItemIndex, - childSplitterIndex, - false - ); + this._setIntersectionHighlight(record, false); } }); @@ -925,91 +921,87 @@ export default class RowOrColumn extends AbstractContentItem { }); this.childElementContainer.append(intersectionSplitter.element); - this._intersectionSplitter.push({ - splitter: intersectionSplitter, - key, - parentSplitterIndex, - childItemIndex, - childSplitterIndex, - }); + this._intersectionSplitter.push(record); } - private _positionIntersectionSplitter(intersection: { - splitter: IntersectionSplitter; - parentSplitterIndex: number; - childItemIndex: number; - childSplitterIndex: number; - }) { - const position = this._getIntersectionPosition( - intersection.parentSplitterIndex, - intersection.childItemIndex, - intersection.childSplitterIndex - ); + private _positionIntersectionSplitter(record: IntersectionRecord) { + const position = this._getIntersectionPosition(record); if (position == null) { return; } - intersection.splitter.element.css({ + record.splitter.element.css({ left: position.left, top: position.top, }); } /** - * Compute intersection coordinates relative to this row/column container. + * Compute intersection coordinates (the centre of the crossing) relative to + * this row/column container. + * + * Uses `getBoundingClientRect` for the container and both splitter elements + * rather than jQuery `.position()`. `.position()` is relative to each + * element's offset parent, which varies with nesting and `position: relative` + * on intermediate items, so adding those values together mis-places the + * handle at some crossings. Rect-based deltas are independent of the offset + * parent chain and always land on the visual crossing. */ private _getIntersectionPosition( - parentSplitterIndex: number, - childItemIndex: number, - childSplitterIndex: number + record: IntersectionRecord ): { left: number; top: number } | null { - const parentSplitter = this._splitter[parentSplitterIndex]; - const childItem = this.contentItems[childItemIndex] as RowOrColumn; - const childSplitter = childItem?._splitter[childSplitterIndex]; - - if (parentSplitter == null || childItem == null || childSplitter == null) { - return null; - } + const parentSplitter = this._splitter[record.parentSplitterIndex]; + const childSplitter = record.stemOwner?._splitter[record.stemSplitterIndex]; - const parentPos = parentSplitter.element.position(); - const childItemPos = childItem.element.position(); - const childSplitterPos = childSplitter.element.position(); + const container = this.childElementContainer[0]; + const parentEl = parentSplitter?.element[0]; + const childEl = childSplitter?.element[0]; - if (parentPos == null || childItemPos == null || childSplitterPos == null) { + if (container == null || parentEl == null || childEl == null) { return null; } + const containerRect = container.getBoundingClientRect(); + const parentRect = parentEl.getBoundingClientRect(); + const childRect = childEl.getBoundingClientRect(); + + const parentCenterX = + parentRect.left + parentRect.width / 2 - containerRect.left; + const parentCenterY = + parentRect.top + parentRect.height / 2 - containerRect.top; + const childCenterX = + childRect.left + childRect.width / 2 - containerRect.left; + const childCenterY = + childRect.top + childRect.height / 2 - containerRect.top; + + // The parent splitter runs along the cross axis (the "bar") and the child + // splitter along the main axis (the "stem"); take each line's centre. if (this._isColumn) { - return { - left: (childItemPos.left ?? 0) + (childSplitterPos.left ?? 0), - top: parentPos.top ?? 0, - }; + return { left: childCenterX, top: parentCenterY }; } - return { - left: parentPos.left ?? 0, - top: (childItemPos.top ?? 0) + (childSplitterPos.top ?? 0), - }; + return { left: parentCenterX, top: childCenterY }; } /** * Toggle the active-line highlight on both splitters that meet at an * intersection. Reuses the standard `.lm_dragging` line style so the 2D - * affordance is visually identical to the existing 1D drag affordance. + * affordance is visually identical to the existing 1D drag affordance, and + * adds `.lm_intersection_line` to lift the lines above pane content so an + * offset junction renders cleanly instead of being clipped by a neighbour. */ private _setIntersectionHighlight( - parentSplitterIndex: number, - childItemIndex: number, - childSplitterIndex: number, + record: IntersectionRecord, highlighted: boolean ) { - const parentSplitter = this._splitter[parentSplitterIndex]; - const childItem = this.contentItems[childItemIndex] as RowOrColumn; - const childSplitter = childItem?._splitter[childSplitterIndex]; + const parentSplitter = this._splitter[record.parentSplitterIndex]; + const childSplitter = record.stemOwner?._splitter[record.stemSplitterIndex]; parentSplitter?.element.toggleClass('lm_dragging', highlighted); + parentSplitter?.element.toggleClass('lm_intersection_line', highlighted); childSplitter?.element.toggleClass('lm_dragging', highlighted); + childSplitter?.element.toggleClass('lm_intersection_line', highlighted); } /** @@ -1017,27 +1009,16 @@ export default class RowOrColumn extends AbstractContentItem { * Calculates movement bounds for both axes (via the existing 1D logic) so the * drag stays within valid ranges, and highlights both perpendicular lines. */ - private _onIntersectionSplitterDragStart( - intersectionSplitter: IntersectionSplitter, - parentSplitterIndex: number, - childItemIndex: number, - childSplitterIndex: number - ) { - const parentSplitter = this._splitter[parentSplitterIndex]; - const childBefore = this.contentItems[childItemIndex] as RowOrColumn; - const childSplitter = childBefore._splitter[childSplitterIndex]; + private _onIntersectionSplitterDragStart(record: IntersectionRecord) { + const parentSplitter = this._splitter[record.parentSplitterIndex]; + const childSplitter = record.stemOwner._splitter[record.stemSplitterIndex]; // Reuse the existing 1D splitter drag logic to compute bounds for each axis. this._onSplitterDragStart(parentSplitter); - childBefore._onSplitterDragStart(childSplitter); + record.stemOwner._onSplitterDragStart(childSplitter); this._isIntersectionDragging = true; - this._setIntersectionHighlight( - parentSplitterIndex, - childItemIndex, - childSplitterIndex, - true - ); + this._setIntersectionHighlight(record, true); $(document.body).addClass('lm_intersection_dragging'); } @@ -1045,21 +1026,52 @@ export default class RowOrColumn extends AbstractContentItem { * Invoked when an intersection splitter's DragListener fires drag. Moves both * splitter lines by delegating to the existing 1D logic, which clamps each * axis to its own valid range. The lines moving form the 2D drag affordance. + * + * The stem line spans the full extent of its owner along the parent axis, so + * when the parent line moves the junction would otherwise detach. The stem is + * stretched to follow the parent line while its far tip stays anchored. + * + * The stretch is applied with a CSS `transform: scale(...)` about the far tip + * rather than by changing the line's `width`/`height`/`top`/`left`. Splitter + * lines are real in-flow elements (floated / `position: relative`), so + * mutating their box size reflows sibling panes and headers (tabs jump, + * content shifts, gaps appear). A transform is painted without affecting + * layout, so the affordance stretches cleanly even for deeply nested grids. */ private _onIntersectionSplitterDrag( - intersectionSplitter: IntersectionSplitter, - parentSplitterIndex: number, - childItemIndex: number, - childSplitterIndex: number, + record: IntersectionRecord, offsetX: number, offsetY: number ) { - const parentSplitter = this._splitter[parentSplitterIndex]; - const childBefore = this.contentItems[childItemIndex] as RowOrColumn; - const childSplitter = childBefore._splitter[childSplitterIndex]; + const parentSplitter = this._splitter[record.parentSplitterIndex]; + const childSplitter = record.stemOwner._splitter[record.stemSplitterIndex]; this._onSplitterDrag(parentSplitter, offsetX, offsetY); - childBefore._onSplitterDrag(childSplitter, offsetX, offsetY); + record.stemOwner._onSplitterDrag(childSplitter, offsetX, offsetY); + + // Scale the stem line along the parent axis so its junction tip tracks the + // parent line while its far tip stays put. `_splitterPosition` is the + // parent's clamped offset; the stem owner extent gives the line's length. + const shift = this._splitterPosition ?? 0; + const fullLength = record.stemOwner.element[this._dimension]() ?? 0; + const newLength = record.junctionAtNearEdge + ? fullLength - shift + : fullLength + shift; + const scale = fullLength > 0 ? newLength / fullLength : 1; + + // Anchor the far tip: scale about the edge opposite the junction. + const farEdge = record.junctionAtNearEdge + ? this._isColumn + ? 'bottom' + : 'right' + : this._isColumn + ? 'top' + : 'left'; + + childSplitter.element.css({ + 'transform-origin': farEdge, + transform: this._isColumn ? `scaleY(${scale})` : `scaleX(${scale})`, + }); } /** @@ -1067,27 +1079,20 @@ export default class RowOrColumn extends AbstractContentItem { * Applies both axis updates atomically (via the existing 1D logic), clears the * highlight unless the pointer is still over the handle, then relayouts once. */ - private _onIntersectionSplitterDragStop( - intersectionSplitter: IntersectionSplitter, - parentSplitterIndex: number, - childItemIndex: number, - childSplitterIndex: number - ) { - const parentSplitter = this._splitter[parentSplitterIndex]; - const childBefore = this.contentItems[childItemIndex] as RowOrColumn; - const childSplitter = childBefore._splitter[childSplitterIndex]; + private _onIntersectionSplitterDragStop(record: IntersectionRecord) { + const parentSplitter = this._splitter[record.parentSplitterIndex]; + const childSplitter = record.stemOwner._splitter[record.stemSplitterIndex]; this._applySplitterDragStop(parentSplitter); - childBefore._applySplitterDragStop(childSplitter); + record.stemOwner._applySplitterDragStop(childSplitter); + + // Clear the stretch transform applied during drag so the stem line falls + // back to its CSS full-extent size once the layout is reapplied. + childSplitter.element.css({ transform: '', 'transform-origin': '' }); this._isIntersectionDragging = false; if (!this._isIntersectionHovered) { - this._setIntersectionHighlight( - parentSplitterIndex, - childItemIndex, - childSplitterIndex, - false - ); + this._setIntersectionHighlight(record, false); } $(document.body).removeClass('lm_intersection_dragging');