From cbc16e7c9f3d3223ac0aa943b1a6acf245a42280 Mon Sep 17 00:00:00 2001 From: AlexanderMoiseev Date: Wed, 28 Feb 2024 20:04:49 +0400 Subject: [PATCH] splitter: multiple panes resize support (#26759) Co-authored-by: EugeniyKiyashko --- .../js/__internal/ui/splitter/splitter.ts | 33 ++++-- .../js/__internal/ui/splitter/utils/layout.ts | 106 ++++++++++-------- .../DevExpress.ui.widgets/splitter.tests.js | 43 ++++--- 3 files changed, 112 insertions(+), 70 deletions(-) diff --git a/packages/devextreme/js/__internal/ui/splitter/splitter.ts b/packages/devextreme/js/__internal/ui/splitter/splitter.ts index d7f9ceb08fe9..10d2cebbc332 100644 --- a/packages/devextreme/js/__internal/ui/splitter/splitter.ts +++ b/packages/devextreme/js/__internal/ui/splitter/splitter.ts @@ -15,9 +15,12 @@ import { RESIZE_EVENT, } from './utils/event'; import { - findLastIndexOfVisibleItem, getCurrentLayout, getDelta, + calculateDelta, + findLastIndexOfVisibleItem, + getCurrentLayout, + getDimensionByOrientation, getInitialLayout, - getNewLayoutState, + getNewLayout, setFlexProp, updateItemsSize, } from './utils/layout'; @@ -132,6 +135,7 @@ class Splitter extends (CollectionWidget as any) { _getResizeHandleConfig(paneId: string): object { const { orientation, + rtlEnabled, allowKeyboardNavigation, } = this.option(); @@ -142,24 +146,35 @@ class Splitter extends (CollectionWidget as any) { 'aria-controls': paneId, }, onResizeStart: (e): void => { - this.layoutState = getCurrentLayout(this._itemElements()); + this._$visibleItems = this._getVisibleItems(); + this._currentLayout = getCurrentLayout(this._$visibleItems); + this._activeResizeHandleIndex = this._getResizeHandleItems().index(e.element); + + this._splitterItemsSize = this._getSummaryItemsSize( + getDimensionByOrientation(orientation), + this._$visibleItems, + true, + ); this._getAction(RESIZE_EVENT.onResizeStart)({ event: e, }); }, - onResize: (e): void => { - const handle = e.event.target; - const delta = getDelta(e.event.offset, this.option('orientation'), this.option('rtlEnabled'), this.$element()); - const newLayout = getNewLayoutState(delta, handle, this.layoutState, this._itemElements()); - updateItemsSize(this._itemElements(), newLayout); + const { event } = e; + + const newLayout = getNewLayout( + this._currentLayout, + calculateDelta(event.offset, orientation, rtlEnabled, this._splitterItemsSize), + this._activeResizeHandleIndex, + ); + + updateItemsSize(this._$visibleItems, newLayout); this._getAction(RESIZE_EVENT.onResize)({ event: e, }); }, - onResizeEnd: (e): void => { this._getAction(RESIZE_EVENT.onResizeEnd)({ event: e, diff --git a/packages/devextreme/js/__internal/ui/splitter/utils/layout.ts b/packages/devextreme/js/__internal/ui/splitter/utils/layout.ts index 91de8b161f92..8cb020c58a74 100644 --- a/packages/devextreme/js/__internal/ui/splitter/utils/layout.ts +++ b/packages/devextreme/js/__internal/ui/splitter/utils/layout.ts @@ -1,3 +1,4 @@ +import type { dxElementWrapper } from '@js/core/renderer'; import $ from '@js/core/renderer'; import { normalizeStyleProp, styleProp, @@ -5,21 +6,19 @@ import { import type { CollectionWidgetItem as Item } from '@js/ui/collection/ui.collection_widget.base'; const FLEX_PROPERTY_NAME = 'flexGrow'; -const INVISIBLE_STATE_CLASS = 'dx-state-invisible'; -const RESIZE_HANDLE_CLASS = 'dx-resize-handle'; -const DEFAULT_RESIZE_HANDLE_SIZE = 8; const ORIENTATION = { horizontal: 'horizontal', vertical: 'vertical', }; -// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types -export function getCurrentLayout(items): number[] { - const itemsDistribution = []; - items.each((index, item) => { - // @ts-expect-error todo: fix error - itemsDistribution.push(parseFloat($(item).css(FLEX_PROPERTY_NAME))); +export function getCurrentLayout($items: dxElementWrapper): number[] { + const itemsDistribution: number[] = []; + $items.each((index, item) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + itemsDistribution.push(parseFloat(($(item) as any).css(FLEX_PROPERTY_NAME))); + + return true; }); return itemsDistribution; @@ -35,61 +34,78 @@ export function findLastIndexOfVisibleItem(items: any[]): number { return -1; } -// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types -function findNextVisibleItemIndex(items, itemIndex: number): number { - for (let i = itemIndex + 1; i < items.length; i += 1) { - if (!$(items[i]).hasClass(INVISIBLE_STATE_CLASS)) { - return i; - } +// eslint-disable-next-line max-len +function findMaxAvailableDelta(currentLayout, firstItemIndex, secondItemIndex, isSizeDecreasing): number { + const firstIndex = isSizeDecreasing ? 0 : secondItemIndex; + const lastIndex = isSizeDecreasing ? firstItemIndex : currentLayout.length - 1; + let maxAvailableDelta = 0; + + // eslint-disable-next-line @typescript-eslint/restrict-plus-operands + for (let i = firstIndex; i <= lastIndex; i += 1) { + // eslint-disable-next-line @typescript-eslint/restrict-plus-operands + maxAvailableDelta += currentLayout[i]; } - return -1; + + return maxAvailableDelta; } // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types -export function getNewLayoutState(delta, handle, currentLayout, items): number[] { - const newLayoutState = [...currentLayout]; - - // @ts-expect-error todo: fix error - const firstItemIndex: number = $(handle).prev().data().dxItemIndex; - const secondItemIndex = findNextVisibleItemIndex(items, firstItemIndex); +export function getNewLayout( + currentLayout: number[], + delta: number, + prevPaneIndex: number, +): number[] { + const newLayout = [...currentLayout]; + + const firstItemIndex: number = prevPaneIndex; + const secondItemIndex = firstItemIndex + 1; + + const isSizeDecreasing = delta < 0; + // eslint-disable-next-line max-len + const maxAvailableDelta = findMaxAvailableDelta(currentLayout, firstItemIndex, secondItemIndex, isSizeDecreasing); + let currentSplitterItemIndex = isSizeDecreasing ? firstItemIndex : secondItemIndex; + const actualDelta: number = Math.min(Math.abs(delta), maxAvailableDelta); + let remainingDelta = actualDelta; + + while (remainingDelta > 0) { + const currentSize = currentLayout[currentSplitterItemIndex]; + if (currentSize >= remainingDelta) { + newLayout[currentSplitterItemIndex] = currentSize - remainingDelta; + remainingDelta = 0; + } else { + remainingDelta -= currentSize; + newLayout[currentSplitterItemIndex] = 0; + } - const decreasingItemIndex = delta < 0 ? firstItemIndex : secondItemIndex; - const currentSize = currentLayout[decreasingItemIndex]; - const actualDelta: number = Math.min(Math.abs(delta), currentSize); - newLayoutState[decreasingItemIndex] = currentSize - actualDelta; + currentSplitterItemIndex += isSizeDecreasing ? -1 : 1; + } - const increasingItemIndex = delta < 0 ? secondItemIndex : firstItemIndex; + const increasingItemIndex = isSizeDecreasing ? secondItemIndex : firstItemIndex; // eslint-disable-next-line @typescript-eslint/restrict-plus-operands - newLayoutState[increasingItemIndex] = currentLayout[increasingItemIndex] + actualDelta; + newLayout[increasingItemIndex] = currentLayout[increasingItemIndex] + actualDelta; // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return newLayoutState; + return newLayout; } // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types -function getOffsetValue(offset, orientation, rtlEnabled): number { +function normalizeOffset(offset, orientation, rtlEnabled): number { const xOffset: number = rtlEnabled ? -offset.x : offset.x; // eslint-disable-next-line @typescript-eslint/no-unsafe-return return orientation === ORIENTATION.horizontal ? xOffset : offset.y; } -// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types -function getElementItemsSizeSum($element, orientation): number { - const splitterSize = $element.get(0).getBoundingClientRect(); - const size: number = orientation === ORIENTATION.horizontal - ? splitterSize.width : splitterSize.height; - - const handlesCount = $element.children(`.${RESIZE_HANDLE_CLASS}`).length; - - const handlesSizeSum = handlesCount * DEFAULT_RESIZE_HANDLE_SIZE; - - return size - handlesSizeSum; +export function getDimensionByOrientation(orientation: string): string { + return orientation === ORIENTATION.horizontal ? 'width' : 'height'; } -// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types -export function getDelta(offset, orientation, rtlEnabled, $element): number { - const delta = (getOffsetValue(offset, orientation, rtlEnabled) - / getElementItemsSizeSum($element, orientation)) * 100; +export function calculateDelta( + offset: number, + orientation: string, + rtlEnabled: boolean, + totalWidth: number, +): number { + const delta = (normalizeOffset(offset, orientation, rtlEnabled) / totalWidth) * 100; return delta; } diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/splitter.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/splitter.tests.js index f9d64eb82d14..9cd7344fa770 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/splitter.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/splitter.tests.js @@ -44,7 +44,7 @@ const moduleConfig = { QUnit.module('Resizing', moduleConfig, () => { function assertLayout(items, expectedLayout, assert) { - items.toArray().forEach((item, index) => { + items.filter(':visible').toArray().forEach((item, index) => { assert.strictEqual(item.style.flexGrow, expectedLayout[index]); }); } @@ -84,7 +84,7 @@ QUnit.module('Resizing', moduleConfig, () => { const items = this.$element.find(`.${SPLITTER_ITEM_CLASS}`); - assertLayout(items, ['50', '0', '50'], assert); + assertLayout(items, ['50', '50'], assert); }); QUnit.test(`first and second items resize should work when middle item is invisible, ${orientation} orientation`, function(assert) { @@ -99,14 +99,14 @@ QUnit.module('Resizing', moduleConfig, () => { const pointer = pointerMock(this.getResizeHandles().eq(0)); pointer.start().dragStart().drag(-25, -25).dragEnd(); - assertLayout(items, ['12.5', '37.5', '0', '25', '25'], assert); + assertLayout(items, ['12.5', '37.5', '25', '25'], assert); }); QUnit.test(`last items resize should work when middle item is invisible, ${orientation} orientation`, function(assert) { this.reinit({ width: 224, height: 224, orientation, - dataSource: [{}, {}, { visible: false, }, { }, {}] + dataSource: [{}, {}, { visible: false, }, {}, {}] }); const items = this.$element.children(`.${SPLITTER_ITEM_CLASS}`); @@ -114,7 +114,7 @@ QUnit.module('Resizing', moduleConfig, () => { const pointer = pointerMock(this.getResizeHandles().eq(2)); pointer.start().dragStart().drag(-25, -25).dragEnd(); - assertLayout(items, ['25', '25', '0', '12.5', '37.5'], assert); + assertLayout(items, ['25', '25', '12.5', '37.5'], assert); }); QUnit.test(`items should be resized when their neighbour item is not visible, ${orientation} orientation`, function(assert) { @@ -129,7 +129,7 @@ QUnit.module('Resizing', moduleConfig, () => { const pointer = pointerMock(this.getResizeHandles().eq(0)); pointer.start().dragStart().drag(50, 50).dragEnd(); - assertLayout(items, ['75', '0', '25'], assert); + assertLayout(items, ['75', '25'], assert); }); QUnit.test(`last two items should be able to resize when first item is not visible, ${orientation} orientation`, function(assert) { @@ -144,7 +144,7 @@ QUnit.module('Resizing', moduleConfig, () => { const pointer = pointerMock(this.getResizeHandles().eq(0)); pointer.start().dragStart().drag(50, 50).dragEnd(); - assertLayout(items, ['0', '75', '25'], assert); + assertLayout(items, ['75', '25'], assert); }); QUnit.test(`splitter should have no resize handles if only 1 item is visible, ${orientation} orientation`, function(assert) { @@ -253,26 +253,37 @@ QUnit.module('Resizing', moduleConfig, () => { }); }); - QUnit.test('resize item should not be resized beyound neighbour', function(assert) { - this.reinit({ height: 208, orientation: 'vertical', dataSource: [{ }, { }, { }, { }] }); + [ + { resizeDistance: 200, expectedSize: ['75', '0', '0', '25'], handleIndex: 0, orientation: 'horizontal', rtl: false }, + { resizeDistance: 200, expectedSize: ['75', '0', '0', '25'], handleIndex: 0, orientation: 'vertical', rtl: false }, + { resizeDistance: 200, expectedSize: ['0', '50', '25', '25'], handleIndex: 0, orientation: 'horizontal', rtl: true }, + { resizeDistance: 300, expectedSize: ['100', '0', '0', '0'], handleIndex: 0, orientation: 'horizontal', rtl: false }, + { resizeDistance: 300, expectedSize: ['100', '0', '0', '0'], handleIndex: 0, orientation: 'vertical', rtl: false }, + { resizeDistance: 200, expectedSize: ['25', '75', '0', '0'], handleIndex: 1, orientation: 'horizontal', rtl: false }, + { resizeDistance: 200, expectedSize: ['25', '75', '0', '0'], handleIndex: 1, orientation: 'vertical', rtl: false }, + { resizeDistance: 200, expectedSize: ['25', '0', '0', '75'], handleIndex: 2, orientation: 'horizontal', rtl: true }, + ].forEach(({ resizeDistance, expectedSize, handleIndex, orientation, rtl }) => { + QUnit.test(`should resize all panes on the way, ${orientation} orientation`, function(assert) { + this.reinit({ width: 424, height: 424, dataSource: [{ }, { }, { }, { }], orientation, rtlEnabled: rtl }); - const items = this.$element.find(`.${SPLITTER_ITEM_CLASS}`); + const items = this.$element.find(`.${SPLITTER_ITEM_CLASS}`); - const pointer = pointerMock(this.getResizeHandles().eq(0)); - pointer.start().dragStart().drag(0, 400).dragEnd(); + const pointer = pointerMock(this.getResizeHandles().eq(handleIndex)); + pointer.start().dragStart().drag(resizeDistance, resizeDistance).dragEnd(); - assertLayout(items, ['50', '0', '25', '25'], assert); + assertLayout(items, expectedSize, assert); + }); }); - QUnit.test('resize item with nested splitter should not be resized beyound neighbour', function(assert) { + QUnit.test('resize item with nested splitter should resize all panes beyound neighbour', function(assert) { this.reinit({ width: 208, dataSource: [ { splitter: { dataSource: [{ }] } }, { }, { }, { splitter: { dataSource: [{ }] } }] }); const items = this.$element.children(`.${SPLITTER_ITEM_CLASS}`); - const pointer = pointerMock(this.getResizeHandles().eq(1)); + const pointer = pointerMock(this.getResizeHandles().eq(2)); pointer.start().dragStart().drag(-400, 0).dragEnd(); - assertLayout(items, ['25', '0', '50', '25'], assert); + assertLayout(items, ['0', '0', '0', '100'], assert); }); });