/*--------------------------------------------------------------------------------------------- * 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 } from 'vs/base/common/lifecycle'; import { Gesture, EventType as TouchEventType, GestureEvent } from 'vs/base/browser/touch'; import * as DOM from 'vs/base/browser/dom'; import Event, { mapEvent, filterEvent } from 'vs/base/common/event'; import { domEvent } from 'vs/base/browser/event'; import { ScrollableElement } from 'vs/base/browser/ui/scrollbar/scrollableElement'; import { ScrollEvent, ScrollbarVisibility } from 'vs/base/common/scrollable'; import { RangeMap, IRange, relativeComplement, intersect, shift } from './rangeMap'; import { IDelegate, IRenderer, IListMouseEvent, IListTouchEvent, IListGestureEvent } from './list'; import { RowCache, IRow } from './rowCache'; import { isWindows } from 'vs/base/common/platform'; import * as browser from 'vs/base/browser/browser'; import { ISpliceable } from 'vs/base/common/sequence'; import { memoize } from 'vs/base/common/decorators'; function canUseTranslate3d(): boolean { if (browser.isFirefox) { return false; } if (browser.getZoomLevel() !== 0) { return false; } // see https://github.com/Microsoft/vscode/issues/24483 if (browser.isChromev56) { const pixelRatio = browser.getPixelRatio(); if (Math.floor(pixelRatio) !== pixelRatio) { // Not an integer return false; } } return true; } interface IItem { id: string; element: T; size: number; templateId: string; row: IRow; } export interface IListViewOptions { useShadows?: boolean; } const DefaultOptions: IListViewOptions = { useShadows: true }; export class ListView implements ISpliceable, IDisposable { private items: IItem[]; private itemId: number; private rangeMap: RangeMap; private cache: RowCache; private renderers = new Map>(); private lastRenderTop: number; private lastRenderHeight: number; private _domNode: HTMLElement; private gesture: Gesture; private rowsContainer: HTMLElement; private scrollableElement: ScrollableElement; private splicing = false; private disposables: IDisposable[]; constructor( container: HTMLElement, private delegate: IDelegate, renderers: IRenderer[], options: IListViewOptions = DefaultOptions ) { this.items = []; this.itemId = 0; this.rangeMap = new RangeMap(); for (const renderer of renderers) { this.renderers.set(renderer.templateId, renderer); } this.cache = new RowCache(this.renderers); this.lastRenderTop = 0; this.lastRenderHeight = 0; this._domNode = document.createElement('div'); this._domNode.className = 'monaco-list'; this.rowsContainer = document.createElement('div'); this.rowsContainer.className = 'monaco-list-rows'; Gesture.addTarget(this.rowsContainer); this.scrollableElement = new ScrollableElement(this.rowsContainer, { alwaysConsumeMouseWheel: true, horizontal: ScrollbarVisibility.Hidden, vertical: ScrollbarVisibility.Auto, useShadows: getOrDefault(options, o => o.useShadows, DefaultOptions.useShadows) }); this._domNode.appendChild(this.scrollableElement.getDomNode()); container.appendChild(this._domNode); this.disposables = [this.rangeMap, this.gesture, this.scrollableElement]; this.scrollableElement.onScroll(this.onScroll, this, this.disposables); domEvent(this.rowsContainer, TouchEventType.Change)(this.onTouchChange, this, this.disposables); this.layout(); } get domNode(): HTMLElement { return this._domNode; } 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; } } 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 = intersect(previousRenderRange, deleteRange); for (let i = removeRange.start; i < removeRange.end; i++) { this.removeItemFromDOM(this.items[i]); } const previousRestRange: IRange = { start: start + deleteCount, end: this.items.length }; const previousRenderedRestRange = intersect(previousRestRange, previousRenderRange); const previousUnrenderedRestRanges = relativeComplement(previousRestRange, previousRenderRange); const inserted = elements.map>(element => ({ id: String(this.itemId++), element, size: this.delegate.getHeight(element), templateId: this.delegate.getTemplateId(element), row: null })); this.rangeMap.splice(start, deleteCount, ...inserted); const 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 = intersect(renderRange, renderedRestRange); for (let i = updateRange.start; i < updateRange.end; i++) { this.updateItemInDOM(this.items[i], i); } const removeRanges = relativeComplement(renderedRestRange, renderRange); for (let r = 0; r < removeRanges.length; r++) { const removeRange = removeRanges[r]; for (let i = removeRange.start; i < removeRange.end; i++) { this.removeItemFromDOM(this.items[i]); } } const unrenderedRestRanges = previousUnrenderedRestRanges.map(r => shift(r, delta)); const elementsRange = { start, end: start + elements.length }; const insertRanges = [elementsRange, ...unrenderedRestRanges].map(r => intersect(renderRange, r)); for (let r = 0; r < insertRanges.length; r++) { const insertRange = insertRanges[r]; for (let i = insertRange.start; i < insertRange.end; i++) { this.insertItemInDOM(this.items[i], i); } } const scrollHeight = this.getContentHeight(); this.rowsContainer.style.height = `${scrollHeight}px`; this.scrollableElement.setScrollDimensions({ scrollHeight }); return deleted.map(i => i.element); } get length(): number { return this.items.length; } get renderHeight(): number { const scrollDimensions = this.scrollableElement.getScrollDimensions(); return scrollDimensions.height; } element(index: number): T { return this.items[index].element; } domElement(index: number): HTMLElement { 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): void { this.scrollableElement.setScrollDimensions({ height: height || DOM.getContentHeight(this._domNode) }); } // Render private render(renderTop: number, renderHeight: number): void { const previousRenderRange = this.getRenderRange(this.lastRenderTop, this.lastRenderHeight); const renderRange = this.getRenderRange(renderTop, renderHeight); const rangesToInsert = relativeComplement(renderRange, previousRenderRange); const rangesToRemove = relativeComplement(previousRenderRange, renderRange); for (const range of rangesToInsert) { for (let i = range.start; i < range.end; i++) { this.insertItemInDOM(this.items[i], i); } } for (const range of rangesToRemove) { for (let i = range.start; i < range.end; i++) { this.removeItemFromDOM(this.items[i], ); } } if (canUseTranslate3d() && !isWindows /* Windows: translate3d breaks subpixel-antialias (ClearType) unless a background is defined */) { const transform = `translate3d(0px, -${renderTop}px, 0px)`; this.rowsContainer.style.transform = transform; this.rowsContainer.style.webkitTransform = transform; } else { this.rowsContainer.style.top = `-${renderTop}px`; } this.lastRenderTop = renderTop; this.lastRenderHeight = renderHeight; } // DOM operations private insertItemInDOM(item: IItem, index: number): void { if (!item.row) { item.row = this.cache.alloc(item.templateId); } if (!item.row.domNode.parentElement) { this.rowsContainer.appendChild(item.row.domNode); } const renderer = this.renderers.get(item.templateId); item.row.domNode.style.top = `${this.elementTop(index)}px`; item.row.domNode.style.height = `${item.size}px`; item.row.domNode.setAttribute('data-index', `${index}`); renderer.renderElement(item.element, index, item.row.templateData); } private updateItemInDOM(item: IItem, index: number): void { item.row.domNode.style.top = `${this.elementTop(index)}px`; item.row.domNode.setAttribute('data-index', `${index}`); } private removeItemFromDOM(item: IItem): void { this.cache.release(item.row); item.row = null; } getContentHeight(): number { return this.rangeMap.size; } getScrollTop(): number { const scrollPosition = this.scrollableElement.getScrollPosition(); return scrollPosition.scrollTop; } setScrollTop(scrollTop: number): void { this.scrollableElement.setScrollPosition({ scrollTop }); } get scrollTop(): number { return this.getScrollTop(); } set scrollTop(scrollTop: number) { this.setScrollTop(scrollTop); } // Events @memoize get onMouseClick(): Event> { return filterEvent(mapEvent(domEvent(this.domNode, 'click'), e => this.toMouseEvent(e)), e => e.index >= 0); } @memoize get onMouseDblClick(): Event> { return filterEvent(mapEvent(domEvent(this.domNode, 'dblclick'), e => this.toMouseEvent(e)), e => e.index >= 0); } @memoize get onMouseUp(): Event> { return filterEvent(mapEvent(domEvent(this.domNode, 'mouseup'), e => this.toMouseEvent(e)), e => e.index >= 0); } @memoize get onMouseDown(): Event> { return filterEvent(mapEvent(domEvent(this.domNode, 'mousedown'), e => this.toMouseEvent(e)), e => e.index >= 0); } @memoize get onMouseOver(): Event> { return filterEvent(mapEvent(domEvent(this.domNode, 'mouseover'), e => this.toMouseEvent(e)), e => e.index >= 0); } @memoize get onMouseMove(): Event> { return filterEvent(mapEvent(domEvent(this.domNode, 'mousemove'), e => this.toMouseEvent(e)), e => e.index >= 0); } @memoize get onMouseOut(): Event> { return filterEvent(mapEvent(domEvent(this.domNode, 'mouseout'), e => this.toMouseEvent(e)), e => e.index >= 0); } @memoize get onContextMenu(): Event> { return filterEvent(mapEvent(domEvent(this.domNode, 'contextmenu'), e => this.toMouseEvent(e)), e => e.index >= 0); } @memoize get onTouchStart(): Event> { return filterEvent(mapEvent(domEvent(this.domNode, 'touchstart'), e => this.toTouchEvent(e)), e => e.index >= 0); } @memoize get onTap(): Event> { return filterEvent(mapEvent(domEvent(this.rowsContainer, TouchEventType.Tap), e => this.toGestureEvent(e)), e => e.index >= 0); } private toMouseEvent(browserEvent: MouseEvent): IListMouseEvent { const index = this.getItemIndexFromEventTarget(browserEvent.target); const element = index < 0 ? undefined : this.items[index].element; return { browserEvent, index, element }; } private toTouchEvent(browserEvent: TouchEvent): IListTouchEvent { const index = this.getItemIndexFromEventTarget(browserEvent.target); const element = index < 0 ? undefined : this.items[index].element; return { browserEvent, index, element }; } private toGestureEvent(browserEvent: GestureEvent): IListGestureEvent { const index = this.getItemIndexFromEventTarget(browserEvent.initialTarget); const element = index < 0 ? undefined : this.items[index].element; return { browserEvent, index, element }; } private onScroll(e: ScrollEvent): void { this.render(e.scrollTop, e.height); } private onTouchChange(event: GestureEvent): void { event.preventDefault(); event.stopPropagation(); this.scrollTop -= event.translationY; } // Util private getItemIndexFromEventTarget(target: EventTarget): number { while (target instanceof HTMLElement && target !== this.rowsContainer) { const element = target as HTMLElement; const rawIndex = element.getAttribute('data-index'); if (rawIndex) { const index = Number(rawIndex); if (!isNaN(index)) { return index; } } target = element.parentElement; } return -1; } private getRenderRange(renderTop: number, renderHeight: number): IRange { return { start: this.rangeMap.indexAt(renderTop), end: this.rangeMap.indexAfter(renderTop + renderHeight - 1) }; } // Dispose dispose() { this.items = null; if (this._domNode && this._domNode.parentElement) { this._domNode.parentNode.removeChild(this._domNode); this._domNode = null; } this.disposables = dispose(this.disposables); } }