mirror of
https://github.com/ckaczor/azuredatastudio.git
synced 2026-01-19 01:25:36 -05:00
Rewrite scrollablesplitview (#11566)
* fix issues with scrollable, maybe * add debounce * remove scrollable * fix events * perf improvements * fix compile errors * fix more compile * add tests * maybe fix tests * 💄 * 💄 * maybe this will work * fix compile * try this * remove some unneeded functionality * fix comment
This commit is contained in:
@@ -1,214 +0,0 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { INavigator } from 'vs/base/common/navigator';
|
||||
|
||||
export interface IView {
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export interface IViewItem {
|
||||
view: IView;
|
||||
top: number;
|
||||
height: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export class HeightMap {
|
||||
|
||||
private heightMap: IViewItem[];
|
||||
private indexes: { [item: string]: number; };
|
||||
|
||||
constructor() {
|
||||
this.heightMap = [];
|
||||
this.indexes = {};
|
||||
}
|
||||
|
||||
public getContentHeight(): number {
|
||||
let last = this.heightMap[this.heightMap.length - 1];
|
||||
return !last ? 0 : last.top + last.height;
|
||||
}
|
||||
|
||||
public onInsertItems(iterator: INavigator<IViewItem>, afterItemId: string | null = null): number | undefined {
|
||||
let viewItem: IViewItem | null = null;
|
||||
let i: number, j: number;
|
||||
let totalSize: number;
|
||||
let sizeDiff = 0;
|
||||
|
||||
if (afterItemId === null) {
|
||||
i = 0;
|
||||
totalSize = 0;
|
||||
} else {
|
||||
i = this.indexes[afterItemId!] + 1;
|
||||
viewItem = this.heightMap[i - 1];
|
||||
|
||||
if (!viewItem) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('view item doesnt exist');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
totalSize = viewItem.top + viewItem.height;
|
||||
}
|
||||
|
||||
const startingIndex = i;
|
||||
|
||||
let itemsToInsert: IViewItem[] = [];
|
||||
|
||||
while (viewItem = iterator.next()) {
|
||||
viewItem.top = totalSize + sizeDiff;
|
||||
|
||||
this.indexes[viewItem.view.id!] = i++;
|
||||
itemsToInsert.push(viewItem);
|
||||
sizeDiff += viewItem.height;
|
||||
}
|
||||
|
||||
this.heightMap.splice(startingIndex, 0, ...itemsToInsert);
|
||||
|
||||
for (j = i; j < this.heightMap.length; j++) {
|
||||
viewItem = this.heightMap[j];
|
||||
viewItem.top += sizeDiff;
|
||||
this.indexes[viewItem.view.id!] = j;
|
||||
}
|
||||
|
||||
for (j = itemsToInsert.length - 1; j >= 0; j--) {
|
||||
this.onInsertItem(itemsToInsert[j]);
|
||||
}
|
||||
|
||||
for (j = this.heightMap.length - 1; j >= i; j--) {
|
||||
this.onRefreshItem(this.heightMap[j]);
|
||||
}
|
||||
|
||||
return sizeDiff;
|
||||
}
|
||||
|
||||
public onInsertItem(item: IViewItem): void {
|
||||
// noop
|
||||
}
|
||||
|
||||
// Contiguous items
|
||||
public onRemoveItems(iterator: INavigator<string>): void {
|
||||
let itemId: string | null = null;
|
||||
let viewItem: IViewItem;
|
||||
let startIndex: number | null = null;
|
||||
let i = 0;
|
||||
let sizeDiff = 0;
|
||||
|
||||
while (itemId = iterator.next()) {
|
||||
i = this.indexes[itemId];
|
||||
viewItem = this.heightMap[i];
|
||||
|
||||
if (!viewItem) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('view item doesnt exist');
|
||||
return;
|
||||
}
|
||||
|
||||
sizeDiff -= viewItem.height;
|
||||
delete this.indexes[itemId];
|
||||
this.onRemoveItem(viewItem);
|
||||
|
||||
if (startIndex === null) {
|
||||
startIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (sizeDiff === 0 || startIndex === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.heightMap.splice(startIndex, i - startIndex + 1);
|
||||
|
||||
for (i = startIndex; i < this.heightMap.length; i++) {
|
||||
viewItem = this.heightMap[i];
|
||||
viewItem.top += sizeDiff;
|
||||
this.indexes[viewItem.view.id!] = i;
|
||||
this.onRefreshItem(viewItem);
|
||||
}
|
||||
}
|
||||
|
||||
public onRemoveItem(item: IViewItem): void {
|
||||
// noop
|
||||
}
|
||||
|
||||
public onRefreshItem(item: IViewItem, needsRender: boolean = false): void {
|
||||
// noop
|
||||
}
|
||||
|
||||
protected updateSize(item: string, size: number): void {
|
||||
let i = this.indexes[item];
|
||||
|
||||
let viewItem = this.heightMap[i];
|
||||
|
||||
viewItem.height = size;
|
||||
}
|
||||
|
||||
protected updateTop(item: string, top: number): void {
|
||||
let i = this.indexes[item];
|
||||
|
||||
let viewItem = this.heightMap[i];
|
||||
|
||||
viewItem.top = top;
|
||||
}
|
||||
|
||||
public itemsCount(): number {
|
||||
return this.heightMap.length;
|
||||
}
|
||||
|
||||
public itemAt(position: number): string {
|
||||
return this.heightMap[this.indexAt(position)].view.id!;
|
||||
}
|
||||
|
||||
public withItemsInRange(start: number, end: number, fn: (item: string) => void): void {
|
||||
start = this.indexAt(start);
|
||||
end = this.indexAt(end);
|
||||
for (let i = start; i <= end; i++) {
|
||||
fn(this.heightMap[i].view.id!);
|
||||
}
|
||||
}
|
||||
|
||||
public indexAt(position: number): number {
|
||||
let left = 0;
|
||||
let right = this.heightMap.length;
|
||||
let center: number;
|
||||
let item: IViewItem;
|
||||
|
||||
// Binary search
|
||||
while (left < right) {
|
||||
center = Math.floor((left + right) / 2);
|
||||
item = this.heightMap[center];
|
||||
|
||||
if (position < item.top) {
|
||||
right = center;
|
||||
} else if (position >= item.top + item.height) {
|
||||
if (left === center) {
|
||||
break;
|
||||
}
|
||||
left = center;
|
||||
} else {
|
||||
return center;
|
||||
}
|
||||
}
|
||||
|
||||
return this.heightMap.length;
|
||||
}
|
||||
|
||||
public indexAfter(position: number): number {
|
||||
return Math.min(this.indexAt(position) + 1, this.heightMap.length);
|
||||
}
|
||||
|
||||
public itemAtIndex(index: number): IViewItem {
|
||||
return this.heightMap[index];
|
||||
}
|
||||
|
||||
public itemAfter(item: IViewItem): IViewItem {
|
||||
return this.heightMap[this.indexes[item.view.id!] + 1] || null;
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.heightMap = [];
|
||||
this.indexes = {};
|
||||
}
|
||||
}
|
||||
@@ -1,901 +0,0 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import 'vs/css!./media/scrollableSplitview';
|
||||
import { HeightMap, IView as HeightIView, IViewItem as HeightIViewItem } from './heightMap';
|
||||
|
||||
import { IDisposable, combinedDisposable, toDisposable } from 'vs/base/common/lifecycle';
|
||||
import { Emitter, Event } from 'vs/base/common/event';
|
||||
import * as types from 'vs/base/common/types';
|
||||
import * as dom from 'vs/base/browser/dom';
|
||||
import { clamp } from 'vs/base/common/numbers';
|
||||
import { range, firstIndex, pushToStart } from 'vs/base/common/arrays';
|
||||
import { Sash, Orientation, ISashEvent as IBaseSashEvent } from 'vs/base/browser/ui/sash/sash';
|
||||
import { ScrollableElement } from 'vs/base/browser/ui/scrollbar/scrollableElement';
|
||||
import { ArrayNavigator } from 'vs/base/common/navigator';
|
||||
import { mixin } from 'vs/base/common/objects';
|
||||
import { ScrollbarVisibility } from 'vs/base/common/scrollable';
|
||||
import { ISplitViewStyles, Sizing } from 'vs/base/browser/ui/splitview/splitview';
|
||||
import { Color } from 'vs/base/common/color';
|
||||
import { domEvent } from 'vs/base/browser/event';
|
||||
import { generateUuid } from 'vs/base/common/uuid';
|
||||
export { Orientation } from 'vs/base/browser/ui/sash/sash';
|
||||
|
||||
export interface ISplitViewOptions {
|
||||
orientation?: Orientation; // default Orientation.VERTICAL
|
||||
styles?: ISplitViewStyles;
|
||||
orthogonalStartSash?: Sash;
|
||||
orthogonalEndSash?: Sash;
|
||||
inverseAltBehavior?: boolean;
|
||||
enableResizing?: boolean;
|
||||
scrollDebounce?: number;
|
||||
verticalScrollbarVisibility?: ScrollbarVisibility;
|
||||
}
|
||||
|
||||
const defaultStyles: ISplitViewStyles = {
|
||||
separatorBorder: Color.transparent
|
||||
};
|
||||
|
||||
const defaultOptions: ISplitViewOptions = {
|
||||
enableResizing: false
|
||||
};
|
||||
|
||||
export interface IView extends HeightIView {
|
||||
readonly element: HTMLElement;
|
||||
readonly minimumSize: number;
|
||||
readonly maximumSize: number;
|
||||
readonly onDidChange: Event<number | undefined>;
|
||||
layout(size: number, orientation: Orientation): void;
|
||||
onAdd?(): void;
|
||||
onRemove?(): void;
|
||||
}
|
||||
|
||||
interface ISashEvent {
|
||||
sash: Sash;
|
||||
start: number;
|
||||
current: number;
|
||||
alt: boolean;
|
||||
}
|
||||
|
||||
interface IViewItem extends HeightIViewItem {
|
||||
view: IView;
|
||||
size: number;
|
||||
container: HTMLElement;
|
||||
disposable: IDisposable;
|
||||
layout(): void;
|
||||
onRemove: () => void;
|
||||
onAdd: () => void;
|
||||
}
|
||||
|
||||
interface ISashItem {
|
||||
sash: Sash;
|
||||
disposable: IDisposable;
|
||||
}
|
||||
|
||||
interface ISashDragState {
|
||||
index: number;
|
||||
start: number;
|
||||
current: number;
|
||||
sizes: number[];
|
||||
minDelta: number;
|
||||
maxDelta: number;
|
||||
alt: boolean;
|
||||
disposable: IDisposable;
|
||||
}
|
||||
|
||||
enum State {
|
||||
Idle,
|
||||
Busy
|
||||
}
|
||||
|
||||
function pushToEnd<T>(arr: T[], value: T): T[] {
|
||||
let didFindValue = false;
|
||||
|
||||
const result = arr.filter(v => {
|
||||
if (v === value) {
|
||||
didFindValue = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (didFindValue) {
|
||||
result.push(value);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export class ScrollableSplitView extends HeightMap implements IDisposable {
|
||||
|
||||
private orientation: Orientation;
|
||||
private el: HTMLElement;
|
||||
private sashContainer: HTMLElement;
|
||||
private viewContainer: HTMLElement;
|
||||
private scrollable: ScrollableElement;
|
||||
private size = 0;
|
||||
private contentSize = 0;
|
||||
private proportions: undefined | number[] = undefined;
|
||||
private viewItems: IViewItem[] = [];
|
||||
private sashItems: ISashItem[] = [];
|
||||
private sashDragState?: ISashDragState;
|
||||
private state: State = State.Idle;
|
||||
private inverseAltBehavior: boolean;
|
||||
|
||||
private lastRenderHeight?: number;
|
||||
private lastRenderTop?: number;
|
||||
|
||||
private options: ISplitViewOptions;
|
||||
|
||||
private dirtyState = false;
|
||||
|
||||
private _onDidSashChange = new Emitter<number>();
|
||||
readonly onDidSashChange = this._onDidSashChange.event;
|
||||
private _onDidSashReset = new Emitter<number>();
|
||||
readonly onDidSashReset = this._onDidSashReset.event;
|
||||
|
||||
private _onScroll = new Emitter<number>();
|
||||
readonly onScroll = this._onScroll.event;
|
||||
|
||||
get length(): number {
|
||||
return this.viewItems.length;
|
||||
}
|
||||
|
||||
get minimumSize(): number {
|
||||
return this.viewItems.reduce((r, item) => r + item.view.minimumSize, 0);
|
||||
}
|
||||
|
||||
get maximumSize(): number {
|
||||
return this.length === 0 ? Number.POSITIVE_INFINITY : this.viewItems.reduce((r, item) => r + item.view.maximumSize, 0);
|
||||
}
|
||||
|
||||
private _orthogonalStartSash: Sash | undefined;
|
||||
get orthogonalStartSash(): Sash | undefined { return this._orthogonalStartSash; }
|
||||
set orthogonalStartSash(sash: Sash | undefined) {
|
||||
for (const sashItem of this.sashItems) {
|
||||
sashItem.sash.orthogonalStartSash = sash;
|
||||
}
|
||||
|
||||
this._orthogonalStartSash = sash;
|
||||
}
|
||||
|
||||
private _orthogonalEndSash: Sash | undefined;
|
||||
get orthogonalEndSash(): Sash | undefined { return this._orthogonalEndSash; }
|
||||
set orthogonalEndSash(sash: Sash | undefined) {
|
||||
for (const sashItem of this.sashItems) {
|
||||
sashItem.sash.orthogonalEndSash = sash;
|
||||
}
|
||||
|
||||
this._orthogonalEndSash = sash;
|
||||
}
|
||||
|
||||
get sashes(): Sash[] {
|
||||
return this.sashItems.map(s => s.sash);
|
||||
}
|
||||
|
||||
constructor(container: HTMLElement, options: ISplitViewOptions = {}) {
|
||||
super();
|
||||
this.orientation = types.isUndefined(options.orientation) ? Orientation.VERTICAL : options.orientation;
|
||||
this.inverseAltBehavior = !!options.inverseAltBehavior;
|
||||
|
||||
this.options = mixin(options, defaultOptions, false);
|
||||
|
||||
this.el = document.createElement('div');
|
||||
this.scrollable = new ScrollableElement(this.el, { vertical: options.verticalScrollbarVisibility });
|
||||
Event.debounce(this.scrollable.onScroll, (l, e) => e, types.isNumber(this.options.scrollDebounce) ? this.options.scrollDebounce : 25)(e => {
|
||||
this.render(e.scrollTop, e.height);
|
||||
this.relayout();
|
||||
this._onScroll.fire(e.scrollTop);
|
||||
});
|
||||
let domNode = this.scrollable.getDomNode();
|
||||
dom.addClass(this.el, 'monaco-scroll-split-view');
|
||||
dom.addClass(domNode, 'monaco-split-view2');
|
||||
dom.addClass(domNode, this.orientation === Orientation.VERTICAL ? 'vertical' : 'horizontal');
|
||||
container.appendChild(domNode);
|
||||
|
||||
this.sashContainer = dom.append(this.el, dom.$('.sash-container'));
|
||||
this.viewContainer = dom.append(this.el, dom.$('.split-view-container'));
|
||||
|
||||
this.style(options.styles || defaultStyles);
|
||||
}
|
||||
|
||||
style(styles: ISplitViewStyles): void {
|
||||
if (styles.separatorBorder.isTransparent()) {
|
||||
dom.removeClass(this.el, 'separator-border');
|
||||
this.el.style.removeProperty('--separator-border');
|
||||
} else {
|
||||
dom.addClass(this.el, 'separator-border');
|
||||
this.el.style.setProperty('--separator-border', styles.separatorBorder.toString());
|
||||
}
|
||||
}
|
||||
|
||||
addViews(views: IView[], sizes: number[] | Sizing, index = this.viewItems.length): void {
|
||||
if (this.state !== State.Idle) {
|
||||
throw new Error('Cant modify splitview');
|
||||
}
|
||||
|
||||
this.state = State.Busy;
|
||||
|
||||
let currentIndex = index;
|
||||
for (let i = 0; i < views.length; i++) {
|
||||
let size: number | Sizing;
|
||||
if (Array.isArray(sizes)) {
|
||||
size = sizes[i];
|
||||
} else {
|
||||
size = sizes;
|
||||
}
|
||||
const view = views[i];
|
||||
view.id = view.id || generateUuid();
|
||||
// Add view
|
||||
const container = dom.$('.split-view-view');
|
||||
|
||||
// removed default adding of the view directly to the container
|
||||
|
||||
const onChangeDisposable = view.onDidChange(size => this.onViewChange(item, size));
|
||||
const containerDisposable = toDisposable(() => {
|
||||
if (container.parentElement) {
|
||||
this.viewContainer.removeChild(container);
|
||||
}
|
||||
this.onRemoveItems(new ArrayNavigator([item.view.id!]));
|
||||
});
|
||||
const disposable = combinedDisposable(onChangeDisposable, containerDisposable);
|
||||
|
||||
const onAdd = view.onAdd ? () => view.onAdd!() : () => { };
|
||||
const onRemove = view.onRemove ? () => view.onRemove!() : () => { };
|
||||
|
||||
const layoutContainer = this.orientation === Orientation.VERTICAL
|
||||
? () => item.container.style.height = `${item.size}px`
|
||||
: () => item.container.style.width = `${item.size}px`;
|
||||
|
||||
const layout = () => {
|
||||
layoutContainer();
|
||||
item.view.layout(item.size, this.orientation);
|
||||
};
|
||||
|
||||
let viewSize: number;
|
||||
|
||||
if (typeof size === 'number') {
|
||||
viewSize = size;
|
||||
} else if (size.type === 'split') {
|
||||
viewSize = this.getViewSize(size.index) / 2;
|
||||
} else {
|
||||
viewSize = view.minimumSize;
|
||||
}
|
||||
|
||||
const item: IViewItem = { onAdd, onRemove, view, container, size: viewSize, layout, disposable, height: viewSize, top: 0, width: 0 };
|
||||
this.viewItems.splice(currentIndex, 0, item);
|
||||
|
||||
this.onInsertItems(new ArrayNavigator([item]), currentIndex > 0 ? this.viewItems[currentIndex - 1].view.id : undefined);
|
||||
|
||||
// Add sash
|
||||
if (this.options.enableResizing && this.viewItems.length > 1) {
|
||||
const sash = this.orientation === Orientation.HORIZONTAL
|
||||
? new Sash(this.sashContainer, { getHorizontalSashTop: (sash: Sash) => this.getSashPosition(sash) }, {
|
||||
orientation: Orientation.HORIZONTAL,
|
||||
orthogonalStartSash: this.orthogonalStartSash,
|
||||
orthogonalEndSash: this.orthogonalEndSash
|
||||
})
|
||||
: new Sash(this.sashContainer, { getVerticalSashLeft: (sash: Sash) => this.getSashPosition(sash) }, {
|
||||
orientation: Orientation.VERTICAL,
|
||||
orthogonalStartSash: this.orthogonalStartSash,
|
||||
orthogonalEndSash: this.orthogonalEndSash
|
||||
});
|
||||
|
||||
const sashEventMapper = this.orientation === Orientation.VERTICAL
|
||||
? (e: IBaseSashEvent) => ({ sash, start: e.startY, current: e.currentY, alt: e.altKey } as ISashEvent)
|
||||
: (e: IBaseSashEvent) => ({ sash, start: e.startX, current: e.currentX, alt: e.altKey } as ISashEvent);
|
||||
|
||||
const onStart = Event.map(sash.onDidStart, sashEventMapper);
|
||||
const onStartDisposable = onStart(this.onSashStart, this);
|
||||
const onChange = Event.map(sash.onDidChange, sashEventMapper);
|
||||
const onChangeDisposable = onChange(this.onSashChange, this);
|
||||
const onEnd = Event.map(sash.onDidEnd, () => firstIndex(this.sashItems, item => item.sash === sash));
|
||||
const onEndDisposable = onEnd(this.onSashEnd, this);
|
||||
const onDidResetDisposable = sash.onDidReset(() => this._onDidSashReset.fire(firstIndex(this.sashItems, item => item.sash === sash)));
|
||||
|
||||
const disposable = combinedDisposable(onStartDisposable, onChangeDisposable, onEndDisposable, onDidResetDisposable, sash);
|
||||
const sashItem: ISashItem = { sash, disposable };
|
||||
|
||||
this.sashItems.splice(currentIndex - 1, 0, sashItem);
|
||||
}
|
||||
|
||||
container.appendChild(view.element);
|
||||
currentIndex++;
|
||||
}
|
||||
|
||||
let highPriorityIndex: number | undefined;
|
||||
|
||||
if (!types.isArray(sizes) && sizes.type === 'split') {
|
||||
highPriorityIndex = sizes.index;
|
||||
}
|
||||
|
||||
this.relayout(index, highPriorityIndex);
|
||||
this.state = State.Idle;
|
||||
|
||||
if (!types.isArray(sizes) && sizes.type === 'distribute') {
|
||||
this.distributeViewSizes();
|
||||
}
|
||||
|
||||
// Re-render the views. Set lastRenderTop and lastRenderHeight to undefined since
|
||||
// this isn't actually scrolling up or down
|
||||
let scrollTop = this.lastRenderTop!;
|
||||
let viewHeight = this.lastRenderHeight!;
|
||||
this.lastRenderTop = 0;
|
||||
this.lastRenderHeight = 0;
|
||||
this.render(scrollTop, viewHeight);
|
||||
}
|
||||
|
||||
addView(view: IView, size: number | Sizing, index = this.viewItems.length): void {
|
||||
if (this.state !== State.Idle) {
|
||||
throw new Error('Cant modify splitview');
|
||||
}
|
||||
|
||||
this.state = State.Busy;
|
||||
|
||||
view.id = view.id || generateUuid();
|
||||
// Add view
|
||||
const container = dom.$('.split-view-view');
|
||||
|
||||
// removed default adding of the view directly to the container
|
||||
|
||||
const onChangeDisposable = view.onDidChange(size => this.onViewChange(item, size));
|
||||
const containerDisposable = toDisposable(() => {
|
||||
if (container.parentElement) {
|
||||
this.viewContainer.removeChild(container);
|
||||
}
|
||||
this.onRemoveItems(new ArrayNavigator([item.view.id!]));
|
||||
});
|
||||
const disposable = combinedDisposable(onChangeDisposable, containerDisposable);
|
||||
|
||||
const onAdd = view.onAdd ? () => view.onAdd!() : () => { };
|
||||
const onRemove = view.onRemove ? () => view.onRemove!() : () => { };
|
||||
|
||||
const layoutContainer = this.orientation === Orientation.VERTICAL
|
||||
? () => item.container.style.height = `${item.size}px`
|
||||
: () => item.container.style.width = `${item.size}px`;
|
||||
|
||||
const layout = () => {
|
||||
layoutContainer();
|
||||
item.view.layout(item.size, this.orientation);
|
||||
};
|
||||
|
||||
let viewSize: number;
|
||||
|
||||
if (typeof size === 'number') {
|
||||
viewSize = size;
|
||||
} else if (size.type === 'split') {
|
||||
viewSize = this.getViewSize(size.index) / 2;
|
||||
} else {
|
||||
viewSize = view.minimumSize;
|
||||
}
|
||||
|
||||
const item: IViewItem = { onAdd, onRemove, view, container, size: viewSize, layout, disposable, height: viewSize, top: 0, width: 0 };
|
||||
this.viewItems.splice(index, 0, item);
|
||||
|
||||
this.onInsertItems(new ArrayNavigator([item]), index > 0 ? this.viewItems[index - 1].view.id : undefined);
|
||||
|
||||
// Add sash
|
||||
if (this.options.enableResizing && this.viewItems.length > 1) {
|
||||
const sash = this.orientation === Orientation.HORIZONTAL
|
||||
? new Sash(this.sashContainer, { getHorizontalSashTop: (sash: Sash) => this.getSashPosition(sash) }, {
|
||||
orientation: Orientation.HORIZONTAL,
|
||||
orthogonalStartSash: this.orthogonalStartSash,
|
||||
orthogonalEndSash: this.orthogonalEndSash
|
||||
})
|
||||
: new Sash(this.sashContainer, { getVerticalSashLeft: (sash: Sash) => this.getSashPosition(sash) }, {
|
||||
orientation: Orientation.VERTICAL,
|
||||
orthogonalStartSash: this.orthogonalStartSash,
|
||||
orthogonalEndSash: this.orthogonalEndSash
|
||||
});
|
||||
|
||||
const sashEventMapper = this.orientation === Orientation.VERTICAL
|
||||
? (e: IBaseSashEvent) => ({ sash, start: e.startY, current: e.currentY, alt: e.altKey } as ISashEvent)
|
||||
: (e: IBaseSashEvent) => ({ sash, start: e.startX, current: e.currentX, alt: e.altKey } as ISashEvent);
|
||||
|
||||
const onStart = Event.map(sash.onDidStart, sashEventMapper);
|
||||
const onStartDisposable = onStart(this.onSashStart, this);
|
||||
const onChange = Event.map(sash.onDidChange, sashEventMapper);
|
||||
const onChangeDisposable = onChange(this.onSashChange, this);
|
||||
const onEnd = Event.map(sash.onDidEnd, () => firstIndex(this.sashItems, item => item.sash === sash));
|
||||
const onEndDisposable = onEnd(this.onSashEnd, this);
|
||||
const onDidResetDisposable = sash.onDidReset(() => this._onDidSashReset.fire(firstIndex(this.sashItems, item => item.sash === sash)));
|
||||
|
||||
const disposable = combinedDisposable(onStartDisposable, onChangeDisposable, onEndDisposable, onDidResetDisposable, sash);
|
||||
const sashItem: ISashItem = { sash, disposable };
|
||||
|
||||
this.sashItems.splice(index - 1, 0, sashItem);
|
||||
}
|
||||
|
||||
container.appendChild(view.element);
|
||||
|
||||
let highPriorityIndex: number | undefined;
|
||||
|
||||
if (typeof size !== 'number' && size.type === 'split') {
|
||||
highPriorityIndex = size.index;
|
||||
}
|
||||
|
||||
this.relayout(index, highPriorityIndex);
|
||||
this.state = State.Idle;
|
||||
|
||||
if (typeof size !== 'number' && size.type === 'distribute') {
|
||||
this.distributeViewSizes();
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
for (let i = this.viewItems.length - 1; i >= 0; i--) {
|
||||
this.removeView(i);
|
||||
}
|
||||
}
|
||||
|
||||
removeView(index: number, sizing?: Sizing): IView {
|
||||
if (this.state !== State.Idle) {
|
||||
throw new Error('Cant modify splitview');
|
||||
}
|
||||
|
||||
this.state = State.Busy;
|
||||
|
||||
if (index < 0 || index >= this.viewItems.length) {
|
||||
throw new Error('Index out of bounds');
|
||||
}
|
||||
|
||||
// Remove view
|
||||
const viewItem = this.viewItems.splice(index, 1)[0];
|
||||
viewItem.disposable.dispose();
|
||||
|
||||
// Remove sash
|
||||
if (this.options.enableResizing && this.viewItems.length >= 1) {
|
||||
const sashIndex = Math.max(index - 1, 0);
|
||||
const sashItem = this.sashItems.splice(sashIndex, 1)[0];
|
||||
sashItem.disposable.dispose();
|
||||
}
|
||||
|
||||
this.relayout();
|
||||
this.state = State.Idle;
|
||||
|
||||
if (sizing && sizing.type === 'distribute') {
|
||||
this.distributeViewSizes();
|
||||
}
|
||||
|
||||
return viewItem.view;
|
||||
}
|
||||
|
||||
moveView(from: number, to: number): void {
|
||||
if (this.state !== State.Idle) {
|
||||
throw new Error('Cant modify splitview');
|
||||
}
|
||||
|
||||
const size = this.getViewSize(from);
|
||||
const view = this.removeView(from);
|
||||
this.addView(view, size, to);
|
||||
}
|
||||
|
||||
swapViews(from: number, to: number): void {
|
||||
if (this.state !== State.Idle) {
|
||||
throw new Error('Cant modify splitview');
|
||||
}
|
||||
|
||||
if (from > to) {
|
||||
return this.swapViews(to, from);
|
||||
}
|
||||
|
||||
const fromSize = this.getViewSize(from);
|
||||
const toSize = this.getViewSize(to);
|
||||
const toView = this.removeView(to);
|
||||
const fromView = this.removeView(from);
|
||||
|
||||
this.addView(toView, fromSize, from);
|
||||
this.addView(fromView, toSize, to);
|
||||
}
|
||||
|
||||
private relayout(lowPriorityIndex?: number, highPriorityIndex?: number): void {
|
||||
const contentSize = this.viewItems.reduce((r, i) => r + i.size, 0);
|
||||
|
||||
this.resize(this.viewItems.length - 1, this.size - contentSize, undefined, lowPriorityIndex, highPriorityIndex);
|
||||
this.distributeEmptySpace();
|
||||
this.layoutViews();
|
||||
this.saveProportions();
|
||||
}
|
||||
|
||||
public setScrollPosition(position: number) {
|
||||
this.scrollable.setScrollPosition({ scrollTop: position });
|
||||
}
|
||||
|
||||
layout(size: number): void {
|
||||
const previousSize = Math.max(this.size, this.contentSize);
|
||||
this.size = size;
|
||||
this.contentSize = 0;
|
||||
this.lastRenderHeight = 0;
|
||||
this.lastRenderTop = 0;
|
||||
|
||||
if (!this.proportions) {
|
||||
this.resize(this.viewItems.length - 1, size - previousSize);
|
||||
} else {
|
||||
for (let i = 0; i < this.viewItems.length; i++) {
|
||||
const item = this.viewItems[i];
|
||||
item.size = clamp(Math.round(this.proportions[i] * size), item.view.minimumSize, item.view.maximumSize);
|
||||
this.updateSize(item.view.id!, size);
|
||||
}
|
||||
}
|
||||
|
||||
this.distributeEmptySpace();
|
||||
this.layoutViews();
|
||||
}
|
||||
|
||||
private saveProportions(): void {
|
||||
if (this.contentSize > 0) {
|
||||
this.proportions = this.viewItems.map(i => i.size / this.contentSize);
|
||||
}
|
||||
}
|
||||
|
||||
private onSashStart({ sash, start, alt }: ISashEvent): void {
|
||||
const index = firstIndex(this.sashItems, item => item.sash === sash);
|
||||
|
||||
// This way, we can press Alt while we resize a sash, macOS style!
|
||||
const disposable = combinedDisposable(
|
||||
domEvent(document.body, 'keydown')(e => resetSashDragState(this.sashDragState!.current, e.altKey)),
|
||||
domEvent(document.body, 'keyup')(() => resetSashDragState(this.sashDragState!.current, false))
|
||||
);
|
||||
|
||||
const resetSashDragState = (start: number, alt: boolean) => {
|
||||
const sizes = this.viewItems.map(i => i.size);
|
||||
let minDelta = Number.NEGATIVE_INFINITY;
|
||||
let maxDelta = Number.POSITIVE_INFINITY;
|
||||
|
||||
if (this.inverseAltBehavior) {
|
||||
alt = !alt;
|
||||
}
|
||||
|
||||
if (alt) {
|
||||
// When we're using the last sash with Alt, we're resizing
|
||||
// the view to the left/up, instead of right/down as usual
|
||||
// Thus, we must do the inverse of the usual
|
||||
const isLastSash = index === this.sashItems.length - 1;
|
||||
|
||||
if (isLastSash) {
|
||||
const viewItem = this.viewItems[index];
|
||||
minDelta = (viewItem.view.minimumSize - viewItem.size) / 2;
|
||||
maxDelta = (viewItem.view.maximumSize - viewItem.size) / 2;
|
||||
} else {
|
||||
const viewItem = this.viewItems[index + 1];
|
||||
minDelta = (viewItem.size - viewItem.view.maximumSize) / 2;
|
||||
maxDelta = (viewItem.size - viewItem.view.minimumSize) / 2;
|
||||
}
|
||||
}
|
||||
|
||||
this.sashDragState = { start, current: start, index, sizes, minDelta, maxDelta, alt, disposable };
|
||||
};
|
||||
|
||||
resetSashDragState(start, alt);
|
||||
}
|
||||
|
||||
private onSashChange({ current }: ISashEvent): void {
|
||||
const { index, start, sizes, alt, minDelta, maxDelta } = this.sashDragState!;
|
||||
this.sashDragState!.current = current;
|
||||
|
||||
const delta = current - start;
|
||||
const newDelta = this.resize(index, delta, sizes, undefined, undefined, minDelta, maxDelta);
|
||||
|
||||
if (alt) {
|
||||
const isLastSash = index === this.sashItems.length - 1;
|
||||
const newSizes = this.viewItems.map(i => i.size);
|
||||
const viewItemIndex = isLastSash ? index : index + 1;
|
||||
const viewItem = this.viewItems[viewItemIndex];
|
||||
const newMinDelta = viewItem.size - viewItem.view.maximumSize;
|
||||
const newMaxDelta = viewItem.size - viewItem.view.minimumSize;
|
||||
const resizeIndex = isLastSash ? index - 1 : index + 1;
|
||||
|
||||
this.resize(resizeIndex, -newDelta, newSizes, undefined, undefined, newMinDelta, newMaxDelta);
|
||||
}
|
||||
|
||||
this.distributeEmptySpace();
|
||||
this.layoutViews();
|
||||
}
|
||||
|
||||
private onSashEnd(index: number): void {
|
||||
this._onDidSashChange.fire(index);
|
||||
this.sashDragState!.disposable.dispose();
|
||||
this.saveProportions();
|
||||
}
|
||||
|
||||
private onViewChange(item: IViewItem, size: number | undefined): void {
|
||||
const index = this.viewItems.indexOf(item);
|
||||
|
||||
if (index < 0 || index >= this.viewItems.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
size = typeof size === 'number' ? size : item.size;
|
||||
size = clamp(size, item.view.minimumSize, item.view.maximumSize);
|
||||
|
||||
if (this.inverseAltBehavior && index > 0) {
|
||||
// In this case, we want the view to grow or shrink both sides equally
|
||||
// so we just resize the "left" side by half and let `resize` do the clamping magic
|
||||
this.resize(index - 1, Math.floor((item.size - size) / 2));
|
||||
this.distributeEmptySpace();
|
||||
this.layoutViews();
|
||||
} else {
|
||||
item.size = size;
|
||||
this.updateSize(item.view.id!, size);
|
||||
let top: number = 0;
|
||||
for (let i = 0; i < this.viewItems.length; i++) {
|
||||
let currentItem: IViewItem = this.viewItems[i];
|
||||
this.updateTop(currentItem.view.id!, top);
|
||||
top += currentItem.size;
|
||||
}
|
||||
this.relayout(index);
|
||||
}
|
||||
}
|
||||
|
||||
resizeView(index: number, size: number): void {
|
||||
if (this.state !== State.Idle) {
|
||||
throw new Error('Cant modify splitview');
|
||||
}
|
||||
|
||||
this.state = State.Busy;
|
||||
|
||||
if (index < 0 || index >= this.viewItems.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const item = this.viewItems[index];
|
||||
size = Math.round(size);
|
||||
size = clamp(size, item.view.minimumSize, item.view.maximumSize);
|
||||
let delta = size - item.size;
|
||||
|
||||
if (delta !== 0 && index < this.viewItems.length - 1) {
|
||||
const downIndexes = range(index + 1, this.viewItems.length);
|
||||
const collapseDown = downIndexes.reduce((r, i) => r + (this.viewItems[i].size - this.viewItems[i].view.minimumSize), 0);
|
||||
const expandDown = downIndexes.reduce((r, i) => r + (this.viewItems[i].view.maximumSize - this.viewItems[i].size), 0);
|
||||
const deltaDown = clamp(delta, -expandDown, collapseDown);
|
||||
|
||||
this.resize(index, deltaDown);
|
||||
delta -= deltaDown;
|
||||
}
|
||||
|
||||
if (delta !== 0 && index > 0) {
|
||||
const upIndexes = range(index - 1, -1);
|
||||
const collapseUp = upIndexes.reduce((r, i) => r + (this.viewItems[i].size - this.viewItems[i].view.minimumSize), 0);
|
||||
const expandUp = upIndexes.reduce((r, i) => r + (this.viewItems[i].view.maximumSize - this.viewItems[i].size), 0);
|
||||
const deltaUp = clamp(-delta, -collapseUp, expandUp);
|
||||
|
||||
this.resize(index - 1, deltaUp);
|
||||
}
|
||||
|
||||
this.distributeEmptySpace();
|
||||
this.layoutViews();
|
||||
this.saveProportions();
|
||||
this.state = State.Idle;
|
||||
}
|
||||
|
||||
distributeViewSizes(): void {
|
||||
const size = Math.floor(this.size / this.viewItems.length);
|
||||
|
||||
for (let i = 0; i < this.viewItems.length - 1; i++) {
|
||||
this.resizeView(i, size);
|
||||
}
|
||||
}
|
||||
|
||||
getViewSize(index: number): number {
|
||||
if (index < 0 || index >= this.viewItems.length) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return this.viewItems[index].size;
|
||||
}
|
||||
|
||||
|
||||
private render(scrollTop: number, viewHeight: number): void {
|
||||
let i: number;
|
||||
let stop: number;
|
||||
|
||||
let renderTop = scrollTop;
|
||||
let renderBottom = scrollTop + viewHeight;
|
||||
let thisRenderBottom = this.lastRenderTop! + this.lastRenderHeight!;
|
||||
|
||||
// when view scrolls down, start rendering from the renderBottom
|
||||
for (i = this.indexAfter(renderBottom) - 1, stop = this.indexAt(Math.max(thisRenderBottom, renderTop)); i >= stop; i--) {
|
||||
if (this.insertItemInDOM(<IViewItem>this.itemAtIndex(i))) {
|
||||
this.dirtyState = true;
|
||||
}
|
||||
}
|
||||
|
||||
// when view scrolls up, start rendering from either this.renderTop or renderBottom
|
||||
for (i = Math.min(this.indexAt(this.lastRenderTop!), this.indexAfter(renderBottom)) - 1, stop = this.indexAt(renderTop); i >= stop; i--) {
|
||||
if (this.insertItemInDOM(<IViewItem>this.itemAtIndex(i))) {
|
||||
this.dirtyState = true;
|
||||
}
|
||||
}
|
||||
|
||||
// when view scrolls down, start unrendering from renderTop
|
||||
for (i = this.indexAt(this.lastRenderTop!), stop = Math.min(this.indexAt(renderTop), this.indexAfter(thisRenderBottom)); i < stop; i++) {
|
||||
if (this.removeItemFromDOM(<IViewItem>this.itemAtIndex(i))) {
|
||||
this.dirtyState = true;
|
||||
}
|
||||
}
|
||||
|
||||
// when view scrolls up, start unrendering from either renderBottom this.renderTop
|
||||
for (i = Math.max(this.indexAfter(renderBottom), this.indexAt(this.lastRenderTop!)), stop = this.indexAfter(thisRenderBottom); i < stop; i++) {
|
||||
if (this.removeItemFromDOM(<IViewItem>this.itemAtIndex(i))) {
|
||||
this.dirtyState = true;
|
||||
}
|
||||
}
|
||||
|
||||
let topItem = this.itemAtIndex(this.indexAt(renderTop));
|
||||
|
||||
if (topItem) {
|
||||
this.viewContainer.style.top = (topItem.top - renderTop) + 'px';
|
||||
}
|
||||
|
||||
this.lastRenderTop = renderTop;
|
||||
this.lastRenderHeight = renderBottom - renderTop;
|
||||
}
|
||||
|
||||
// DOM changes
|
||||
|
||||
private insertItemInDOM(item: IViewItem): boolean {
|
||||
if (item.container.parentElement) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let elementAfter: HTMLElement | undefined = undefined;
|
||||
let itemAfter = <IViewItem>this.itemAfter(item);
|
||||
|
||||
if (itemAfter && itemAfter.container) {
|
||||
elementAfter = itemAfter.container;
|
||||
}
|
||||
|
||||
if (elementAfter === undefined) {
|
||||
this.viewContainer.appendChild(item.container);
|
||||
} else {
|
||||
try {
|
||||
this.viewContainer.insertBefore(item.container, elementAfter);
|
||||
} catch (e) {
|
||||
// console.warn('Failed to locate previous tree element');
|
||||
this.viewContainer.appendChild(item.container);
|
||||
}
|
||||
}
|
||||
|
||||
item.layout();
|
||||
|
||||
item.onAdd();
|
||||
return true;
|
||||
}
|
||||
|
||||
private removeItemFromDOM(item: IViewItem): boolean {
|
||||
if (!item || !item.container || !item.container.parentElement) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.viewContainer.removeChild(item.container);
|
||||
|
||||
item.onRemove();
|
||||
return true;
|
||||
}
|
||||
|
||||
private resize(
|
||||
index: number,
|
||||
delta: number,
|
||||
sizes = this.viewItems.map(i => i.size),
|
||||
lowPriorityIndex?: number,
|
||||
highPriorityIndex?: number,
|
||||
overloadMinDelta: number = Number.NEGATIVE_INFINITY,
|
||||
overloadMaxDelta: number = Number.POSITIVE_INFINITY
|
||||
): number {
|
||||
if (index < 0 || index >= this.viewItems.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const upIndexes = range(index, -1);
|
||||
const downIndexes = range(index + 1, this.viewItems.length);
|
||||
|
||||
if (typeof highPriorityIndex === 'number') {
|
||||
pushToStart(upIndexes, highPriorityIndex);
|
||||
pushToStart(downIndexes, highPriorityIndex);
|
||||
}
|
||||
|
||||
if (typeof lowPriorityIndex === 'number') {
|
||||
pushToEnd(upIndexes, lowPriorityIndex);
|
||||
pushToEnd(downIndexes, lowPriorityIndex);
|
||||
}
|
||||
|
||||
const upItems = upIndexes.map(i => this.viewItems[i]);
|
||||
const upSizes = upIndexes.map(i => sizes[i]);
|
||||
|
||||
const downItems = downIndexes.map(i => this.viewItems[i]);
|
||||
const downSizes = downIndexes.map(i => sizes[i]);
|
||||
|
||||
const minDeltaUp = upIndexes.reduce((r, i) => r + (this.viewItems[i].view.minimumSize - sizes[i]), 0);
|
||||
const maxDeltaUp = upIndexes.reduce((r, i) => r + (this.viewItems[i].view.maximumSize - sizes[i]), 0);
|
||||
const maxDeltaDown = downIndexes.length === 0 ? Number.POSITIVE_INFINITY : downIndexes.reduce((r, i) => r + (sizes[i] - this.viewItems[i].view.minimumSize), 0);
|
||||
const minDeltaDown = downIndexes.length === 0 ? Number.NEGATIVE_INFINITY : downIndexes.reduce((r, i) => r + (sizes[i] - this.viewItems[i].view.maximumSize), 0);
|
||||
const minDelta = Math.max(minDeltaUp, minDeltaDown, overloadMinDelta);
|
||||
const maxDelta = Math.min(maxDeltaDown, maxDeltaUp, overloadMaxDelta);
|
||||
|
||||
delta = clamp(delta, minDelta, maxDelta);
|
||||
|
||||
for (let i = 0, deltaUp = delta; i < upItems.length; i++) {
|
||||
const item = upItems[i];
|
||||
const size = clamp(upSizes[i] + deltaUp, item.view.minimumSize, item.view.maximumSize);
|
||||
const viewDelta = size - upSizes[i];
|
||||
|
||||
deltaUp -= viewDelta;
|
||||
item.size = size;
|
||||
this.updateSize(item.view.id!, size);
|
||||
this.dirtyState = true;
|
||||
}
|
||||
|
||||
for (let i = 0, deltaDown = delta; i < downItems.length; i++) {
|
||||
const item = downItems[i];
|
||||
const size = clamp(downSizes[i] - deltaDown, item.view.minimumSize, item.view.maximumSize);
|
||||
const viewDelta = size - downSizes[i];
|
||||
|
||||
deltaDown += viewDelta;
|
||||
item.size = size;
|
||||
this.updateSize(item.view.id!, size);
|
||||
this.dirtyState = true;
|
||||
}
|
||||
|
||||
return delta;
|
||||
}
|
||||
|
||||
private distributeEmptySpace(): void {
|
||||
let contentSize = this.viewItems.reduce((r, i) => r + i.size, 0);
|
||||
let emptyDelta = this.size - contentSize;
|
||||
|
||||
for (let i = this.viewItems.length - 1; emptyDelta !== 0 && i >= 0; i--) {
|
||||
const item = this.viewItems[i];
|
||||
const size = clamp(item.size + emptyDelta, item.view.minimumSize, item.view.maximumSize);
|
||||
const viewDelta = size - item.size;
|
||||
|
||||
emptyDelta -= viewDelta;
|
||||
item.size = size;
|
||||
this.updateSize(item.view.id!, size);
|
||||
}
|
||||
}
|
||||
|
||||
private layoutViews(): void {
|
||||
// Save new content size
|
||||
this.contentSize = this.viewItems.reduce((r, i) => r + i.size, 0);
|
||||
|
||||
if (this.dirtyState) {
|
||||
for (let i = this.indexAt(this.lastRenderTop!); i <= this.indexAfter(this.lastRenderTop! + this.lastRenderHeight!) - 1; i++) {
|
||||
this.viewItems[i].layout();
|
||||
if (this.options.enableResizing) {
|
||||
this.sashItems[i].sash.layout();
|
||||
}
|
||||
}
|
||||
this.dirtyState = false;
|
||||
}
|
||||
|
||||
this.scrollable.setScrollDimensions({
|
||||
scrollHeight: this.contentSize,
|
||||
height: this.size
|
||||
});
|
||||
}
|
||||
|
||||
private getSashPosition(sash: Sash): number {
|
||||
let position = 0;
|
||||
|
||||
for (let i = 0; i < this.sashItems.length; i++) {
|
||||
position += this.viewItems[i].size;
|
||||
|
||||
if (this.sashItems[i].sash === sash) {
|
||||
return position;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.viewItems.forEach(i => i.disposable.dispose());
|
||||
this.viewItems = [];
|
||||
|
||||
this.sashItems.forEach(i => i.disposable.dispose());
|
||||
this.sashItems = [];
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,22 @@
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
.split-view-container {
|
||||
.scrollable-view {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.monaco-scroll-split-view {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.scrollable-view .scrollable-view-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.scrollable-view .scrollable-view-child {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
338
src/sql/base/browser/ui/scrollableView/scrollableView.ts
Normal file
338
src/sql/base/browser/ui/scrollableView/scrollableView.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import 'vs/css!./scrollableView';
|
||||
|
||||
import { RangeMap } from 'vs/base/browser/ui/list/rangeMap';
|
||||
import { SmoothScrollableElement } from 'vs/base/browser/ui/scrollbar/scrollableElement';
|
||||
import { Scrollable, ScrollbarVisibility, INewScrollDimensions, ScrollEvent } from 'vs/base/common/scrollable';
|
||||
import { getOrDefault } from 'vs/base/common/objects';
|
||||
import * as DOM from 'vs/base/browser/dom';
|
||||
import { Disposable, IDisposable, dispose } from 'vs/base/common/lifecycle';
|
||||
import { domEvent } from 'vs/base/browser/event';
|
||||
import { Event } from 'vs/base/common/event';
|
||||
import { Range, IRange } from 'vs/base/common/range';
|
||||
import { clamp } from 'vs/base/common/numbers';
|
||||
|
||||
export interface IScrollableViewOptions {
|
||||
useShadows?: boolean;
|
||||
smoothScrolling?: boolean;
|
||||
verticalScrollMode?: ScrollbarVisibility;
|
||||
additionalScrollHeight?: number;
|
||||
}
|
||||
|
||||
const DefaultOptions: IScrollableViewOptions = {
|
||||
useShadows: true,
|
||||
verticalScrollMode: ScrollbarVisibility.Auto
|
||||
};
|
||||
|
||||
export interface IView {
|
||||
layout(height: number, width: number): void;
|
||||
readonly onDidChange: Event<number>;
|
||||
readonly element: HTMLElement;
|
||||
readonly minimumSize: number;
|
||||
readonly maximumSize: number;
|
||||
onDidInsert?(): void;
|
||||
onDidRemove?(): void;
|
||||
}
|
||||
|
||||
interface IItem {
|
||||
readonly view: IView;
|
||||
size: number;
|
||||
domNode?: HTMLElement;
|
||||
onDidInsertDisposable?: IDisposable; // I don't trust the children
|
||||
onDidRemoveDisposable?: IDisposable; // I don't trust the children
|
||||
disposables: IDisposable[];
|
||||
}
|
||||
|
||||
export class ScrollableView extends Disposable {
|
||||
private readonly rangeMap = new RangeMap();
|
||||
private readonly scrollableElement: SmoothScrollableElement;
|
||||
private readonly scrollable: Scrollable;
|
||||
private readonly viewContainer = DOM.$('div.scrollable-view-container');
|
||||
private readonly domNode = DOM.$('div.scrollable-view');
|
||||
|
||||
private scrollableElementUpdateDisposable?: IDisposable;
|
||||
private additionalScrollHeight: number;
|
||||
private _scrollHeight = 0;
|
||||
private renderHeight = 0;
|
||||
private lastRenderTop = 0;
|
||||
private lastRenderHeight = 0;
|
||||
private readonly items: IItem[] = [];
|
||||
|
||||
private width: number = 0;
|
||||
|
||||
get contentHeight(): number { return this.rangeMap.size; }
|
||||
get onDidScroll(): Event<ScrollEvent> { return this.scrollableElement.onScroll; }
|
||||
get length(): number { return this.items.length; }
|
||||
|
||||
constructor(container: HTMLElement, options: IScrollableViewOptions = DefaultOptions) {
|
||||
super();
|
||||
|
||||
this.additionalScrollHeight = typeof options.additionalScrollHeight === 'undefined' ? 0 : options.additionalScrollHeight;
|
||||
|
||||
this.scrollable = new Scrollable(getOrDefault(options, o => o.smoothScrolling, false) ? 125 : 0, cb => DOM.scheduleAtNextAnimationFrame(cb));
|
||||
this.scrollableElement = this._register(new SmoothScrollableElement(this.viewContainer, {
|
||||
alwaysConsumeMouseWheel: true,
|
||||
horizontal: ScrollbarVisibility.Hidden,
|
||||
vertical: getOrDefault(options, o => o.verticalScrollMode, DefaultOptions.verticalScrollMode),
|
||||
useShadows: getOrDefault(options, o => o.useShadows, DefaultOptions.useShadows),
|
||||
}, this.scrollable));
|
||||
this.domNode.appendChild(this.scrollableElement.getDomNode());
|
||||
container.appendChild(this.domNode);
|
||||
|
||||
this._register(Event.debounce(this.scrollableElement.onScroll, (l, e) => e, 25)(this.onScroll, this));
|
||||
|
||||
// Prevent the monaco-scrollable-element from scrolling
|
||||
// https://github.com/Microsoft/vscode/issues/44181
|
||||
this._register(domEvent(this.scrollableElement.getDomNode(), 'scroll')
|
||||
(e => (e.target as HTMLElement).scrollTop = 0));
|
||||
}
|
||||
|
||||
elementTop(index: number): number {
|
||||
return this.rangeMap.positionAt(index);
|
||||
}
|
||||
|
||||
layout(height?: number, width?: number): void {
|
||||
let scrollDimensions: INewScrollDimensions = {
|
||||
height: typeof height === 'number' ? height : DOM.getContentHeight(this.domNode)
|
||||
};
|
||||
|
||||
this.renderHeight = scrollDimensions.height;
|
||||
|
||||
this.width = width ?? DOM.getContentWidth(this.domNode);
|
||||
|
||||
this.calculateItemHeights();
|
||||
|
||||
if (this.scrollableElementUpdateDisposable) {
|
||||
this.scrollableElementUpdateDisposable.dispose();
|
||||
this.scrollableElementUpdateDisposable = null;
|
||||
scrollDimensions.scrollHeight = this.scrollHeight;
|
||||
}
|
||||
|
||||
this.scrollableElement.setScrollDimensions(scrollDimensions);
|
||||
}
|
||||
|
||||
setScrollTop(scrollTop: number): void {
|
||||
if (this.scrollableElementUpdateDisposable) {
|
||||
this.scrollableElementUpdateDisposable.dispose();
|
||||
this.scrollableElementUpdateDisposable = null;
|
||||
this.scrollableElement.setScrollDimensions({ scrollHeight: this.scrollHeight });
|
||||
}
|
||||
|
||||
this.scrollableElement.setScrollPosition({ scrollTop });
|
||||
}
|
||||
|
||||
private rerender(lastRenderRange: IRange) {
|
||||
this.calculateItemHeights();
|
||||
this.render(lastRenderRange, this.lastRenderTop, this.lastRenderHeight, true);
|
||||
|
||||
this.eventuallyUpdateScrollDimensions();
|
||||
}
|
||||
|
||||
public addViews(views: IView[]): void { // @todo anthonydresser add ability to splice into the middle of the list and remove a particular index
|
||||
const lastRenderRange = this.getRenderRange(this.lastRenderTop, this.lastRenderHeight);
|
||||
const items = views.map(view => ({ size: view.minimumSize, view, disposables: [], index: 0 }));
|
||||
|
||||
items.map(i => i.disposables.push(i.view.onDidChange(() => this.rerender(this.getRenderRange(this.lastRenderTop, this.lastRenderHeight)))));
|
||||
|
||||
// calculate heights
|
||||
this.splice(this.items.length, 0, items);
|
||||
this.rerender(lastRenderRange);
|
||||
}
|
||||
|
||||
private splice(index: number, deleteCount: number, items: IItem[] = []): IItem[] {
|
||||
this.rangeMap.splice(index, deleteCount, items);
|
||||
const ret = this.items.splice(index, deleteCount, ...items);
|
||||
return ret;
|
||||
}
|
||||
|
||||
public addView(view: IView): void {
|
||||
this.addViews([view]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all views
|
||||
*/
|
||||
public clear(): void {
|
||||
const lastRenderRange = this.getRenderRange(this.lastRenderTop, this.lastRenderHeight);
|
||||
for (const item of this.items) {
|
||||
if (item.domNode) {
|
||||
DOM.clearNode(item.domNode);
|
||||
DOM.removeNode(item.domNode);
|
||||
item.domNode = undefined;
|
||||
}
|
||||
dispose(item.disposables);
|
||||
}
|
||||
this.splice(0, this.items.length);
|
||||
this.rerender(lastRenderRange);
|
||||
}
|
||||
|
||||
private calculateItemHeights() {
|
||||
let currentMin = 0;
|
||||
for (const item of this.items) {
|
||||
currentMin += item.view.minimumSize;
|
||||
if (currentMin > this.renderHeight) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (currentMin > this.renderHeight) { // the items will fill the render height, so just use min heights
|
||||
this.items.forEach((item, index) => {
|
||||
if (item.size !== item.view.minimumSize) {
|
||||
item.size = item.view.minimumSize;
|
||||
this.rangeMap.splice(index, 1, [item]);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// try to even distribute
|
||||
let renderHeightRemaining = this.renderHeight;
|
||||
this.items.forEach((item, index) => {
|
||||
const desiredheight = Math.floor(renderHeightRemaining / (this.items.length - index));
|
||||
const newSize = clamp(desiredheight, item.view.minimumSize, item.view.maximumSize);
|
||||
if (item.size !== newSize) {
|
||||
item.size = newSize;
|
||||
this.rangeMap.splice(index, 1, [item]);
|
||||
}
|
||||
renderHeightRemaining -= item.size;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
get scrollHeight(): number {
|
||||
return this._scrollHeight + this.additionalScrollHeight;
|
||||
}
|
||||
|
||||
private onScroll(e: ScrollEvent): void {
|
||||
const previousRenderRange = this.getRenderRange(this.lastRenderTop, this.lastRenderHeight);
|
||||
this.render(previousRenderRange, e.scrollTop, e.height);
|
||||
}
|
||||
|
||||
private getRenderRange(renderTop: number, renderHeight: number): IRange {
|
||||
return {
|
||||
start: this.rangeMap.indexAt(renderTop),
|
||||
end: this.rangeMap.indexAfter(renderTop + renderHeight - 1)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// Render
|
||||
|
||||
private render(previousRenderRange: IRange, renderTop: number, renderHeight: number, updateItemsInDOM: boolean = false): void {
|
||||
const renderRange = this.getRenderRange(renderTop, renderHeight);
|
||||
|
||||
const rangesToInsert = Range.relativeComplement(renderRange, previousRenderRange);
|
||||
const rangesToRemove = Range.relativeComplement(previousRenderRange, renderRange);
|
||||
const beforeElement = this.getNextToLastElement(rangesToInsert);
|
||||
|
||||
if (updateItemsInDOM) {
|
||||
const rangesToUpdate = Range.intersect(previousRenderRange, renderRange);
|
||||
|
||||
for (let i = rangesToUpdate.start; i < rangesToUpdate.end; i++) {
|
||||
this.updateItemInDOM(this.items[i], i);
|
||||
}
|
||||
}
|
||||
|
||||
for (const range of rangesToInsert) {
|
||||
for (let i = range.start; i < range.end; i++) {
|
||||
this.insertItemInDOM(i, beforeElement);
|
||||
}
|
||||
}
|
||||
|
||||
for (const range of rangesToRemove) {
|
||||
for (let i = range.start; i < range.end; i++) {
|
||||
this.removeItemFromDOM(i);
|
||||
}
|
||||
}
|
||||
|
||||
this.viewContainer.style.top = `-${renderTop}px`;
|
||||
|
||||
this.lastRenderTop = renderTop;
|
||||
this.lastRenderHeight = renderHeight;
|
||||
}
|
||||
|
||||
// DOM operations
|
||||
|
||||
private insertItemInDOM(index: number, beforeElement: HTMLElement | null): void {
|
||||
const item = this.items[index];
|
||||
|
||||
if (!item.domNode) {
|
||||
item.domNode = DOM.$('div.scrollable-view-child');
|
||||
item.domNode.appendChild(item.view.element);
|
||||
}
|
||||
|
||||
if (!item.domNode!.parentElement) {
|
||||
if (beforeElement) {
|
||||
this.viewContainer.insertBefore(item.domNode!, beforeElement);
|
||||
} else {
|
||||
this.viewContainer.appendChild(item.domNode!);
|
||||
}
|
||||
}
|
||||
|
||||
this.updateItemInDOM(item, index, false);
|
||||
|
||||
item.onDidRemoveDisposable?.dispose();
|
||||
item.onDidInsertDisposable = DOM.scheduleAtNextAnimationFrame(() => {
|
||||
// we don't trust the items to be performant so don't interrupt our operations
|
||||
if (item.view.onDidInsert) {
|
||||
item.view.onDidInsert();
|
||||
}
|
||||
item.view.layout(item.size, this.width);
|
||||
});
|
||||
}
|
||||
|
||||
private updateItemInDOM(item: IItem, index: number, layout: boolean = true): void {
|
||||
item.domNode!.style.top = `${this.elementTop(index)}px`;
|
||||
item.domNode!.style.width = `${this.width}px`;
|
||||
item.domNode!.style.height = `${item.size}px`;
|
||||
if (layout) {
|
||||
DOM.scheduleAtNextAnimationFrame(() => {
|
||||
item.view.layout(item.size, this.width);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private removeItemFromDOM(index: number): void {
|
||||
const item = this.items[index];
|
||||
|
||||
if (item) {
|
||||
item.domNode.remove();
|
||||
item.onDidInsertDisposable?.dispose();
|
||||
if (item.view.onDidRemove) {
|
||||
item.onDidRemoveDisposable = DOM.scheduleAtNextAnimationFrame(() => {
|
||||
// we don't trust the items to be performant so don't interrupt our operations
|
||||
item.view.onDidRemove();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getNextToLastElement(ranges: IRange[]): HTMLElement | null {
|
||||
const lastRange = ranges[ranges.length - 1];
|
||||
|
||||
if (!lastRange) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextToLastItem = this.items[lastRange.end];
|
||||
|
||||
if (!nextToLastItem) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return nextToLastItem.domNode;
|
||||
}
|
||||
|
||||
private eventuallyUpdateScrollDimensions(): void {
|
||||
this._scrollHeight = this.contentHeight;
|
||||
this.viewContainer.style.height = `${this._scrollHeight}px`;
|
||||
|
||||
if (!this.scrollableElementUpdateDisposable) {
|
||||
this.scrollableElementUpdateDisposable = DOM.scheduleAtNextAnimationFrame(() => {
|
||||
this.scrollableElement.setScrollDimensions({ scrollHeight: this.scrollHeight });
|
||||
this.scrollableElementUpdateDisposable = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the Source EULA. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { IView, ScrollableView } from 'sql/base/browser/ui/scrollableView/scrollableView';
|
||||
import { Emitter } from 'vs/base/common/event';
|
||||
import * as assert from 'assert';
|
||||
import { Disposable } from 'vs/base/common/lifecycle';
|
||||
import { timeout } from 'vs/base/common/async';
|
||||
|
||||
class TestView extends Disposable implements IView {
|
||||
|
||||
constructor(public minimumSize: number, public maximumSize: number) { super(); }
|
||||
|
||||
private readonly _onDidLayout = this._register(new Emitter<{ height: number, width: number }>());
|
||||
public readonly onDidLayout = this._onDidLayout.event;
|
||||
layout(height: number, width: number): void {
|
||||
this._size = height;
|
||||
this._onDidLayout.fire({ height, width });
|
||||
}
|
||||
|
||||
private _size: number;
|
||||
public get size(): number {
|
||||
return this._size;
|
||||
}
|
||||
|
||||
public readonly onDidChangeEmitter = this._register(new Emitter<number>());
|
||||
public readonly onDidChange = this.onDidChangeEmitter.event;
|
||||
|
||||
public readonly element = document.createElement('div');
|
||||
|
||||
private readonly _onDidInsertEmitter = this._register(new Emitter<void>());
|
||||
public readonly onDidInsertEvent = this._onDidInsertEmitter.event;
|
||||
onDidInsert?(): void {
|
||||
this._onDidInsertEmitter.fire();
|
||||
}
|
||||
|
||||
private readonly _onDidRemoveEmitter = this._register(new Emitter<void>());
|
||||
public readonly onDidRemoveEvent = this._onDidRemoveEmitter.event;
|
||||
onDidRemove?(): void {
|
||||
this._onDidRemoveEmitter.fire();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
suite('ScrollableView', () => {
|
||||
let container: HTMLElement;
|
||||
|
||||
setup(() => {
|
||||
container = document.createElement('div');
|
||||
container.style.position = 'absolute';
|
||||
container.style.width = `${200}px`;
|
||||
container.style.height = `${200}px`;
|
||||
});
|
||||
|
||||
test('creates empty view', () => {
|
||||
const scrollableView = new ScrollableView(container);
|
||||
scrollableView.layout(200, 200);
|
||||
assert.equal(container.firstElementChild!.firstElementChild!.firstElementChild!.childElementCount, 0, 'view should be empty');
|
||||
scrollableView.dispose();
|
||||
});
|
||||
|
||||
test('adds and removes views correctly', async () => {
|
||||
const view1 = new TestView(20, 20);
|
||||
const view2 = new TestView(20, 20);
|
||||
const view3 = new TestView(20, 20);
|
||||
const scrollableView = new ScrollableView(container);
|
||||
|
||||
scrollableView.layout(200, 200);
|
||||
|
||||
scrollableView.addView(view1);
|
||||
scrollableView.addView(view2);
|
||||
scrollableView.addView(view3);
|
||||
|
||||
// we only update the scroll dimensions asynchronously
|
||||
await waitForAnimation();
|
||||
|
||||
let viewQuery = getViewChildren(container);
|
||||
assert.equal(viewQuery.length, 3, 'split view should have 3 views');
|
||||
|
||||
scrollableView.clear();
|
||||
|
||||
viewQuery = getViewChildren(container);
|
||||
assert.equal(viewQuery.length, 0, 'split view should have no views');
|
||||
|
||||
view1.dispose();
|
||||
view2.dispose();
|
||||
view3.dispose();
|
||||
scrollableView.dispose();
|
||||
});
|
||||
|
||||
test('shrinks views correctly', async () => {
|
||||
const view1 = new TestView(20, Number.POSITIVE_INFINITY);
|
||||
const view2 = new TestView(20, Number.POSITIVE_INFINITY);
|
||||
const view3 = new TestView(20, Number.POSITIVE_INFINITY);
|
||||
const scrollableView = new ScrollableView(container);
|
||||
|
||||
scrollableView.layout(200, 200);
|
||||
|
||||
scrollableView.addView(view1);
|
||||
|
||||
await waitForAnimation();
|
||||
|
||||
assert.equal(view1.size, 200, 'view1 is entire size');
|
||||
|
||||
scrollableView.addView(view2);
|
||||
|
||||
await waitForAnimation();
|
||||
|
||||
assert.equal(view1.size, 100, 'view1 is half size');
|
||||
assert.equal(view2.size, 100, 'view2 is half size');
|
||||
|
||||
scrollableView.addView(view3);
|
||||
|
||||
await waitForAnimation();
|
||||
|
||||
assert.equal(view1.size, 66, 'view1 is third size');
|
||||
assert.equal(view2.size, 67, 'view2 is third size');
|
||||
assert.equal(view3.size, 67, 'view3 is third size');
|
||||
});
|
||||
|
||||
test('honors minimum size', async () => {
|
||||
const view1 = new TestView(100, Number.POSITIVE_INFINITY);
|
||||
const view2 = new TestView(100, Number.POSITIVE_INFINITY);
|
||||
const view3 = new TestView(100, Number.POSITIVE_INFINITY);
|
||||
const scrollableView = new ScrollableView(container);
|
||||
|
||||
scrollableView.layout(200, 200);
|
||||
|
||||
scrollableView.addViews([view1, view2, view3]);
|
||||
|
||||
await waitForAnimation();
|
||||
|
||||
assert.equal(view1.size, 100, 'view1 is minimum size');
|
||||
assert.equal(view2.size, 100, 'view2 is minimum size');
|
||||
assert.equal(view3.size, undefined, 'view3 should not have been layout yet');
|
||||
});
|
||||
|
||||
test('reacts to changes in views', async () => {
|
||||
const view1 = new TestView(100, Number.POSITIVE_INFINITY);
|
||||
const view2 = new TestView(100, Number.POSITIVE_INFINITY);
|
||||
const view3 = new TestView(100, Number.POSITIVE_INFINITY);
|
||||
const scrollableView = new ScrollableView(container);
|
||||
|
||||
scrollableView.layout(200, 200);
|
||||
|
||||
scrollableView.addViews([view1, view2, view3]);
|
||||
|
||||
await waitForAnimation();
|
||||
|
||||
view1.minimumSize = 130;
|
||||
view1.onDidChangeEmitter.fire(0);
|
||||
|
||||
await waitForAnimation();
|
||||
|
||||
assert.equal(view1.size, 130, 'view1 should be 130');
|
||||
assert.equal(view2.size, 100, 'view2 should still be minimum size');
|
||||
assert.equal(view3.size, undefined, 'view3 should not have been layout yet');
|
||||
});
|
||||
|
||||
test('programmatically scrolls', async () => {
|
||||
const view1 = new TestView(100, Number.POSITIVE_INFINITY);
|
||||
const view2 = new TestView(100, Number.POSITIVE_INFINITY);
|
||||
const view3 = new TestView(100, Number.POSITIVE_INFINITY);
|
||||
const scrollableView = new ScrollableView(container);
|
||||
|
||||
scrollableView.layout(200, 200);
|
||||
|
||||
scrollableView.addViews([view1, view2, view3]);
|
||||
|
||||
await waitForAnimation();
|
||||
|
||||
assert.equal(view1.size, 100, 'view1 is minimum size');
|
||||
assert.equal(view2.size, 100, 'view2 is minimum size');
|
||||
assert.equal(view3.size, undefined, 'view3 should not have been layout yet');
|
||||
assert.equal(getViewChildren(container).length, 2, 'only 2 views are rendered');
|
||||
|
||||
scrollableView.setScrollTop(100);
|
||||
|
||||
await waitForAnimation();
|
||||
assert.equal(view2.size, 100, 'view2 is minimum size');
|
||||
assert.equal(view3.size, 100, 'view3 is minimum size');
|
||||
assert.equal(getViewChildren(container).length, 2, 'only 2 views are rendered');
|
||||
});
|
||||
});
|
||||
|
||||
function waitForAnimation(): Promise<void> {
|
||||
return timeout(200);
|
||||
}
|
||||
|
||||
function getViewChildren(container: HTMLElement): NodeListOf<Element> {
|
||||
return container.querySelectorAll('.scrollable-view .scrollable-view-container > .scrollable-view-child');
|
||||
}
|
||||
Reference in New Issue
Block a user