diff --git a/packages/golden-layout/scss/goldenlayout-base.scss b/packages/golden-layout/scss/goldenlayout-base.scss index 0538a8a0b9..583c4bba43 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; @@ -79,6 +84,32 @@ $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 +// 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) 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..67e79a1fa2 --- /dev/null +++ b/packages/golden-layout/src/__tests__/intersection-drag.test.ts @@ -0,0 +1,731 @@ +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('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(); + + 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(); + } + }); + + 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/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..02501995e5 100644 --- a/packages/golden-layout/src/items/RowOrColumn.ts +++ b/packages/golden-layout/src/items/RowOrColumn.ts @@ -1,10 +1,26 @@ 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'; +/** + * 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,6 +28,7 @@ export default class RowOrColumn extends AbstractContentItem { parent: AbstractContentItem | null; private _splitter: Splitter[] = []; + private _intersectionSplitter: IntersectionRecord[] = []; private _splitterSize: number; private _splitterGrabSize: number; private _isColumn: boolean; @@ -19,6 +36,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 +221,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 +242,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 +617,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 +644,458 @@ 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(): Set { + this.childElementContainer.css('position', 'relative'); + + const ensuredKeys = new Set(); + + for ( + let parentSplitterIndex = 0; + parentSplitterIndex < this._splitter.length; + parentSplitterIndex++ + ) { + const beforeItem = this.contentItems[parentSplitterIndex]; + const afterItem = this.contentItems[parentSplitterIndex + 1]; + + 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, + stem.stemOwner, + stem.stemSplitterIndex, + stem.junctionAtNearEdge + ); + } + } + + 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 + ); + } + } + + /** + * 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; + const ensuredKeys = 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++) { + this._positionIntersectionSplitter(this._intersectionSplitter[i]); + } + + // 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); + } + + /** + * Create a single intersection splitter anchored in this row/column overlay + * at the given coordinates. + */ + private _ensureIntersectionSplitter( + key: string, + parentSplitterIndex: number, + stemOwner: RowOrColumn, + stemSplitterIndex: number, + junctionAtNearEdge: boolean + ) { + const existing = this._intersectionSplitter.find(item => item.key === key); + if (existing != null) { + existing.parentSplitterIndex = parentSplitterIndex; + existing.stemOwner = stemOwner; + existing.stemSplitterIndex = stemSplitterIndex; + existing.junctionAtNearEdge = junctionAtNearEdge; + return; + } + + const intersectionSplitter = new IntersectionSplitter( + this._splitterSize, + this._splitterGrabSize + ); + + // 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', (offsetX: number, offsetY: number) => + this._onIntersectionSplitterDrag(record, offsetX, offsetY) + ); + 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(record, true); + }); + intersectionSplitter.element.on('mouseleave', () => { + this._isIntersectionHovered = false; + if (!this._isIntersectionDragging) { + this._setIntersectionHighlight(record, false); + } + }); + + intersectionSplitter.element.css({ + position: 'absolute', + left: 0, + top: 0, + transform: 'translate(-50%, -50%)', + zIndex: 60, + }); + + this.childElementContainer.append(intersectionSplitter.element); + this._intersectionSplitter.push(record); + } + + private _positionIntersectionSplitter(record: IntersectionRecord) { + const position = this._getIntersectionPosition(record); + + if (position == null) { + return; + } + + record.splitter.element.css({ + left: position.left, + top: position.top, + }); + } + + /** + * 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( + record: IntersectionRecord + ): { left: number; top: number } | null { + const parentSplitter = this._splitter[record.parentSplitterIndex]; + const childSplitter = record.stemOwner?._splitter[record.stemSplitterIndex]; + + const container = this.childElementContainer[0]; + const parentEl = parentSplitter?.element[0]; + const childEl = childSplitter?.element[0]; + + 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: childCenterX, top: parentCenterY }; + } + + 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, 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( + record: IntersectionRecord, + highlighted: boolean + ) { + 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); + } + + /** + * 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(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); + record.stemOwner._onSplitterDragStart(childSplitter); + + this._isIntersectionDragging = true; + this._setIntersectionHighlight(record, 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. + * + * 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( + record: IntersectionRecord, + offsetX: number, + offsetY: number + ) { + const parentSplitter = this._splitter[record.parentSplitterIndex]; + const childSplitter = record.stemOwner._splitter[record.stemSplitterIndex]; + + this._onSplitterDrag(parentSplitter, 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})`, + }); + } + + /** + * 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(record: IntersectionRecord) { + const parentSplitter = this._splitter[record.parentSplitterIndex]; + const childSplitter = record.stemOwner._splitter[record.stemSplitterIndex]; + + this._applySplitterDragStop(parentSplitter); + 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(record, false); + } + $(document.body).removeClass('lm_intersection_dragging'); + + this._scheduleSetSize(); } }