Skip to content

Commit

Permalink
splitter: multiple panes resize support (#26759)
Browse files Browse the repository at this point in the history
Co-authored-by: EugeniyKiyashko <[email protected]>
  • Loading branch information
AlexanderMoiseev and EugeniyKiyashko authored Feb 28, 2024
1 parent 60ededd commit cbc16e7
Show file tree
Hide file tree
Showing 3 changed files with 112 additions and 70 deletions.
33 changes: 24 additions & 9 deletions packages/devextreme/js/__internal/ui/splitter/splitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -132,6 +135,7 @@ class Splitter extends (CollectionWidget as any) {
_getResizeHandleConfig(paneId: string): object {
const {
orientation,
rtlEnabled,
allowKeyboardNavigation,
} = this.option();

Expand All @@ -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,
Expand Down
106 changes: 61 additions & 45 deletions packages/devextreme/js/__internal/ui/splitter/utils/layout.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,24 @@
import type { dxElementWrapper } from '@js/core/renderer';
import $ from '@js/core/renderer';
import {
normalizeStyleProp, styleProp,
} from '@js/core/utils/style';
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;
Expand All @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
});
}
Expand Down Expand Up @@ -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) {
Expand All @@ -99,22 +99,22 @@ 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}`);

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) {
Expand All @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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);
});
});

Expand Down

0 comments on commit cbc16e7

Please sign in to comment.