Files
code-server/src/vs/base/browser/ui/list/listView.ts
Joe Previte be3e823608 Squashed 'lib/vscode/' content from commit e5a624b788
git-subtree-dir: lib/vscode
git-subtree-split: e5a624b788d92b8d34d1392e4c4d9789406efe8f
2020-12-15 15:52:33 -07:00

1341 lines
42 KiB
TypeScript

/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { getOrDefault } from 'vs/base/common/objects';
import { IDisposable, dispose, Disposable, toDisposable, DisposableStore } from 'vs/base/common/lifecycle';
import { Gesture, EventType as TouchEventType, GestureEvent } from 'vs/base/browser/touch';
import { Event, Emitter } from 'vs/base/common/event';
import { domEvent } from 'vs/base/browser/event';
import { SmoothScrollableElement } from 'vs/base/browser/ui/scrollbar/scrollableElement';
import { ScrollEvent, ScrollbarVisibility, INewScrollDimensions, Scrollable } from 'vs/base/common/scrollable';
import { RangeMap, shift } from './rangeMap';
import { IListVirtualDelegate, IListRenderer, IListMouseEvent, IListTouchEvent, IListGestureEvent, IListDragEvent, IListDragAndDrop, ListDragOverEffect } from './list';
import { RowCache, IRow } from './rowCache';
import { ISpliceable } from 'vs/base/common/sequence';
import { memoize } from 'vs/base/common/decorators';
import { Range, IRange } from 'vs/base/common/range';
import { equals, distinct } from 'vs/base/common/arrays';
import { DataTransfers, StaticDND, IDragAndDropData } from 'vs/base/browser/dnd';
import { disposableTimeout, Delayer } from 'vs/base/common/async';
import { isFirefox } from 'vs/base/browser/browser';
import { IMouseWheelEvent } from 'vs/base/browser/mouseEvent';
import { $, animate, getContentHeight, getContentWidth, getTopLeftOffset, scheduleAtNextAnimationFrame } from 'vs/base/browser/dom';
interface IItem<T> {
readonly id: string;
readonly element: T;
readonly templateId: string;
row: IRow | null;
size: number;
width: number | undefined;
hasDynamicHeight: boolean;
lastDynamicHeightWidth: number | undefined;
uri: string | undefined;
dropTarget: boolean;
dragStartDisposable: IDisposable;
}
export interface IListViewDragAndDrop<T> extends IListDragAndDrop<T> {
getDragElements(element: T): T[];
}
export interface IListViewAccessibilityProvider<T> {
getSetSize?(element: T, index: number, listLength: number): number;
getPosInSet?(element: T, index: number): number;
getRole?(element: T): string | undefined;
isChecked?(element: T): boolean | undefined;
}
export interface IListViewOptionsUpdate {
readonly additionalScrollHeight?: number;
readonly smoothScrolling?: boolean;
readonly horizontalScrolling?: boolean;
}
export interface IListViewOptions<T> extends IListViewOptionsUpdate {
readonly dnd?: IListViewDragAndDrop<T>;
readonly useShadows?: boolean;
readonly verticalScrollMode?: ScrollbarVisibility;
readonly setRowLineHeight?: boolean;
readonly setRowHeight?: boolean;
readonly supportDynamicHeights?: boolean;
readonly mouseSupport?: boolean;
readonly accessibilityProvider?: IListViewAccessibilityProvider<T>;
readonly transformOptimization?: boolean;
}
const DefaultOptions = {
useShadows: true,
verticalScrollMode: ScrollbarVisibility.Auto,
setRowLineHeight: true,
setRowHeight: true,
supportDynamicHeights: false,
dnd: {
getDragElements<T>(e: T) { return [e]; },
getDragURI() { return null; },
onDragStart(): void { },
onDragOver() { return false; },
drop() { }
},
horizontalScrolling: false,
transformOptimization: true
};
export class ElementsDragAndDropData<T, TContext = void> implements IDragAndDropData {
readonly elements: T[];
private _context: TContext | undefined;
public get context(): TContext | undefined {
return this._context;
}
public set context(value: TContext | undefined) {
this._context = value;
}
constructor(elements: T[]) {
this.elements = elements;
}
update(): void { }
getData(): T[] {
return this.elements;
}
}
export class ExternalElementsDragAndDropData<T> implements IDragAndDropData {
readonly elements: T[];
constructor(elements: T[]) {
this.elements = elements;
}
update(): void { }
getData(): T[] {
return this.elements;
}
}
export class NativeDragAndDropData implements IDragAndDropData {
readonly types: any[];
readonly files: any[];
constructor() {
this.types = [];
this.files = [];
}
update(dataTransfer: DataTransfer): void {
if (dataTransfer.types) {
this.types.splice(0, this.types.length, ...dataTransfer.types);
}
if (dataTransfer.files) {
this.files.splice(0, this.files.length);
for (let i = 0; i < dataTransfer.files.length; i++) {
const file = dataTransfer.files.item(i);
if (file && (file.size || file.type)) {
this.files.push(file);
}
}
}
}
getData(): any {
return {
types: this.types,
files: this.files
};
}
}
function equalsDragFeedback(f1: number[] | undefined, f2: number[] | undefined): boolean {
if (Array.isArray(f1) && Array.isArray(f2)) {
return equals(f1, f2!);
}
return f1 === f2;
}
class ListViewAccessibilityProvider<T> implements Required<IListViewAccessibilityProvider<T>> {
readonly getSetSize: (element: any, index: number, listLength: number) => number;
readonly getPosInSet: (element: any, index: number) => number;
readonly getRole: (element: T) => string | undefined;
readonly isChecked: (element: T) => boolean | undefined;
constructor(accessibilityProvider?: IListViewAccessibilityProvider<T>) {
if (accessibilityProvider?.getSetSize) {
this.getSetSize = accessibilityProvider.getSetSize.bind(accessibilityProvider);
} else {
this.getSetSize = (e, i, l) => l;
}
if (accessibilityProvider?.getPosInSet) {
this.getPosInSet = accessibilityProvider.getPosInSet.bind(accessibilityProvider);
} else {
this.getPosInSet = (e, i) => i + 1;
}
if (accessibilityProvider?.getRole) {
this.getRole = accessibilityProvider.getRole.bind(accessibilityProvider);
} else {
this.getRole = _ => 'listitem';
}
if (accessibilityProvider?.isChecked) {
this.isChecked = accessibilityProvider.isChecked.bind(accessibilityProvider);
} else {
this.isChecked = _ => undefined;
}
}
}
export class ListView<T> implements ISpliceable<T>, IDisposable {
private static InstanceCount = 0;
readonly domId = `list_id_${++ListView.InstanceCount}`;
readonly domNode: HTMLElement;
private items: IItem<T>[];
private itemId: number;
private rangeMap: RangeMap;
private cache: RowCache<T>;
private renderers = new Map<string, IListRenderer<any /* TODO@joao */, any>>();
private lastRenderTop: number;
private lastRenderHeight: number;
private renderWidth = 0;
private rowsContainer: HTMLElement;
private scrollable: Scrollable;
private scrollableElement: SmoothScrollableElement;
private _scrollHeight: number = 0;
private scrollableElementUpdateDisposable: IDisposable | null = null;
private scrollableElementWidthDelayer = new Delayer<void>(50);
private splicing = false;
private dragOverAnimationDisposable: IDisposable | undefined;
private dragOverAnimationStopDisposable: IDisposable = Disposable.None;
private dragOverMouseY: number = 0;
private setRowLineHeight: boolean;
private setRowHeight: boolean;
private supportDynamicHeights: boolean;
private additionalScrollHeight: number;
private accessibilityProvider: ListViewAccessibilityProvider<T>;
private scrollWidth: number | undefined;
private dnd: IListViewDragAndDrop<T>;
private canDrop: boolean = false;
private currentDragData: IDragAndDropData | undefined;
private currentDragFeedback: number[] | undefined;
private currentDragFeedbackDisposable: IDisposable = Disposable.None;
private onDragLeaveTimeout: IDisposable = Disposable.None;
private readonly disposables: DisposableStore = new DisposableStore();
private readonly _onDidChangeContentHeight = new Emitter<number>();
readonly onDidChangeContentHeight: Event<number> = Event.latch(this._onDidChangeContentHeight.event);
get contentHeight(): number { return this.rangeMap.size; }
get onDidScroll(): Event<ScrollEvent> { return this.scrollableElement.onScroll; }
get onWillScroll(): Event<ScrollEvent> { return this.scrollableElement.onWillScroll; }
get containerDomNode(): HTMLElement { return this.rowsContainer; }
private _horizontalScrolling: boolean = false;
private get horizontalScrolling(): boolean { return this._horizontalScrolling; }
private set horizontalScrolling(value: boolean) {
if (value === this._horizontalScrolling) {
return;
}
if (value && this.supportDynamicHeights) {
throw new Error('Horizontal scrolling and dynamic heights not supported simultaneously');
}
this._horizontalScrolling = value;
this.domNode.classList.toggle('horizontal-scrolling', this._horizontalScrolling);
if (this._horizontalScrolling) {
for (const item of this.items) {
this.measureItemWidth(item);
}
this.updateScrollWidth();
this.scrollableElement.setScrollDimensions({ width: getContentWidth(this.domNode) });
this.rowsContainer.style.width = `${Math.max(this.scrollWidth || 0, this.renderWidth)}px`;
} else {
this.scrollableElementWidthDelayer.cancel();
this.scrollableElement.setScrollDimensions({ width: this.renderWidth, scrollWidth: this.renderWidth });
this.rowsContainer.style.width = '';
}
}
constructor(
container: HTMLElement,
private virtualDelegate: IListVirtualDelegate<T>,
renderers: IListRenderer<any /* TODO@joao */, any>[],
options: IListViewOptions<T> = DefaultOptions as IListViewOptions<T>
) {
if (options.horizontalScrolling && options.supportDynamicHeights) {
throw new Error('Horizontal scrolling and dynamic heights not supported simultaneously');
}
this.items = [];
this.itemId = 0;
this.rangeMap = new RangeMap();
for (const renderer of renderers) {
this.renderers.set(renderer.templateId, renderer);
}
this.cache = this.disposables.add(new RowCache(this.renderers));
this.lastRenderTop = 0;
this.lastRenderHeight = 0;
this.domNode = document.createElement('div');
this.domNode.className = 'monaco-list';
this.domNode.classList.add(this.domId);
this.domNode.tabIndex = 0;
this.domNode.classList.toggle('mouse-support', typeof options.mouseSupport === 'boolean' ? options.mouseSupport : true);
this._horizontalScrolling = getOrDefault(options, o => o.horizontalScrolling, DefaultOptions.horizontalScrolling);
this.domNode.classList.toggle('horizontal-scrolling', this._horizontalScrolling);
this.additionalScrollHeight = typeof options.additionalScrollHeight === 'undefined' ? 0 : options.additionalScrollHeight;
this.accessibilityProvider = new ListViewAccessibilityProvider(options.accessibilityProvider);
this.rowsContainer = document.createElement('div');
this.rowsContainer.className = 'monaco-list-rows';
const transformOptimization = getOrDefault(options, o => o.transformOptimization, DefaultOptions.transformOptimization);
if (transformOptimization) {
this.rowsContainer.style.transform = 'translate3d(0px, 0px, 0px)';
}
this.disposables.add(Gesture.addTarget(this.rowsContainer));
this.scrollable = new Scrollable(getOrDefault(options, o => o.smoothScrolling, false) ? 125 : 0, cb => scheduleAtNextAnimationFrame(cb));
this.scrollableElement = this.disposables.add(new SmoothScrollableElement(this.rowsContainer, {
alwaysConsumeMouseWheel: true,
horizontal: ScrollbarVisibility.Auto,
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.scrollableElement.onScroll(this.onScroll, this, this.disposables);
domEvent(this.rowsContainer, TouchEventType.Change)(this.onTouchChange, this, this.disposables);
// Prevent the monaco-scrollable-element from scrolling
// https://github.com/microsoft/vscode/issues/44181
domEvent(this.scrollableElement.getDomNode(), 'scroll')
(e => (e.target as HTMLElement).scrollTop = 0, null, this.disposables);
Event.map(domEvent(this.domNode, 'dragover'), e => this.toDragEvent(e))(this.onDragOver, this, this.disposables);
Event.map(domEvent(this.domNode, 'drop'), e => this.toDragEvent(e))(this.onDrop, this, this.disposables);
domEvent(this.domNode, 'dragleave')(this.onDragLeave, this, this.disposables);
domEvent(window, 'dragend')(this.onDragEnd, this, this.disposables);
this.setRowLineHeight = getOrDefault(options, o => o.setRowLineHeight, DefaultOptions.setRowLineHeight);
this.setRowHeight = getOrDefault(options, o => o.setRowHeight, DefaultOptions.setRowHeight);
this.supportDynamicHeights = getOrDefault(options, o => o.supportDynamicHeights, DefaultOptions.supportDynamicHeights);
this.dnd = getOrDefault<IListViewOptions<T>, IListViewDragAndDrop<T>>(options, o => o.dnd, DefaultOptions.dnd);
this.layout();
}
updateOptions(options: IListViewOptionsUpdate) {
if (options.additionalScrollHeight !== undefined) {
this.additionalScrollHeight = options.additionalScrollHeight;
}
if (options.smoothScrolling !== undefined) {
this.scrollable.setSmoothScrollDuration(options.smoothScrolling ? 125 : 0);
}
if (options.horizontalScrolling !== undefined) {
this.horizontalScrolling = options.horizontalScrolling;
}
}
triggerScrollFromMouseWheelEvent(browserEvent: IMouseWheelEvent) {
this.scrollableElement.triggerScrollFromMouseWheelEvent(browserEvent);
}
updateElementHeight(index: number, size: number, anchorIndex: number | null): void {
if (index < 0 || index >= this.items.length) {
return;
}
if (this.items[index].size === size) {
return;
}
const lastRenderRange = this.getRenderRange(this.lastRenderTop, this.lastRenderHeight);
let heightDiff = 0;
if (index < lastRenderRange.start) {
// do not scroll the viewport if resized element is out of viewport
heightDiff = size - this.items[index].size;
} else {
if (anchorIndex !== null && anchorIndex > index && anchorIndex <= lastRenderRange.end) {
// anchor in viewport
// resized elemnet in viewport and above the anchor
heightDiff = size - this.items[index].size;
} else {
heightDiff = 0;
}
}
this.rangeMap.splice(index, 1, [{ size: size }]);
this.items[index].size = size;
this.render(lastRenderRange, Math.max(0, this.lastRenderTop + heightDiff), this.lastRenderHeight, undefined, undefined, true);
this.eventuallyUpdateScrollDimensions();
if (this.supportDynamicHeights) {
this._rerender(this.lastRenderTop, this.lastRenderHeight);
}
}
splice(start: number, deleteCount: number, elements: T[] = []): T[] {
if (this.splicing) {
throw new Error('Can\'t run recursive splices.');
}
this.splicing = true;
try {
return this._splice(start, deleteCount, elements);
} finally {
this.splicing = false;
this._onDidChangeContentHeight.fire(this.contentHeight);
}
}
private _splice(start: number, deleteCount: number, elements: T[] = []): T[] {
const previousRenderRange = this.getRenderRange(this.lastRenderTop, this.lastRenderHeight);
const deleteRange = { start, end: start + deleteCount };
const removeRange = Range.intersect(previousRenderRange, deleteRange);
for (let i = removeRange.start; i < removeRange.end; i++) {
this.removeItemFromDOM(i);
}
const previousRestRange: IRange = { start: start + deleteCount, end: this.items.length };
const previousRenderedRestRange = Range.intersect(previousRestRange, previousRenderRange);
const previousUnrenderedRestRanges = Range.relativeComplement(previousRestRange, previousRenderRange);
const inserted = elements.map<IItem<T>>(element => ({
id: String(this.itemId++),
element,
templateId: this.virtualDelegate.getTemplateId(element),
size: this.virtualDelegate.getHeight(element),
width: undefined,
hasDynamicHeight: !!this.virtualDelegate.hasDynamicHeight && this.virtualDelegate.hasDynamicHeight(element),
lastDynamicHeightWidth: undefined,
row: null,
uri: undefined,
dropTarget: false,
dragStartDisposable: Disposable.None
}));
let deleted: IItem<T>[];
// TODO@joao: improve this optimization to catch even more cases
if (start === 0 && deleteCount >= this.items.length) {
this.rangeMap = new RangeMap();
this.rangeMap.splice(0, 0, inserted);
this.items = inserted;
deleted = [];
} else {
this.rangeMap.splice(start, deleteCount, inserted);
deleted = this.items.splice(start, deleteCount, ...inserted);
}
const delta = elements.length - deleteCount;
const renderRange = this.getRenderRange(this.lastRenderTop, this.lastRenderHeight);
const renderedRestRange = shift(previousRenderedRestRange, delta);
const updateRange = Range.intersect(renderRange, renderedRestRange);
for (let i = updateRange.start; i < updateRange.end; i++) {
this.updateItemInDOM(this.items[i], i);
}
const removeRanges = Range.relativeComplement(renderedRestRange, renderRange);
for (const range of removeRanges) {
for (let i = range.start; i < range.end; i++) {
this.removeItemFromDOM(i);
}
}
const unrenderedRestRanges = previousUnrenderedRestRanges.map(r => shift(r, delta));
const elementsRange = { start, end: start + elements.length };
const insertRanges = [elementsRange, ...unrenderedRestRanges].map(r => Range.intersect(renderRange, r));
const beforeElement = this.getNextToLastElement(insertRanges);
for (const range of insertRanges) {
for (let i = range.start; i < range.end; i++) {
this.insertItemInDOM(i, beforeElement);
}
}
this.eventuallyUpdateScrollDimensions();
if (this.supportDynamicHeights) {
this._rerender(this.scrollTop, this.renderHeight);
}
return deleted.map(i => i.element);
}
private eventuallyUpdateScrollDimensions(): void {
this._scrollHeight = this.contentHeight;
this.rowsContainer.style.height = `${this._scrollHeight}px`;
if (!this.scrollableElementUpdateDisposable) {
this.scrollableElementUpdateDisposable = scheduleAtNextAnimationFrame(() => {
this.scrollableElement.setScrollDimensions({ scrollHeight: this.scrollHeight });
this.updateScrollWidth();
this.scrollableElementUpdateDisposable = null;
});
}
}
private eventuallyUpdateScrollWidth(): void {
if (!this.horizontalScrolling) {
this.scrollableElementWidthDelayer.cancel();
return;
}
this.scrollableElementWidthDelayer.trigger(() => this.updateScrollWidth());
}
private updateScrollWidth(): void {
if (!this.horizontalScrolling) {
return;
}
let scrollWidth = 0;
for (const item of this.items) {
if (typeof item.width !== 'undefined') {
scrollWidth = Math.max(scrollWidth, item.width);
}
}
this.scrollWidth = scrollWidth;
this.scrollableElement.setScrollDimensions({ scrollWidth: scrollWidth === 0 ? 0 : (scrollWidth + 10) });
}
updateWidth(index: number): void {
if (!this.horizontalScrolling || typeof this.scrollWidth === 'undefined') {
return;
}
const item = this.items[index];
this.measureItemWidth(item);
if (typeof item.width !== 'undefined' && item.width > this.scrollWidth) {
this.scrollWidth = item.width;
this.scrollableElement.setScrollDimensions({ scrollWidth: this.scrollWidth + 10 });
}
}
rerender(): void {
if (!this.supportDynamicHeights) {
return;
}
for (const item of this.items) {
item.lastDynamicHeightWidth = undefined;
}
this._rerender(this.lastRenderTop, this.lastRenderHeight);
}
get length(): number {
return this.items.length;
}
get renderHeight(): number {
const scrollDimensions = this.scrollableElement.getScrollDimensions();
return scrollDimensions.height;
}
get firstVisibleIndex(): number {
const range = this.getRenderRange(this.lastRenderTop, this.lastRenderHeight);
const firstElTop = this.rangeMap.positionAt(range.start);
const nextElTop = this.rangeMap.positionAt(range.start + 1);
if (nextElTop !== -1) {
const firstElMidpoint = (nextElTop - firstElTop) / 2 + firstElTop;
if (firstElMidpoint < this.scrollTop) {
return range.start + 1;
}
}
return range.start;
}
get lastVisibleIndex(): number {
const range = this.getRenderRange(this.lastRenderTop, this.lastRenderHeight);
return range.end - 1;
}
element(index: number): T {
return this.items[index].element;
}
indexOf(element: T): number {
return this.items.findIndex(item => item.element === element);
}
domElement(index: number): HTMLElement | null {
const row = this.items[index].row;
return row && row.domNode;
}
elementHeight(index: number): number {
return this.items[index].size;
}
elementTop(index: number): number {
return this.rangeMap.positionAt(index);
}
indexAt(position: number): number {
return this.rangeMap.indexAt(position);
}
indexAfter(position: number): number {
return this.rangeMap.indexAfter(position);
}
layout(height?: number, width?: number): void {
let scrollDimensions: INewScrollDimensions = {
height: typeof height === 'number' ? height : getContentHeight(this.domNode)
};
if (this.scrollableElementUpdateDisposable) {
this.scrollableElementUpdateDisposable.dispose();
this.scrollableElementUpdateDisposable = null;
scrollDimensions.scrollHeight = this.scrollHeight;
}
this.scrollableElement.setScrollDimensions(scrollDimensions);
if (typeof width !== 'undefined') {
this.renderWidth = width;
if (this.supportDynamicHeights) {
this._rerender(this.scrollTop, this.renderHeight);
}
if (this.horizontalScrolling) {
this.scrollableElement.setScrollDimensions({
width: typeof width === 'number' ? width : getContentWidth(this.domNode)
});
}
}
}
// Render
private render(previousRenderRange: IRange, renderTop: number, renderHeight: number, renderLeft: number | undefined, scrollWidth: number | undefined, 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);
}
}
if (renderLeft !== undefined) {
this.rowsContainer.style.left = `-${renderLeft}px`;
}
this.rowsContainer.style.top = `-${renderTop}px`;
if (this.horizontalScrolling && scrollWidth !== undefined) {
this.rowsContainer.style.width = `${Math.max(scrollWidth, this.renderWidth)}px`;
}
this.lastRenderTop = renderTop;
this.lastRenderHeight = renderHeight;
}
// DOM operations
private insertItemInDOM(index: number, beforeElement: HTMLElement | null): void {
const item = this.items[index];
if (!item.row) {
item.row = this.cache.alloc(item.templateId);
const role = this.accessibilityProvider.getRole(item.element) || 'listitem';
item.row!.domNode!.setAttribute('role', role);
const checked = this.accessibilityProvider.isChecked(item.element);
if (typeof checked !== 'undefined') {
item.row!.domNode!.setAttribute('aria-checked', String(!!checked));
}
}
if (!item.row.domNode!.parentElement) {
if (beforeElement) {
this.rowsContainer.insertBefore(item.row.domNode!, beforeElement);
} else {
this.rowsContainer.appendChild(item.row.domNode!);
}
}
this.updateItemInDOM(item, index);
const renderer = this.renderers.get(item.templateId);
if (!renderer) {
throw new Error(`No renderer found for template id ${item.templateId}`);
}
if (renderer) {
renderer.renderElement(item.element, index, item.row.templateData, item.size);
}
const uri = this.dnd.getDragURI(item.element);
item.dragStartDisposable.dispose();
item.row.domNode!.draggable = !!uri;
if (uri) {
const onDragStart = domEvent(item.row.domNode!, 'dragstart');
item.dragStartDisposable = onDragStart(event => this.onDragStart(item.element, uri, event));
}
if (this.horizontalScrolling) {
this.measureItemWidth(item);
this.eventuallyUpdateScrollWidth();
}
}
private measureItemWidth(item: IItem<T>): void {
if (!item.row || !item.row.domNode) {
return;
}
item.row.domNode.style.width = isFirefox ? '-moz-fit-content' : 'fit-content';
item.width = getContentWidth(item.row.domNode);
const style = window.getComputedStyle(item.row.domNode);
if (style.paddingLeft) {
item.width += parseFloat(style.paddingLeft);
}
if (style.paddingRight) {
item.width += parseFloat(style.paddingRight);
}
item.row.domNode.style.width = '';
}
private updateItemInDOM(item: IItem<T>, index: number): void {
item.row!.domNode!.style.top = `${this.elementTop(index)}px`;
if (this.setRowHeight) {
item.row!.domNode!.style.height = `${item.size}px`;
}
if (this.setRowLineHeight) {
item.row!.domNode!.style.lineHeight = `${item.size}px`;
}
item.row!.domNode!.setAttribute('data-index', `${index}`);
item.row!.domNode!.setAttribute('data-last-element', index === this.length - 1 ? 'true' : 'false');
item.row!.domNode!.setAttribute('aria-setsize', String(this.accessibilityProvider.getSetSize(item.element, index, this.length)));
item.row!.domNode!.setAttribute('aria-posinset', String(this.accessibilityProvider.getPosInSet(item.element, index)));
item.row!.domNode!.setAttribute('id', this.getElementDomId(index));
item.row!.domNode!.classList.toggle('drop-target', item.dropTarget);
}
private removeItemFromDOM(index: number): void {
const item = this.items[index];
item.dragStartDisposable.dispose();
const renderer = this.renderers.get(item.templateId);
if (item.row && renderer && renderer.disposeElement) {
renderer.disposeElement(item.element, index, item.row.templateData, item.size);
}
this.cache.release(item.row!);
item.row = null;
if (this.horizontalScrolling) {
this.eventuallyUpdateScrollWidth();
}
}
getScrollTop(): number {
const scrollPosition = this.scrollableElement.getScrollPosition();
return scrollPosition.scrollTop;
}
setScrollTop(scrollTop: number): void {
if (this.scrollableElementUpdateDisposable) {
this.scrollableElementUpdateDisposable.dispose();
this.scrollableElementUpdateDisposable = null;
this.scrollableElement.setScrollDimensions({ scrollHeight: this.scrollHeight });
}
this.scrollableElement.setScrollPosition({ scrollTop });
}
getScrollLeft(): number {
const scrollPosition = this.scrollableElement.getScrollPosition();
return scrollPosition.scrollLeft;
}
setScrollLeft(scrollLeft: number): void {
if (this.scrollableElementUpdateDisposable) {
this.scrollableElementUpdateDisposable.dispose();
this.scrollableElementUpdateDisposable = null;
this.scrollableElement.setScrollDimensions({ scrollWidth: this.scrollWidth });
}
this.scrollableElement.setScrollPosition({ scrollLeft });
}
get scrollTop(): number {
return this.getScrollTop();
}
set scrollTop(scrollTop: number) {
this.setScrollTop(scrollTop);
}
get scrollHeight(): number {
return this._scrollHeight + (this.horizontalScrolling ? 10 : 0) + this.additionalScrollHeight;
}
// Events
@memoize get onMouseClick(): Event<IListMouseEvent<T>> { return Event.map(domEvent(this.domNode, 'click'), e => this.toMouseEvent(e)); }
@memoize get onMouseDblClick(): Event<IListMouseEvent<T>> { return Event.map(domEvent(this.domNode, 'dblclick'), e => this.toMouseEvent(e)); }
@memoize get onMouseMiddleClick(): Event<IListMouseEvent<T>> { return Event.filter(Event.map(domEvent(this.domNode, 'auxclick'), e => this.toMouseEvent(e as MouseEvent)), e => e.browserEvent.button === 1); }
@memoize get onMouseUp(): Event<IListMouseEvent<T>> { return Event.map(domEvent(this.domNode, 'mouseup'), e => this.toMouseEvent(e)); }
@memoize get onMouseDown(): Event<IListMouseEvent<T>> { return Event.map(domEvent(this.domNode, 'mousedown'), e => this.toMouseEvent(e)); }
@memoize get onMouseOver(): Event<IListMouseEvent<T>> { return Event.map(domEvent(this.domNode, 'mouseover'), e => this.toMouseEvent(e)); }
@memoize get onMouseMove(): Event<IListMouseEvent<T>> { return Event.map(domEvent(this.domNode, 'mousemove'), e => this.toMouseEvent(e)); }
@memoize get onMouseOut(): Event<IListMouseEvent<T>> { return Event.map(domEvent(this.domNode, 'mouseout'), e => this.toMouseEvent(e)); }
@memoize get onContextMenu(): Event<IListMouseEvent<T>> { return Event.map(domEvent(this.domNode, 'contextmenu'), e => this.toMouseEvent(e)); }
@memoize get onTouchStart(): Event<IListTouchEvent<T>> { return Event.map(domEvent(this.domNode, 'touchstart'), e => this.toTouchEvent(e)); }
@memoize get onTap(): Event<IListGestureEvent<T>> { return Event.map(domEvent(this.rowsContainer, TouchEventType.Tap), e => this.toGestureEvent(e)); }
private toMouseEvent(browserEvent: MouseEvent): IListMouseEvent<T> {
const index = this.getItemIndexFromEventTarget(browserEvent.target || null);
const item = typeof index === 'undefined' ? undefined : this.items[index];
const element = item && item.element;
return { browserEvent, index, element };
}
private toTouchEvent(browserEvent: TouchEvent): IListTouchEvent<T> {
const index = this.getItemIndexFromEventTarget(browserEvent.target || null);
const item = typeof index === 'undefined' ? undefined : this.items[index];
const element = item && item.element;
return { browserEvent, index, element };
}
private toGestureEvent(browserEvent: GestureEvent): IListGestureEvent<T> {
const index = this.getItemIndexFromEventTarget(browserEvent.initialTarget || null);
const item = typeof index === 'undefined' ? undefined : this.items[index];
const element = item && item.element;
return { browserEvent, index, element };
}
private toDragEvent(browserEvent: DragEvent): IListDragEvent<T> {
const index = this.getItemIndexFromEventTarget(browserEvent.target || null);
const item = typeof index === 'undefined' ? undefined : this.items[index];
const element = item && item.element;
return { browserEvent, index, element };
}
private onScroll(e: ScrollEvent): void {
try {
const previousRenderRange = this.getRenderRange(this.lastRenderTop, this.lastRenderHeight);
this.render(previousRenderRange, e.scrollTop, e.height, e.scrollLeft, e.scrollWidth);
if (this.supportDynamicHeights) {
// Don't update scrollTop from within an scroll event
// so we don't break smooth scrolling. #104144
this._rerender(e.scrollTop, e.height, false);
}
} catch (err) {
console.error('Got bad scroll event:', e);
throw err;
}
}
private onTouchChange(event: GestureEvent): void {
event.preventDefault();
event.stopPropagation();
this.scrollTop -= event.translationY;
}
// DND
private onDragStart(element: T, uri: string, event: DragEvent): void {
if (!event.dataTransfer) {
return;
}
const elements = this.dnd.getDragElements(element);
event.dataTransfer.effectAllowed = 'copyMove';
event.dataTransfer.setData(DataTransfers.RESOURCES, JSON.stringify([uri]));
if (event.dataTransfer.setDragImage) {
let label: string | undefined;
if (this.dnd.getDragLabel) {
label = this.dnd.getDragLabel(elements, event);
}
if (typeof label === 'undefined') {
label = String(elements.length);
}
const dragImage = $('.monaco-drag-image');
dragImage.textContent = label;
document.body.appendChild(dragImage);
event.dataTransfer.setDragImage(dragImage, -10, -10);
setTimeout(() => document.body.removeChild(dragImage), 0);
}
this.currentDragData = new ElementsDragAndDropData(elements);
StaticDND.CurrentDragAndDropData = new ExternalElementsDragAndDropData(elements);
if (this.dnd.onDragStart) {
this.dnd.onDragStart(this.currentDragData, event);
}
}
private onDragOver(event: IListDragEvent<T>): boolean {
event.browserEvent.preventDefault(); // needed so that the drop event fires (https://stackoverflow.com/questions/21339924/drop-event-not-firing-in-chrome)
this.onDragLeaveTimeout.dispose();
if (StaticDND.CurrentDragAndDropData && StaticDND.CurrentDragAndDropData.getData() === 'vscode-ui') {
return false;
}
this.setupDragAndDropScrollTopAnimation(event.browserEvent);
if (!event.browserEvent.dataTransfer) {
return false;
}
// Drag over from outside
if (!this.currentDragData) {
if (StaticDND.CurrentDragAndDropData) {
// Drag over from another list
this.currentDragData = StaticDND.CurrentDragAndDropData;
} else {
// Drag over from the desktop
if (!event.browserEvent.dataTransfer.types) {
return false;
}
this.currentDragData = new NativeDragAndDropData();
}
}
const result = this.dnd.onDragOver(this.currentDragData, event.element, event.index, event.browserEvent);
this.canDrop = typeof result === 'boolean' ? result : result.accept;
if (!this.canDrop) {
this.currentDragFeedback = undefined;
this.currentDragFeedbackDisposable.dispose();
return false;
}
event.browserEvent.dataTransfer.dropEffect = (typeof result !== 'boolean' && result.effect === ListDragOverEffect.Copy) ? 'copy' : 'move';
let feedback: number[];
if (typeof result !== 'boolean' && result.feedback) {
feedback = result.feedback;
} else {
if (typeof event.index === 'undefined') {
feedback = [-1];
} else {
feedback = [event.index];
}
}
// sanitize feedback list
feedback = distinct(feedback).filter(i => i >= -1 && i < this.length).sort((a, b) => a - b);
feedback = feedback[0] === -1 ? [-1] : feedback;
if (equalsDragFeedback(this.currentDragFeedback, feedback)) {
return true;
}
this.currentDragFeedback = feedback;
this.currentDragFeedbackDisposable.dispose();
if (feedback[0] === -1) { // entire list feedback
this.domNode.classList.add('drop-target');
this.rowsContainer.classList.add('drop-target');
this.currentDragFeedbackDisposable = toDisposable(() => {
this.domNode.classList.remove('drop-target');
this.rowsContainer.classList.remove('drop-target');
});
} else {
for (const index of feedback) {
const item = this.items[index]!;
item.dropTarget = true;
if (item.row && item.row.domNode) {
item.row.domNode.classList.add('drop-target');
}
}
this.currentDragFeedbackDisposable = toDisposable(() => {
for (const index of feedback) {
const item = this.items[index]!;
item.dropTarget = false;
if (item.row && item.row.domNode) {
item.row.domNode.classList.remove('drop-target');
}
}
});
}
return true;
}
private onDragLeave(): void {
this.onDragLeaveTimeout.dispose();
this.onDragLeaveTimeout = disposableTimeout(() => this.clearDragOverFeedback(), 100);
}
private onDrop(event: IListDragEvent<T>): void {
if (!this.canDrop) {
return;
}
const dragData = this.currentDragData;
this.teardownDragAndDropScrollTopAnimation();
this.clearDragOverFeedback();
this.currentDragData = undefined;
StaticDND.CurrentDragAndDropData = undefined;
if (!dragData || !event.browserEvent.dataTransfer) {
return;
}
event.browserEvent.preventDefault();
dragData.update(event.browserEvent.dataTransfer);
this.dnd.drop(dragData, event.element, event.index, event.browserEvent);
}
private onDragEnd(event: DragEvent): void {
this.canDrop = false;
this.teardownDragAndDropScrollTopAnimation();
this.clearDragOverFeedback();
this.currentDragData = undefined;
StaticDND.CurrentDragAndDropData = undefined;
if (this.dnd.onDragEnd) {
this.dnd.onDragEnd(event);
}
}
private clearDragOverFeedback(): void {
this.currentDragFeedback = undefined;
this.currentDragFeedbackDisposable.dispose();
this.currentDragFeedbackDisposable = Disposable.None;
}
// DND scroll top animation
private setupDragAndDropScrollTopAnimation(event: DragEvent): void {
if (!this.dragOverAnimationDisposable) {
const viewTop = getTopLeftOffset(this.domNode).top;
this.dragOverAnimationDisposable = animate(this.animateDragAndDropScrollTop.bind(this, viewTop));
}
this.dragOverAnimationStopDisposable.dispose();
this.dragOverAnimationStopDisposable = disposableTimeout(() => {
if (this.dragOverAnimationDisposable) {
this.dragOverAnimationDisposable.dispose();
this.dragOverAnimationDisposable = undefined;
}
}, 1000);
this.dragOverMouseY = event.pageY;
}
private animateDragAndDropScrollTop(viewTop: number): void {
if (this.dragOverMouseY === undefined) {
return;
}
const diff = this.dragOverMouseY - viewTop;
const upperLimit = this.renderHeight - 35;
if (diff < 35) {
this.scrollTop += Math.max(-14, Math.floor(0.3 * (diff - 35)));
} else if (diff > upperLimit) {
this.scrollTop += Math.min(14, Math.floor(0.3 * (diff - upperLimit)));
}
}
private teardownDragAndDropScrollTopAnimation(): void {
this.dragOverAnimationStopDisposable.dispose();
if (this.dragOverAnimationDisposable) {
this.dragOverAnimationDisposable.dispose();
this.dragOverAnimationDisposable = undefined;
}
}
// Util
private getItemIndexFromEventTarget(target: EventTarget | null): number | undefined {
const scrollableElement = this.scrollableElement.getDomNode();
let element: HTMLElement | null = target as (HTMLElement | null);
while (element instanceof HTMLElement && element !== this.rowsContainer && scrollableElement.contains(element)) {
const rawIndex = element.getAttribute('data-index');
if (rawIndex) {
const index = Number(rawIndex);
if (!isNaN(index)) {
return index;
}
}
element = element.parentElement;
}
return undefined;
}
private getRenderRange(renderTop: number, renderHeight: number): IRange {
return {
start: this.rangeMap.indexAt(renderTop),
end: this.rangeMap.indexAfter(renderTop + renderHeight - 1)
};
}
/**
* Given a stable rendered state, checks every rendered element whether it needs
* to be probed for dynamic height. Adjusts scroll height and top if necessary.
*/
private _rerender(renderTop: number, renderHeight: number, updateScrollTop: boolean = true): void {
const previousRenderRange = this.getRenderRange(renderTop, renderHeight);
// Let's remember the second element's position, this helps in scrolling up
// and preserving a linear upwards scroll movement
let anchorElementIndex: number | undefined;
let anchorElementTopDelta: number | undefined;
if (renderTop === this.elementTop(previousRenderRange.start)) {
anchorElementIndex = previousRenderRange.start;
anchorElementTopDelta = 0;
} else if (previousRenderRange.end - previousRenderRange.start > 1) {
anchorElementIndex = previousRenderRange.start + 1;
anchorElementTopDelta = this.elementTop(anchorElementIndex) - renderTop;
}
let heightDiff = 0;
while (true) {
const renderRange = this.getRenderRange(renderTop, renderHeight);
let didChange = false;
for (let i = renderRange.start; i < renderRange.end; i++) {
const diff = this.probeDynamicHeight(i);
if (diff !== 0) {
this.rangeMap.splice(i, 1, [this.items[i]]);
}
heightDiff += diff;
didChange = didChange || diff !== 0;
}
if (!didChange) {
if (heightDiff !== 0) {
this.eventuallyUpdateScrollDimensions();
}
const unrenderRanges = Range.relativeComplement(previousRenderRange, renderRange);
for (const range of unrenderRanges) {
for (let i = range.start; i < range.end; i++) {
if (this.items[i].row) {
this.removeItemFromDOM(i);
}
}
}
const renderRanges = Range.relativeComplement(renderRange, previousRenderRange);
for (const range of renderRanges) {
for (let i = range.start; i < range.end; i++) {
const afterIndex = i + 1;
const beforeRow = afterIndex < this.items.length ? this.items[afterIndex].row : null;
const beforeElement = beforeRow ? beforeRow.domNode : null;
this.insertItemInDOM(i, beforeElement);
}
}
for (let i = renderRange.start; i < renderRange.end; i++) {
if (this.items[i].row) {
this.updateItemInDOM(this.items[i], i);
}
}
if (updateScrollTop && typeof anchorElementIndex === 'number') {
this.scrollTop = this.elementTop(anchorElementIndex) - anchorElementTopDelta!;
}
this._onDidChangeContentHeight.fire(this.contentHeight);
return;
}
}
}
private probeDynamicHeight(index: number): number {
const item = this.items[index];
if (!item.hasDynamicHeight || item.lastDynamicHeightWidth === this.renderWidth) {
return 0;
}
if (!!this.virtualDelegate.hasDynamicHeight && !this.virtualDelegate.hasDynamicHeight(item.element)) {
return 0;
}
const size = item.size;
if (!this.setRowHeight && item.row && item.row.domNode) {
let newSize = item.row.domNode.offsetHeight;
item.size = newSize;
item.lastDynamicHeightWidth = this.renderWidth;
return newSize - size;
}
const row = this.cache.alloc(item.templateId);
row.domNode!.style.height = '';
this.rowsContainer.appendChild(row.domNode!);
const renderer = this.renderers.get(item.templateId);
if (renderer) {
renderer.renderElement(item.element, index, row.templateData, undefined);
if (renderer.disposeElement) {
renderer.disposeElement(item.element, index, row.templateData, undefined);
}
}
item.size = row.domNode!.offsetHeight;
if (this.virtualDelegate.setDynamicHeight) {
this.virtualDelegate.setDynamicHeight(item.element, item.size);
}
item.lastDynamicHeightWidth = this.renderWidth;
this.rowsContainer.removeChild(row.domNode!);
this.cache.release(row);
return item.size - size;
}
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;
}
if (!nextToLastItem.row) {
return null;
}
return nextToLastItem.row.domNode;
}
getElementDomId(index: number): string {
return `${this.domId}_${index}`;
}
// Dispose
dispose() {
if (this.items) {
for (const item of this.items) {
if (item.row) {
const renderer = this.renderers.get(item.row.templateId);
if (renderer) {
renderer.disposeTemplate(item.row.templateData);
}
}
}
this.items = [];
}
if (this.domNode && this.domNode.parentNode) {
this.domNode.parentNode.removeChild(this.domNode);
}
dispose(this.disposables);
}
}